diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..4cef3c944
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,40 @@
+# Patterns are path-anchored under packages/ rather than `**//` because
+# BuildKit treats `/` as matching files too, so `**/build/` would also
+# hide the per-package `scripts/build` entry scripts.
+
+.git
+.github
+**/node_modules
+.pnpm-store
+
+# Build outputs (regenerated by `pnpm build`).
+packages/*/artifacts
+packages/*/cache
+packages/*/cache_forge
+packages/*/forge-artifacts
+packages/*/out
+packages/*/dist
+packages/*/build
+packages/*/typechain-types
+packages/*/coverage
+packages/*/types
+
+# Forge crash dumps (also gitignored). Match only the per-package `core` file at the
+# package root — NOT `**/core`, which would also exclude `packages/toolshed/src/core/`
+# and other directories named `core` deeper in the tree.
+packages/*/core
+
+# Editor / OS noise
+.vscode
+.idea
+.DS_Store
+
+# Local env / addresses (must not leak into a published image)
+.env
+**/.env
+**/addresses-local*.json
+**/localNetwork.json
+**/.keystore
+
+# Docs and audit PDFs aren't needed at runtime
+docs
diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index 23c24f1be..5a7def0ac 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -1,4 +1,5 @@
name: Setup
+description: Install system deps, Foundry, Node.js, pnpm, and the workspace's dependencies.
runs:
using: composite
@@ -15,13 +16,10 @@ runs:
shell: bash
run: corepack enable
- name: Install Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
- node-version: 20
+ node-version-file: '.nvmrc'
cache: 'pnpm'
- - name: Set up pnpm via Corepack
- shell: bash
- run: corepack prepare pnpm@10.17.0 --activate
- name: Install dependencies
shell: bash
run: pnpm install --frozen-lockfile
diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index 9f7c1a7a7..bc35bc4f2 100644
--- a/.github/workflows/build-test.yml
+++ b/.github/workflows/build-test.yml
@@ -6,7 +6,6 @@ env:
on:
pull_request:
- branches: '*'
workflow_dispatch:
jobs:
@@ -15,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
submodules: recursive
@@ -34,14 +33,14 @@ jobs:
- name: Find coverage files
id: coverage_files
run: |
- # Find all coverage-final.json files
- COVERAGE_FILES=$(find ./packages -name "coverage-final.json" -path "*/coverage/*" | tr '\n' ',' | sed 's/,$//')
+ # Find coverage files: Istanbul JSON (Hardhat) and lcov (Forge)
+ COVERAGE_FILES=$(find ./packages \( -name "coverage-final.json" -o -name "lcov.info" \) -path "*/coverage/*" | tr '\n' ',' | sed 's/,$//')
echo "files=$COVERAGE_FILES" >> $GITHUB_OUTPUT
echo "Found coverage files: $COVERAGE_FILES"
- name: Upload coverage reports
if: steps.coverage_files.outputs.files != ''
- uses: codecov/codecov-action@v3
+ uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ steps.coverage_files.outputs.files }}
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 7746988af..729e38f6c 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -2,14 +2,13 @@ name: Lint
# This workflow runs linting on files in the repository
# It can be configured to run on all files or just changed files
-# It will fail the build if there are errors, but only report warnings
+# It will fail the build if any linter fails
env:
CI: true
on:
pull_request:
- branches: '*'
workflow_dispatch:
inputs:
lint_mode:
@@ -27,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 0 # Needed to get all history for comparing changes
@@ -65,7 +64,7 @@ jobs:
# Get changed files, filtering out deleted files and files in ignored directories
CHANGED_TS_JS=$(git diff --name-only --diff-filter=d $BASE_SHA $HEAD_SHA | grep -E '\.(js|ts|jsx|tsx|cjs|mjs)$' | grep -v -E '(node_modules|dist|build|cache|reports|lib|coverage|artifacts|typechain|hardhat-cache|ignition/deployments|ignition/modules/artifacts)' || true)
- CHANGED_SOL=$(git diff --name-only --diff-filter=d $BASE_SHA $HEAD_SHA | grep -E '\.sol$' | grep -v -E '(node_modules|dist|build|cache|reports|lib|coverage|artifacts|typechain|hardhat-cache|ignition/deployments|ignition/modules/artifacts)' || true)
+ CHANGED_SOL=$(git diff --name-only --diff-filter=d $BASE_SHA $HEAD_SHA | grep -E '\.sol$' | grep -v -E '(node_modules|dist|build|cache|reports|lib|coverage|artifacts|typechain|hardhat-cache|ignition/deployments|ignition/modules/artifacts|/test/)' || true)
CHANGED_MD=$(git diff --name-only --diff-filter=d $BASE_SHA $HEAD_SHA | grep -E '\.md$' | grep -v -E '(node_modules|dist|build|cache|reports|lib|coverage|artifacts|typechain|hardhat-cache|ignition/deployments|ignition/modules/artifacts)' || true)
CHANGED_JSON=$(git diff --name-only --diff-filter=d $BASE_SHA $HEAD_SHA | grep -E '\.json$' | grep -v -E '(node_modules|dist|build|cache|reports|lib|coverage|artifacts|typechain|hardhat-cache|ignition/deployments|ignition/modules/artifacts)' || true)
CHANGED_YAML=$(git diff --name-only --diff-filter=d $BASE_SHA $HEAD_SHA | grep -E '\.(yml|yaml)$' | grep -v -E '(node_modules|dist|build|cache|reports|lib|coverage|artifacts|typechain|hardhat-cache|ignition/deployments|ignition/modules/artifacts)' || true)
@@ -99,278 +98,179 @@ jobs:
echo "- JSON: $JSON_COUNT"
echo "- YAML: $YAML_COUNT"
- - name: Lint TypeScript/JavaScript files (ESLint)
- id: lint_ts_eslint
+ - name: Lint Solidity files
+ id: lint_sol
continue-on-error: true
run: |
if [ "${{ steps.lint_mode.outputs.mode }}" = "all" ]; then
- echo "Running ESLint on all TypeScript/JavaScript files..."
- npx eslint --max-warnings=0 '**/*.{js,ts,cjs,mjs,jsx,tsx}'
- echo "ts_eslint_exit_code=$?" >> $GITHUB_OUTPUT
- elif [ "${{ steps.changed_files.outputs.ts_js_count }}" -gt "0" ]; then
- echo "Running ESLint on changed TypeScript/JavaScript files..."
- cat changed_ts_js.txt | xargs npx eslint --max-warnings=0
- echo "ts_eslint_exit_code=$?" >> $GITHUB_OUTPUT
- else
- echo "No TypeScript/JavaScript files to lint with ESLint."
- echo "ts_eslint_exit_code=0" >> $GITHUB_OUTPUT
- fi
-
- - name: Lint TypeScript/JavaScript files (Prettier)
- id: lint_ts_prettier
- continue-on-error: true
- run: |
- if [ "${{ steps.lint_mode.outputs.mode }}" = "all" ]; then
- echo "Checking all TypeScript/JavaScript files with Prettier..."
- npx prettier --check --cache --log-level warn '**/*.{js,ts,cjs,mjs,jsx,tsx}'
- echo "ts_prettier_exit_code=$?" >> $GITHUB_OUTPUT
- elif [ "${{ steps.changed_files.outputs.ts_js_count }}" -gt "0" ]; then
- echo "Checking changed TypeScript/JavaScript files with Prettier..."
- cat changed_ts_js.txt | xargs npx prettier --check --cache --log-level warn
- echo "ts_prettier_exit_code=$?" >> $GITHUB_OUTPUT
- else
- echo "No TypeScript/JavaScript files to check with Prettier."
- echo "ts_prettier_exit_code=0" >> $GITHUB_OUTPUT
- fi
-
- - name: Lint Solidity files (Solhint)
- id: lint_sol_solhint
- continue-on-error: true
- run: |
- if [ "${{ steps.lint_mode.outputs.mode }}" = "all" ]; then
- echo "Running Solhint on all Solidity files..."
- npx solhint --max-warnings=0 --noPrompt --noPoster 'packages/*/contracts/**/*.sol'
- echo "sol_solhint_exit_code=$?" >> $GITHUB_OUTPUT
+ echo "Linting all Solidity files in each package..."
+ # Run solhint in each workspace package that has contracts (check mode)
+ pnpm -r exec bash -c 'if [ -d "contracts" ]; then npx solhint --max-warnings=0 --noPrompt --noPoster "contracts/**/*.sol" 2>/dev/null || exit 1; fi'
+ # Check formatting with prettier
+ SOL_FILES=$(git ls-files '*.sol')
+ if [ -n "$SOL_FILES" ]; then
+ echo "$SOL_FILES" | tr '\n' '\0' | xargs -0 npx prettier --check --cache --log-level warn
+ else
+ echo "No Solidity files found"
+ fi
elif [ "${{ steps.changed_files.outputs.sol_count }}" -gt "0" ]; then
- echo "Running Solhint on changed Solidity files..."
- cat changed_sol.txt | xargs npx solhint --max-warnings=0 --noPrompt --noPoster
- echo "sol_solhint_exit_code=$?" >> $GITHUB_OUTPUT
+ echo "Linting changed Solidity files..."
+ # Lint each changed file from its package directory
+ for file in $(cat changed_sol.txt); do
+ # Walk up to find package.json
+ dir="$file"
+ found=false
+ while [ "$dir" != "." ]; do
+ dir=$(dirname "$dir")
+ if [ -f "$dir/package.json" ]; then
+ relative_file="${file#$dir/}"
+ echo " Checking $file"
+ (cd "$dir" && npx solhint --max-warnings=0 --noPrompt --noPoster "$relative_file")
+ found=true
+ break
+ fi
+ done
+ if [ "$found" = false ]; then
+ echo "::error::No package.json found for $file - workflow needs fixing"
+ exit 1
+ fi
+ done
+
+ # Check formatting with prettier
+ cat changed_sol.txt | tr '\n' '\0' | xargs -0 npx prettier --check --cache --log-level warn
else
- echo "No Solidity files to lint with Solhint."
- echo "sol_solhint_exit_code=0" >> $GITHUB_OUTPUT
+ echo "No Solidity files to lint"
fi
- - name: Lint Solidity files (Prettier)
- id: lint_sol_prettier
+ - name: Lint TypeScript/JavaScript files
+ id: lint_ts
continue-on-error: true
run: |
if [ "${{ steps.lint_mode.outputs.mode }}" = "all" ]; then
- echo "Checking all Solidity files with Prettier..."
- npx prettier --check --cache --log-level warn '**/*.sol'
- echo "sol_prettier_exit_code=$?" >> $GITHUB_OUTPUT
- elif [ "${{ steps.changed_files.outputs.sol_count }}" -gt "0" ]; then
- echo "Checking changed Solidity files with Prettier..."
- cat changed_sol.txt | xargs npx prettier --check --cache --log-level warn
- echo "sol_prettier_exit_code=$?" >> $GITHUB_OUTPUT
- else
- echo "No Solidity files to check with Prettier."
- echo "sol_prettier_exit_code=0" >> $GITHUB_OUTPUT
- fi
-
- - name: Lint Markdown files (Markdownlint)
- id: lint_md_markdownlint
- continue-on-error: true
- run: |
- if [ "${{ steps.lint_mode.outputs.mode }}" = "all" ]; then
- echo "Running Markdownlint on all Markdown files..."
- npx markdownlint --ignore-path .gitignore --ignore-path .markdownlintignore '**/*.md'
- echo "md_markdownlint_exit_code=$?" >> $GITHUB_OUTPUT
- elif [ "${{ steps.changed_files.outputs.md_count }}" -gt "0" ]; then
- echo "Running Markdownlint on changed Markdown files..."
- cat changed_md.txt | xargs npx markdownlint --ignore-path .gitignore --ignore-path .markdownlintignore
- echo "md_markdownlint_exit_code=$?" >> $GITHUB_OUTPUT
+ echo "Linting all TypeScript/JavaScript files..."
+ TS_FILES=$(git ls-files '*.js' '*.ts' '*.cjs' '*.mjs' '*.jsx' '*.tsx')
+ if [ -n "$TS_FILES" ]; then
+ echo "$TS_FILES" | tr '\n' '\0' | xargs -0 npx eslint --max-warnings=0 --no-warn-ignored
+ echo "$TS_FILES" | tr '\n' '\0' | xargs -0 npx prettier --check --cache --log-level warn --no-error-on-unmatched-pattern
+ else
+ echo "No TypeScript/JavaScript files found"
+ fi
+ elif [ "${{ steps.changed_files.outputs.ts_js_count }}" -gt "0" ]; then
+ echo "Linting changed TypeScript/JavaScript files..."
+ cat changed_ts_js.txt | tr '\n' '\0' | xargs -0 npx eslint --max-warnings=0 --no-warn-ignored
+ cat changed_ts_js.txt | tr '\n' '\0' | xargs -0 npx prettier --check --cache --log-level warn --no-error-on-unmatched-pattern
else
- echo "No Markdown files to lint with Markdownlint."
- echo "md_markdownlint_exit_code=0" >> $GITHUB_OUTPUT
+ echo "No TypeScript/JavaScript files to lint"
fi
- - name: Lint Markdown files (Prettier)
- id: lint_md_prettier
+ - name: Lint Markdown files
+ id: lint_md
continue-on-error: true
run: |
if [ "${{ steps.lint_mode.outputs.mode }}" = "all" ]; then
- echo "Checking all Markdown files with Prettier..."
- npx prettier --check --cache --log-level warn '**/*.md'
- echo "md_prettier_exit_code=$?" >> $GITHUB_OUTPUT
+ echo "Linting all Markdown files..."
+ MD_FILES=$(git ls-files '*.md')
+ if [ -n "$MD_FILES" ]; then
+ echo "$MD_FILES" | tr '\n' '\0' | xargs -0 npx markdownlint --ignore-path .gitignore --ignore-path .markdownlintignore
+ echo "$MD_FILES" | tr '\n' '\0' | xargs -0 npx prettier --check --cache --log-level warn --no-error-on-unmatched-pattern
+ else
+ echo "No Markdown files found"
+ fi
elif [ "${{ steps.changed_files.outputs.md_count }}" -gt "0" ]; then
- echo "Checking changed Markdown files with Prettier..."
- cat changed_md.txt | xargs npx prettier --check --cache --log-level warn
- echo "md_prettier_exit_code=$?" >> $GITHUB_OUTPUT
+ echo "Linting changed Markdown files..."
+ cat changed_md.txt | tr '\n' '\0' | xargs -0 npx markdownlint --ignore-path .gitignore --ignore-path .markdownlintignore
+ cat changed_md.txt | tr '\n' '\0' | xargs -0 npx prettier --check --cache --log-level warn --no-error-on-unmatched-pattern
else
- echo "No Markdown files to check with Prettier."
- echo "md_prettier_exit_code=0" >> $GITHUB_OUTPUT
+ echo "No Markdown files to lint"
fi
- - name: Lint JSON files (Prettier)
- id: lint_json_prettier
+ - name: Lint JSON files
+ id: lint_json
continue-on-error: true
run: |
if [ "${{ steps.lint_mode.outputs.mode }}" = "all" ]; then
- echo "Checking all JSON files with Prettier..."
- npx prettier --check --cache --log-level warn '**/*.json'
- echo "json_prettier_exit_code=$?" >> $GITHUB_OUTPUT
+ echo "Linting all JSON files..."
+ # Exclude Ignition deployment artifacts and other build artifacts
+ JSON_FILES=$(git ls-files '*.json' | { grep -v -E '(ignition/deployments/.*/artifacts/|ignition/deployments/.*/build-info/|/\.openzeppelin/|deployments/.*/solcInputs/)' || true; })
+ if [ -n "$JSON_FILES" ]; then
+ echo "$JSON_FILES" | tr '\n' '\0' | xargs -0 npx prettier --check --cache --log-level warn --no-error-on-unmatched-pattern
+ else
+ echo "No JSON files found"
+ fi
elif [ "${{ steps.changed_files.outputs.json_count }}" -gt "0" ]; then
- echo "Checking changed JSON files with Prettier..."
- cat changed_json.txt | xargs npx prettier --check --cache --log-level warn
- echo "json_prettier_exit_code=$?" >> $GITHUB_OUTPUT
+ echo "Linting changed JSON files..."
+ JSON_FILES=$(cat changed_json.txt | { grep -v -E '(ignition/deployments/.*/artifacts/|ignition/deployments/.*/build-info/|/\.openzeppelin/|deployments/.*/solcInputs/)' || true; })
+ if [ -n "$JSON_FILES" ]; then
+ echo "$JSON_FILES" | tr '\n' '\0' | xargs -0 npx prettier --check --cache --log-level warn --no-error-on-unmatched-pattern
+ else
+ echo "No JSON files to lint (after filtering build artifacts)"
+ fi
else
- echo "No JSON files to check with Prettier."
- echo "json_prettier_exit_code=0" >> $GITHUB_OUTPUT
+ echo "No JSON files to lint"
fi
- - name: Lint YAML files (yaml-lint)
- id: lint_yaml_yamllint
+ - name: Lint YAML files
+ id: lint_yaml
continue-on-error: true
run: |
if [ "${{ steps.lint_mode.outputs.mode }}" = "all" ]; then
- echo "Running yaml-lint on all YAML files..."
- npx yaml-lint .github/**/*.{yml,yaml}
- echo "yaml_yamllint_exit_code=$?" >> $GITHUB_OUTPUT
+ echo "Linting all YAML files..."
+ YAML_FILES=$(git ls-files '*.yml' '*.yaml')
+ if [ -n "$YAML_FILES" ]; then
+ echo "$YAML_FILES" | tr '\n' '\0' | xargs -0 npx yaml-lint
+ echo "$YAML_FILES" | tr '\n' '\0' | xargs -0 npx prettier --check --cache --log-level warn --no-error-on-unmatched-pattern
+ else
+ echo "No YAML files found"
+ fi
elif [ "${{ steps.changed_files.outputs.yaml_count }}" -gt "0" ]; then
- echo "Running yaml-lint on changed YAML files..."
- cat changed_yaml.txt | xargs npx yaml-lint
- echo "yaml_yamllint_exit_code=$?" >> $GITHUB_OUTPUT
+ echo "Linting changed YAML files..."
+ cat changed_yaml.txt | tr '\n' '\0' | xargs -0 npx yaml-lint
+ cat changed_yaml.txt | tr '\n' '\0' | xargs -0 npx prettier --check --cache --log-level warn --no-error-on-unmatched-pattern
else
- echo "No YAML files to lint with yaml-lint."
- echo "yaml_yamllint_exit_code=0" >> $GITHUB_OUTPUT
+ echo "No YAML files to lint"
fi
- - name: Lint YAML files (Prettier)
- id: lint_yaml_prettier
+ - name: Lint Forge files
+ id: lint_forge
continue-on-error: true
run: |
+ # Find packages with lint:forge script defined
+ FORGE_PACKAGES=$(find packages -name "package.json" -exec grep -l '"lint:forge"' {} \; | xargs -I{} dirname {} | sort)
+ if [ -z "$FORGE_PACKAGES" ]; then
+ echo "No packages with lint:forge script found"
+ exit 0
+ fi
+ echo "Packages with lint:forge: $FORGE_PACKAGES"
+
if [ "${{ steps.lint_mode.outputs.mode }}" = "all" ]; then
- echo "Checking all YAML files with Prettier..."
- npx prettier --check --cache --log-level warn '**/*.{yml,yaml}'
- echo "yaml_prettier_exit_code=$?" >> $GITHUB_OUTPUT
- elif [ "${{ steps.changed_files.outputs.yaml_count }}" -gt "0" ]; then
- echo "Checking changed YAML files with Prettier..."
- cat changed_yaml.txt | xargs npx prettier --check --cache --log-level warn
- echo "yaml_prettier_exit_code=$?" >> $GITHUB_OUTPUT
+ echo "Linting all Forge files..."
+ pnpm lint:forge
+ elif [ "${{ steps.changed_files.outputs.sol_count }}" -gt "0" ]; then
+ # Build regex pattern from packages with lint:forge
+ FORGE_PATTERN=$(echo "$FORGE_PACKAGES" | tr '\n' '|' | sed 's/|$//')
+ FORGE_FILES=$(cat changed_sol.txt | grep -E "^($FORGE_PATTERN)/" || true)
+ if [ -n "$FORGE_FILES" ]; then
+ echo "Found Forge-related changes, running forge lint..."
+ pnpm lint:forge
+ else
+ echo "No Forge-related Solidity files changed"
+ fi
else
- echo "No YAML files to check with Prettier."
- echo "yaml_prettier_exit_code=0" >> $GITHUB_OUTPUT
+ echo "No Solidity files to lint with Forge"
fi
- - name: Determine overall status
- id: status
+ - name: Check lint results
+ if: always()
run: |
- # Collect all exit codes
- TS_ESLINT_EXIT_CODE="${{ steps.lint_ts_eslint.outputs.ts_eslint_exit_code }}"
- TS_PRETTIER_EXIT_CODE="${{ steps.lint_ts_prettier.outputs.ts_prettier_exit_code }}"
- SOL_SOLHINT_EXIT_CODE="${{ steps.lint_sol_solhint.outputs.sol_solhint_exit_code }}"
- SOL_PRETTIER_EXIT_CODE="${{ steps.lint_sol_prettier.outputs.sol_prettier_exit_code }}"
- SOL_NATSPEC_EXIT_CODE="${{ steps.lint_sol_natspec.outputs.sol_natspec_exit_code }}"
- MD_MARKDOWNLINT_EXIT_CODE="${{ steps.lint_md_markdownlint.outputs.md_markdownlint_exit_code }}"
- MD_PRETTIER_EXIT_CODE="${{ steps.lint_md_prettier.outputs.md_prettier_exit_code }}"
- JSON_PRETTIER_EXIT_CODE="${{ steps.lint_json_prettier.outputs.json_prettier_exit_code }}"
- YAML_YAMLLINT_EXIT_CODE="${{ steps.lint_yaml_yamllint.outputs.yaml_yamllint_exit_code }}"
- YAML_PRETTIER_EXIT_CODE="${{ steps.lint_yaml_prettier.outputs.yaml_prettier_exit_code }}"
-
- # Initialize counters
- ERRORS=0
- WARNINGS=0
-
- # Check each exit code
- # Exit code 1 typically indicates errors
- # Exit code 2 or higher might indicate warnings or other issues
-
- # TypeScript/JavaScript - ESLint
- if [ "$TS_ESLINT_EXIT_CODE" = "1" ]; then
- echo "::error::ESLint found errors in TypeScript/JavaScript files"
- ERRORS=$((ERRORS+1))
- elif [ "$TS_ESLINT_EXIT_CODE" != "0" ]; then
- echo "::warning::ESLint found warnings in TypeScript/JavaScript files"
- WARNINGS=$((WARNINGS+1))
- fi
-
- # TypeScript/JavaScript - Prettier
- if [ "$TS_PRETTIER_EXIT_CODE" = "1" ]; then
- echo "::error::Prettier found formatting issues in TypeScript/JavaScript files"
- ERRORS=$((ERRORS+1))
- elif [ "$TS_PRETTIER_EXIT_CODE" != "0" ]; then
- echo "::warning::Prettier found warnings in TypeScript/JavaScript files"
- WARNINGS=$((WARNINGS+1))
- fi
-
- # Solidity - Solhint
- if [ "$SOL_SOLHINT_EXIT_CODE" = "1" ]; then
- echo "::error::Solhint found errors in Solidity files"
- ERRORS=$((ERRORS+1))
- elif [ "$SOL_SOLHINT_EXIT_CODE" != "0" ]; then
- echo "::warning::Solhint found warnings in Solidity files"
- WARNINGS=$((WARNINGS+1))
- fi
-
- # Solidity - Prettier
- if [ "$SOL_PRETTIER_EXIT_CODE" = "1" ]; then
- echo "::error::Prettier found formatting issues in Solidity files"
- ERRORS=$((ERRORS+1))
- elif [ "$SOL_PRETTIER_EXIT_CODE" != "0" ]; then
- echo "::warning::Prettier found warnings in Solidity files"
- WARNINGS=$((WARNINGS+1))
- fi
-
- # Markdown - Markdownlint
- if [ "$MD_MARKDOWNLINT_EXIT_CODE" = "1" ]; then
- echo "::error::Markdownlint found errors in Markdown files"
- ERRORS=$((ERRORS+1))
- elif [ "$MD_MARKDOWNLINT_EXIT_CODE" != "0" ]; then
- echo "::warning::Markdownlint found warnings in Markdown files"
- WARNINGS=$((WARNINGS+1))
- fi
-
- # Markdown - Prettier
- if [ "$MD_PRETTIER_EXIT_CODE" = "1" ]; then
- echo "::error::Prettier found formatting issues in Markdown files"
- ERRORS=$((ERRORS+1))
- elif [ "$MD_PRETTIER_EXIT_CODE" != "0" ]; then
- echo "::warning::Prettier found warnings in Markdown files"
- WARNINGS=$((WARNINGS+1))
- fi
-
- # JSON - Prettier
- if [ "$JSON_PRETTIER_EXIT_CODE" = "1" ]; then
- echo "::error::Prettier found formatting issues in JSON files"
- ERRORS=$((ERRORS+1))
- elif [ "$JSON_PRETTIER_EXIT_CODE" != "0" ]; then
- echo "::warning::Prettier found warnings in JSON files"
- WARNINGS=$((WARNINGS+1))
- fi
-
- # YAML - yaml-lint
- if [ "$YAML_YAMLLINT_EXIT_CODE" = "1" ]; then
- echo "::error::yaml-lint found errors in YAML files"
- ERRORS=$((ERRORS+1))
- elif [ "$YAML_YAMLLINT_EXIT_CODE" != "0" ]; then
- echo "::warning::yaml-lint found warnings in YAML files"
- WARNINGS=$((WARNINGS+1))
- fi
-
- # YAML - Prettier
- if [ "$YAML_PRETTIER_EXIT_CODE" = "1" ]; then
- echo "::error::Prettier found formatting issues in YAML files"
- ERRORS=$((ERRORS+1))
- elif [ "$YAML_PRETTIER_EXIT_CODE" != "0" ]; then
- echo "::warning::Prettier found warnings in YAML files"
- WARNINGS=$((WARNINGS+1))
- fi
-
- # Create summary
- LINT_MODE="${{ steps.lint_mode.outputs.mode }}"
- if [ "$ERRORS" -gt 0 ]; then
- echo "summary=❌ Linting ($LINT_MODE files) failed with $ERRORS error types and $WARNINGS warning types." >> $GITHUB_OUTPUT
- echo "Linting failed with errors. CI build will fail."
+ # Check if any lint step failed
+ if [ "${{ steps.lint_sol.outcome }}" = "failure" ] || \
+ [ "${{ steps.lint_ts.outcome }}" = "failure" ] || \
+ [ "${{ steps.lint_md.outcome }}" = "failure" ] || \
+ [ "${{ steps.lint_json.outcome }}" = "failure" ] || \
+ [ "${{ steps.lint_yaml.outcome }}" = "failure" ] || \
+ [ "${{ steps.lint_forge.outcome }}" = "failure" ]; then
+ echo "❌ One or more linters failed"
exit 1
- elif [ "$WARNINGS" -gt 0 ]; then
- echo "summary=⚠️ Linting ($LINT_MODE files) passed with $WARNINGS warning types. CI build will continue." >> $GITHUB_OUTPUT
- echo "Linting found warnings but no errors. CI build will continue."
- exit 0
else
- echo "summary=✅ All linters ($LINT_MODE files) passed successfully with no errors or warnings." >> $GITHUB_OUTPUT
- echo "All linters passed successfully."
- exit 0
+ echo "✅ All linters passed"
fi
-
- - name: Post Summary
- run: echo "${{ steps.status.outputs.summary }}" >> $GITHUB_STEP_SUMMARY
diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml
new file mode 100644
index 000000000..9304aa390
--- /dev/null
+++ b/.github/workflows/publish-image.yml
@@ -0,0 +1,148 @@
+# Builds the workspace image multi-arch (linux/amd64 + linux/arm64) and pushes
+# to ghcr.io/graphprotocol/contracts. Consumed by local-network's
+# graph-contracts wrapper via CONTRACTS_VERSION (pin a `:sha-` for
+# reproducibility).
+#
+# Native runner per platform (no QEMU), per-platform digest push, manifest
+# merge in a separate job. Runs independently of build-test.yml — they share
+# `pnpm install + pnpm build` but stay decoupled so test feedback isn't tied
+# to release packaging.
+
+name: Publish container image
+
+permissions:
+ contents: read
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ - 'deployment/**'
+ tags:
+ - 'v*'
+
+env:
+ IMAGE: ghcr.io/graphprotocol/contracts
+
+jobs:
+ build:
+ name: Build (${{ matrix.platform }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: linux/amd64
+ runner: ubuntu-24.04
+ - platform: linux/arm64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
+ permissions:
+ contents: read
+ packages: write
+ attestations: write
+ id-token: write
+ steps:
+ - name: Prepare platform pair
+ run: |
+ platform=${{ matrix.platform }}
+ echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
+
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ submodules: recursive
+
+ - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Docker labels
+ id: meta
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
+ with:
+ images: ${{ env.IMAGE }}
+
+ - name: Build and push by digest
+ id: build
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ with:
+ context: .
+ file: Dockerfile
+ platforms: ${{ matrix.platform }}
+ labels: ${{ steps.meta.outputs.labels }}
+ pull: true
+ cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }}
+ cache-to: type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }}
+ outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
+
+ - name: Export digest
+ if: github.event_name != 'pull_request'
+ run: |
+ mkdir -p ${{ runner.temp }}/digests
+ digest="${{ steps.build.outputs.digest }}"
+ touch "${{ runner.temp }}/digests/${digest#sha256:}"
+
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ if: github.event_name != 'pull_request'
+ with:
+ name: digests-${{ env.PLATFORM_PAIR }}
+ path: ${{ runner.temp }}/digests/*
+ if-no-files-found: error
+ retention-days: 1
+
+ merge:
+ name: Merge into multi-arch manifest
+ needs: build
+ # Avoids orphan per-platform digest blobs if a `need` is skipped or fails.
+ if: |
+ !cancelled()
+ && needs.build.result == 'success'
+ && github.event_name != 'pull_request'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ path: ${{ runner.temp }}/digests
+ pattern: digests-*
+ merge-multiple: true
+
+ - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Docker tags
+ id: meta
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
+ with:
+ images: ${{ env.IMAGE }}
+ tags: |
+ type=ref,event=tag
+ # Moving per-branch tag for casual use; pin :sha- for reproducibility.
+ type=ref,event=branch
+ # Ensures workflow_dispatch from any branch yields a usable tag.
+ type=sha,enable=true
+
+ # Glob `*` expands to the digest-named files from the build job.
+ - name: Create manifest list and push
+ working-directory: ${{ runner.temp }}/digests
+ run: |
+ docker buildx imagetools create \
+ $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+ $(printf '${{ env.IMAGE }}@sha256:%s ' *)
+
+ - name: Inspect image
+ run: |
+ docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index ea8d80315..aaa0a2152 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,3 +1,24 @@
+# Publishes a single workspace package to npm via OIDC trusted publishing.
+# No NPM_TOKEN is used; the job exchanges its GitHub OIDC token for a short-lived
+# npm credential and attaches SLSA provenance via `--provenance`.
+#
+# Prerequisite: each package in the `package` choice list must have a Trusted
+# Publisher entry configured on npmjs.com under Settings → Publishing access.
+# Use owner=`graphprotocol`, repo=`contracts`, workflow=`publish.yml`,
+# environment=blank. Adding a package to the choice list without that npm-side
+# entry will 403 at the publish step.
+#
+# Conventions:
+# - `tag=latest` is reserved for the changeset-driven release flow (see README).
+# Use a custom dist-tag (`dips`, `sepolia`, `next`, …) for ad-hoc or
+# pre-release publishes so the stable channel is never overwritten.
+# - `dry_run=true` validates the workflow end-to-end without consuming a
+# version or pushing a git tag — useful when adding a new package or
+# verifying a fresh Trusted Publisher entry.
+#
+# Fallback: maintainers with publish rights on `@graphprotocol/*` can still run
+# `pnpm publish` locally if OIDC is unavailable.
+
name: Publish package to NPM
on:
@@ -8,29 +29,49 @@ on:
required: true
type: choice
options:
+ - address-book
- contracts
- - sdk
+ - interfaces
+ - toolshed
tag:
description: 'Tag to publish'
required: true
type: string
default: latest
+ dry_run:
+ description: 'Dry-run (validate only, no publish or git tag)'
+ required: false
+ type: boolean
+ default: false
jobs:
publish:
name: Publish package
runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: write
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up environment
uses: ./.github/actions/setup
- - name: Set npm token for publishing
- run: pnpm config set //registry.npmjs.org/:_authToken ${{ secrets.GRAPHPROTOCOL_NPM_TOKEN }}
+ - name: Read package info
+ id: pkg
+ shell: bash
+ run: |
+ PKG_NAME=$(node -p "require('./packages/${{ inputs.package }}/package.json').name")
+ PKG_VERSION=$(node -p "require('./packages/${{ inputs.package }}/package.json').version")
+ echo "tag=${PKG_NAME}@${PKG_VERSION}" >> $GITHUB_OUTPUT
- name: Publish 🚀
shell: bash
run: |
pushd packages/${{ inputs.package }}
- pnpm publish --tag ${{ inputs.tag }} --access public --no-git-checks
+ pnpm publish --provenance --tag ${{ inputs.tag }} --access public --no-git-checks ${{ inputs.dry_run && '--dry-run' || '' }}
+ - name: Tag release
+ if: ${{ !inputs.dry_run }}
+ run: |
+ git tag ${{ steps.pkg.outputs.tag }}
+ git push origin ${{ steps.pkg.outputs.tag }}
diff --git a/.github/workflows/require-audit-label.yml b/.github/workflows/require-audit-label.yml
new file mode 100644
index 000000000..826c229ad
--- /dev/null
+++ b/.github/workflows/require-audit-label.yml
@@ -0,0 +1,56 @@
+name: Require Audit Label
+
+on:
+ pull_request:
+ branches: [main]
+ types: [opened, labeled, unlabeled, synchronize]
+
+jobs:
+ check-label:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Get changed files
+ id: changed
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const { data: files } = await github.rest.pulls.listFiles({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: context.issue.number,
+ per_page: 100
+ });
+
+ // Filter for .sol files, excluding tests
+ const solFiles = files
+ .map(f => f.filename)
+ .filter(f => f.endsWith('.sol'))
+ .filter(f => !f.includes('/test/'))
+ .filter(f => !f.includes('/tests/'))
+ .filter(f => !f.endsWith('.t.sol'));
+
+ console.log('Non-test Solidity files changed:', solFiles);
+ core.setOutput('has_sol_files', solFiles.length > 0);
+ core.setOutput('sol_files', solFiles.join('\n'));
+
+ - name: Check for required label
+ if: steps.changed.outputs.has_sol_files == 'true'
+ run: |
+ echo "Solidity files changed (excluding tests):"
+ echo "${{ steps.changed.outputs.sol_files }}"
+ echo ""
+
+ LABELS='${{ toJson(github.event.pull_request.labels.*.name) }}'
+ if echo "$LABELS" | grep -q '"audited"'; then
+ echo "✓ PR has 'audited' label"
+ else
+ echo "::error::This PR modifies Solidity contract files and must have the 'audited' label before merging to main."
+ echo ""
+ echo "If this code has been audited, add the 'audited' label to proceed."
+ exit 1
+ fi
+
+ - name: Skip check (no contract changes)
+ if: steps.changed.outputs.has_sol_files == 'false'
+ run: |
+ echo "✓ No non-test Solidity files changed, skipping audit label check"
diff --git a/.github/workflows/verifydeployed.yml b/.github/workflows/verifydeployed.yml
index ba682fc21..d61ecd95a 100644
--- a/.github/workflows/verifydeployed.yml
+++ b/.github/workflows/verifydeployed.yml
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up environment
@@ -36,7 +36,7 @@ jobs:
pnpm build
- name: Save build artifacts
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v7
with:
name: contract-artifacts
path: |
@@ -49,7 +49,7 @@ jobs:
needs: build
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v6
- name: Set up environment
uses: ./.github/actions/setup
- name: Build
@@ -57,7 +57,7 @@ jobs:
pushd packages/contracts
pnpm build || pnpm build
- name: Get build artifacts
- uses: actions/download-artifact@v3
+ uses: actions/download-artifact@v8
with:
name: contract-artifacts
diff --git a/.gitignore b/.gitignore
index 73a50607e..ba06116ad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,10 +3,6 @@ yarn-debug.log*
yarn-error.log*
node.log
-# Core dumps
-core
-core.*
-
# Dependency directories
node_modules/
.pnpm-store/
@@ -33,15 +29,22 @@ packages/*/.eslintcache
dist/
dist-v5/
build/
+packages/contracts/**/types/
+deployments/hardhat/
+*.js.map
+*.d.ts.map
+
+# Generated types (typechain output)
typechain/
+typechain-src/
typechain-types/
-types/
types-v5/
wagmi/
-types/
-deployments/hardhat/
-*.js.map
-*.d.ts.map
+packages/contracts/types/
+packages/contracts-test/types/
+packages/interfaces/types/
+packages/token-distribution/types/
+packages/issuance/types/
# TypeScript incremental compilation cache
**/tsconfig.tsbuildinfo
@@ -56,6 +59,9 @@ bin/
.env
.DS_Store
.vscode
+# Forge core dumps
+**/core
+!**/core/
# Coverage and other reports
coverage/
@@ -101,3 +107,15 @@ tx-builder-*.json
**/horizon-localNetwork/
**/subgraph-service-localNetwork/
!**/ignition/**/artifacts/
+
+# Temporary test working directories
+**/testing-coverage/
+
+# Claude AI settings
+.claude/
+
+# Tenderly
+.tenderly-artifacts/
+
+# NFS stale file handles
+.nfs*
diff --git a/.markdownlint.json b/.markdownlint.json
index 1a6cd5315..6ec4812d2 100644
--- a/.markdownlint.json
+++ b/.markdownlint.json
@@ -5,5 +5,6 @@
"MD013": false,
"MD024": { "siblings_only": true },
"MD029": { "style": "ordered" },
- "MD033": false
+ "MD033": false,
+ "MD040": false
}
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 000000000..b6f27f135
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 000000000..a45fd52cc
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+24
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
new file mode 100644
index 000000000..67210468b
--- /dev/null
+++ b/DEPLOYMENT.md
@@ -0,0 +1,218 @@
+# Deployment Strategy
+
+This document outlines the branching and deployment strategy for Solidity contracts in this repository.
+
+## Overview
+
+We use **per-environment deployment branches**. Each deploy to an environment gets its own `deployment//YYYY-MM-DD/` branch, branched from `main`, used to run the deploy and capture artifacts, then fast-forward merged back. Every testnet and mainnet deploy is tagged as a self-contained snapshot. Testnet is staging, not development — see principle #4.
+
+```mermaid
+flowchart LR
+ main1["main
(always audited)"]
+ branch["deployment/<env>/YYYY-MM-DD/<name>
branched from main"]
+ deploy["deploy to <env>
tag: deploy/<env>/YYYY-MM-DD/<name>"]
+ merge["FF merge back to main
delete branch"]
+ main2["main"]
+
+ main1 -->|branch| branch
+ branch --> deploy
+ deploy --> merge
+ merge --> main2
+```
+
+A release typically flows through environments in sequence — local/scratch for development, then testnet, then mainnet — but each environment uses its own independent branch cut from `main`. There is no single long-lived branch cascading from testnet to mainnet; the audited `main` is the only shared substrate.
+
+For hotfixes, branch from the tag in production instead of from `main`:
+
+```
+deploy/mainnet/YYYY-MM-DD/ ──branch──► deployment/mainnet/YYYY-MM-DD/-hotfix
+ │
+ ├─► fix + audit
+ ├─► deploy ──► tag: deploy/mainnet/YYYY-MM-DD/-hotfix
+ └──PR──► merge back to main
+```
+
+## Key Principles
+
+1. **Work in feat branches.** Development happens in `feat/*` branches, merged to `main` when complete.
+
+2. **`main` is always audited.** PRs modifying production Solidity require the `audited` label to merge to `main`.
+
+3. **Deployment branches are per-environment.** Each deploy creates a `deployment//YYYY-MM-DD/` branch (e.g. `deployment/testnet/2026-04-19/rewards-manager-and-subgraph-service`), branched from `main`. For testnet and mainnet the branch is short-lived (hours to days) and carries only artifacts and script tweaks — no contract changes. For scratch it may persist longer and host Solidity iteration, with any changes reaching `main` only via a `feat/*` PR.
+
+4. **Testnet is staging, not development.** Testnet is a pre-production mirror of mainnet, not a place to iterate on contract design. Redeploying updated contracts to testnet pollutes its historical state and can force custom off-chain handling (e.g. subgraph code for events that were later removed pre-mainnet) that then has to be maintained indefinitely. Iterate on the local network or scratch deployments instead (see [Pre-deployment Testing](#pre-deployment-testing)); graduate to testnet only with high confidence that the same contracts will reach mainnet.
+
+5. **Hotfix branches are branched from the tag they patch.** A hotfix branches from the `deploy/mainnet/YYYY-MM-DD/` tag currently in production, not from `main`. Keeps the hotfix diff minimal and avoids shipping accumulated but undeployed work on `main`.
+
+6. **Tag every testnet and mainnet deploy.** Each deploy to testnet or mainnet creates an immutable `deploy//YYYY-MM-DD/` tag reproducing the full state at that moment: source, scripts, and artifacts. The tag is the release record. Tagging in other environments (e.g. scratch) is optional and used at the operator's discretion — useful when a scratch state is worth pinning, unnecessary for throwaway iteration.
+
+7. **Prefer rebase and FF merge for testnet and mainnet.** Testnet and mainnet branches should FF back to `main` to preserve the audit-hash → deployed-bytes link. If `main` advances during the deploy window, rebase before merging.
+
+## Branches
+
+| Branch | Purpose | Lifetime |
+| ------------------------------------ | ------------------------------------------- | -------------------------------------------------------------------------- |
+| `feat/*` | Active development | Until merged to `main` |
+| `main` | Audited, deployment-ready code | Permanent |
+| `deployment//YYYY-MM-DD/` | Workspace for one deploy to one environment | Hours to days for testnet/mainnet; may persist for scratch while iterating |
+
+Environments in active use today are `testnet` (Arbitrum Sepolia) and `mainnet` (Arbitrum One). The scheme accommodates additional environments (e.g. a dedicated pre-release staging chain) by adding further `` tokens — no change to the mechanics.
+
+## Tags
+
+Testnet and mainnet deploys are always tagged with an immutable annotated tag. Other environments may be tagged at operator discretion using the same format.
+
+- `deploy/testnet/YYYY-MM-DD/` — testnet deployment snapshot (Arbitrum Sepolia)
+- `deploy/mainnet/YYYY-MM-DD/` — mainnet deployment snapshot (Arbitrum One)
+
+Including a descriptive `` is recommended. A short hyphenated identifier (e.g. `rewards-manager-and-subgraph-service`, `fix-activation`) makes tags self-describing, gives operators something meaningful to search on, and naturally prevents collisions when multiple deploys happen on the same day. The date segment ensures chronological sort regardless.
+
+Each tag is self-contained: its tree includes the deployed `.sol` sources, the deployment scripts used, and the resulting artifacts (`addresses.json`, etc.). The annotated tag body additionally records deployer identity and the list of changed contracts. Reproducing a past deploy is `git checkout ` and nothing else.
+
+### Finding and working with deployed code
+
+Check out what's currently on mainnet:
+
+```bash
+git checkout "$(git tag -l 'deploy/mainnet/*' | sort | tail -1)"
+```
+
+Check out what's currently on testnet:
+
+```bash
+git checkout "$(git tag -l 'deploy/testnet/*' | sort | tail -1)"
+```
+
+List all deployment tags:
+
+```bash
+git tag -l "deploy/*"
+```
+
+Diff between last mainnet deploy and current main:
+
+```bash
+git diff "$(git tag -l 'deploy/mainnet/*' | sort | tail -1)"..main
+```
+
+List active deployment branches (per environment):
+
+```bash
+git branch -a --list 'deployment/testnet/*'
+git branch -a --list 'deployment/mainnet/*'
+```
+
+## Workflows
+
+### Pre-deployment Testing
+
+Iteration on contract design happens on environments that don't pollute the canonical testnet state:
+
+- **Local network**: a self-contained network run locally as docker containers, bundling chain node, contracts, and off-chain services. The default for development and integration testing.
+- **Scratch deployments**: a fresh, separate protocol instance on Arbitrum Sepolia — same chain as the canonical testnet, but distinct protocol instance. A `deployment/scratch/...` branch may persist across multiple iterations and carry contract changes as development progresses; anything worth keeping lands on `main` via a `feat/*` PR, and the scratch branch can be discarded.
+
+The deployment scripts are written to be network- and instance-agnostic, so the same code path runs against local, scratch, testnet, and mainnet. A release only graduates to testnet once local and scratch testing give high confidence that no further contract changes are needed.
+
+Terminology: "testnet" always refers to the canonical Graph Protocol testnet instance on Arbitrum Sepolia. A scratch deployment on Sepolia is not "testnet" — same chain, different protocol instance.
+
+### Testnet and Mainnet Deployment
+
+Testnet deploys happen against a release already expected to reach mainnet unchanged (principle #4). Mainnet follows with the same contract source; typical differences between the two deploys are artifact files and operational parameters that vary by environment.
+
+The two deploys use the same procedure, each on its own branch cut from the current `main` (the mainnet branch is cut after the testnet merge-back, so it already includes testnet artifacts):
+
+1. **Branch.** From current `main`, create `deployment//YYYY-MM-DD/` and push it. Open a tracking PR back to `main`.
+2. **Deploy.** Run the deployment scripts against the target network. Commit artifacts, push.
+3. **Tag.** Run `tag-deployment.sh --network --name ...` to create `deploy//YYYY-MM-DD/`. Push the tag.
+4. **Merge.** Fast-forward merge the PR back into `main`. Delete the branch. If `main` advanced during the window, rebase before merging.
+
+Network mapping: testnet → `arbitrumSepolia`, mainnet → `arbitrumOne`.
+
+### Emergency Hotfix
+
+For critical mainnet issues:
+
+1. Branch `deployment/mainnet/YYYY-MM-DD/-hotfix` from the current `deploy/mainnet/YYYY-MM-DD/` tag and push it.
+2. Apply the fix. If it touches contract source, it must be audited before deploy. Commit and push; open a PR back to `main` at this point — it stays open for the duration of the hotfix as the review/tracking thread and becomes the merge-back PR.
+3. Run the deployment scripts against mainnet. If the fix warrants pre-mainnet verification, run it against the local network or a scratch deployment first (per [Pre-deployment Testing](#pre-deployment-testing)) rather than cutting a separate testnet deploy, which would otherwise race the mainnet hotfix. Commit artifacts and push.
+4. Run `tag-deployment.sh --network arbitrumOne --name -hotfix ...` to create the `deploy/mainnet/YYYY-MM-DD/-hotfix` tag. Push the tag.
+5. Review and merge the open PR back into `main`. The `audited` label applies to any contract changes in this PR.
+6. Delete the hotfix branch.
+7. If other deployment branches are active at hotfix time, incorporate the hotfix into them (rebase or cherry-pick) before their deploys.
+
+## Audit Integrity
+
+Audits certify that specific files have specific content. The operational question is always:
+
+> For every file in the audit scope, do its current bytes match the audited version's bytes?
+
+Principles #2, #3, and #7 preserve this for testnet and mainnet by construction: audited bytes reach `main` via `feat/*` PRs, their deployment branches carry no contract changes, and FF merges keep the audit-hash → deployed-bytes link intact. Scratch branches may hold in-progress contract work, but none of it reaches testnet or mainnet without first landing on audited `main`.
+
+The audit scope is a transitive closure — a reviewed contract's imports are implicitly in scope even if the PR didn't touch them — and the audit reference is a pinned commit SHA, not a PR number or label. A CI check can back up this cultural preference with a mechanical one: diff the audited paths between the last audit tag and `HEAD`, and require either an empty diff or a fresh audit. See [Appendix A: Audit Integrity CI Check](#appendix-a-audit-integrity-ci-check).
+
+## Automation
+
+### Tagging
+
+Tag creation is a **scripted operator step**, run after the deploy. The script captures context a CI workflow couldn't — which deploy script ran, with what flags, by whom, which contracts changed — baked into an annotated tag body, optionally signed.
+
+Implementation: [`packages/deployment/scripts/tag-deployment.sh`](packages/deployment/scripts/tag-deployment.sh). It takes `--deployer`, `--network`, `--name` (recommended), and `--base`; diffs each address book (`packages/horizon/addresses.json`, `packages/subgraph-service/addresses.json`, `packages/issuance/addresses.json`) against the base ref to enumerate new / updated / removed contracts; and creates the annotated tag in the `deploy//YYYY-MM-DD/` format defined above (or the bare-date fallback when no name is given).
+
+Typical invocation after the artifact commit is pushed:
+
+```bash
+packages/deployment/scripts/tag-deployment.sh \
+ --deployer "packages/deployment --tags RewardsManager,SubgraphService" \
+ --network arbitrumSepolia \
+ --name rewards-manager-and-subgraph-service
+```
+
+The script prints a preview (tag name, commit, annotation body), asks for confirmation, and creates a signed annotated tag. Run `tag-deployment.sh --help` for the full option list (`--dry-run`, `--yes`, `--no-sign`, `--base`, …).
+
+Then push:
+
+```bash
+git push origin
+```
+
+The diff against `--base` is what populates the tag body's "contracts" section. The default of the previous deploy tag for the same environment is normally correct. For an initial deploy on an environment (no prior tag exists), pass `--base` explicitly.
+
+### Audit Label Requirement
+
+PRs to `main` modifying Solidity contract files require an `audited` label before merging (`.github/workflows/require-audit-label.yml`).
+
+- **Applies to:** `.sol` files outside of test directories
+- **Excludes:** Files in `/test/`, `/tests/`, or ending in `.t.sol`
+- **Label:** `audited`
+
+This enforces principle #2: code in `main` must be audited.
+
+## Appendix A: Audit Integrity CI Check
+
+A future workflow to enforce the byte-equality property at CI level rather than relying on the cultural FF-preference. Sketched here; design decisions still to make before implementation.
+
+### Approach
+
+1. **Audit tags.** Each completed audit produces an annotated tag of the form `audit/YYYY-MM-DD/` pointing at the commit the auditors signed off on. The tag body records the auditor, the scope (which files/paths), and a link to the audit report.
+2. **Scope definition.** The "audit scope" is the set of file paths the auditors reviewed, together with the transitive closure of their Solidity imports. Stored as a path list (or glob) in the audit tag's annotation body so it can be parsed programmatically.
+3. **CI check.** On every PR to `main` (or every push to `deployment/*`), resolve the most recent `audit/*` tag that covers each in-scope file and compute `git diff HEAD -- `. If non-empty for any in-scope file, require either:
+ - The PR to carry the `audited` label (operator asserts the diff has been re-reviewed), or
+ - A new `audit/*` tag to land that covers the current `HEAD` for those paths.
+4. **Empty diff ⇒ automatic pass.** When the audited bytes on `HEAD` match the audit tag's bytes exactly for all in-scope files, no human intervention is needed — the CI proves trivially that `HEAD` still matches what was audited.
+
+### Open design decisions
+
+- **Where does "audit scope" live?** Most robust: in the `audit/*` tag body as a path list. Alternative: a checked-in `audits/manifest.json`. The tag-body approach keeps the scope immutable alongside the reference commit; the file approach is easier to edit when scopes overlap or evolve.
+- **Multi-audit composition.** Different contracts may be covered by different audits. The CI needs a deterministic "most recent audit covering file X" lookup. Overlapping scopes require conflict resolution (most specific wins? most recent?).
+- **Transitive closure computation.** For `.sol` files, the importer graph is machine-derivable. A pre-commit or CI step should expand a human-declared scope (e.g. "the `IssuanceAllocator` contract") into the full transitive closure, so scope drift (an import added after audit) is caught automatically.
+- **Path inclusion/exclusion rules.** The current `require-audit-label.yml` excludes `/test/`, `/tests/`, and `*.t.sol`, but there are other helper, mock, and internal-only contracts that aren't audit targets (migration scaffolding, local fixtures, temporary scripts). A robust check needs either an explicit in-scope list or a clearer directory convention.
+
+### Prerequisite: reorganize non-production Solidity
+
+The current tree mixes production contracts with helpers, mocks, and internal tooling in the same directories. Before the CI check is meaningful:
+
+- Move non-production Solidity into clearly-named directories outside any plausible audit scope (e.g. `mocks/`, `helpers/`, `scripts/`, a top-level `non-audit/` tree per package).
+- Make audit scope a directory-level property wherever possible ("everything under `packages//contracts/` is audit scope; nothing else is") so that inclusion is inferrable from path rather than requiring a bespoke filter.
+- Update `require-audit-label.yml`'s filter in the same pass so its exclusions match the new layout.
+
+Until this reorganization lands, an audit-integrity CI check is possible but would rely on hand-maintained path lists — fragile and easy to drift from reality. The reorganization is low-risk refactoring but should be done in its own PR (itself audited for scope equivalence), separately from adopting this deployment proposal.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..2d3ce01df
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,27 @@
+# Workspace base image: deps + `pnpm install` + `pnpm build`, no entrypoint.
+# Consumers (e.g. local-network's graph-contracts wrapper) layer their own
+# deploy script on top, or `docker run` to invoke pnpm/hardhat directly.
+
+FROM node:24-bookworm-slim
+
+# libudev-dev / libusb-1.0-0-dev: native deps of hardhat-secure-accounts.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ curl git jq python3 make g++ libudev-dev libusb-1.0-0-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=ghcr.io/foundry-rs/foundry:stable \
+ /usr/local/bin/forge /usr/local/bin/cast /usr/local/bin/
+
+ENV COREPACK_ENABLE_STRICT=1
+RUN corepack enable
+
+# Husky's postinstall needs .git and is pointless in an image build.
+ENV HUSKY=0
+
+WORKDIR /opt/contracts
+
+COPY . .
+
+RUN pnpm install --frozen-lockfile \
+ && pnpm build
diff --git a/README.md b/README.md
index 2fa5496a2..665592e2c 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,8 @@
-
-
+
+
@@ -153,175 +153,53 @@ git push --follow-tags
**Note**: this step is meant to be run on the main branch.
-Packages are published and distributed via NPM. To publish a package, run the following command from the root of the repository:
+The [`Publish package to NPM`](.github/workflows/publish.yml) workflow is the standard publish path. It uses OIDC trusted publishing — no `NPM_TOKEN` is involved, and SLSA provenance is attached automatically. Anyone with `workflow_dispatch` permission on the repo can run it; no local npm credentials needed. The workflow also creates and pushes the package's git tag after a successful publish (so Step 3 can be skipped when using this path).
-```bash
-# Publish the packages
-pnpm changeset publish
+Dispatch from the Actions tab, or via `gh`:
-# Alternatively use
-pnpm publish --recursive
+```bash
+gh workflow run publish.yml -f package=interfaces -f tag=latest -f dry_run=false
```
-Alternatively, there is a GitHub action that can be manually triggered to publish a package.
-
-## Linting Configuration
-
-This monorepo uses a comprehensive linting setup with multiple tools to ensure code quality and consistency across all packages.
-
-### Linting Tools Overview
-
-- **ESLint**: JavaScript/TypeScript code quality and style enforcement
-- **Prettier**: Code formatting for JavaScript, TypeScript, JSON, Markdown, YAML, and Solidity
-- **Solhint**: Solidity-specific linting for smart contracts
-- **Markdownlint**: Markdown formatting and style consistency
-- **YAML Lint**: YAML file validation and formatting
-
-### Configuration Architecture
-
-The linting configuration follows a hierarchical structure where packages inherit from root-level configurations:
-
-#### ESLint Configuration
-
-- **Root Configuration**: `eslint.config.mjs` - Modern flat config format
-- **Direct Command**: `npx eslint '**/*.{js,ts,cjs,mjs,jsx,tsx}' --fix`
-- **Behavior**: ESLint automatically searches up parent directories to find configuration files
-- **Package Inheritance**: Packages automatically inherit the root ESLint configuration without needing local config files
-- **Global Ignores**: Configured to exclude autogenerated files (`.graphclient-extracted/`, `lib/`) and build outputs
-
-#### Prettier Configuration
-
-- **Root Configuration**: `prettier.config.cjs` - Base formatting rules for all file types
-- **Direct Command**: `npx prettier -w --cache '**/*.{js,ts,cjs,mjs,jsx,tsx,json,md,sol,yml,yaml}'`
-- **Package Inheritance**: Packages that need Prettier must have a `prettier.config.cjs` file that inherits from the shared config
-- **Example Package Config**:
-
- ```javascript
- const baseConfig = require('../../prettier.config.cjs')
- module.exports = { ...baseConfig }
- ```
-
-- **Ignore Files**: `.prettierignore` excludes lock files, build outputs, and third-party dependencies
-
-#### Solidity Linting (Solhint)
+Inputs:
-- **Root Configuration**: `.solhint.json` - Base Solidity linting rules extending `solhint:recommended`
-- **Direct Command**: `npx solhint 'contracts/**/*.sol'` (add `--fix` for auto-fixing)
-- **List Applied Rules**: `npx solhint list-rules`
-- **TODO Comment Checking**: `scripts/check-todos.sh` - Blocks commits and linting if TODO/FIXME/XXX/HACK comments are found in changed Solidity files
-- **Package Inheritance**: Packages can extend the root config with package-specific rules
-- **Configuration Inheritance Limitation**: Solhint has a limitation where nested `extends` don't work properly. When a local config extends a parent config that itself extends `solhint:recommended`, the built-in ruleset is ignored.
-- **Recommended Package Extension Pattern**:
+- `package` — the workspace package to publish (one of `address-book`, `contracts`, `interfaces`, `toolshed`).
+- `tag` — npm dist-tag. Use `latest` for stable releases; use a custom tag (`dips`, `sepolia`, `next`, …) for pre-releases so the stable channel isn't overwritten.
+- `dry_run` — when `true`, validates the workflow without consuming a version or pushing a git tag.
- ```json
- {
- "extends": ["solhint:recommended", "./../../.solhint.json"],
- "rules": {
- "no-console": "off",
- "import-path-check": "off"
- }
- }
- ```
+The workflow publishes one package per dispatch; for a multi-package release, dispatch once per package.
-#### Markdown Linting (Markdownlint)
+**Prerequisite:** each package on the choice list must have a Trusted Publisher entry on npmjs.com (Settings → Publishing access) with owner `graphprotocol`, repo `contracts`, workflow `publish.yml`, environment blank. Adding a new package to the workflow's `package` input without configuring its npm-side entry first will 403 at the publish step.
-- **Root Configuration**: `.markdownlint.json` - Markdown formatting and style rules
-- **Direct Command**: `npx markdownlint '**/*.md' --fix`
-- **Ignore Files**: `.markdownlintignore` automatically picked up by markdownlint CLI
-- **Global Application**: Applied to all markdown files across the monorepo
+#### Alternative: local publish
-### Linting Scripts
-
-#### Root Level Scripts
+For maintainers with publish rights on `@graphprotocol/*` — useful as a fallback if OIDC is unavailable, or for packages not on the workflow's choice list. Run from the root of a clean checkout:
```bash
-# Run all linting tools
-pnpm lint
-
-# Individual linting commands
-pnpm lint:ts # ESLint + Prettier for TypeScript/JavaScript
-pnpm lint:sol # TODO check + Solhint + Prettier for Solidity (runs recursively)
-pnpm lint:md # Markdownlint + Prettier for Markdown
-pnpm lint:json # Prettier for JSON files
-pnpm lint:yaml # YAML linting + Prettier
-
-# Lint only staged files (useful for manual pre-commit checks)
-pnpm lint:staged # Run linting on git-staged files only
+# Publish the packages
+pnpm changeset publish
+
+# Alternatively use
+pnpm publish --recursive
```
-#### Package Level Scripts
+## Linting
-Each package can define its own linting scripts that work with the inherited configurations:
+This monorepo uses multiple linting tools: ESLint, Prettier, Solhint, Forge Lint, Markdownlint, and YAML Lint.
```bash
-# Example from packages/contracts
-pnpm lint:sol # Solhint for contracts in this package only
-pnpm lint:ts # ESLint for TypeScript files in this package
+pnpm lint # Run all linters
+pnpm lint:staged # Lint only staged files
```
-### Pre-commit Hooks (lint-staged)
-
-The repository uses `lint-staged` with Husky to run linting on staged files before commits:
-
-- **Automatic**: Runs automatically on `git commit` via Husky pre-commit hook
-- **Manual**: Run `pnpm lint:staged` to manually check staged files before committing
-- **Configuration**: Root `package.json` contains lint-staged configuration
-- **Custom Script**: `scripts/lint-staged-run.sh` filters out generated files that shouldn't be linted
-- **File Type Handling**:
- - `.{js,ts,cjs,mjs,jsx,tsx}`: ESLint + Prettier
- - `.sol`: TODO check + Solhint + Prettier
- - `.md`: Markdownlint + Prettier
- - `.json`: Prettier only
- - `.{yml,yaml}`: YAML lint + Prettier
-
-**Usage**: `pnpm lint:staged` is particularly useful when you want to check what linting changes will be applied to your staged files before actually committing.
-
-### TODO Comment Enforcement
-
-The repository enforces TODO comment resolution to maintain code quality:
-
-- **Scope**: Applies only to Solidity (`.sol`) files
-- **Detection**: Finds TODO, FIXME, XXX, and HACK comments (case-insensitive)
-- **Triggers**:
- - **Pre-commit**: Blocks commits if TODO comments exist in files being committed
- - **Regular linting**: Flags TODO comments in locally changed, staged, or untracked Solidity files
-- **Script**: `scripts/check-todos.sh` (must be run from repository root)
-- **Bypass**: Use `git commit --no-verify` to bypass (not recommended for production)
-
-### Key Design Principles
-
-1. **Hierarchical Configuration**: Root configurations provide base rules, packages can extend as needed
-2. **Tool-Specific Inheritance**: ESLint searches up automatically, Prettier requires explicit inheritance
-3. **Generated File Exclusion**: Multiple layers of exclusion for autogenerated content
-4. **Consistent Formatting**: Prettier ensures consistent code formatting across all file types
-5. **Fail-Fast Linting**: Pre-commit hooks catch issues before they enter the repository
-
-### Configuration Files Reference
-
-| Tool | Root Config | Package Config | Ignore Files |
-| ------------ | --------------------- | -------------------------------- | ---------------------------- |
-| ESLint | `eslint.config.mjs` | Auto-inherited | Built into config |
-| Prettier | `prettier.config.cjs` | `prettier.config.cjs` (inherits) | `.prettierignore` |
-| Solhint | `.solhint.json` | `.solhint.json` (array extends) | N/A |
-| Markdownlint | `.markdownlint.json` | Auto-inherited | `.markdownlintignore` |
-| Lint-staged | `package.json` | N/A | `scripts/lint-staged-run.sh` |
-
-### Troubleshooting
-
-- **ESLint not finding config**: ESLint searches up parent directories automatically - no local config needed
-- **Prettier not working**: Packages need a `prettier.config.cjs` that inherits from root config
-- **Solhint missing rules**: If extending a parent config, use array format: `["solhint:recommended", "./../../.solhint.json"]` to ensure all rules are loaded
-- **Solhint inheritance not working**: Nested extends don't work - parent config's `solhint:recommended` won't be inherited with simple string extends
-- **Solhint rule reference**: Use `npx solhint list-rules` to see all available rules and their descriptions
-- **Generated files being linted**: Check ignore patterns in `.prettierignore`, `.markdownlintignore`, and ESLint config
-- **Preview lint changes before commit**: Use `pnpm lint:staged` to see what changes will be applied to staged files
-- **Commit blocked by linting**: Fix the linting issues or use `git commit --no-verify` to bypass (not recommended)
+See [docs/Linting.md](docs/Linting.md) for detailed configuration, inline suppression syntax, and troubleshooting.
## Documentation
-> Coming soon
+- [Deployment Strategy](DEPLOYMENT.md) — Branching model and deployment workflow for Solidity contracts
+- [Linting](docs/Linting.md) — Linting configuration and troubleshooting
-For now, each package has its own README with more specific documentation you can check out.
+Each package also has its own README with package-specific documentation.
## Contributing
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 000000000..267d0e258
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,13 @@
+# Local build of the workspace image. Pair with local-network's
+# CONTRACTS_VERSION=local to consume this build from there.
+#
+# `docker compose build` (or `just build-image`) produces
+# `ghcr.io/graphprotocol/contracts:${CONTRACTS_TAG:-local}`. Override
+# CONTRACTS_TAG to distinguish multiple in-flight checkouts.
+
+services:
+ contracts:
+ image: ghcr.io/graphprotocol/contracts:${CONTRACTS_TAG:-local}
+ build:
+ context: .
+ dockerfile: Dockerfile
diff --git a/docs/ForgeLintSymlinkIssue.md b/docs/ForgeLintSymlinkIssue.md
new file mode 100644
index 000000000..d3309c862
--- /dev/null
+++ b/docs/ForgeLintSymlinkIssue.md
@@ -0,0 +1,150 @@
+# Foundry Issue Draft: forge lint symlink loop
+
+**Repository:**
+
+---
+
+## Title
+
+`forge lint` fails with "too many levels of symbolic links" in pnpm workspaces
+
+## Component
+
+`forge-lint`
+
+## Describe the bug
+
+`forge lint` fails with OS error 40 ("too many levels of symbolic links") when the project uses pnpm workspaces with circular symlinks. This is a standard pnpm workspace pattern where sub-packages depend on their parent package.
+
+The `[lint] ignore` configuration does not prevent this - forge appears to traverse the entire filesystem tree before applying the ignore filter, hitting the symlink loop in the process.
+
+Note: `forge build` and `forge test` work correctly in the same project, suggesting they use different traversal logic that handles or avoids symlink loops.
+
+## Error message
+
+```
+Error: attempting to read `/path/to/project/node_modules/@graphprotocol/contracts/testing/node_modules/@graphprotocol/contracts/testing/node_modules/@graphprotocol/contracts/testing/[...repeating...]/contracts/governance` resulted in an error: Too many levels of symbolic links (os error 40)
+```
+
+## To reproduce
+
+1. Create a pnpm workspace with package A
+2. Package A has a sub-directory (e.g., `testing/`) with its own `package.json`
+3. The sub-package lists package A as a dependency
+4. pnpm creates a symlink: `A/testing/node_modules/A` → `../../..` (circular)
+5. Run `forge lint`
+
+### Minimal reproduction
+
+```bash
+# Create workspace
+mkdir -p workspace/packages/parent/child
+cd workspace
+
+# Root package.json
+cat > package.json << 'EOF'
+{
+ "name": "workspace",
+ "private": true
+}
+EOF
+
+# pnpm workspace config
+cat > pnpm-workspace.yaml << 'EOF'
+packages:
+ - 'packages/*'
+ - 'packages/*/child'
+EOF
+
+# Parent package
+cat > packages/parent/package.json << 'EOF'
+{
+ "name": "@example/parent",
+ "version": "1.0.0"
+}
+EOF
+
+# Child package that depends on parent
+cat > packages/parent/child/package.json << 'EOF'
+{
+ "name": "@example/child",
+ "version": "1.0.0",
+ "dependencies": {
+ "@example/parent": "workspace:^"
+ }
+}
+EOF
+
+# Install - pnpm creates circular symlink
+pnpm install
+
+# Verify circular symlink exists
+ls -la packages/parent/child/node_modules/@example/parent
+# Shows: parent -> ../../..
+
+# Create minimal foundry project
+cat > packages/parent/foundry.toml << 'EOF'
+[profile.default]
+src = 'contracts'
+libs = ["node_modules"]
+
+[lint]
+ignore = ["node_modules/**/*"]
+EOF
+
+mkdir -p packages/parent/contracts
+cat > packages/parent/contracts/Example.sol << 'EOF'
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+contract Example {}
+EOF
+
+# This fails
+cd packages/parent && forge lint
+```
+
+## Expected behavior
+
+`forge lint` should either:
+
+1. Apply the `ignore` configuration during traversal (not after), skipping `node_modules` entirely
+2. Detect and handle symlink loops gracefully (track visited inodes)
+3. Respect the `libs` configuration to avoid deep traversal into library directories
+
+## Environment
+
+- forge version: 1.5.1-stable
+- OS: Linux
+- Package manager: pnpm 9.x with workspaces
+
+## Root cause hypothesis
+
+The traversal logic appears to recursively walk the entire directory tree before applying `ignore` patterns, rather than pruning during traversal.
+
+Evidence:
+
+- `node_modules/@graphprotocol/contracts/testing/` is not a standard Solidity directory
+- It's not in the package exports
+- It's not `src`, `test`, `script`, or `lib`
+- Yet forge descends into it (and its nested `node_modules`)
+
+The fix should apply ignore patterns during traversal (using something like `filter_entry` in walkdir) to prune directories before descending, not filter results after traversal.
+
+## Additional context
+
+This pattern is common in monorepos where:
+
+- A main package exists (e.g., `@graphprotocol/contracts`)
+- Sub-packages for testing/tooling exist within it (e.g., `contracts/testing/`)
+- Sub-packages depend on the parent for shared code
+
+pnpm resolves this by creating symlinks back to the parent, which is intentional and works correctly with Node.js module resolution (which has built-in cycle detection).
+
+The workaround of removing these symlinks would break the workspace, so it's not viable.
+
+## Workaround (current)
+
+None that preserves full functionality. Options:
+
+- Skip `forge lint` and use only `solhint`
+- Manually delete circular symlinks before linting (breaks workspace)
diff --git a/docs/IGraphProxyAdminInterfaceFix.md b/docs/IGraphProxyAdminInterfaceFix.md
new file mode 100644
index 000000000..f17ea388c
--- /dev/null
+++ b/docs/IGraphProxyAdminInterfaceFix.md
@@ -0,0 +1,201 @@
+# IGraphProxyAdmin Interface Signature Fix
+
+## Issue
+
+The IGraphProxyAdmin interface in `packages/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol` had incorrect function signatures that didn't match the actual GraphProxyAdmin contract implementation.
+
+### Understanding the Two Different `acceptProxy` Methods
+
+There are **two different contracts** with similar-sounding methods, which can cause confusion:
+
+1. **GraphUpgradeable** (base class for implementation contracts):
+
+ ```solidity
+ // Called ON the implementation contract
+ function acceptProxy(IGraphProxy _proxy) external onlyProxyAdmin(_proxy) {
+ _proxy.acceptUpgrade();
+ }
+ ```
+
+ This is inherited by implementation contracts like RewardsManager, Staking, etc.
+
+2. **GraphProxyAdmin** (admin contract that manages upgrades):
+
+ ```solidity
+ // Called ON the admin contract, which then calls the implementation
+ function acceptProxy(GraphUpgradeable _implementation, IGraphProxy _proxy) external onlyGovernor {
+ _implementation.acceptProxy(_proxy);
+ }
+ ```
+
+ This is the admin contract that orchestrates upgrades.
+
+**IGraphProxyAdmin represents the second one** - the GraphProxyAdmin admin contract, not the GraphUpgradeable base class.
+
+### Incorrect Interface (Before)
+
+The interface mistakenly used the single-parameter signature from GraphUpgradeable:
+
+```solidity
+function acceptProxy(IGraphProxy proxy) external;
+
+function acceptProxyAndCall(IGraphProxy proxy, bytes calldata data) external;
+```
+
+### Actual GraphProxyAdmin Implementation
+
+From `packages/contracts/contracts/upgrades/GraphProxyAdmin.sol`:
+
+```solidity
+function acceptProxy(GraphUpgradeable _implementation, IGraphProxy _proxy) external onlyGovernor {
+ _implementation.acceptProxy(_proxy);
+}
+
+function acceptProxyAndCall(
+ GraphUpgradeable _implementation,
+ IGraphProxy _proxy,
+ bytes calldata _data
+) external onlyGovernor {
+ _implementation.acceptProxyAndCall(_proxy, _data);
+}
+```
+
+The interface was **missing the first parameter** (`implementation` address) from both functions. It had copied the signature from GraphUpgradeable instead of using the correct GraphProxyAdmin signature.
+
+## Impact
+
+### Why This Mattered
+
+The deployment package (`@graphprotocol/deployment`) needs to call `acceptProxy` with the correct signature to upgrade proxy contracts. The function requires TWO parameters:
+
+1. The implementation contract address
+2. The proxy contract address
+
+Because the interface was wrong, the deployment code had to work around it by loading the full contract ABI instead of using the cleaner interface ABI:
+
+```typescript
+// packages/deployment/lib/abis.ts (old workaround)
+// Note: Load from actual contract, not interface, because IGraphProxyAdmin is outdated
+// Interface shows: acceptProxy(IGraphProxy proxy)
+// Contract has: acceptProxy(GraphUpgradeable _implementation, IGraphProxy _proxy)
+export const GRAPH_PROXY_ADMIN_ABI = loadAbi(
+ '@graphprotocol/contracts/artifacts/contracts/upgrades/GraphProxyAdmin.sol/GraphProxyAdmin.json',
+)
+```
+
+### Why Horizon is Not Affected
+
+GraphDirectory in horizon (`packages/horizon/contracts/utilities/GraphDirectory.sol`) imports and uses IGraphProxyAdmin, but **only as a type reference**:
+
+```solidity
+IGraphProxyAdmin private immutable GRAPH_PROXY_ADMIN;
+
+constructor(address controller) {
+ GRAPH_PROXY_ADMIN = IGraphProxyAdmin(_getContractFromController("GraphProxyAdmin"));
+}
+
+function _graphProxyAdmin() internal view returns (IGraphProxyAdmin) {
+ return GRAPH_PROXY_ADMIN;
+}
+```
+
+GraphDirectory:
+
+- Stores the address as an immutable reference
+- Returns it via a getter function
+- **Never calls any methods on IGraphProxyAdmin** (like `acceptProxy`)
+
+Since horizon doesn't call the methods, fixing the interface signature doesn't break horizon.
+
+## Fix Applied
+
+### Updated Interface
+
+```solidity
+/**
+ * @notice Accept ownership of a proxy contract
+ * @param implementation The implementation contract accepting the proxy
+ * @param proxy The proxy contract to accept
+ */
+function acceptProxy(address implementation, IGraphProxy proxy) external;
+
+/**
+ * @notice Accept ownership of a proxy contract and call a function
+ * @param implementation The implementation contract accepting the proxy
+ * @param proxy The proxy contract to accept
+ * @param data The calldata to execute after accepting
+ */
+function acceptProxyAndCall(address implementation, IGraphProxy proxy, bytes calldata data) external;
+```
+
+**Notes on parameter type choice:**
+
+- Used `address` instead of `GraphUpgradeable` for the implementation parameter
+- This avoids creating a dependency from interfaces package to contracts package
+- The actual contract uses `GraphUpgradeable`, but `address` is compatible (Solidity allows passing addresses for contract types)
+- The ABI encoding is identical - both produce the same function selector and parameter encoding
+
+**Call flow for context:**
+
+```
+Deployer/Governor
+ → GraphProxyAdmin.acceptProxy(implAddress, proxyAddress) ← IGraphProxyAdmin represents THIS
+ → implAddress.acceptProxy(proxyAddress) ← GraphUpgradeable provides this
+ → proxyAddress.acceptUpgrade()
+```
+
+### Updated Deployment Code
+
+Removed the workaround comment and switched to using the interface:
+
+```typescript
+// packages/deployment/lib/abis.ts (now clean)
+export const GRAPH_PROXY_ADMIN_ABI = loadAbi(
+ '@graphprotocol/interfaces/artifacts/contracts/contracts/upgrades/IGraphProxyAdmin.sol/IGraphProxyAdmin.json',
+)
+```
+
+## Files Changed
+
+1. `packages/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol`
+ - Fixed `acceptProxy` signature
+ - Fixed `acceptProxyAndCall` signature
+
+2. `packages/deployment/lib/abis.ts`
+ - Removed workaround comment
+ - Changed to load from interface instead of full contract
+
+## Testing
+
+Build verification:
+
+- ✅ interfaces package builds successfully
+- ✅ deployment package dependencies build successfully
+- ✅ No TypeScript compilation errors
+- ✅ Hardhat compilation successful
+
+The deployment code in `packages/deployment/lib/upgrade-implementation.ts` already calls acceptProxy with both parameters:
+
+```typescript
+const acceptData = encodeFunctionData({
+ abi: GRAPH_PROXY_ADMIN_ABI,
+ functionName: 'acceptProxy',
+ args: [pendingImpl as `0x${string}`, proxyAddress as `0x${string}`],
+})
+```
+
+This call now works with the corrected interface ABI.
+
+## Recommendation
+
+This fix should be safe to merge. The interface now accurately reflects the actual contract implementation, and no existing code is broken by the change since:
+
+1. Deployment already expects the two-parameter signature
+2. Horizon only uses the type, never calls the methods
+3. The fix aligns the interface with reality, reducing confusion
+
+## Questions for Team Review
+
+1. Are there other consumers of IGraphProxyAdmin that might be affected?
+2. Should this be considered a breaking change requiring a major version bump of @graphprotocol/interfaces?
+3. Is there a reason the interface was historically wrong (legacy compatibility concerns)?
diff --git a/docs/Linting.md b/docs/Linting.md
new file mode 100644
index 000000000..f77a0c61a
--- /dev/null
+++ b/docs/Linting.md
@@ -0,0 +1,210 @@
+# Linting Configuration
+
+This monorepo uses a comprehensive linting setup with multiple tools to ensure code quality and consistency across all packages.
+
+## Linting Tools Overview
+
+- **ESLint**: JavaScript/TypeScript code quality and style enforcement
+- **Prettier**: Code formatting for JavaScript, TypeScript, JSON, Markdown, YAML, and Solidity
+- **Solhint**: Solidity-specific linting for smart contracts
+- **Forge Lint**: Foundry's Solidity linter (for packages using Forge)
+- **TODO Check**: Reports TODO/FIXME/XXX/HACK comments in Solidity files (informational)
+- **Markdownlint**: Markdown formatting and style consistency
+- **YAML Lint**: YAML file validation and formatting
+
+## Configuration Architecture
+
+The linting configuration follows a hierarchical structure where packages inherit from root-level configurations.
+
+### ESLint Configuration
+
+- **Root Configuration**: `eslint.config.mjs` - Modern flat config format
+- **Direct Command**: `npx eslint '**/*.{js,ts,cjs,mjs,jsx,tsx}' --fix`
+- **Behavior**: ESLint automatically searches up parent directories to find configuration files
+- **Package Inheritance**: Packages automatically inherit the root ESLint configuration without needing local config files
+- **Global Ignores**: Configured to exclude autogenerated files (`.graphclient-extracted/`, `lib/`) and build outputs
+
+### Prettier Configuration
+
+- **Root Configuration**: `prettier.config.cjs` - Base formatting rules for all file types
+- **Direct Command**: `npx prettier -w --cache '**/*.{js,ts,cjs,mjs,jsx,tsx,json,md,sol,yml,yaml}'`
+- **Package Inheritance**: Packages that need Prettier must have a `prettier.config.cjs` file that inherits from the shared config
+- **Example Package Config**:
+
+ ```javascript
+ const baseConfig = require('../../prettier.config.cjs')
+ module.exports = { ...baseConfig }
+ ```
+
+- **Ignore Files**: `.prettierignore` excludes lock files, build outputs, and third-party dependencies
+
+### Solidity Linting (Solhint)
+
+- **Root Configuration**: `.solhint.json` - Base Solidity linting rules extending `solhint:recommended`
+- **Direct Command**: `npx solhint 'contracts/**/*.sol'` (add `--fix` for auto-fixing)
+- **List Applied Rules**: `npx solhint list-rules`
+- **Package Inheritance**: Packages can extend the root config with package-specific rules
+- **Configuration Inheritance Limitation**: Solhint has a limitation where nested `extends` don't work properly. When a local config extends a parent config that itself extends `solhint:recommended`, the built-in ruleset is ignored.
+- **Recommended Package Extension Pattern**:
+
+ ```json
+ {
+ "extends": ["solhint:recommended", "./../../.solhint.json"],
+ "rules": {
+ "no-console": "off",
+ "import-path-check": "off"
+ }
+ }
+ ```
+
+### Forge Lint
+
+Forge lint is Foundry's built-in Solidity linter. Packages using Foundry can add `lint:forge` to their lint scripts.
+
+- **Package Configuration**: `foundry.toml` with `[lint]` section
+- **Direct Command**: `forge lint` or `forge lint contracts/`
+- **Available in**: Packages with `lint:forge` script defined (horizon, subgraph-service, issuance)
+
+### Markdown Linting (Markdownlint)
+
+- **Root Configuration**: `.markdownlint.json` - Markdown formatting and style rules
+- **Direct Command**: `npx markdownlint '**/*.md' --fix`
+- **Ignore Files**: `.markdownlintignore` automatically picked up by markdownlint CLI
+- **Package Inheritance**: Packages that need Markdownlint must have a `.markdownlint.json` file that extends the root config
+- **Example Package Config**:
+
+ ```json
+ {
+ "extends": "../../.markdownlint.json"
+ }
+ ```
+
+## Inline Lint Suppression
+
+When you need to suppress a lint warning for a specific line or item, use the appropriate comment directive.
+
+### Solhint Suppression
+
+```solidity
+// Disable for next line (can have intervening comments before target)
+// solhint-disable-next-line func-name-mixedcase
+
+// Disable for previous line
+function example() {
+ // solhint-disable-previous-line no-empty-blocks
+}
+
+// Block disable/enable
+// solhint-disable no-console
+console.log("debug");
+// solhint-enable no-console
+```
+
+### Forge Lint Suppression
+
+```solidity
+ // Disable for next item (function, struct, etc. - AST-aware)
+// forge-lint: disable-next-item(mixed-case-function)
+
+// Note: forge-lint uses "next-item" not "next-line"
+// It applies to the entire syntactic construct, not just the next line
+```
+
+### Combined Example
+
+For functions that need both Solhint and Forge lint suppression (e.g., OpenZeppelin-style initializers):
+
+```solidity
+// solhint-disable-next-line func-name-mixedcase
+// forge-lint: disable-next-item(mixed-case-function)
+/**
+ * @notice Internal function to initialize the contract
+ */
+function __ContractName_init(address param) internal {
+ // initialization code
+}
+```
+
+Note: Place suppression comments before natspec to avoid warnings about comments not directly preceding the function.
+
+## Linting Scripts
+
+### Root Level Scripts
+
+```bash
+# Run all linting tools
+pnpm lint
+
+# Individual linting commands
+pnpm lint:ts # ESLint + Prettier for TypeScript/JavaScript
+pnpm lint:sol # TODO check + Solhint + Prettier for Solidity (runs recursively)
+pnpm lint:forge # Forge lint for packages that support it
+pnpm lint:md # Markdownlint + Prettier for Markdown
+pnpm lint:json # Prettier for JSON files
+pnpm lint:yaml # YAML linting + Prettier
+
+# Lint only staged files (useful for manual pre-commit checks)
+pnpm lint:staged # Run linting on git-staged files only
+```
+
+### Package Level Scripts
+
+Each package can define its own linting scripts that work with the inherited configurations:
+
+```bash
+# Example from packages/contracts
+pnpm lint:sol # Solhint for contracts in this package only
+pnpm lint:ts # ESLint for TypeScript files in this package
+```
+
+## Pre-commit Hooks (lint-staged)
+
+The repository uses `lint-staged` with Husky to run linting on staged files before commits:
+
+- **Automatic**: Runs automatically on `git commit` via Husky pre-commit hook
+- **Manual**: Run `pnpm lint:staged` to manually check staged files before committing
+- **Configuration**: Root `package.json` contains lint-staged configuration
+- **Custom Script**: `scripts/lint-staged-run.sh` filters out generated files that shouldn't be linted
+- **File Type Handling**:
+ - `.{js,ts,cjs,mjs,jsx,tsx}`: ESLint + Prettier
+ - `.sol`: TODO check + Solhint + Prettier
+ - `.md`: Markdownlint + Prettier
+ - `.json`: Prettier only
+ - `.{yml,yaml}`: YAML lint + Prettier
+
+**Usage**: `pnpm lint:staged` is particularly useful when you want to check what linting changes will be applied to your staged files before actually committing.
+
+## TODO Comment Checking
+
+The repository reports TODO comments in Solidity files to help track technical debt:
+
+- **Scope**: Applies only to Solidity (`.sol`) files
+- **Detection**: Finds TODO, FIXME, XXX, and HACK comments (case-insensitive)
+- **Behavior**: Informational only - does not block commits or fail linting
+- **Included in**: `lint:sol` and `lint:staged` scripts
+- **Script**: `scripts/check-todos.sh` (must be run from repository root)
+
+## Configuration Files Reference
+
+| Tool | Root Config | Package Config | Ignore Files |
+| ------------ | ------------------------ | -------------------------------- | ---------------------------- |
+| ESLint | `eslint.config.mjs` | Auto-inherited | Built into config |
+| Prettier | `prettier.config.cjs` | `prettier.config.cjs` (inherits) | `.prettierignore` |
+| Solhint | `.solhint.json` | `.solhint.json` (array extends) | N/A |
+| Forge Lint | N/A | `foundry.toml` `[lint]` section | N/A |
+| TODO Check | `scripts/check-todos.sh` | N/A | N/A |
+| Markdownlint | `.markdownlint.json` | `.markdownlint.json` (extends) | `.markdownlintignore` |
+| Lint-staged | `package.json` | N/A | `scripts/lint-staged-run.sh` |
+
+## Troubleshooting
+
+- **ESLint not finding config**: ESLint searches up parent directories automatically - no local config needed
+- **Prettier not working**: Packages need a `prettier.config.cjs` that inherits from root config
+- **Markdownlint not working**: Packages need a `.markdownlint.json` that extends root config
+- **Solhint missing rules**: If extending a parent config, use array format: `["solhint:recommended", "./../../.solhint.json"]` to ensure all rules are loaded
+- **Solhint inheritance not working**: Nested extends don't work - parent config's `solhint:recommended` won't be inherited with simple string extends
+- **Solhint rule reference**: Use `npx solhint list-rules` to see all available rules and their descriptions
+- **Generated files being linted**: Check ignore patterns in `.prettierignore`, `.markdownlintignore`, and ESLint config
+- **Preview lint changes before commit**: Use `pnpm lint:staged` to see what changes will be applied to staged files
+- **Commit blocked by linting**: Fix the linting issues or use `git commit --no-verify` to bypass (not recommended)
+- **Forge lint symlink errors**: Forge follows symlinks when scanning for files, which can cause "Too many levels of symbolic links" errors in packages with nested workspace dependencies. If a package has a `test/` subproject with workspace symlinks that create loops, rename the directory (e.g., to `testing/`) so forge doesn't scan it by default.
diff --git a/docs/PaymentsTrustModel.md b/docs/PaymentsTrustModel.md
new file mode 100644
index 000000000..07bff2468
--- /dev/null
+++ b/docs/PaymentsTrustModel.md
@@ -0,0 +1,176 @@
+# Payments Trust Model
+
+This document describes the trust assumptions between the five core actors in the Graph Horizon payments protocol: **payer**, **collector**, **data service**, **receiver**, and **escrow**. The general model is described first, followed by specifics of the current implementation (RecurringCollector, SubgraphService, RAM).
+
+## Trust Summary
+
+| Relationship | Trust | Mitigation |
+| --------------------------- | ----------------------------------------- | ------------------------------------------------ |
+| Payer → Collector | Enforces agreed caps | Protocol-deployed; escrow caps absolute exposure |
+| Payer → Receiver | Claimed work is honest | Post-hoc disputes + stake locking |
+| Receiver → Payer (EOA) | Escrow stays funded | Thaw period; on-chain visibility |
+| Receiver → Payer (contract) | Escrow stays funded; not block collection | RecurringAgreementManager: protocol-deployed |
+| Receiver → Collector | Correctly caps and forwards payment | Protocol-deployed; code is transparent |
+| Receiver → Data Service | Correct computation; not paused | Protocol-deployed; code is transparent |
+| Receiver → Escrow | Releases funds on valid collection | Stateless; no discretionary logic |
+| Data Service ↔ Collector | Each trusts the other's domain | Two-layer capping; independent validation |
+
+## Actors
+
+| Actor | Role | Examples |
+| ---------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
+| **Payer** | Funds escrow; authorizes collector contracts | RecurringAgreementManager (protocol-managed), external payer (ECDSA-signed) |
+| **Collector** | Validates payment requests; enforces per-agreement caps | RecurringCollector |
+| **Data service** | Entry point for collection; computes amounts earned | SubgraphService |
+| **Receiver** | Service provider receiving payment | Indexer |
+| **Escrow** | Holds GRT per (payer, collector, receiver) tuple; enforces thaw periods | PaymentsEscrow |
+
+## Payment Flow (General Model)
+
+```
+│ Receiver
+└─> Data Service.collect(work done)
+ └─> Collector.collect(tokens earned)
+ │ validates payment terms, caps amount
+ └─> PaymentsEscrow.collect(tokens to collect)
+ └─> GraphPayments.collect(tokens collected)
+ │ distributes to: protocol (burned), data service, delegation pool, receiver
+ <───┘
+ <───┘
+ <───┘
+<───┘
+```
+
+Any data service and collector can plug into this flow. The PaymentsEscrow and GraphPayments layers are fixed protocol infrastructure. The data service computes its own token amount; the collector independently caps it; the actual payment is `min(tokens earned, agreement cap)`, and escrow reverts if balance is insufficient.
+
+### RecurringCollector Extensions
+
+RecurringCollector adds payer callbacks when the payer is a contract:
+
+```
+│ Receiver
+└─> Data Service.collect(work done)
+ └─> RecurringCollector.collect(tokens earned)
+ │ validates agreement terms, caps amount
+ │ validates receiver has active provision with data service
+ │ if 0 < tokensToCollect AND payer is contract:
+ │ if implements IProviderEligibility:
+ │ require payer.isEligible(receiver) ← can BLOCK
+ │ try payer.beforeCollection(id, tokens) (can't block)
+ └─> PaymentsEscrow.collect(tokens to collect)
+ └─> GraphPayments.collect(tokens collected)
+ │ distributes to: protocol (burned), data service, delegation pool, receiver
+ <───┘
+ <───┘
+ │ if payer is contract: (even if tokensToCollect == 0)
+ │ try payer.afterCollection(id, tokens) (can't block)
+ <───┘
+<───┘
+```
+
+- **`isEligible`**: fail-open gate — only an explicit return of `0` blocks collection; call failures (reverts, malformed data) are ignored to prevent a buggy payer from griefing the receiver. Only called when `0 < tokensToCollect`.
+- **`beforeCollection`**: try-catch — allows payer to top up escrow (RAM uses this for JIT deposits), but cannot block (though a malicious contract payer could consume excessive gas). Only called when `0 < tokensToCollect`.
+- **`afterCollection`**: try-catch — allows payer to reconcile state post-collection, cannot block (same gas exhaustion caveat). Called even when `tokensToCollect == 0` (zero-token collections still trigger reconciliation).
+
+## Trust Relationships
+
+### Payer → Collector
+
+**Trust required**: The payer authorizes the collector contract and trusts it to enforce payment terms; that it will not collect more than the agreed-upon amounts per collection period.
+
+**Mitigation**: The collector is a protocol-deployed contract with fixed logic. The escrow balance provides an absolute ceiling — the collector cannot extract more than the deposited balance.
+
+> _RecurringCollector_: enforces per-agreement caps of `maxOngoingTokensPerSecond × maxSecondsPerCollection` (plus `maxInitialTokens` on first collection) per collection window. The payer's exposure is bounded by the agreement terms they signed or authorized.
+
+### Payer → Receiver
+
+**Trust required**: The receiver is paid immediately when collecting based on claimed work done. The payer relies on post-hoc enforcement rather than on-chain validation of the receiver's claims.
+
+**Mitigation**: The payment protocol itself is agnostic to what evidence the receiver provides — that is the data service's domain.
+
+> _SubgraphService_: the receiver submits a POI (Proof of Indexing) which is emitted in events but not validated on-chain. Payment proceeds regardless of POI correctness. The dispute system provides post-hoc enforcement: fishermen can challenge invalid POIs, and the indexer's locked stake (`tokensCollected × stakeToFeesRatio`) serves as economic collateral during the dispute period.
+>
+> _RAM as payer_: the payer is the protocol itself, and if configured, an eligibility oracle gates the receiver's ability to collect (checked by RecurringCollector via `IProviderEligibility`).
+
+### Receiver → Payer
+
+**Trust minimised by escrow**: The escrow is the primary trust-minimisation mechanism — to avoid trust in the payer, the receiver should bound uncollected work to what the escrow guarantees rather than relying on the payer to top up.
+
+Caveats on effective escrow (contract payers introduce additional trust requirements — see caveat 3):
+
+1. **Thawing reduces effective balance** — a payer can initiate a thaw; once the thaw period completes, those tokens are withdrawable. The receiver should account for the thawing period and any in-progress thaws when assessing available escrow.
+2. **Cancellation freezes the collection window** at `canceledAt` — the receiver can still collect for the period up to cancellation (with `minSecondsPerCollection` bypassed), but no further.
+3. **Contract payers can block** — if the payer is a contract that implements `IProviderEligibility`, it can deny collection via `isEligible` (see [RecurringCollector Extensions](#recurringcollector-extensions)).
+
+**Mitigation**: The thawing period provides a window for the receiver to collect before funds are withdrawn. The escrow balance and thaw state are publicly visible on-chain.
+
+> _RAM as payer_: RAM automates escrow maintenance (Full/OnDemand/JIT modes). When not operating in Full escrow mode, the receiver also depends on RAM's ability to fund at collection time. Mitigation: RAM is a protocol-deployed contract — its funding logic is transparent and predictable, with no adversarial incentive to deny payment.
+
+### Receiver → Data Service
+
+**Trust required**: The receiver (or their operator) calls the data service's `collect()` directly. The receiver trusts it to:
+
+1. **Compute amounts correctly** — the data service determines its claim of what is earned
+2. **Not be paused** — the data service may have a pause mechanism that would block collection
+
+**Mitigation**: The data service is a protocol-deployed contract. Token amounts are capped by the collector independently, so data service overstatement is bounded.
+
+> _SubgraphService_: `_tokensToCollect` computes the amount earned. The `enforceService` modifier requires the caller to be authorized by the receiver (indexer) for their provision.
+
+### Receiver → Escrow
+
+**Trust required**: The receiver trusts escrow to release funds when a valid collection is presented. The receiver has no direct access to escrow — funds can only flow through the authorized collection path (data service → collector → escrow → GraphPayments → receiver).
+
+**Mitigation**: Escrow is a stateless intermediary — it debits the payer's balance and forwards to GraphPayments. No discretionary logic. The failure modes are insufficient balance or protocol-wide pause (escrow's `collect` has a `notPaused` modifier).
+
+### Data Service → Collector
+
+**Trust required**: The data service trusts the collector to faithfully enforce temporal and amount-based caps. The data service provides its own token calculation, but the collector applies `min(requested, cap)` — the data service relies on this capping being correct.
+
+**Mitigation**: Both are protocol-deployed contracts. The two-layer capping model means neither layer alone determines the payout — the minimum of both applies.
+
+### Collector → Data Service
+
+**Trust required**: The collector trusts the data service to call `collect()` only with valid, legitimate payment requests. The collector validates payment terms but relies on the data service to verify service delivery.
+
+**Mitigation**: The collector validates its own domain (agreement existence, temporal bounds, amount caps) independently.
+
+> _RecurringCollector + SubgraphService_: the collector validates RCA terms; the data service verifies allocation status and emits POIs for dispute.
+
+## Who Can Block Collection?
+
+Which actors can prevent a collection from succeeding, and how:
+
+| Actor | Can block? | How (general model) |
+| ------------ | ---------- | ---------------------------------------------- |
+| Payer | Yes | Contract payer only, via `isEligible` |
+| Collector | Yes | Reject payment request based on its own rules |
+| Data service | Yes | Pause mechanism; code-level revert conditions |
+| Receiver | No | Can only initiate, not block |
+| Escrow | Yes | Insufficient balance; also protocol-wide pause |
+
+### Implementation-Specific Notes
+
+**ECDSA-signed agreements** (external payer): the payer is an EOA and has no on-chain blocking mechanism. The receiver's trust is bounded by the current escrow balance (minus any thawing amount).
+
+**RAM-managed agreements** (protocol payer): the payer (RAM) has no adversarial incentive to block. If an eligibility oracle is configured, blocking trust effectively transfers to the oracle (see [RecurringCollector Extensions](#recurringcollector-extensions)).
+
+## Trust Reduction Mechanisms
+
+| Mechanism | What it bounds | Actor protected | Scope |
+| --------------------------------------------------------------- | ------------------------------------------------------------------ | --------------- | ------------------------ |
+| Escrow deposit + thaw period | Payer can't instantly withdraw | Receiver | General |
+| Two-layer token capping | Neither data service nor collector alone sets amount | Payer | General |
+| Collector-enforced agreement terms | Per-collection exposure | Payer | General |
+| Cancellation still allows final collection | Receiver collects accrued amount | Receiver | General |
+| Dispute system + stake locking | Invalid POIs are challengeable | Payer / network | SubgraphService |
+| Eligibility oracle | Ineligible receivers denied | Payer | RecurringCollector + RAM |
+| `lastCollectionAt` advancing only through validated collections | No fake liveness signals (advances even on zero-token collections) | All | RecurringCollector |
+
+## Related Documents
+
+- [MaxSecondsPerCollectionCap.md](../packages/horizon/contracts/payments/collectors/MaxSecondsPerCollectionCap.md) — Two-layer capping semantics
+- [RecurringAgreementManager.md](../packages/issuance/contracts/agreement/RecurringAgreementManager.md) — RAM escrow management
+- [RewardsEligibilityOracle.md](../packages/issuance/contracts/eligibility/RewardsEligibilityOracle.md) — Oracle trust model and failsafe
+- [RewardAccountingSafety.md](./RewardAccountingSafety.md) — Reward accounting invariants
+- [RewardConditions.md](./RewardConditions.md) — Reclaim conditions
diff --git a/docs/RewardAccountingSafety.md b/docs/RewardAccountingSafety.md
new file mode 100644
index 000000000..23270a345
--- /dev/null
+++ b/docs/RewardAccountingSafety.md
@@ -0,0 +1,177 @@
+# Reward Accounting Safety
+
+This document describes the mechanisms that prevent reward mis-accounting (double-counting or unintentional loss).
+
+## Two-Level Accumulation Model
+
+Rewards flow through two levels before reaching allocations:
+
+```
+Global Issuance
+ │
+ ▼ (proportional to signal)
+┌──────────────────────────────────────────────┐
+│ Level 1: Signal → Subgraph │
+│ accRewardsPerSignal → accRewardsForSubgraph │
+└──────────────────────────────────────────────┘
+ │
+ ▼ (proportional to allocated tokens)
+┌─────────────────────────────────────────┐
+│ Level 2: Subgraph → Allocation │
+│ accRewardsPerAllocatedToken → claim │
+└─────────────────────────────────────────┘
+```
+
+Each level uses the same pattern: an accumulator increases over time, and participants snapshot their starting point to calculate their share.
+
+## Core Safety Mechanism: Snapshots
+
+**Principle**: Rewards = (current_accumulator - snapshot) × tokens
+
+Snapshots prevent double-counting by recording each participant's starting point:
+
+| Component | Accumulator | Snapshot | Prevents |
+| ---------- | ----------------------------- | ----------------------------- | ----------------------------------------------- |
+| Subgraph | `accRewardsPerSignal` | `accRewardsPerSignalSnapshot` | Same subgraph counting same reward period twice |
+| Allocation | `accRewardsPerAllocatedToken` | Stored in allocation state | Same allocation claiming same rewards twice |
+
+After any update, snapshot = current accumulator. Next calculation starts from zero delta.
+
+## Key Invariants
+
+### 1. Monotonic Accumulators
+
+All accumulators only increase (never decrease):
+
+| Accumulator | Behavior When Not Claimable |
+| ----------------------------- | ----------------------------------------------------------- |
+| `accRewardsPerSignal` | Always increases |
+| `accRewardsForSubgraph` | Stops increasing (rewards reclaimed, not accumulated) |
+| `accRewardsPerAllocatedToken` | Stops increasing (rewards reclaimed instead of distributed) |
+
+When a subgraph is not claimable (denied or below minimum signal), rewards are reclaimed directly without updating `accRewardsForSubgraph`. This means `accRewardsForSubgraph` only tracks rewards that are actually distributed to allocations.
+
+**Why it matters**: Decreasing accumulators would cause negative reward calculations or allow re-claiming past rewards.
+
+### 2. Snapshot Consistency
+
+After every state update, snapshot equals current accumulator value.
+
+**Why it matters**: Stale snapshots would allow the same reward period to be counted multiple times.
+
+### 3. Update-Before-Change
+
+Accumulators must be updated BEFORE any state change that affects reward distribution:
+
+- Before `issuancePerBlock` changes → call `updateAccRewardsPerSignal()`
+- Before signal changes → call `onSubgraphSignalUpdate()`
+- Before allocation changes → call `onSubgraphAllocationUpdate()`
+
+**Why it matters**: Changing distribution parameters without first crediting accrued rewards would lose or misattribute those rewards.
+
+## Critical Call Ordering
+
+### Allocation Creation
+
+```solidity
+// In AllocationManager._allocate():
+_allocationData = _getAllocationData(_subgraphDeploymentId); // ① Calls onSubgraphAllocationUpdate
+_allocations.create(...); // ② Creates allocation
+_allocations.snapshotRewards(..., onSubgraphAllocationUpdate()); // ③ Updates snapshot
+```
+
+**Why this order matters**:
+
+- Step ① with zero allocations → triggers NO_ALLOCATED_TOKENS reclaim for gap period
+- Step ② creates allocation → now allocatedTokens > 0
+- Step ③ same block → newRewards ≈ 0, just confirms snapshot
+
+**If reversed**: Gap-period rewards would be distributed to accumulator but no allocation could claim them (all snapshots would be at/above post-distribution level).
+
+### Reward Claiming
+
+```solidity
+// In AllocationManager._presentPoi():
+rewards = takeRewards(_allocationId); // ① Mints rewards
+snapshotRewards(_allocationId, onSubgraphAllocationUpdate(...)); // ② Updates snapshot
+clearPendingRewards(_allocationId); // ③ Clears pending
+```
+
+**Why this order matters**:
+
+- Step ① calculates and mints based on current snapshot
+- Step ② updates snapshot to current accumulator
+- Future claims start from new snapshot (zero delta for same block)
+
+## Reclaim as Safety Net
+
+Every reward path that cannot reach an allocation has a reclaim handler:
+
+| Condition | When Triggered | Reclaim Reason |
+| -------------------- | ------------------------------------------------------------ | ------------------------ |
+| No global signal | `updateAccRewardsPerSignal()` with signalledTokens = 0 | `NO_SIGNAL` |
+| Subgraph denied | `onSubgraphSignalUpdate()` or `onSubgraphAllocationUpdate()` | `SUBGRAPH_DENIED` |
+| Below minimum signal | `onSubgraphSignalUpdate()` or `onSubgraphAllocationUpdate()` | `BELOW_MINIMUM_SIGNAL` |
+| No allocations | `onSubgraphSignalUpdate()` or `onSubgraphAllocationUpdate()` | `NO_ALLOCATED_TOKENS` |
+| Indexer ineligible | `takeRewards()` | `INDEXER_INELIGIBLE` |
+| Stale/zero POI | `_presentPoi()` | `STALE_POI` / `ZERO_POI` |
+| Allocation close | `_closeAllocation()` | `CLOSE_ALLOCATION` |
+
+**Reclaim priority**: reason-specific address → defaultReclaimAddress → dropped (no mint)
+
+## Potential Failure Modes (Mitigated)
+
+| Failure Mode | How Prevented |
+| ---------------------------- | ------------------------------------------------------------------------------------ |
+| Double-mint same rewards | Snapshot updated after every claim; same-block calls return ~0 |
+| Rewards stuck in accumulator | NO_ALLOCATED_TOKENS reclaim before allocation creation |
+| Gap period loss | `_getAllocationData` calls `onSubgraphAllocationUpdate` before allocation exists |
+| Denial-period accumulation | `accRewardsForSubgraph` tracks; `accRewardsPerAllocatedToken` frozen; diff reclaimed |
+| Signal change mid-period | `onSubgraphSignalUpdate` hook called before signal changes |
+
+## Division of Responsibility
+
+RewardsManager and issuers share responsibility for correct reward accounting:
+
+**RewardsManager** handles what it can observe:
+
+- Reclaims rewards when subgraph conditions prevent distribution (denied, below minimum, zero allocations)
+- Denies rewards at claim time when indexer is ineligible
+- Maintains accumulator and snapshot state
+
+**Issuers** control claim timing and can defer:
+
+- AllocationManager defers claims for `SUBGRAPH_DENIED` and `ALLOCATION_TOO_YOUNG` by returning early
+- This preserves allocation state so rewards remain claimable after conditions change
+- RM cannot know issuer intent, so issuers must decide when to attempt claims
+
+**Example - Subgraph Denial** (see [RewardConditions.md](./RewardConditions.md#subgraph_denied) for full details):
+
+- RM: Reclaims new rewards; freezes `accRewardsPerAllocatedToken`
+- AM: Defers claim; preserves uncollected rewards (no snapshot update)
+- After undeny: AM can claim preserved uncollected rewards
+
+## Issuer Requirements
+
+RewardsManager relies on issuers to maintain shared state correctly.
+
+**Required hook**:
+
+| Hook | When to Call |
+| ---------------------------- | ------------------------- |
+| `onSubgraphAllocationUpdate` | Before allocation changes |
+
+Note: If the issuer collects curation fees (`curation.collect()`), it must also call `onSubgraphSignalUpdate` before the collect since that changes signal. SubgraphService does this in `_collectQueryFees`.
+
+**Allocation snapshot management**:
+
+Allocation snapshots are stored in issuer contracts, not RewardsManager. After each `takeRewards()` or `reclaimRewards()` call, issuers must update the allocation's snapshot to the current `accRewardsPerAllocatedToken`. Failure to snapshot allows the same rewards to be claimed again.
+
+**Authorized issuers**: SubgraphService (active), Staking (deprecated, legacy allocations only)
+
+## Other Hook Callers
+
+| Hook | Caller | Trigger |
+| --------------------------- | ------------------------- | ---------------------------------------------- |
+| `updateAccRewardsPerSignal` | RewardsManager (internal) | Before `issuancePerBlock` or allocator changes |
+| `onSubgraphSignalUpdate` | Curation | Before mint/burn signal |
diff --git a/docs/RewardConditions.md b/docs/RewardConditions.md
new file mode 100644
index 000000000..6ccc891df
--- /dev/null
+++ b/docs/RewardConditions.md
@@ -0,0 +1,249 @@
+# Reward Conditions: Collection and Reclaim Reference
+
+Quick reference for all reward conditions and how they are handled across RewardsManager and AllocationManager.
+
+## Summary Table
+
+| Condition | Identifier | Handled By | Action | Rewards Outcome |
+| ---------------------- | ----------------------------------- | ----------------- | ------------------------- | -------------------------------------- |
+| `NONE` | `bytes32(0)` | — | Normal path | Claimed by indexer |
+| `NO_SIGNAL` | `keccak256("NO_SIGNAL")` | RewardsManager | Reclaim | To reclaim address |
+| `SUBGRAPH_DENIED` | `keccak256("SUBGRAPH_DENIED")` | Both | Reclaim (RM) / Defer (AM) | New: reclaimed; Uncollected: preserved |
+| `BELOW_MINIMUM_SIGNAL` | `keccak256("BELOW_MINIMUM_SIGNAL")` | RewardsManager | Reclaim | To reclaim address |
+| `NO_ALLOCATED_TOKENS` | `keccak256("NO_ALLOCATED_TOKENS")` | RewardsManager | Reclaim | To reclaim address |
+| `INDEXER_INELIGIBLE` | `keccak256("INDEXER_INELIGIBLE")` | RewardsManager | Reclaim | To reclaim address |
+| `STALE_POI` | `keccak256("STALE_POI")` | AllocationManager | Reclaim | To reclaim address |
+| `ZERO_POI` | `keccak256("ZERO_POI")` | AllocationManager | Reclaim | To reclaim address |
+| `ALLOCATION_TOO_YOUNG` | `keccak256("ALLOCATION_TOO_YOUNG")` | AllocationManager | Defer | Preserved for later |
+| `CLOSE_ALLOCATION` | `keccak256("CLOSE_ALLOCATION")` | AllocationManager | Reclaim | To reclaim address |
+
+## Reward Distribution Levels
+
+Rewards flow through three levels, with reclaim possible at each:
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ Level 0: Global Issuance │
+│ ───────────────────────────────────────────────────────────────── │
+│ updateAccRewardsPerSignal() │
+│ │
+│ Reclaim: NO_SIGNAL (when total signalled tokens = 0) │
+└─────────────────────────────────────────────────────────────────────┘
+ │
+ ▼ proportional to signal
+┌─────────────────────────────────────────────────────────────────────┐
+│ Level 1: Subgraph │
+│ ───────────────────────────────────────────────────────────────── │
+│ onSubgraphSignalUpdate() / onSubgraphAllocationUpdate() │
+│ │
+│ Reclaim: SUBGRAPH_DENIED, BELOW_MINIMUM_SIGNAL, NO_ALLOCATED_TOKENS │
+│ │
+│ Behavior: │
+│ - accRewardsForSubgraph only increases when claimable │
+│ - accRewardsPerAllocatedToken only increases when claimable │
+│ - Non-claimable rewards are reclaimed immediately, not stored │
+└─────────────────────────────────────────────────────────────────────┘
+ │
+ ▼ proportional to allocated tokens
+┌─────────────────────────────────────────────────────────────────────┐
+│ Level 2: Allocation │
+│ ───────────────────────────────────────────────────────────────── │
+│ takeRewards() / reclaimRewards() / _presentPoi() │
+│ │
+│ Reclaim: INDEXER_INELIGIBLE (at takeRewards) │
+│ STALE_POI, ZERO_POI, CLOSE_ALLOCATION (at _presentPoi) │
+│ │
+│ Defer: SUBGRAPH_DENIED, ALLOCATION_TOO_YOUNG (preserves state) │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+## Condition Details
+
+### Global Level (RewardsManager.updateAccRewardsPerSignal)
+
+#### NO_SIGNAL
+
+- **Trigger**: Total signalled tokens across all subgraphs = 0
+- **Effect**: Issuance cannot be distributed proportionally to signal
+- **Handling**: Reclaim to configured address (or drop if unconfigured)
+
+### Subgraph Level (RewardsManager.onSubgraphAllocationUpdate)
+
+#### SUBGRAPH_DENIED
+
+- **Trigger**: `isDenied(subgraphDeploymentId)` returns true
+- **Effect**: `accRewardsPerAllocatedToken` stops increasing
+- **Handling**: New rewards reclaimed; accumulator frozen (uncollected rewards preserved)
+- **Note**: If no SUBGRAPH_DENIED reclaim address AND signal < minimum, reclaims as BELOW_MINIMUM_SIGNAL instead
+
+**Reward disposition by period:**
+
+| Period | Disposition |
+| ------------- | -------------------------------------------------------- |
+| Before denial | Claimable after undeny |
+| During denial | Reclaimed to protocol (or dropped if no reclaim address) |
+| Post-undeny | Claimable normally |
+
+**Effect on allocations:**
+
+- _Existing allocations_: Uncollected rewards preserved (accumulator frozen, snapshot unchanged); cannot claim while denied; claimable after undeny
+- _New allocations (created while denied)_: Start with frozen baseline; only earn rewards after undeny
+- _POI presentation_: Indexers should continue presenting POIs to prevent staleness (returns 0 but maintains allocation health)
+
+**Edge cases:**
+
+| Scenario | Behavior |
+| ---------------------------------- | --------------------------------------------------------- |
+| All allocations close while denied | Frozen state preserved; new allocations use that baseline |
+| Redundant deny/undeny calls | No state change (idempotent) |
+| Zero reclaim address | Denial-period rewards dropped (never minted) |
+
+#### BELOW_MINIMUM_SIGNAL
+
+- **Trigger**: Subgraph signal < `minimumSubgraphSignal` (and not denied)
+- **Effect**: `accRewardsPerAllocatedToken` stops increasing
+- **Handling**: Rewards reclaimed to configured address
+
+#### NO_ALLOCATED_TOKENS
+
+- **Trigger**: Subgraph has signal but zero allocated tokens
+- **Effect**: Rewards cannot be distributed to allocations
+- **Handling**: Reclaim to configured address
+- **Note**: Triggered when condition is NONE but no allocations exist, or when original condition has no reclaim address
+
+### Allocation Level (RewardsManager.takeRewards)
+
+#### INDEXER_INELIGIBLE
+
+- **Trigger**: `eligibilityOracle.isEligible(indexer)` returns false at claim time
+- **Effect**: Indexer cannot claim earned rewards
+- **Handling**: Rewards reclaimed to configured address
+- **Precedence**: SUBGRAPH_DENIED takes precedence if both apply
+
+### Allocation Level (AllocationManager.\_presentPoi)
+
+Conditions checked in order (first match wins):
+
+#### STALE_POI
+
+- **Trigger**: `maxPOIStaleness` < Time since last POI
+- **Effect**: Allocation locked out due to inactivity
+- **Handling**: Rewards reclaimed; allocation snapshotted; pending cleared
+
+#### ZERO_POI
+
+- **Trigger**: POI submitted is `bytes32(0)`
+- **Effect**: No proof of indexing work provided
+- **Handling**: Rewards reclaimed; allocation snapshotted; pending cleared
+
+#### ALLOCATION_TOO_YOUNG
+
+- **Trigger**: `currentEpoch <= allocation.createdAtEpoch`
+- **Effect**: Allocation hasn't existed for a full epoch
+- **Handling**: **Deferred** (returns 0, no snapshot update, rewards preserved)
+
+#### SUBGRAPH_DENIED (soft deny)
+
+- **Trigger**: `isDenied(subgraphDeploymentId)` at POI presentation
+- **Effect**: Cannot claim while denied
+- **Handling**: **Deferred** (returns 0, no snapshot update, uncollected rewards preserved)
+
+#### CLOSE_ALLOCATION
+
+- **Trigger**: Allocation being closed (force or normal)
+- **Effect**: Uncollected rewards cannot go to indexer
+- **Handling**: Rewards reclaimed; allocation snapshotted
+
+## Action Types
+
+### Reclaim
+
+Rewards are minted to a configured reclaim address:
+
+1. Try reason-specific: `reclaimAddresses[condition]`
+2. Fallback: `defaultReclaimAddress`
+3. If neither configured: rewards dropped (not minted)
+
+Emits `RewardsReclaimed(reason, rewards, indexer, allocationId, subgraphDeploymentId)`
+
+### Defer
+
+Rewards are preserved for later collection:
+
+- Returns 0 without modifying allocation state
+- No snapshot update (preserves claim position)
+- Allows claiming when condition clears
+
+### Claim (Normal)
+
+Rewards minted to rewards issuer for distribution:
+
+- Emits `HorizonRewardsAssigned`
+- Allocation snapshotted to prevent double-claim
+- Pending rewards cleared
+
+## Reclaim Address Configuration
+
+```solidity
+// Governor-only functions
+setReclaimAddress(bytes32 reason, address newAddress) // Per-condition
+setDefaultReclaimAddress(address newAddress) // Fallback
+
+// Example configuration
+reclaimAddresses[SUBGRAPH_DENIED] = treasuryAddress;
+reclaimAddresses[INDEXER_INELIGIBLE] = treasuryAddress;
+reclaimAddresses[NO_SIGNAL] = treasuryAddress;
+defaultReclaimAddress = treasuryAddress; // Catch-all
+```
+
+**Important**: Changes apply retroactively to all future reclaims.
+
+## Parameter Changes: minimumSubgraphSignal
+
+### Retroactive Application Risk
+
+When `minimumSubgraphSignal` is changed via `setMinimumSubgraphSignal()`, existing subgraphs are NOT automatically updated. When subgraphs are later updated (via signal/allocation changes), the **current** threshold is applied to ALL pending rewards since their last update, regardless of historical threshold values.
+
+**Impact:**
+
+| Change Direction | Effect |
+| ------------------- | ------------------------------------------------------------------------ |
+| Threshold increases | Pending rewards on previously eligible subgraphs are reclaimed |
+| Threshold decreases | Previously ineligible subgraphs retroactively accumulate pending rewards |
+
+### Required Mitigation Process
+
+To prevent retroactive application to long historical periods:
+
+1. **Communicate** the planned threshold change with a specific future date
+2. **Wait** - notice period allows participants to adjust signal if desired
+3. **Identify** affected subgraphs off-chain (those crossing the threshold)
+4. **Call** `onSubgraphSignalUpdate()` for all affected subgraphs to accumulate pending rewards under current eligibility rules
+5. **Execute** threshold change via `setMinimumSubgraphSignal()` (promptly after step 4, ideally same block)
+
+**Responsibility:** Governance handles steps 3-5; participants may optionally adjust signal in step 2.
+
+For implementation details, see NatSpec documentation on `RewardsManager.setMinimumSubgraphSignal()`.
+
+## Key Behaviors
+
+### Snapshot Updates
+
+| Action | Updates Snapshot | Clears Pending |
+| ------------ | ---------------- | -------------- |
+| Claim (NONE) | Yes | Yes |
+| Reclaim | Yes | Yes |
+| Defer | No | No |
+
+### Accumulator Behavior When Not Claimable
+
+| Field | Behavior |
+| ----------------------------- | ---------------------------------------------- |
+| `accRewardsForSubgraph` | Does NOT increase (rewards reclaimed directly) |
+| `accRewardsPerAllocatedToken` | Does NOT increase (rewards not distributed) |
+| New rewards | Reclaimed immediately to configured address |
+| Pre-existing stored rewards | Still shown as distributable in view functions |
+
+## Related Documentation
+
+- [RewardAccountingSafety.md](./RewardAccountingSafety.md) - Safety mechanisms and invariants
diff --git a/docs/RewardsBehaviourChanges.md b/docs/RewardsBehaviourChanges.md
new file mode 100644
index 000000000..63c17c4c2
--- /dev/null
+++ b/docs/RewardsBehaviourChanges.md
@@ -0,0 +1,175 @@
+# Rewards Behaviour Changes
+
+Functional summary of how reward behaviour changed between the Horizon mainnet baseline and the current issuance upgrade.
+
+## Activation Overview
+
+Changes fall into two categories:
+
+- **Automatic on upgrade:** New logic that activates immediately when the upgraded contracts are deployed behind their proxies. No governance action required. These include: zero-signal detection, zero-allocated-tokens reclaim, POI presentation paths (claim/reclaim/defer), allocation resize staleness check, allocation close reclaim, and the `POIPresented` event.
+
+- **Governance-gated:** Features that require explicit governance transactions after upgrade. Until configured, the system preserves legacy behaviour (rewards are dropped, not reclaimed). These include: setting the issuance allocator, configuring reclaim addresses (per-condition and default), setting the eligibility oracle, and changing the minimum subgraph signal threshold.
+
+This two-phase approach allows a safe upgrade with the new infrastructure in place, while governance coordinates separate activation steps for each optional feature.
+
+## Issuance Rate
+
+**Before:** A single `issuancePerBlock` storage variable, set by governance via `setIssuancePerBlock()`, determined all reward issuance.
+
+**After:** An optional `issuanceAllocator` contract can be set by governance. When set, the effective issuance rate comes from the allocator (which can distribute issuance across multiple targets). When unset, the legacy `issuancePerBlock` value is used as a fallback. The allocator calls `beforeIssuanceAllocationChange()` on the RewardsManager before changing rates, ensuring accumulators are snapshotted first.
+
+**Activates:** Governance-gated — requires `setIssuanceAllocator()`. Until called, the legacy `issuancePerBlock` value continues to apply.
+
+## Reward Conditions
+
+A new `RewardsCondition` library defines typed `bytes32` identifiers for every situation where rewards cannot be distributed normally:
+
+| Condition | Trigger |
+| ---------------------- | ---------------------------------------------------- |
+| `NO_SIGNAL` | Zero total curation signal globally |
+| `SUBGRAPH_DENIED` | Subgraph is on the denylist |
+| `BELOW_MINIMUM_SIGNAL` | Subgraph signal below `minimumSubgraphSignal` |
+| `NO_ALLOCATED_TOKENS` | Subgraph has signal but zero allocated tokens |
+| `INDEXER_INELIGIBLE` | Indexer fails eligibility oracle check at claim time |
+| `STALE_POI` | POI presented after staleness deadline |
+| `ZERO_POI` | POI is `bytes32(0)` |
+| `ALLOCATION_TOO_YOUNG` | Allocation created in the current epoch |
+| `CLOSE_ALLOCATION` | Allocation being closed with uncollected rewards |
+
+**Activates:** Automatic on upgrade — the library and all condition checks are available immediately once the upgraded contracts are deployed.
+
+## Reclaim System
+
+**Before:** When rewards could not be distributed (denied subgraph, below-signal subgraph, stale POI, etc.), the tokens were silently lost -- never minted to anyone.
+
+**After:** Undistributable rewards are _reclaimed_ by minting them to a configurable address. Governance can set a per-condition address via `setReclaimAddress(condition, address)` and a catch-all fallback via `setDefaultReclaimAddress(address)`. If neither is configured for a given condition, rewards are still not minted (preserving the old drop behaviour). Every reclaim emits a `RewardsReclaimed` event with the condition, amount, indexer, allocation, and subgraph.
+
+**Activates:** Governance-gated — requires `setReclaimAddress()` and/or `setDefaultReclaimAddress()` for each condition. Until configured, rewards are dropped (preserving legacy behaviour).
+
+## Zero Global Signal
+
+**Before:** Issuance during periods with zero total curation signal was silently lost.
+
+**After:** Detected in `updateAccRewardsPerSignal()` and reclaimed as `NO_SIGNAL`.
+
+**Activates:** Automatic on upgrade — detection is built into the accumulator update. Reclaim requires a configured address for `NO_SIGNAL`.
+
+## Subgraph-Level Denial
+
+**Before:** Denial was a binary gate checked only at `takeRewards()` time. When a subgraph was denied, `takeRewards()` returned 0 and emitted `RewardsDenied`. The calling AllocationManager still advanced the allocation's reward snapshot, permanently dropping those rewards.
+
+**After:** Denial is handled at two levels:
+
+- **RewardsManager (accumulator level):** When `onSubgraphSignalUpdate` or `onSubgraphAllocationUpdate` is called for a denied subgraph, `accRewardsForSubgraph` and `accRewardsPerAllocatedToken` freeze (stop increasing). New rewards accruing during the denial period are reclaimed immediately rather than accumulated. `setDenied()` now snapshots accumulators before changing denial state so the boundary is clean.
+
+- **AllocationManager (claim level):** POI presentation for a denied subgraph is _deferred_ -- returns 0 **without advancing the allocation's snapshot**. This preserves uncollected pre-denial rewards. When the subgraph is later un-denied, those preserved rewards become claimable again.
+
+**Activates:** Automatic on upgrade — the accumulator-level freeze and claim-level deferral apply immediately. Denial state itself is set via `setDenied()` (Governor or SubgraphAvailabilityOracle).
+
+## Below-Minimum Signal
+
+**Before:** `getAccRewardsForSubgraph()` silently excluded rewards for subgraphs below `minimumSubgraphSignal`. Those rewards were lost.
+
+**After:** The same exclusion occurs, but excluded rewards are reclaimed to the `BELOW_MINIMUM_SIGNAL` address instead of being lost. Changes to `minimumSubgraphSignal` apply retroactively to all pending rewards at the next accumulator update, so governance should call `onSubgraphSignalUpdate()` on affected subgraphs before changing the threshold.
+
+**Activates:** Automatic on upgrade for the reclaim path. Threshold changes via `setMinimumSubgraphSignal()` are retroactive — governance should call `onSubgraphSignalUpdate()` on affected subgraphs before changing the threshold.
+
+## Zero Allocated Tokens
+
+**Before:** When a subgraph had signal but no allocations, `getAccRewardsPerAllocatedToken()` returned 0 for per-token rewards. The subgraph-level accumulator still grew, but the rewards were stranded -- distributable to no one.
+
+**After:** Detected as `NO_ALLOCATED_TOKENS` and reclaimed. When allocations resume, `accRewardsPerAllocatedToken` resumes from its stored value rather than resetting to zero.
+
+**Activates:** Automatic on upgrade — detection is built into the accumulator update.
+
+## Indexer Eligibility
+
+**Before:** No per-indexer eligibility checks existed.
+
+**After:** An optional `rewardsEligibilityOracle` can be set by governance. When set, `takeRewards()` checks `isEligible(indexer)` at claim time. If the indexer is ineligible, rewards are denied (emitting `RewardsDeniedDueToEligibility`) and reclaimed to the `INDEXER_INELIGIBLE` address. Subgraph denial takes precedence: if a subgraph is denied, eligibility is not checked.
+
+**Activates:** Governance-gated — requires `setRewardsEligibilityOracle()`. Until called, no eligibility checks are performed.
+
+## POI Presentation (AllocationManager)
+
+**Before:** A single conditional expression decided whether `takeRewards()` was called. If any condition failed (stale, zero POI, too young, altruistic), rewards were set to 0. The allocation's reward snapshot always advanced and pending rewards were always cleared, permanently dropping any undistributable rewards.
+
+**After:** Three distinct paths based on the determined condition:
+
+1. **Claim** (`NONE`): `takeRewards()` mints tokens, distributed to indexer and delegators. Snapshot advances.
+2. **Reclaim** (`STALE_POI`, `ZERO_POI`): `reclaimRewards()` mints tokens to the reclaim address. Snapshot advances and pending rewards are cleared.
+3. **Defer** (`ALLOCATION_TOO_YOUNG`, `SUBGRAPH_DENIED`): Returns 0 **without advancing the snapshot or clearing pending rewards**. Rewards are preserved for later collection. Accumulators are still updated via `onSubgraphAllocationUpdate()` to keep reclaim tracking current.
+
+The POI presentation timestamp is now recorded immediately on entry (before condition evaluation), so the staleness clock resets regardless of reward outcome. Over-delegation force-close is skipped on the deferred path to avoid closing allocations with preserved uncollected rewards.
+
+**Activates:** Automatic on upgrade — the three-path logic applies to all POI presentations immediately.
+
+## Allocation Resize
+
+**Before:** Resizing always accumulated pending rewards for the delta period, regardless of allocation staleness.
+
+**After:** If the allocation is stale at resize time, pending rewards are reclaimed as `STALE_POI` and cleared. This prevents stale allocations from silently accumulating pending rewards through repeated resizes.
+
+**Activates:** Automatic on upgrade — applies to all resize operations immediately.
+
+## Allocation Close
+
+**Before:** Closing an allocation advanced the snapshot and closed it. Any uncollected rewards were permanently lost.
+
+**After:** Before closing, `reclaimRewards(CLOSE_ALLOCATION, allocationId)` is called to mint uncollected rewards to the reclaim address.
+
+**Activates:** Automatic on upgrade — applies to all close operations immediately.
+
+## Observability
+
+A new `POIPresented` event is emitted on every POI presentation, including the determined `condition` as a `bytes32` field. This provides off-chain visibility into why a given presentation did or did not result in rewards, which was previously invisible.
+
+**Activates:** Automatic on upgrade — emitted on every POI presentation immediately.
+
+## View Functions
+
+Several view functions were added or changed to expose the new reward state.
+
+### Accumulator Views Freeze for Non-Claimable Subgraphs
+
+The existing accumulator view functions now exclude rewards for subgraphs that are not claimable (denied, below minimum signal, or with zero allocated tokens). Previously these accumulators always grew; callers reading them as continuously-increasing counters need to account for the new freeze behaviour.
+
+**`getAccRewardsForSubgraph()`** — Previously always returned a growing value regardless of subgraph state. Now returns a frozen value when the subgraph is not claimable: the internal helper `_getSubgraphRewardsState()` determines a `RewardsCondition`, and when the condition is anything other than `NONE`, new rewards are excluded from the returned total. The accumulator resumes growing when the subgraph becomes claimable again.
+
+**`getAccRewardsPerAllocatedToken()`** — Derives from `getAccRewardsForSubgraph()`, so it inherits the freeze. When the subgraph is not claimable, new per-token rewards are zero because the subgraph-level delta is zero. At snapshot points the implementation zeroes `undistributedRewards` and reclaims them instead of adding them to `accRewardsPerAllocatedToken`.
+
+**`getRewards()`** — Returns the claimable reward estimate for an allocation. Because it reads `getAccRewardsPerAllocatedToken()`, it now returns a frozen value for allocations on non-claimable subgraphs. Pre-existing `accRewardsPending` from prior resizes is still included. Note: indexer eligibility is _not_ checked here (only at `takeRewards()` time), so the view does not reflect eligibility-based denial.
+
+**`getNewRewardsPerSignal()`** — No visible change in return value. Internally it now separates claimable from unclaimable issuance (zero-signal periods), but the public view still returns only the claimable portion. The unclaimable portion is reclaimed as `NO_SIGNAL` at the next `updateAccRewardsPerSignal()` call.
+
+### New Getters on IRewardsManager
+
+| Function | Returns | Purpose |
+| ----------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `getIssuanceAllocator()` | `IIssuanceAllocationDistribution` | Current allocator contract (zero if unset) |
+| `getReclaimAddress(bytes32 reason)` | `address` | Per-condition reclaim address (zero if unconfigured) |
+| `getDefaultReclaimAddress()` | `address` | Fallback reclaim address |
+| `getRewardsEligibilityOracle()` | `IRewardsEligibility` | Current eligibility oracle (zero if unset) |
+| `getAllocatedIssuancePerBlock()` | `uint256` | Effective issuance rate — returns the allocator rate when set, otherwise falls back to storage. Replaces the legacy `getRewardsIssuancePerBlock()` for callers that need the protocol rate |
+| `getRawIssuancePerBlock()` | `uint256` | Raw storage value, ignoring the allocator. Useful for debugging allocator configuration |
+
+### Changed Return Semantics
+
+**`getAllocationData()`** (IRewardsIssuer, implemented by SubgraphService) now returns a sixth value, `accRewardsPending`, representing accumulated rewards from allocation resizing that have not yet been claimed. Callers that destructure the return tuple need updating.
+
+**`IAllocation.State`** struct adds two fields: `accRewardsPending` (pending rewards from resize) and `createdAtEpoch` (epoch when the allocation was created). Both affect the return value of `getAllocation()`.
+
+## Provenance
+
+Merge commits into `main` that introduced the changes described above, in chronological order.
+
+| Date | Merge | PR | Scope |
+| ---------- | ----------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 2025-12-16 | `ff2f00a62` | #1265 | Eligibility oracle audit doc fixes (TRST-L-1, TRST-L-2) |
+| 2025-12-16 | `48be37a20` | #1267 | Issuance allocator audit fix — default allocation, `setReclaimAddress` |
+| 2025-12-31 | `89f1321c4` | #1272 | Issuance allocator audit fix v3 — forced reclaim, PPM-to-absolute migration |
+| 2026-01-08 | `3d274a4f1` | #1255 | Issuance baseline — RewardsManager extensions, eligibility interface, test suites |
+| 2026-01-08 | `363924149` | #1256 | Rewards Eligibility Oracle — full oracle implementation |
+| 2026-01-08 | `cdef9b5fd` | #1257 | Issuance Allocator — full allocator, RewardsReclaim library, allocation close reclaim |
+| 2026-02-17 | `ada315500` | #1279 | Rewards reclaiming (audited) — RewardsCondition rename, `setDefaultReclaimAddress`, subgraph denial accumulator handling, zero-signal reclaim, POI three-path logic, `POIPresented` event |
+| 2026-02-19 | `127b7ef6f` | #1280 | Issuance umbrella merge — all prior work plus stale-allocation-resize reclaim (TRST-R-1) |
diff --git a/docs/archive/CompilerUpgrade0833.md b/docs/archive/CompilerUpgrade0833.md
new file mode 100644
index 000000000..03a1773c3
--- /dev/null
+++ b/docs/archive/CompilerUpgrade0833.md
@@ -0,0 +1,151 @@
+# Compiler Upgrade: Solidity 0.8.33 + viaIR
+
+This document captures the bytecode size changes resulting from the compiler configuration upgrade in the `subgraph-service` and `issuance` packages.
+
+## Configuration Changes
+
+### subgraph-service
+
+| Setting | Before | After |
+| ---------------- | ------- | ------- |
+| Solidity Version | 0.8.27 | 0.8.33 |
+| EVM Version | paris | cancun |
+| Optimizer | enabled | enabled |
+| Optimizer Runs | 10 | 100 |
+| viaIR | false | true |
+
+### issuance
+
+| Setting | Before | After |
+| ---------------- | ------- | ------- |
+| Solidity Version | 0.8.27 | 0.8.33 |
+| EVM Version | cancun | cancun |
+| Optimizer | enabled | enabled |
+| Optimizer Runs | 100 | 100 |
+| viaIR | false | true |
+
+## Subgraph-Service Contract Bytecode Sizes
+
+All contracts defined in `packages/subgraph-service/contracts/`:
+
+| Contract | Source File | Before (KiB) | After (KiB) | Change (KiB) | Change (%) |
+| --------------------------- | ------------------------------------------------- | ------------ | ----------- | ------------ | ---------- |
+| **SubgraphService** | contracts/SubgraphService.sol | 24.455 | 23.110 | **-1.345** | -5.5% |
+| **DisputeManager** | contracts/DisputeManager.sol | 13.278 | 10.917 | **-2.361** | -17.8% |
+| Allocation | contracts/libraries/Allocation.sol | 0.084 | 0.056 | -0.028 | -33.3% |
+| Attestation | contracts/libraries/Attestation.sol | 0.084 | 0.056 | -0.028 | -33.3% |
+| LegacyAllocation | contracts/libraries/LegacyAllocation.sol | 0.084 | 0.056 | -0.028 | -33.3% |
+| SubgraphServiceV1Storage | contracts/SubgraphServiceStorage.sol | (abstract) | (abstract) | - | - |
+| DisputeManagerV1Storage | contracts/DisputeManagerStorage.sol | (abstract) | (abstract) | - | - |
+| AllocationManager | contracts/utilities/AllocationManager.sol | (abstract) | (abstract) | - | - |
+| AllocationManagerV1Storage | contracts/utilities/AllocationManagerStorage.sol | (abstract) | (abstract) | - | - |
+| AttestationManager | contracts/utilities/AttestationManager.sol | (abstract) | (abstract) | - | - |
+| AttestationManagerV1Storage | contracts/utilities/AttestationManagerStorage.sol | (abstract) | (abstract) | - | - |
+| Directory | contracts/utilities/Directory.sol | (abstract) | (abstract) | - | - |
+
+### Initcode Size (Subgraph-Service Contracts)
+
+| Contract | Before (KiB) | After (KiB) | Change (KiB) |
+| ------------------- | ------------ | ----------- | ------------ |
+| **SubgraphService** | 26.109 | 24.894 | **-1.215** |
+| **DisputeManager** | 14.649 | 12.342 | **-2.307** |
+
+## Issuance Contract Bytecode Sizes
+
+All contracts defined in `packages/issuance/contracts/`:
+
+| Contract | Source File | Before (KiB) | After (KiB) | Change (KiB) | Change (%) |
+| ---------------------------- | -------------------------------------------------- | ------------ | ----------- | ------------ | ---------- |
+| **IssuanceAllocator** | contracts/allocate/IssuanceAllocator.sol | 10.444 | 10.250 | **-0.194** | -1.9% |
+| **RewardsEligibilityOracle** | contracts/eligibility/RewardsEligibilityOracle.sol | 4.316 | 4.554 | +0.238 | +5.5% |
+| **DirectAllocation** | contracts/allocate/DirectAllocation.sol | 2.978 | 3.393 | +0.415 | +13.9% |
+| BaseUpgradeable | contracts/common/BaseUpgradeable.sol | (abstract) | (abstract) | - | - |
+
+### Initcode Size (Issuance Contracts)
+
+| Contract | Before (KiB) | After (KiB) | Change (KiB) |
+| ---------------------------- | ------------ | ----------- | ------------ |
+| **IssuanceAllocator** | 10.817 | 10.601 | **-0.216** |
+| **RewardsEligibilityOracle** | 4.666 | 4.881 | +0.215 |
+| **DirectAllocation** | 3.330 | 3.723 | +0.393 |
+
+### Test Contracts (Issuance)
+
+| Contract | Before (KiB) | After (KiB) | Change (KiB) |
+| ---------------------------- | ------------ | ----------- | ------------ |
+| IssuanceAllocatorTestHarness | 10.641 | 10.331 | -0.310 |
+| MockReentrantTarget | 1.886 | 1.535 | -0.351 |
+| MockNotificationTracker | 0.495 | 0.438 | -0.057 |
+| MockRevertingTarget | 0.342 | 0.250 | -0.092 |
+| MockSimpleTarget | 0.293 | 0.237 | -0.056 |
+| MockERC165 | 0.188 | 0.141 | -0.047 |
+
+## Dependency Library Sizes
+
+Libraries from horizon and other packages compiled as part of subgraph-service:
+
+### Horizon Libraries
+
+| Library | Before (KiB) | After (KiB) | Change (KiB) |
+| ---------------- | ------------ | ----------- | ------------ |
+| LinkedList | 0.084 | 0.056 | -0.028 |
+| TokenUtils | 0.084 | 0.056 | -0.028 |
+| UintRange | 0.084 | 0.056 | -0.028 |
+| MathUtils | 0.084 | 0.056 | -0.028 |
+| PPMMath | 0.084 | 0.056 | -0.028 |
+| ProvisionTracker | 0.084 | 0.056 | -0.028 |
+
+### OpenZeppelin Libraries
+
+| Library | Before (KiB) | After (KiB) | Change (KiB) |
+| ---------------- | ------------ | ----------- | ------------ |
+| Address | 0.084 | 0.056 | -0.028 |
+| Panic | 0.084 | 0.056 | -0.028 |
+| Strings | 0.084 | 0.056 | -0.028 |
+| Errors | 0.084 | 0.056 | -0.028 |
+| MessageHashUtils | 0.084 | 0.056 | -0.028 |
+| SafeCast | 0.084 | 0.056 | -0.028 |
+| ECDSA | 0.084 | 0.056 | -0.028 |
+| SignedMath | 0.084 | 0.056 | -0.028 |
+| Math | 0.084 | 0.056 | -0.028 |
+
+### Interfaces Package
+
+| Contract | Before (KiB) | After (KiB) | Change (KiB) |
+| ---------------- | ------------ | ----------- | ------------ |
+| RewardsCondition | 0.458 | 0.520 | +0.062 |
+
+## Key Observations
+
+### subgraph-service
+
+1. **SubgraphService now fits within mainnet limit**: The 24 KiB contract size limit was exceeded before (24.455 KiB). After the upgrade, it's safely under at 23.110 KiB.
+
+2. **Significant savings on main contracts**: Despite increasing optimizer runs from 10 to 100 (which typically increases size for runtime gas savings), the viaIR pipeline produced smaller bytecode:
+ - SubgraphService: -1.345 KiB (-5.5%)
+ - DisputeManager: -2.361 KiB (-17.8%)
+
+3. **Abstract contracts have no bytecode**: Storage contracts (e.g., `SubgraphServiceV1Storage`), utility contracts (`AllocationManager`, `AttestationManager`, `Directory`) are inherited by deployable contracts and have no standalone bytecode.
+
+4. **Library stub sizes reduced**: All library stubs decreased from 0.084 KiB to 0.056 KiB (-33%), indicating more efficient metadata encoding.
+
+### issuance
+
+1. **IssuanceAllocator reduced**: The main contract decreased slightly (-0.194 KiB, -1.9%) with viaIR enabled.
+
+2. **Smaller contracts increased**: DirectAllocation (+13.9%) and RewardsEligibilityOracle (+5.5%) increased in size. This is expected behavior as viaIR optimizations are more effective on larger contracts with complex inheritance patterns.
+
+3. **Test contracts all decreased**: All mock/test contracts benefited from viaIR, showing -5% to -19% reductions.
+
+## Why viaIR Reduces Size
+
+The viaIR (Intermediate Representation) compilation pipeline:
+
+- Uses Yul as an intermediate language
+- Enables more aggressive cross-function optimizations
+- Removes redundant code paths more effectively
+- Particularly beneficial for large contracts with complex inheritance
+
+## Date
+
+Comparison performed: 2026-01-25
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 7931af7d0..8613cad3b 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -219,7 +219,7 @@ const eslintConfig = [
// Add Mocha globals for test files
{
- files: ['**/*.test.ts', '**/*.test.js', '**/test/**/*.ts', '**/test/**/*.js'],
+ files: ['**/*.test.ts', '**/*.test.js', '**/test*/**/*.ts', '**/test*/**/*.js'],
languageOptions: {
globals: {
...globals.mocha,
diff --git a/justfile b/justfile
new file mode 100644
index 000000000..8c3362811
--- /dev/null
+++ b/justfile
@@ -0,0 +1,8 @@
+default:
+ @just --list
+
+# Build the workspace image locally as `ghcr.io/graphprotocol/contracts:local`.
+# Override the tag with `CONTRACTS_TAG=foo just build-image`. Consumed by
+# local-network's graph-contracts wrapper when CONTRACTS_VERSION=local.
+build-image:
+ docker compose build
diff --git a/package.json b/package.json
index 62f07a03f..c66f0334f 100644
--- a/package.json
+++ b/package.json
@@ -5,22 +5,27 @@
"license": "GPL-2.0-or-later",
"repository": "git@github.com:graphprotocol/contracts.git",
"author": "Edge & Node",
- "packageManager": "pnpm@10.17.0",
+ "packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",
+ "engines": {
+ "node": "^24",
+ "pnpm": "^10.28"
+ },
"scripts": {
"postinstall": "husky",
"clean": "pnpm -r run clean",
"clean:all": "pnpm clean && rm -rf node_modules packages/*/node_modules packages/*/*/node_modules",
"build": "pnpm -r run build:self",
"todo": "node scripts/check-todos.mjs",
- "lint": "pnpm lint:ts; pnpm lint:sol; pnpm lint:md; pnpm lint:json; pnpm lint:yaml",
+ "lint": "pnpm lint:ts; pnpm lint:sol; pnpm lint:forge; pnpm lint:md; pnpm lint:json; pnpm lint:yaml",
"lint:staged": "lint-staged; pnpm todo",
- "lint:ts": "eslint --fix --cache '**/*.{js,ts,cjs,mjs,jsx,tsx}'; prettier -w --cache --log-level warn '**/*.{js,ts,cjs,mjs,jsx,tsx}'",
- "lint:sol": "pnpm -r run lint:sol; prettier -w --cache --log-level warn '**/*.sol'; pnpm todo",
- "lint:md": "markdownlint --fix --ignore-path .gitignore --ignore-path .markdownlintignore '**/*.md'; prettier -w --cache --log-level warn '**/*.md'",
- "lint:json": "prettier -w --cache --log-level warn '**/*.json'",
- "lint:yaml": "npx yaml-lint .github/**/*.{yml,yaml} packages/contracts/task/config/*.yml; prettier -w --cache --log-level warn '**/*.{yml,yaml}'",
- "format": "prettier -w --cache --log-level warn '**/*.{js,ts,cjs,mjs,jsx,tsx,json,md,yaml,yml}'",
+ "lint:ts": "eslint --fix --cache 'packages/**/*.{js,ts,cjs,mjs,jsx,tsx}' 'scripts/**/*.{js,ts,cjs,mjs}' '*.{js,ts,cjs,mjs}'; prettier -w --cache --log-level warn 'packages/**/*.{js,ts,cjs,mjs,jsx,tsx}' 'scripts/**/*.{js,ts,cjs,mjs}' '*.{js,ts,cjs,mjs}'",
+ "lint:sol": "pnpm -r run lint:sol; prettier -w --cache --log-level warn 'packages/**/*.sol'; pnpm todo",
+ "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}'",
"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"
},
"devDependencies": {
@@ -51,8 +56,17 @@
"overrides": {
"@types/node": "^20.17.50"
},
+ "packageExtensions": {
+ "@nomiclabs/hardhat-waffle@*": {
+ "dependencies": {
+ "@ethereum-waffle/chai": "*",
+ "@ethereum-waffle/provider": "*"
+ }
+ }
+ },
"patchedDependencies": {
- "typechain@8.3.2": "patches/typechain@8.3.2.patch"
+ "typechain@8.3.2": "patches/typechain@8.3.2.patch",
+ "rocketh@0.17.13": "patches/rocketh@0.17.13.patch"
}
},
"lint-staged": {
@@ -68,7 +82,7 @@
"markdownlint --fix",
"prettier -w --cache --log-level warn"
],
- "*.json": "prettier -w --cache --log-level warn",
+ "*.{json,json5}": "prettier -w --cache --log-level warn",
"*.{yml,yaml}": [
"npx yamllint",
"prettier -w --cache --log-level warn"
diff --git a/packages/address-book/CHANGELOG.md b/packages/address-book/CHANGELOG.md
index f009dba89..1427d84c2 100644
--- a/packages/address-book/CHANGELOG.md
+++ b/packages/address-book/CHANGELOG.md
@@ -1,5 +1,17 @@
# @graphprotocol/address-book
+## 1.2.0
+
+### Minor Changes
+
+- Upgraded Rewards Manager and Subgraph Service with Rewards Eligibility Oracle and rewards reclaiming.
+
+## 1.1.0
+
+### Minor Changes
+
+- Graph Horizon phase 3 mainnet deployment
+
## 1.0.1
### Patch Changes
diff --git a/packages/address-book/docs/PublishingGuide.md b/packages/address-book/docs/PublishingGuide.md
new file mode 100644
index 000000000..d4b021783
--- /dev/null
+++ b/packages/address-book/docs/PublishingGuide.md
@@ -0,0 +1,108 @@
+# Publishing @graphprotocol/address-book
+
+Step-by-step guide for releasing a new version of the address-book package and deploying it to the network monitor.
+
+## Prerequisites
+
+- npm publish access for the `@graphprotocol` scope
+- Write access to the [network-monitor](https://github.com/edgeandnode/network-monitor) repo
+- Ability to trigger GitHub Actions workflows in both repos
+
+## Step 1: Update Address Files
+
+Update the source address files in the contracts monorepo. These live in:
+
+- `packages/horizon/addresses.json`
+- `packages/subgraph-service/addresses.json`
+- `packages/issuance/addresses.json`
+
+The address-book package symlinks to these files during development, so changes here are automatically reflected locally.
+
+## Step 2: Create a Changeset
+
+From the monorepo root:
+
+```bash
+pnpm changeset
+```
+
+- Select `@graphprotocol/address-book`
+- Choose the bump type (patch/minor/major)
+- Describe what changed (e.g., "update arbitrumSepolia addresses after deployment")
+
+## Step 3: Version the Package
+
+```bash
+pnpm changeset version
+```
+
+This consumes the changeset, bumps the version in `packages/address-book/package.json`, and updates `CHANGELOG.md`.
+
+## Step 4: Commit and Push
+
+```bash
+git add .
+git commit -m "chore: release @graphprotocol/address-book vX.Y.Z"
+git push
+```
+
+## Step 5: Publish to npm
+
+1. Go to the contracts monorepo → Actions → "Publish package to NPM"
+2. Select `address-book` as the package
+3. Set tag to `latest` (or a pre-release tag)
+4. Run workflow
+
+The workflow automatically:
+
+- Publishes to npm (symlinks are converted to real files via `prepublishOnly`)
+- Creates and pushes a git tag (`@graphprotocol/address-book@X.Y.Z`)
+
+## Step 6: Verify on npm
+
+```bash
+npm view @graphprotocol/address-book version
+```
+
+Confirm the new version is live.
+
+## Step 7: Update the Network Monitor
+
+In the [network-monitor](https://github.com/edgeandnode/network-monitor) repo:
+
+1. Update `package.json` to reference the new version:
+
+ ```json
+ "@graphprotocol/address-book": "X.Y.Z",
+ ```
+
+2. Run `yarn` to update the lockfile
+3. Commit and push
+
+The network monitor imports addresses from:
+
+- `@graphprotocol/address-book/horizon/addresses.json` (in `src/env.ts`)
+- `@graphprotocol/address-book/subgraph-service/addresses.json` (in `src/env.ts`, `src/tests/contracts.ts`)
+
+## Step 8: Deploy the Network Monitor
+
+1. Go to the network-monitor repo → Actions → "Deployment"
+2. Choose the target cluster:
+ - **`network`** → production (mainnet)
+ - **`testnet`** → testnet
+3. Run workflow
+
+This builds a Docker image, pushes it to `ghcr.io/edgeandnode/network-monitor`, and restarts the StatefulSet on GKE.
+
+## Quick Reference
+
+| Step | Action | Where |
+| ---- | ------------------------------- | ----------------------------- |
+| 1 | Update address files | contracts monorepo |
+| 2 | `pnpm changeset` | contracts monorepo |
+| 3 | `pnpm changeset version` | contracts monorepo |
+| 4 | Commit + push | contracts monorepo |
+| 5 | Publish to npm (auto-tags) | contracts monorepo GH Actions |
+| 6 | Verify on npm | npmjs.com |
+| 7 | Bump version in network-monitor | network-monitor repo |
+| 8 | Deploy network monitor | network-monitor GH Actions |
diff --git a/packages/address-book/package.json b/packages/address-book/package.json
index 512d51a71..f0e85dbd0 100644
--- a/packages/address-book/package.json
+++ b/packages/address-book/package.json
@@ -1,15 +1,19 @@
{
"name": "@graphprotocol/address-book",
- "version": "1.0.1",
+ "version": "1.2.1",
"publishConfig": {
"access": "public"
},
"description": "Contract addresses for The Graph Protocol",
"author": "Edge & Node",
"license": "GPL-2.0-or-later",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/graphprotocol/contracts",
+ "directory": "packages/address-book"
+ },
"exports": {
- "./horizon/addresses.json": "./src/horizon/addresses.json",
- "./subgraph-service/addresses.json": "./src/subgraph-service/addresses.json"
+ "./*/addresses.json": "./src/*/addresses.json"
},
"files": [
"src/**/*.json",
diff --git a/packages/address-book/scripts/copy-addresses-for-publish.js b/packages/address-book/scripts/copy-addresses-for-publish.js
index 5fdfdc2c2..0665359d4 100755
--- a/packages/address-book/scripts/copy-addresses-for-publish.js
+++ b/packages/address-book/scripts/copy-addresses-for-publish.js
@@ -3,63 +3,52 @@
/**
* Copy Addresses for Publishing
*
- * This script copies the actual addresses.json files from horizon and subgraph-service
- * packages to replace the symlinks before npm publish.
- *
- * Why we need this:
- * - Development uses symlinks (committed to git) for convenience
- * - npm publish doesn't include symlinks in the published package
- * - We need actual files in the published package for consumers
- *
- * The postpublish script will restore the symlinks after publishing.
+ * Replaces the dev-time symlinks under src//addresses.json with real
+ * file copies before npm publish — npm does not include symlinks in the
+ * published tarball. restore-symlinks.js puts the symlinks back afterwards.
*/
const fs = require('fs')
const path = require('path')
+const SOURCES = require('./sources')
-const FILES_TO_COPY = [
- {
- source: '../../../horizon/addresses.json',
- target: 'src/horizon/addresses.json',
- },
- {
- source: '../../../subgraph-service/addresses.json',
- target: 'src/subgraph-service/addresses.json',
- },
-]
+const ROOT = path.resolve(__dirname, '..')
+const SRC = path.join(ROOT, 'src')
-function copyFileForPublish(source, target) {
- const targetPath = path.resolve(__dirname, '..', target)
- const sourcePath = path.resolve(path.dirname(targetPath), source)
+function copyOne(name) {
+ const sourcePath = path.resolve(ROOT, '..', name, 'addresses.json')
+ const targetDir = path.join(SRC, name)
+ const targetPath = path.join(targetDir, 'addresses.json')
- // Ensure source exists
if (!fs.existsSync(sourcePath)) {
console.error(`❌ Source file ${sourcePath} does not exist`)
process.exit(1)
}
- // Remove existing symlink
- if (fs.existsSync(targetPath)) {
- fs.unlinkSync(targetPath)
- }
+ fs.mkdirSync(targetDir, { recursive: true })
+ fs.rmSync(targetPath, { force: true })
+ fs.copyFileSync(sourcePath, targetPath)
+ console.log(`✅ Copied for publish: src/${name}/addresses.json`)
+}
- // Copy actual file
- try {
- fs.copyFileSync(sourcePath, targetPath)
- console.log(`✅ Copied for publish: ${target} <- ${source}`)
- } catch (error) {
- console.error(`❌ Failed to copy ${source} to ${target}:`, error.message)
+function checkDrift() {
+ const dirs = fs
+ .readdirSync(SRC)
+ .filter((d) => fs.statSync(path.join(SRC, d)).isDirectory())
+ .sort()
+ const expected = [...SOURCES].sort()
+ if (JSON.stringify(dirs) !== JSON.stringify(expected)) {
+ console.error(`❌ Drift between SOURCES and src/`)
+ console.error(` SOURCES: [${expected.join(', ')}]`)
+ console.error(` src/ : [${dirs.join(', ')}]`)
process.exit(1)
}
}
function main() {
console.log('📦 Copying address files for npm publish...')
-
- for (const { source, target } of FILES_TO_COPY) {
- copyFileForPublish(source, target)
- }
-
+ for (const name of SOURCES) copyOne(name)
+ checkDrift()
console.log('✅ Address files copied for publish!')
}
diff --git a/packages/address-book/scripts/restore-symlinks.js b/packages/address-book/scripts/restore-symlinks.js
index 05e5ec6f9..eb2f1f5df 100755
--- a/packages/address-book/scripts/restore-symlinks.js
+++ b/packages/address-book/scripts/restore-symlinks.js
@@ -3,50 +3,32 @@
/**
* Restore Symlinks After Publishing
*
- * This script restores the symlinks after npm publish completes.
- * The prepublishOnly script replaces symlinks with actual files for publishing,
- * and this script puts the symlinks back for development.
+ * Restores the dev-time symlinks under src//addresses.json after
+ * npm publish. copy-addresses-for-publish.js replaces them with real files
+ * for the publish step; this puts them back.
*/
const fs = require('fs')
const path = require('path')
+const SOURCES = require('./sources')
-const SYMLINKS_TO_RESTORE = [
- {
- target: '../../../horizon/addresses.json',
- link: 'src/horizon/addresses.json',
- },
- {
- target: '../../../subgraph-service/addresses.json',
- link: 'src/subgraph-service/addresses.json',
- },
-]
+const ROOT = path.resolve(__dirname, '..')
+const SRC = path.join(ROOT, 'src')
-function restoreSymlink(target, link) {
- const linkPath = path.resolve(__dirname, '..', link)
+function restoreOne(name) {
+ const linkTarget = `../../../${name}/addresses.json`
+ const linkDir = path.join(SRC, name)
+ const linkPath = path.join(linkDir, 'addresses.json')
- // Remove the copied file
- if (fs.existsSync(linkPath)) {
- fs.unlinkSync(linkPath)
- }
-
- // Restore symlink
- try {
- fs.symlinkSync(target, linkPath)
- console.log(`✅ Restored symlink: ${link} -> ${target}`)
- } catch (error) {
- console.error(`❌ Failed to restore symlink ${link}:`, error.message)
- process.exit(1)
- }
+ fs.mkdirSync(linkDir, { recursive: true })
+ fs.rmSync(linkPath, { force: true })
+ fs.symlinkSync(linkTarget, linkPath)
+ console.log(`✅ Restored symlink: src/${name}/addresses.json -> ${linkTarget}`)
}
function main() {
console.log('🔗 Restoring symlinks after publish...')
-
- for (const { target, link } of SYMLINKS_TO_RESTORE) {
- restoreSymlink(target, link)
- }
-
+ for (const name of SOURCES) restoreOne(name)
console.log('✅ Symlinks restored!')
}
diff --git a/packages/address-book/scripts/sources.js b/packages/address-book/scripts/sources.js
new file mode 100644
index 000000000..6885c1a2b
--- /dev/null
+++ b/packages/address-book/scripts/sources.js
@@ -0,0 +1,4 @@
+// Source packages exported from @graphprotocol/address-book.
+// Each name corresponds to packages//addresses.json (source of truth)
+// and packages/address-book/src//addresses.json (publish symlink).
+module.exports = ['horizon', 'issuance', 'subgraph-service']
diff --git a/packages/address-book/src/issuance/addresses.json b/packages/address-book/src/issuance/addresses.json
new file mode 120000
index 000000000..b73ad34ff
--- /dev/null
+++ b/packages/address-book/src/issuance/addresses.json
@@ -0,0 +1 @@
+../../../issuance/addresses.json
\ No newline at end of file
diff --git a/packages/contracts/test/.solcover.js b/packages/contracts-test/.solcover.js
similarity index 96%
rename from packages/contracts/test/.solcover.js
rename to packages/contracts-test/.solcover.js
index 7181b78fa..125581cd1 100644
--- a/packages/contracts/test/.solcover.js
+++ b/packages/contracts-test/.solcover.js
@@ -1,4 +1,4 @@
-const skipFiles = ['bancor', 'ens', 'erc1056', 'arbitrum', 'tests/arbitrum']
+const skipFiles = ['bancor', 'ens', 'erc1056', 'arbitrum', 'tests', '*Mock.sol']
module.exports = {
providerOptions: {
diff --git a/packages/contracts/test/CHANGELOG.md b/packages/contracts-test/CHANGELOG.md
similarity index 100%
rename from packages/contracts/test/CHANGELOG.md
rename to packages/contracts-test/CHANGELOG.md
diff --git a/packages/contracts/test/config/graph.arbitrum-goerli.yml b/packages/contracts-test/config/graph.arbitrum-goerli.yml
similarity index 100%
rename from packages/contracts/test/config/graph.arbitrum-goerli.yml
rename to packages/contracts-test/config/graph.arbitrum-goerli.yml
diff --git a/packages/contracts/test/config/graph.arbitrum-hardhat.yml b/packages/contracts-test/config/graph.arbitrum-hardhat.yml
similarity index 100%
rename from packages/contracts/test/config/graph.arbitrum-hardhat.yml
rename to packages/contracts-test/config/graph.arbitrum-hardhat.yml
diff --git a/packages/contracts/test/config/graph.arbitrum-localhost.yml b/packages/contracts-test/config/graph.arbitrum-localhost.yml
similarity index 100%
rename from packages/contracts/test/config/graph.arbitrum-localhost.yml
rename to packages/contracts-test/config/graph.arbitrum-localhost.yml
diff --git a/packages/contracts/test/config/graph.arbitrum-one.yml b/packages/contracts-test/config/graph.arbitrum-one.yml
similarity index 100%
rename from packages/contracts/test/config/graph.arbitrum-one.yml
rename to packages/contracts-test/config/graph.arbitrum-one.yml
diff --git a/packages/contracts/test/config/graph.arbitrum-sepolia.yml b/packages/contracts-test/config/graph.arbitrum-sepolia.yml
similarity index 100%
rename from packages/contracts/test/config/graph.arbitrum-sepolia.yml
rename to packages/contracts-test/config/graph.arbitrum-sepolia.yml
diff --git a/packages/contracts/test/config/graph.goerli.yml b/packages/contracts-test/config/graph.goerli.yml
similarity index 100%
rename from packages/contracts/test/config/graph.goerli.yml
rename to packages/contracts-test/config/graph.goerli.yml
diff --git a/packages/contracts/test/config/graph.hardhat.yml b/packages/contracts-test/config/graph.hardhat.yml
similarity index 100%
rename from packages/contracts/test/config/graph.hardhat.yml
rename to packages/contracts-test/config/graph.hardhat.yml
diff --git a/packages/contracts/test/config/graph.localhost.yml b/packages/contracts-test/config/graph.localhost.yml
similarity index 100%
rename from packages/contracts/test/config/graph.localhost.yml
rename to packages/contracts-test/config/graph.localhost.yml
diff --git a/packages/contracts/test/config/graph.mainnet.yml b/packages/contracts-test/config/graph.mainnet.yml
similarity index 100%
rename from packages/contracts/test/config/graph.mainnet.yml
rename to packages/contracts-test/config/graph.mainnet.yml
diff --git a/packages/contracts/test/config/graph.sepolia.yml b/packages/contracts-test/config/graph.sepolia.yml
similarity index 100%
rename from packages/contracts/test/config/graph.sepolia.yml
rename to packages/contracts-test/config/graph.sepolia.yml
diff --git a/packages/contracts-test/contracts b/packages/contracts-test/contracts
new file mode 120000
index 000000000..e741e39c3
--- /dev/null
+++ b/packages/contracts-test/contracts
@@ -0,0 +1 @@
+../contracts/contracts
\ No newline at end of file
diff --git a/packages/contracts/test/hardhat.config.ts b/packages/contracts-test/hardhat.config.ts
similarity index 75%
rename from packages/contracts/test/hardhat.config.ts
rename to packages/contracts-test/hardhat.config.ts
index 1d8ed1c58..718359730 100644
--- a/packages/contracts/test/hardhat.config.ts
+++ b/packages/contracts-test/hardhat.config.ts
@@ -5,6 +5,7 @@ import '@nomiclabs/hardhat-waffle'
import '@typechain/hardhat'
import 'dotenv/config'
import 'hardhat-gas-reporter'
+import 'hardhat-ignore-warnings'
import 'solidity-coverage'
// Test-specific tasks
import './tasks/migrate/nitro'
@@ -38,12 +39,24 @@ const config: HardhatUserConfig = {
paths: {
tests: './tests/unit',
cache: './cache',
- graph: '..',
+ graph: '../contracts',
artifacts: './artifacts',
},
typechain: {
outDir: 'types',
},
+ warnings: {
+ // Suppress warnings from legacy OpenZeppelin contracts and external dependencies
+ 'arbos-precompiles/**/*': {
+ default: 'off',
+ },
+ '@openzeppelin/contracts/**/*': {
+ default: 'off',
+ },
+ 'contracts/staking/StakingExtension.sol': {
+ 5667: 'off', // Unused function parameter
+ },
+ },
defaultNetwork: 'hardhat',
networks: {
hardhat: {
@@ -57,9 +70,10 @@ const config: HardhatUserConfig = {
mnemonic: DEFAULT_TEST_MNEMONIC,
},
hardfork: 'london',
- // Graph Protocol extensions
+ // Graph Protocol extensions (not in standard Hardhat types)
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,
@@ -74,6 +88,7 @@ const config: HardhatUserConfig = {
currency: 'USD',
outputFile: 'reports/gas-report.log',
},
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
export default config
diff --git a/packages/contracts/test/package.json b/packages/contracts-test/package.json
similarity index 98%
rename from packages/contracts/test/package.json
rename to packages/contracts-test/package.json
index d3e93a843..1ced3bca4 100644
--- a/packages/contracts/test/package.json
+++ b/packages/contracts-test/package.json
@@ -23,6 +23,7 @@
},
"dependencies": {
"@graphprotocol/contracts": "workspace:^",
+ "@graphprotocol/interfaces": "workspace:^",
"@graphprotocol/sdk": "0.6.0"
},
"devDependencies": {
diff --git a/packages/contracts-test/prettier.config.cjs b/packages/contracts-test/prettier.config.cjs
new file mode 100644
index 000000000..18006454f
--- /dev/null
+++ b/packages/contracts-test/prettier.config.cjs
@@ -0,0 +1,5 @@
+const baseConfig = require('../contracts/prettier.config.cjs')
+
+module.exports = {
+ ...baseConfig,
+}
diff --git a/packages/contracts/test/scripts/coverage b/packages/contracts-test/scripts/coverage
similarity index 100%
rename from packages/contracts/test/scripts/coverage
rename to packages/contracts-test/scripts/coverage
diff --git a/packages/contracts/test/scripts/e2e b/packages/contracts-test/scripts/e2e
similarity index 100%
rename from packages/contracts/test/scripts/e2e
rename to packages/contracts-test/scripts/e2e
diff --git a/packages/contracts/test/scripts/evm b/packages/contracts-test/scripts/evm
similarity index 100%
rename from packages/contracts/test/scripts/evm
rename to packages/contracts-test/scripts/evm
diff --git a/packages/contracts/test/scripts/setup-symlinks b/packages/contracts-test/scripts/setup-symlinks
similarity index 89%
rename from packages/contracts/test/scripts/setup-symlinks
rename to packages/contracts-test/scripts/setup-symlinks
index 357efaa4f..9c7f72949 100755
--- a/packages/contracts/test/scripts/setup-symlinks
+++ b/packages/contracts-test/scripts/setup-symlinks
@@ -9,9 +9,9 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEST_DIR="$(dirname "$SCRIPT_DIR")"
-# Create symbolic link from contracts to ../contracts
+# Create symbolic link from contracts to ../contracts/contracts
CONTRACTS_LINK="$TEST_DIR/contracts"
-CONTRACTS_TARGET="../contracts"
+CONTRACTS_TARGET="../contracts/contracts"
if [ -L "$CONTRACTS_LINK" ]; then
# Check if the link points to the correct target
diff --git a/packages/contracts/test/scripts/test b/packages/contracts-test/scripts/test
similarity index 72%
rename from packages/contracts/test/scripts/test
rename to packages/contracts-test/scripts/test
index 36888a096..7b5c01372 100755
--- a/packages/contracts/test/scripts/test
+++ b/packages/contracts-test/scripts/test
@@ -21,11 +21,11 @@ fi
### Main
# Init address book
-echo {} > ../addresses-local.json
+echo {} > ../contracts/addresses-local.json
# TODO: fix this! For some reason the resolved package does not have a few required files
-echo {} > ../../../node_modules/.pnpm/@graphprotocol+contracts@7.2.1/node_modules/@graphprotocol/contracts/addresses-local.json
-cp -r ../config ../../../node_modules/.pnpm/@graphprotocol+contracts@7.2.1/node_modules/@graphprotocol/contracts/config
+echo {} > ../../node_modules/.pnpm/@graphprotocol+contracts@7.2.1/node_modules/@graphprotocol/contracts/addresses-local.json
+cp -r ../contracts/config ../../node_modules/.pnpm/@graphprotocol+contracts@7.2.1/node_modules/@graphprotocol/contracts/config
mkdir -p reports
diff --git a/packages/contracts/test/scripts/test-coverage-file b/packages/contracts-test/scripts/test-coverage-file
similarity index 100%
rename from packages/contracts/test/scripts/test-coverage-file
rename to packages/contracts-test/scripts/test-coverage-file
diff --git a/packages/contracts/test/tasks/migrate/nitro.ts b/packages/contracts-test/tasks/migrate/nitro.ts
similarity index 98%
rename from packages/contracts/test/tasks/migrate/nitro.ts
rename to packages/contracts-test/tasks/migrate/nitro.ts
index 0cd551a18..480830cb4 100644
--- a/packages/contracts/test/tasks/migrate/nitro.ts
+++ b/packages/contracts-test/tasks/migrate/nitro.ts
@@ -60,5 +60,5 @@ task('migrate:nitro:address-book', 'Write arbitrum addresses to address book')
},
}
- fs.writeFileSync(taskArgs.arbitrumAddressBook, JSON.stringify(addressBook))
+ fs.writeFileSync(taskArgs.arbitrumAddressBook, JSON.stringify(addressBook, null, 2) + '\n')
})
diff --git a/packages/contracts/test/tasks/test-upgrade.ts b/packages/contracts-test/tasks/test-upgrade.ts
similarity index 100%
rename from packages/contracts/test/tasks/test-upgrade.ts
rename to packages/contracts-test/tasks/test-upgrade.ts
diff --git a/packages/contracts/test/tests/unit/curation/configuration.test.ts b/packages/contracts-test/tests/unit/curation/configuration.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/curation/configuration.test.ts
rename to packages/contracts-test/tests/unit/curation/configuration.test.ts
diff --git a/packages/contracts/test/tests/unit/curation/curation.test.ts b/packages/contracts-test/tests/unit/curation/curation.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/curation/curation.test.ts
rename to packages/contracts-test/tests/unit/curation/curation.test.ts
diff --git a/packages/contracts/test/tests/unit/disputes/common.ts b/packages/contracts-test/tests/unit/disputes/common.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/disputes/common.ts
rename to packages/contracts-test/tests/unit/disputes/common.ts
diff --git a/packages/contracts/test/tests/unit/disputes/configuration.test.ts b/packages/contracts-test/tests/unit/disputes/configuration.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/disputes/configuration.test.ts
rename to packages/contracts-test/tests/unit/disputes/configuration.test.ts
diff --git a/packages/contracts/test/tests/unit/disputes/poi.test.ts b/packages/contracts-test/tests/unit/disputes/poi.test.ts
similarity index 95%
rename from packages/contracts/test/tests/unit/disputes/poi.test.ts
rename to packages/contracts-test/tests/unit/disputes/poi.test.ts
index b465f5986..b391dd0d4 100644
--- a/packages/contracts/test/tests/unit/disputes/poi.test.ts
+++ b/packages/contracts-test/tests/unit/disputes/poi.test.ts
@@ -1,4 +1,4 @@
-import { DisputeManager } from '@graphprotocol/contracts'
+import { DisputeManager, IRewardsManager } from '@graphprotocol/contracts'
import { EpochManager } from '@graphprotocol/contracts'
import { GraphToken } from '@graphprotocol/contracts'
import { IStaking } from '@graphprotocol/contracts'
@@ -30,6 +30,7 @@ describe('DisputeManager:POI', () => {
let epochManager: EpochManager
let grt: GraphToken
let staking: IStaking
+ let rewardsManager: IRewardsManager
// Derive some channel keys for each indexer used to sign attestations
const indexerChannelKey = deriveChannelKey()
@@ -92,10 +93,15 @@ describe('DisputeManager:POI', () => {
epochManager = contracts.EpochManager as EpochManager
grt = contracts.GraphToken as GraphToken
staking = contracts.Staking as IStaking
+ rewardsManager = contracts.RewardsManager as IRewardsManager
// Give some funds to the fisherman
await grt.connect(governor).mint(fisherman.address, fishermanTokens)
await grt.connect(fisherman).approve(disputeManager.address, fishermanTokens)
+
+ // 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 () {
diff --git a/packages/contracts/test/tests/unit/disputes/query.test.ts b/packages/contracts-test/tests/unit/disputes/query.test.ts
similarity index 98%
rename from packages/contracts/test/tests/unit/disputes/query.test.ts
rename to packages/contracts-test/tests/unit/disputes/query.test.ts
index 73238b4e0..e411bd028 100644
--- a/packages/contracts/test/tests/unit/disputes/query.test.ts
+++ b/packages/contracts-test/tests/unit/disputes/query.test.ts
@@ -1,5 +1,5 @@
import { createAttestation, Receipt } from '@graphprotocol/common-ts'
-import { DisputeManager } from '@graphprotocol/contracts'
+import { DisputeManager, IRewardsManager } from '@graphprotocol/contracts'
import { EpochManager } from '@graphprotocol/contracts'
import { GraphToken } from '@graphprotocol/contracts'
import { IStaking } from '@graphprotocol/contracts'
@@ -35,6 +35,7 @@ describe('DisputeManager:Query', () => {
let epochManager: EpochManager
let grt: GraphToken
let staking: IStaking
+ let rewardsManager: IRewardsManager
// Derive some channel keys for each indexer used to sign attestations
const indexer1ChannelKey = deriveChannelKey()
@@ -121,6 +122,7 @@ describe('DisputeManager:Query', () => {
epochManager = contracts.EpochManager as EpochManager
grt = contracts.GraphToken as GraphToken
staking = contracts.Staking as IStaking
+ rewardsManager = contracts.RewardsManager as IRewardsManager
// Give some funds to the fisherman
for (const dst of [fisherman, fisherman2]) {
@@ -139,6 +141,10 @@ describe('DisputeManager:Query', () => {
indexerAddress: indexer.address,
receipt,
}
+
+ // 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 () {
diff --git a/packages/contracts/test/tests/unit/epochs.test.ts b/packages/contracts-test/tests/unit/epochs.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/epochs.test.ts
rename to packages/contracts-test/tests/unit/epochs.test.ts
diff --git a/packages/contracts/test/tests/unit/gateway/bridgeEscrow.test.ts b/packages/contracts-test/tests/unit/gateway/bridgeEscrow.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/gateway/bridgeEscrow.test.ts
rename to packages/contracts-test/tests/unit/gateway/bridgeEscrow.test.ts
diff --git a/packages/contracts/test/tests/unit/gateway/l1GraphTokenGateway.test.ts b/packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/gateway/l1GraphTokenGateway.test.ts
rename to packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts
diff --git a/packages/contracts/test/tests/unit/gns.test.ts b/packages/contracts-test/tests/unit/gns.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/gns.test.ts
rename to packages/contracts-test/tests/unit/gns.test.ts
diff --git a/packages/contracts/test/tests/unit/governance/controller.test.ts b/packages/contracts-test/tests/unit/governance/controller.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/governance/controller.test.ts
rename to packages/contracts-test/tests/unit/governance/controller.test.ts
diff --git a/packages/contracts/test/tests/unit/governance/governed.test.ts b/packages/contracts-test/tests/unit/governance/governed.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/governance/governed.test.ts
rename to packages/contracts-test/tests/unit/governance/governed.test.ts
diff --git a/packages/contracts/test/tests/unit/governance/pausing.test.ts b/packages/contracts-test/tests/unit/governance/pausing.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/governance/pausing.test.ts
rename to packages/contracts-test/tests/unit/governance/pausing.test.ts
diff --git a/packages/contracts/test/tests/unit/graphToken.test.ts b/packages/contracts-test/tests/unit/graphToken.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/graphToken.test.ts
rename to packages/contracts-test/tests/unit/graphToken.test.ts
diff --git a/packages/contracts/test/tests/unit/l2/l2ArbitrumMessengerMock.ts b/packages/contracts-test/tests/unit/l2/l2ArbitrumMessengerMock.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/l2/l2ArbitrumMessengerMock.ts
rename to packages/contracts-test/tests/unit/l2/l2ArbitrumMessengerMock.ts
diff --git a/packages/contracts/test/tests/unit/l2/l2Curation.test.ts b/packages/contracts-test/tests/unit/l2/l2Curation.test.ts
similarity index 97%
rename from packages/contracts/test/tests/unit/l2/l2Curation.test.ts
rename to packages/contracts-test/tests/unit/l2/l2Curation.test.ts
index 6ee8a5cd3..a680ec28c 100644
--- a/packages/contracts/test/tests/unit/l2/l2Curation.test.ts
+++ b/packages/contracts-test/tests/unit/l2/l2Curation.test.ts
@@ -154,7 +154,7 @@ describe('L2Curation', () => {
let me: SignerWithAddress
let governor: SignerWithAddress
let curator: SignerWithAddress
- let stakingMock: SignerWithAddress
+ let subgraphServiceMock: SignerWithAddress
let gnsImpersonator: Signer
let fixture: NetworkFixture
@@ -310,8 +310,8 @@ describe('L2Curation', () => {
const beforeTotalBalance = await grt.balanceOf(curation.address)
// Source of tokens must be the staking for this to work
- await grt.connect(stakingMock).transfer(curation.address, tokensToCollect)
- const tx = curation.connect(stakingMock).collect(subgraphDeploymentID, tokensToCollect)
+ 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
@@ -325,7 +325,7 @@ describe('L2Curation', () => {
before(async function () {
// Use stakingMock so we can call collect
- ;[me, curator, stakingMock] = await graph.getTestAccounts()
+ ;[me, curator, subgraphServiceMock] = await graph.getTestAccounts()
;({ governor } = await graph.getNamedAccounts())
fixture = new NetworkFixture(graph.provider)
contracts = await fixture.load(governor, true)
@@ -343,8 +343,11 @@ describe('L2Curation', () => {
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(stakingMock.address, tokensToCollect)
- await grt.connect(stakingMock).approve(curation.address, tokensToCollect)
+ 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 () {
@@ -514,10 +517,10 @@ describe('L2Curation', () => {
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'), stakingMock.address)
+ 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(stakingMock).collect(subgraphDeploymentID, tokensToCollect)
+ const tx = curation.connect(subgraphServiceMock).collect(subgraphDeploymentID, tokensToCollect)
await expect(tx).revertedWith('Subgraph deployment must be curated to collect fees')
})
})
@@ -529,11 +532,11 @@ describe('L2Curation', () => {
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 or staking contract')
+ 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'), stakingMock.address)
+ 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'))
@@ -544,7 +547,7 @@ describe('L2Curation', () => {
})
it('should collect tokens and then unsignal all', async function () {
- await controller.connect(governor).setContractProxy(utils.id('Staking'), stakingMock.address)
+ 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
@@ -556,7 +559,7 @@ describe('L2Curation', () => {
})
it('should collect tokens and then unsignal multiple times', async function () {
- await controller.connect(governor).setContractProxy(utils.id('Staking'), stakingMock.address)
+ 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
diff --git a/packages/contracts/test/tests/unit/l2/l2GNS.test.ts b/packages/contracts-test/tests/unit/l2/l2GNS.test.ts
similarity index 85%
rename from packages/contracts/test/tests/unit/l2/l2GNS.test.ts
rename to packages/contracts-test/tests/unit/l2/l2GNS.test.ts
index 5b8f1d028..0fd691939 100644
--- a/packages/contracts/test/tests/unit/l2/l2GNS.test.ts
+++ b/packages/contracts-test/tests/unit/l2/l2GNS.test.ts
@@ -2,12 +2,10 @@ 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,
@@ -44,7 +42,6 @@ interface L1SubgraphParams {
describe('L2GNS', () => {
const graph = hre.graph()
let me: SignerWithAddress
- let attacker: SignerWithAddress
let other: SignerWithAddress
let governor: SignerWithAddress
let fixture: NetworkFixture
@@ -58,7 +55,6 @@ describe('L2GNS', () => {
let gns: L2GNS
let curation: L2Curation
let grt: GraphToken
- let staking: IL2Staking
let newSubgraph0: PublishSubgraph
let newSubgraph1: PublishSubgraph
@@ -109,7 +105,7 @@ describe('L2GNS', () => {
before(async function () {
newSubgraph0 = buildSubgraph()
- ;[me, attacker, other] = await graph.getTestAccounts()
+ ;[me, other] = await graph.getTestAccounts()
;({ governor } = await graph.getNamedAccounts())
fixture = new NetworkFixture(graph.provider)
@@ -118,7 +114,6 @@ describe('L2GNS', () => {
fixtureContracts = await fixture.load(governor, true)
l2GraphTokenGateway = fixtureContracts.L2GraphTokenGateway as L2GraphTokenGateway
gns = fixtureContracts.L2GNS as L2GNS
- staking = fixtureContracts.L2Staking as unknown as IL2Staking
curation = fixtureContracts.L2Curation as L2Curation
grt = fixtureContracts.GraphToken as GraphToken
@@ -354,61 +349,6 @@ describe('L2GNS', () => {
.emit(gns, 'SignalMinted')
.withArgs(l2SubgraphId, me.address, expectedNSignal, expectedSignal, curatedTokens)
})
- it('protects the owner against a rounding attack', async function () {
- const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams()
- const collectTokens = curatedTokens.mul(20)
-
- await staking.connect(governor).setCurationPercentage(100000)
-
- // Set up an indexer account with some stake
- await grt.connect(governor).mint(attacker.address, toGRT('1000000'))
- // Curate 1 wei GRT by minting 1 GRT and burning most of it
- await grt.connect(attacker).approve(curation.address, toBN(1))
- await curation.connect(attacker).mint(newSubgraph0.subgraphDeploymentID, toBN(1), 0)
-
- // Check this actually gave us 1 wei signal
- expect(await curation.getCurationPoolTokens(newSubgraph0.subgraphDeploymentID)).eq(1)
- await grt.connect(attacker).approve(staking.address, toGRT('1000000'))
- await staking.connect(attacker).stake(toGRT('100000'))
- const channelKey = deriveChannelKey()
- // Allocate to the same deployment ID
- await staking
- .connect(attacker)
- .allocateFrom(
- attacker.address,
- newSubgraph0.subgraphDeploymentID,
- toGRT('100000'),
- channelKey.address,
- randomHexBytes(32),
- await channelKey.generateProof(attacker.address),
- )
- // Spoof some query fees, 10% of which will go to the Curation pool
- await staking.connect(attacker).collect(collectTokens, channelKey.address)
- // The curation pool now has 1 wei shares and a lot of tokens, so the rounding attack is prepared
- // But L2GNS will protect the owner by sending the tokens
- 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,
- 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, 'CuratorBalanceReturnedToBeneficiary')
- await expect(tx).emit(gns, 'SubgraphUpgraded').withArgs(l2SubgraphId, 0, 0, newSubgraph0.subgraphDeploymentID)
- await expect(tx)
- .emit(gns, 'SubgraphVersionUpdated')
- .withArgs(l2SubgraphId, newSubgraph0.subgraphDeploymentID, versionMetadata)
- await expect(tx).emit(gns, 'SubgraphL2TransferFinalized').withArgs(l2SubgraphId)
- })
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])
@@ -654,50 +594,6 @@ describe('L2GNS', () => {
expect(gnsBalanceAfter).eq(gnsBalanceBefore)
})
- it('protects the curator against a rounding attack', async function () {
- // Transfer a subgraph from L1 with only 1 wei GRT of curated signal
- const { l1SubgraphId, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams()
- const curatedTokens = toBN('1')
- await transferMockSubgraphFromL1(l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata)
- // Prepare the rounding attack by setting up an indexer and collecting a lot of query fees
- const curatorTokens = toGRT('10000')
- const collectTokens = curatorTokens.mul(20)
- await staking.connect(governor).setCurationPercentage(100000)
- // Set up an indexer account with some stake
- await grt.connect(governor).mint(attacker.address, toGRT('1000000'))
-
- await grt.connect(attacker).approve(staking.address, toGRT('1000000'))
- await staking.connect(attacker).stake(toGRT('100000'))
- const channelKey = deriveChannelKey()
- // Allocate to the same deployment ID
- await staking
- .connect(attacker)
- .allocateFrom(
- attacker.address,
- newSubgraph0.subgraphDeploymentID,
- toGRT('100000'),
- channelKey.address,
- randomHexBytes(32),
- await channelKey.generateProof(attacker.address),
- )
- // Spoof some query fees, 10% of which will go to the Curation pool
- await staking.connect(attacker).collect(collectTokens, channelKey.address)
-
- 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, curatorTokens, callhookData)
- await expect(tx)
- .emit(gns, 'CuratorBalanceReturnedToBeneficiary')
- .withArgs(l1SubgraphId, me.address, curatorTokens)
- const curatorTokensAfter = await grt.balanceOf(me.address)
- expect(curatorTokensAfter).eq(curatorTokensBefore.add(curatorTokens))
- 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:
diff --git a/packages/contracts/test/tests/unit/l2/l2GraphToken.test.ts b/packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/l2/l2GraphToken.test.ts
rename to packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts
diff --git a/packages/contracts/test/tests/unit/l2/l2GraphTokenGateway.test.ts b/packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/l2/l2GraphTokenGateway.test.ts
rename to packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts
diff --git a/packages/contracts/test/tests/unit/l2/l2Staking.test.ts b/packages/contracts-test/tests/unit/l2/l2Staking.test.ts
similarity index 97%
rename from packages/contracts/test/tests/unit/l2/l2Staking.test.ts
rename to packages/contracts-test/tests/unit/l2/l2Staking.test.ts
index 39dc75e7a..cf22eaba0 100644
--- a/packages/contracts/test/tests/unit/l2/l2Staking.test.ts
+++ b/packages/contracts-test/tests/unit/l2/l2Staking.test.ts
@@ -1,4 +1,4 @@
-import { IL2Staking } from '@graphprotocol/contracts'
+import { IL2Staking, IRewardsManager } from '@graphprotocol/contracts'
import { L2GraphTokenGateway } from '@graphprotocol/contracts'
import { GraphToken } from '@graphprotocol/contracts'
import { EpochManager, L1GNS, L1GraphTokenGateway, L1Staking } from '@graphprotocol/contracts'
@@ -35,6 +35,7 @@ describe('L2Staking', () => {
let l2GraphTokenGateway: L2GraphTokenGateway
let staking: IL2Staking
let grt: GraphToken
+ let rewardsManager: IRewardsManager
const tokens10k = toGRT('10000')
const tokens100k = toGRT('100000')
@@ -88,6 +89,7 @@ describe('L2Staking', () => {
l1StakingMock = l1MockContracts.L1Staking as L1Staking
l1GNSMock = l1MockContracts.L1GNS as L1GNS
l1GRTGatewayMock = l1MockContracts.L1GraphTokenGateway as L1GraphTokenGateway
+ rewardsManager = fixtureContracts.RewardsManager as IRewardsManager
// Deploy L2 arbitrum bridge
await fixture.loadL2ArbitrumBridge(governor)
@@ -99,6 +101,10 @@ describe('L2Staking', () => {
await grt.connect(me).approve(staking.address, tokens1m)
await grt.connect(governor).mint(other.address, tokens1m)
await grt.connect(other).approve(staking.address, tokens1m)
+
+ // 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 () {
diff --git a/packages/contracts/test/tests/unit/lib/fixtures.ts b/packages/contracts-test/tests/unit/lib/fixtures.ts
similarity index 99%
rename from packages/contracts/test/tests/unit/lib/fixtures.ts
rename to packages/contracts-test/tests/unit/lib/fixtures.ts
index 44ed50faa..2fc370da8 100644
--- a/packages/contracts/test/tests/unit/lib/fixtures.ts
+++ b/packages/contracts-test/tests/unit/lib/fixtures.ts
@@ -74,7 +74,7 @@ export class NetworkFixture {
async load(deployer: SignerWithAddress, l2Deploy?: boolean): Promise {
// Use instrumented artifacts when running coverage tests, otherwise use local artifacts
- const artifactsDir = isRunningUnderCoverage() ? './artifacts' : '../artifacts'
+ const artifactsDir = isRunningUnderCoverage() ? './artifacts' : '../contracts/artifacts'
const contracts = await deployGraphNetwork(
'addresses-local.json',
diff --git a/packages/contracts/test/tests/unit/lib/gnsUtils.ts b/packages/contracts-test/tests/unit/lib/gnsUtils.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/lib/gnsUtils.ts
rename to packages/contracts-test/tests/unit/lib/gnsUtils.ts
diff --git a/packages/contracts/test/tests/unit/lib/graphTokenTests.ts b/packages/contracts-test/tests/unit/lib/graphTokenTests.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/lib/graphTokenTests.ts
rename to packages/contracts-test/tests/unit/lib/graphTokenTests.ts
diff --git a/packages/contracts/test/tests/unit/payments/allocationExchange.test.ts b/packages/contracts-test/tests/unit/payments/allocationExchange.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/payments/allocationExchange.test.ts
rename to packages/contracts-test/tests/unit/payments/allocationExchange.test.ts
diff --git a/packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts
new file mode 100644
index 000000000..168166745
--- /dev/null
+++ b/packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts
@@ -0,0 +1,425 @@
+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-config.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-config.test.ts
new file mode 100644
index 000000000..bd3b2569a
--- /dev/null
+++ b/packages/contracts-test/tests/unit/rewards/rewards-config.test.ts
@@ -0,0 +1,320 @@
+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-distribution.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts
new file mode 100644
index 000000000..e34ace2fd
--- /dev/null
+++ b/packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts
@@ -0,0 +1,745 @@
+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/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts
new file mode 100644
index 000000000..c2137dc64
--- /dev/null
+++ b/packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts
@@ -0,0 +1,700 @@
+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-interface.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts
new file mode 100644
index 000000000..db77d5f9b
--- /dev/null
+++ b/packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts
@@ -0,0 +1,144 @@
+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/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts
new file mode 100644
index 000000000..8047b8fd6
--- /dev/null
+++ b/packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts
@@ -0,0 +1,420 @@
+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-reclaim.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts
new file mode 100644
index 000000000..a1a17269a
--- /dev/null
+++ b/packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts
@@ -0,0 +1,1115 @@
+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/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts
new file mode 100644
index 000000000..930c2b21c
--- /dev/null
+++ b/packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts
@@ -0,0 +1,564 @@
+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
new file mode 100644
index 000000000..da09d182e
--- /dev/null
+++ b/packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts
@@ -0,0 +1,444 @@
+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/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts
new file mode 100644
index 000000000..58338cac8
--- /dev/null
+++ b/packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts
@@ -0,0 +1,483 @@
+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/contracts/test/tests/unit/rewards/rewards.test.ts b/packages/contracts-test/tests/unit/rewards/rewards.test.ts
similarity index 72%
rename from packages/contracts/test/tests/unit/rewards/rewards.test.ts
rename to packages/contracts-test/tests/unit/rewards/rewards.test.ts
index e6171cc13..240d78178 100644
--- a/packages/contracts/test/tests/unit/rewards/rewards.test.ts
+++ b/packages/contracts-test/tests/unit/rewards/rewards.test.ts
@@ -15,15 +15,23 @@ import {
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 { 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', () => {
@@ -151,6 +159,10 @@ describe('Rewards', () => {
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 () {
@@ -330,39 +342,68 @@ describe('Rewards', () => {
describe('getAccRewardsForSubgraph', function () {
it('accrued for each subgraph', async function () {
- // Curator1 - Update total signalled
+ // Setup: signal and allocations for two subgraphs
const signalled1 = toGRT('1500')
- await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0)
- const tracker1 = await RewardsTracker.create()
-
- // Curator2 - Update total signalled
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)
- // Snapshot
- const tracker2 = await RewardsTracker.create()
- await tracker1.snapshot()
+ // 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)
- // Jump
+ 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)
- // Snapshot
- await tracker1.snapshot()
- await tracker2.snapshot()
+ // Get final rewards
+ const endRewardsSG1 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1)
+ const endRewardsSG2 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID2)
- // Calculate rewards
- const rewardsPerSignal1 = tracker1.accumulated
- const rewardsPerSignal2 = tracker2.accumulated
- const expectedRewardsSG1 = rewardsPerSignal1.mul(signalled1).div(WeiPerEther)
- const expectedRewardsSG2 = rewardsPerSignal2.mul(signalled2).div(WeiPerEther)
+ // Calculate accrued rewards during the period
+ const accruedSG1 = endRewardsSG1.sub(startRewardsSG1)
+ const accruedSG2 = endRewardsSG2.sub(startRewardsSG2)
- // Get rewards from contract
- const contractRewardsSG1 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1)
- const contractRewardsSG2 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID2)
+ // 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
- expect(toRound(expectedRewardsSG1)).eq(toRound(contractRewardsSG1))
- expect(toRound(expectedRewardsSG2)).eq(toRound(contractRewardsSG2))
+ // 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 () {
@@ -388,6 +429,24 @@ describe('Rewards', () => {
// 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()
@@ -471,7 +530,10 @@ describe('Rewards', () => {
await helpers.mine(ISSUANCE_RATE_PERIODS)
// Prepare expected results
- const expectedSubgraphRewards = toGRT('1400') // 7 blocks since signaling to when we do getAccRewardsForSubgraph
+ // 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
@@ -711,9 +773,13 @@ describe('Rewards', () => {
// Close allocation. At this point rewards should be collected for that indexer
const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes())
- await expect(tx)
- .emit(rewardsManager, 'HorizonRewardsAssigned')
- .withArgs(indexer1.address, allocationID1, toBN(0))
+ 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 () {
@@ -729,9 +795,13 @@ describe('Rewards', () => {
// Close allocation. At this point rewards should be collected for that indexer
const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes())
- await expect(tx)
- .emit(rewardsManager, 'HorizonRewardsAssigned')
- .withArgs(indexer1.address, allocationID1, toBN(0))
+ 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 () {
@@ -746,9 +816,13 @@ describe('Rewards', () => {
// Close allocation. At this point rewards should be collected for that indexer
const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes())
- await expect(tx)
- .emit(rewardsManager, 'HorizonRewardsAssigned')
- .withArgs(indexer1.address, allocationID1, toBN(0))
+ 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 () {
@@ -857,15 +931,22 @@ describe('Rewards', () => {
})
it('should deny rewards if subgraph on denylist', async function () {
- // Setup
+ // Setup: create allocation BEFORE denying the subgraph
await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address)
- await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true)
+
+ // Align with the epoch boundary
+ await helpers.mineEpoch(epochManager)
+
+ // Create allocation
await setupIndexerAllocation()
- // Jump
+ // Jump to earn some rewards
await helpers.mineEpoch(epochManager)
- // Close allocation. At this point rewards should be collected for that indexer
+ // 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)
})
@@ -889,7 +970,11 @@ describe('Rewards', () => {
// Close allocation. At this point rewards should be zero
const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes())
- await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned').withArgs(indexer1.address, allocationID1, 0)
+ 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()
@@ -899,6 +984,235 @@ describe('Rewards', () => {
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)
+ })
})
})
@@ -1053,12 +1367,12 @@ describe('Rewards', () => {
// 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)
}
-
- // snapshot block before any accrual (we substract 1 because accrual starts after the first mint happens)
- const b1 = await epochManager.blockNum().then((x) => x.toNumber() - 1)
+ await hre.network.provider.send('evm_mine')
+ await hre.network.provider.send('evm_setAutomine', [true])
// allocate
const tokensToAllocate = toGRT('12500')
@@ -1078,6 +1392,9 @@ describe('Rewards', () => {
.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)
@@ -1091,8 +1408,12 @@ describe('Rewards', () => {
const accrual = await getRewardsAccrual(subgraphs)
const b2 = await epochManager.blockNum().then((x) => x.toNumber())
- // round comparison because there is a small precision error due to dividing and accrual per signal
- expect(toRound(accrual.all)).eq(toRound(ISSUANCE_PER_BLOCK.mul(b2 - b1)))
+ // 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/contracts/test/tests/unit/rewards/subgraphAvailability.test.ts b/packages/contracts-test/tests/unit/rewards/subgraphAvailability.test.ts
similarity index 99%
rename from packages/contracts/test/tests/unit/rewards/subgraphAvailability.test.ts
rename to packages/contracts-test/tests/unit/rewards/subgraphAvailability.test.ts
index 988a84aba..a4afbffa5 100644
--- a/packages/contracts/test/tests/unit/rewards/subgraphAvailability.test.ts
+++ b/packages/contracts-test/tests/unit/rewards/subgraphAvailability.test.ts
@@ -344,7 +344,7 @@ describe('SubgraphAvailabilityManager', () => {
const tx = await subgraphAvailabilityManager.connect(oracleThree).voteMany(subgraphs, denied, 2)
await expect(tx).to.emit(rewardsManager, 'RewardsDenylistUpdated').withArgs(subgraphDeploymentID1, tx.blockNumber)
- await expect(tx).to.emit(rewardsManager, 'RewardsDenylistUpdated').withArgs(subgraphDeploymentID2, 0)
+ // subgraphDeploymentID2 voted false but was never denied, so setDenied is idempotent (no event)
await expect(tx).to.emit(rewardsManager, 'RewardsDenylistUpdated').withArgs(subgraphDeploymentID3, tx.blockNumber)
// check that subgraphs are denied
diff --git a/packages/contracts/test/tests/unit/serviceRegisty.test.ts b/packages/contracts-test/tests/unit/serviceRegisty.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/serviceRegisty.test.ts
rename to packages/contracts-test/tests/unit/serviceRegisty.test.ts
diff --git a/packages/contracts/test/tests/unit/staking/allocation.test.ts b/packages/contracts-test/tests/unit/staking/allocation.test.ts
similarity index 99%
rename from packages/contracts/test/tests/unit/staking/allocation.test.ts
rename to packages/contracts-test/tests/unit/staking/allocation.test.ts
index dd28aa73d..76de77a35 100644
--- a/packages/contracts/test/tests/unit/staking/allocation.test.ts
+++ b/packages/contracts-test/tests/unit/staking/allocation.test.ts
@@ -379,6 +379,10 @@ describe('Staking:Allocation', () => {
// Give some funds to the delegator and approve staking contract to use funds on delegator behalf
await grt.connect(governor).mint(delegator.address, tokensToDelegate)
await grt.connect(delegator).approve(staking.address, tokensToDelegate)
+
+ // 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 () {
diff --git a/packages/contracts/test/tests/unit/staking/configuration.test.ts b/packages/contracts-test/tests/unit/staking/configuration.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/staking/configuration.test.ts
rename to packages/contracts-test/tests/unit/staking/configuration.test.ts
diff --git a/packages/contracts/test/tests/unit/staking/delegation.test.ts b/packages/contracts-test/tests/unit/staking/delegation.test.ts
similarity index 98%
rename from packages/contracts/test/tests/unit/staking/delegation.test.ts
rename to packages/contracts-test/tests/unit/staking/delegation.test.ts
index 71f911006..3542e817e 100644
--- a/packages/contracts/test/tests/unit/staking/delegation.test.ts
+++ b/packages/contracts-test/tests/unit/staking/delegation.test.ts
@@ -1,4 +1,4 @@
-import { EpochManager } from '@graphprotocol/contracts'
+import { EpochManager, IRewardsManager } from '@graphprotocol/contracts'
import { GraphToken } from '@graphprotocol/contracts'
import { IStaking } from '@graphprotocol/contracts'
import { deriveChannelKey, GraphNetworkContracts, helpers, randomHexBytes, toBN, toGRT } from '@graphprotocol/sdk'
@@ -29,6 +29,7 @@ describe('Staking::Delegation', () => {
let epochManager: EpochManager
let grt: GraphToken
let staking: IStaking
+ let rewardsManager: IRewardsManager
// Test values
const poi = randomHexBytes()
@@ -159,6 +160,7 @@ describe('Staking::Delegation', () => {
epochManager = contracts.EpochManager as EpochManager
grt = contracts.GraphToken as GraphToken
staking = contracts.Staking as IStaking
+ rewardsManager = contracts.RewardsManager as IRewardsManager
// Distribute test funds
for (const wallet of [delegator, delegator2]) {
@@ -173,6 +175,10 @@ describe('Staking::Delegation', () => {
}
await grt.connect(governor).mint(assetHolder.address, tokensToCollect)
await grt.connect(assetHolder).approve(staking.address, tokensToCollect)
+
+ // 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 () {
diff --git a/packages/contracts/test/tests/unit/staking/l2Transfer.test.ts b/packages/contracts-test/tests/unit/staking/l2Transfer.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/staking/l2Transfer.test.ts
rename to packages/contracts-test/tests/unit/staking/l2Transfer.test.ts
diff --git a/packages/contracts/test/tests/unit/staking/rebate.test.ts b/packages/contracts-test/tests/unit/staking/rebate.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/staking/rebate.test.ts
rename to packages/contracts-test/tests/unit/staking/rebate.test.ts
diff --git a/packages/contracts/test/tests/unit/staking/staking.test.ts b/packages/contracts-test/tests/unit/staking/staking.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/staking/staking.test.ts
rename to packages/contracts-test/tests/unit/staking/staking.test.ts
diff --git a/packages/contracts/test/tests/unit/upgrade/admin.test.ts b/packages/contracts-test/tests/unit/upgrade/admin.test.ts
similarity index 100%
rename from packages/contracts/test/tests/unit/upgrade/admin.test.ts
rename to packages/contracts-test/tests/unit/upgrade/admin.test.ts
diff --git a/packages/contracts/test/tsconfig.json b/packages/contracts-test/tsconfig.json
similarity index 100%
rename from packages/contracts/test/tsconfig.json
rename to packages/contracts-test/tsconfig.json
diff --git a/packages/contracts/test/utils/coverage.ts b/packages/contracts-test/utils/coverage.ts
similarity index 100%
rename from packages/contracts/test/utils/coverage.ts
rename to packages/contracts-test/utils/coverage.ts
diff --git a/packages/contracts/.solhint.json b/packages/contracts/.solhint.json
index d30847305..780d82f39 100644
--- a/packages/contracts/.solhint.json
+++ b/packages/contracts/.solhint.json
@@ -1,3 +1,3 @@
{
- "extends": ["solhint:recommended", "./../../.solhint.json"]
+ "extends": "./../../.solhint.json"
}
diff --git a/packages/contracts/contracts/arbitrum/AddressAliasHelper.sol b/packages/contracts/contracts/arbitrum/AddressAliasHelper.sol
index 740b70361..005df41c0 100644
--- a/packages/contracts/contracts/arbitrum/AddressAliasHelper.sol
+++ b/packages/contracts/contracts/arbitrum/AddressAliasHelper.sol
@@ -25,8 +25,15 @@
pragma solidity ^0.7.6;
+/**
+ * @title Address Alias Helper Library
+ * @author Edge & Node
+ * @notice Utility library for converting addresses between L1 and L2 in Arbitrum
+ */
library AddressAliasHelper {
- uint160 constant offset = uint160(0x1111000000000000000000000000000000001111);
+ /// @dev Offset used for L1 to L2 address aliasing
+ // solhint-disable-next-line const-name-snakecase
+ uint160 internal constant offset = uint160(0x1111000000000000000000000000000000001111);
/// @notice Utility function that converts the address in the L1 that submitted a tx to
/// the inbox to the msg.sender viewed in the L2
diff --git a/packages/contracts/contracts/arbitrum/IArbToken.sol b/packages/contracts/contracts/arbitrum/IArbToken.sol
deleted file mode 100644
index d7d5a2d8c..000000000
--- a/packages/contracts/contracts/arbitrum/IArbToken.sol
+++ /dev/null
@@ -1,47 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-
-/*
- * Copyright 2020, Offchain Labs, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- * Originally copied from:
- * https://github.com/OffchainLabs/arbitrum/tree/e3a6307ad8a2dc2cad35728a2a9908cfd8dd8ef9/packages/arb-bridge-peripherals
- *
- * MODIFIED from Offchain Labs' implementation:
- * - Changed solidity version to 0.7.6 (pablo@edgeandnode.com)
- *
- */
-
-/**
- * @title Minimum expected interface for L2 token that interacts with the L2 token bridge (this is the interface necessary
- * for a custom token that interacts with the bridge, see TestArbCustomToken.sol for an example implementation).
- */
-pragma solidity ^0.7.6;
-
-interface IArbToken {
- /**
- * @notice should increase token supply by amount, and should (probably) only be callable by the L1 bridge.
- */
- function bridgeMint(address account, uint256 amount) external;
-
- /**
- * @notice should decrease token supply by amount, and should (probably) only be callable by the L1 bridge.
- */
- function bridgeBurn(address account, uint256 amount) external;
-
- /**
- * @return address of layer 1 token
- */
- function l1Address() external view returns (address);
-}
diff --git a/packages/contracts/contracts/arbitrum/IBridge.sol b/packages/contracts/contracts/arbitrum/IBridge.sol
deleted file mode 100644
index 536ee075b..000000000
--- a/packages/contracts/contracts/arbitrum/IBridge.sol
+++ /dev/null
@@ -1,72 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-
-/*
- * Copyright 2021, Offchain Labs, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- * Originally copied from:
- * https://github.com/OffchainLabs/arbitrum/tree/e3a6307ad8a2dc2cad35728a2a9908cfd8dd8ef9/packages/arb-bridge-eth
- *
- * MODIFIED from Offchain Labs' implementation:
- * - Changed solidity version to 0.7.6 (pablo@edgeandnode.com)
- *
- */
-
-pragma solidity ^0.7.6;
-
-interface IBridge {
- event MessageDelivered(
- uint256 indexed messageIndex,
- bytes32 indexed beforeInboxAcc,
- address inbox,
- uint8 kind,
- address sender,
- bytes32 messageDataHash
- );
-
- event BridgeCallTriggered(address indexed outbox, address indexed destAddr, uint256 amount, bytes data);
-
- event InboxToggle(address indexed inbox, bool enabled);
-
- event OutboxToggle(address indexed outbox, bool enabled);
-
- function deliverMessageToInbox(
- uint8 kind,
- address sender,
- bytes32 messageDataHash
- ) external payable returns (uint256);
-
- function executeCall(
- address destAddr,
- uint256 amount,
- bytes calldata data
- ) external returns (bool success, bytes memory returnData);
-
- // These are only callable by the admin
- function setInbox(address inbox, bool enabled) external;
-
- function setOutbox(address inbox, bool enabled) external;
-
- // View functions
-
- function activeOutbox() external view returns (address);
-
- function allowedInboxes(address inbox) external view returns (bool);
-
- function allowedOutboxes(address outbox) external view returns (bool);
-
- function inboxAccs(uint256 index) external view returns (bytes32);
-
- function messageCount() external view returns (uint256);
-}
diff --git a/packages/contracts/contracts/arbitrum/IInbox.sol b/packages/contracts/contracts/arbitrum/IInbox.sol
deleted file mode 100644
index a9315bbf8..000000000
--- a/packages/contracts/contracts/arbitrum/IInbox.sol
+++ /dev/null
@@ -1,88 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-
-/*
- * Copyright 2021, Offchain Labs, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- * Originally copied from:
- * https://github.com/OffchainLabs/arbitrum/tree/e3a6307ad8a2dc2cad35728a2a9908cfd8dd8ef9/packages/arb-bridge-eth
- *
- * MODIFIED from Offchain Labs' implementation:
- * - Changed solidity version to 0.7.6 (pablo@edgeandnode.com)
- *
- */
-
-pragma solidity ^0.7.6;
-
-import "./IBridge.sol";
-import "./IMessageProvider.sol";
-
-interface IInbox is IMessageProvider {
- function sendL2Message(bytes calldata messageData) external returns (uint256);
-
- function sendUnsignedTransaction(
- uint256 maxGas,
- uint256 gasPriceBid,
- uint256 nonce,
- address destAddr,
- uint256 amount,
- bytes calldata data
- ) external returns (uint256);
-
- function sendContractTransaction(
- uint256 maxGas,
- uint256 gasPriceBid,
- address destAddr,
- uint256 amount,
- bytes calldata data
- ) external returns (uint256);
-
- function sendL1FundedUnsignedTransaction(
- uint256 maxGas,
- uint256 gasPriceBid,
- uint256 nonce,
- address destAddr,
- bytes calldata data
- ) external payable returns (uint256);
-
- function sendL1FundedContractTransaction(
- uint256 maxGas,
- uint256 gasPriceBid,
- address destAddr,
- bytes calldata data
- ) external payable returns (uint256);
-
- function createRetryableTicket(
- address destAddr,
- uint256 arbTxCallValue,
- uint256 maxSubmissionCost,
- address submissionRefundAddress,
- address valueRefundAddress,
- uint256 maxGas,
- uint256 gasPriceBid,
- bytes calldata data
- ) external payable returns (uint256);
-
- function depositEth(uint256 maxSubmissionCost) external payable returns (uint256);
-
- function bridge() external view returns (IBridge);
-
- function pauseCreateRetryables() external;
-
- function unpauseCreateRetryables() external;
-
- function startRewriteAddress() external;
-
- function stopRewriteAddress() external;
-}
diff --git a/packages/contracts/contracts/arbitrum/IMessageProvider.sol b/packages/contracts/contracts/arbitrum/IMessageProvider.sol
deleted file mode 100644
index 8fbfdb171..000000000
--- a/packages/contracts/contracts/arbitrum/IMessageProvider.sol
+++ /dev/null
@@ -1,32 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-
-/*
- * Copyright 2021, Offchain Labs, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- * Originally copied from:
- * https://github.com/OffchainLabs/arbitrum/tree/e3a6307ad8a2dc2cad35728a2a9908cfd8dd8ef9/packages/arb-bridge-eth
- *
- * MODIFIED from Offchain Labs' implementation:
- * - Changed solidity version to 0.7.6 (pablo@edgeandnode.com)
- *
- */
-
-pragma solidity ^0.7.6;
-
-interface IMessageProvider {
- event InboxMessageDelivered(uint256 indexed messageNum, bytes data);
-
- event InboxMessageDeliveredFromOrigin(uint256 indexed messageNum);
-}
diff --git a/packages/contracts/contracts/arbitrum/IOutbox.sol b/packages/contracts/contracts/arbitrum/IOutbox.sol
deleted file mode 100644
index 2e4f05bd5..000000000
--- a/packages/contracts/contracts/arbitrum/IOutbox.sol
+++ /dev/null
@@ -1,57 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-
-/*
- * Copyright 2021, Offchain Labs, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- * Originally copied from:
- * https://github.com/OffchainLabs/arbitrum/tree/e3a6307ad8a2dc2cad35728a2a9908cfd8dd8ef9/packages/arb-bridge-eth
- *
- * MODIFIED from Offchain Labs' implementation:
- * - Changed solidity version to 0.7.6 (pablo@edgeandnode.com)
- *
- */
-
-pragma solidity ^0.7.6;
-
-interface IOutbox {
- event OutboxEntryCreated(
- uint256 indexed batchNum,
- uint256 outboxEntryIndex,
- bytes32 outputRoot,
- uint256 numInBatch
- );
- event OutBoxTransactionExecuted(
- address indexed destAddr,
- address indexed l2Sender,
- uint256 indexed outboxEntryIndex,
- uint256 transactionIndex
- );
-
- function l2ToL1Sender() external view returns (address);
-
- function l2ToL1Block() external view returns (uint256);
-
- function l2ToL1EthBlock() external view returns (uint256);
-
- function l2ToL1Timestamp() external view returns (uint256);
-
- function l2ToL1BatchNum() external view returns (uint256);
-
- function l2ToL1OutputId() external view returns (bytes32);
-
- function processOutgoingMessages(bytes calldata sendsData, uint256[] calldata sendLengths) external;
-
- function outboxEntryExists(uint256 batchNum) external view returns (bool);
-}
diff --git a/packages/contracts/contracts/arbitrum/ITokenGateway.sol b/packages/contracts/contracts/arbitrum/ITokenGateway.sol
deleted file mode 100644
index 3b12e578e..000000000
--- a/packages/contracts/contracts/arbitrum/ITokenGateway.sol
+++ /dev/null
@@ -1,74 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-
-/*
- * Copyright 2020, Offchain Labs, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- * Originally copied from:
- * https://github.com/OffchainLabs/arbitrum/tree/e3a6307ad8a2dc2cad35728a2a9908cfd8dd8ef9/packages/arb-bridge-peripherals
- *
- * MODIFIED from Offchain Labs' implementation:
- * - Changed solidity version to 0.7.6 (pablo@edgeandnode.com)
- *
- */
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-interface ITokenGateway {
- /// @notice event deprecated in favor of DepositInitiated and WithdrawalInitiated
- // event OutboundTransferInitiated(
- // address token,
- // address indexed _from,
- // address indexed _to,
- // uint256 indexed _transferId,
- // uint256 _amount,
- // bytes _data
- // );
-
- /// @notice event deprecated in favor of DepositFinalized and WithdrawalFinalized
- // event InboundTransferFinalized(
- // address token,
- // address indexed _from,
- // address indexed _to,
- // uint256 indexed _transferId,
- // uint256 _amount,
- // bytes _data
- // );
-
- function outboundTransfer(
- address token,
- address to,
- uint256 amunt,
- uint256 maxas,
- uint256 gasPiceBid,
- bytes calldata data
- ) external payable returns (bytes memory);
-
- function finalizeInboundTransfer(
- address token,
- address from,
- address to,
- uint256 amount,
- bytes calldata data
- ) external payable;
-
- /**
- * @notice Calculate the address used when bridging an ERC20 token
- * @dev the L1 and L2 address oracles may not always be in sync.
- * For example, a custom token may have been registered but not deployed or the contract self destructed.
- * @param l1ERC20 address of L1 token
- * @return L2 address of a bridged ERC20 token
- */
- function calculateL2TokenAddress(address l1ERC20) external view returns (address);
-}
diff --git a/packages/contracts/contracts/arbitrum/L1ArbitrumMessenger.sol b/packages/contracts/contracts/arbitrum/L1ArbitrumMessenger.sol
index 839e1930b..0428e9b87 100644
--- a/packages/contracts/contracts/arbitrum/L1ArbitrumMessenger.sol
+++ b/packages/contracts/contracts/arbitrum/L1ArbitrumMessenger.sol
@@ -25,20 +25,49 @@
pragma solidity ^0.7.6;
-import "./IInbox.sol";
-import "./IOutbox.sol";
+import { IInbox } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/IInbox.sol";
+import { IOutbox } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/IOutbox.sol";
+import { IBridge } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/IBridge.sol";
-/// @notice L1 utility contract to assist with L1 <=> L2 interactions
-/// @dev this is an abstract contract instead of library so the functions can be easily overridden when testing
+/**
+ * @title L1 Arbitrum Messenger
+ * @author Edge & Node
+ * @notice L1 utility contract to assist with L1 <=> L2 interactions
+ * @dev this is an abstract contract instead of library so the functions can be easily overridden when testing
+ */
abstract contract L1ArbitrumMessenger {
+ /**
+ * @notice Emitted when a transaction is sent to L2
+ * @param _from Address sending the transaction
+ * @param _to Address receiving the transaction on L2
+ * @param _seqNum Sequence number of the retryable ticket
+ * @param _data Transaction data
+ */
event TxToL2(address indexed _from, address indexed _to, uint256 indexed _seqNum, bytes _data);
+ /**
+ * @dev Parameters for L2 gas configuration
+ * @param _maxSubmissionCost Maximum cost for submitting the transaction
+ * @param _maxGas Maximum gas for the L2 transaction
+ * @param _gasPriceBid Gas price bid for the L2 transaction
+ */
struct L2GasParams {
uint256 _maxSubmissionCost;
uint256 _maxGas;
uint256 _gasPriceBid;
}
+ /**
+ * @notice Send a transaction to L2 using gas parameters struct
+ * @param _inbox Address of the inbox contract
+ * @param _to Destination address on L2
+ * @param _user Address that will be credited as the sender
+ * @param _l1CallValue ETH value to send with the L1 transaction
+ * @param _l2CallValue ETH value to send with the L2 transaction
+ * @param _l2GasParams Gas parameters for the L2 transaction
+ * @param _data Calldata for the L2 transaction
+ * @return Sequence number of the retryable ticket
+ */
function sendTxToL2(
address _inbox,
address _to,
@@ -63,6 +92,19 @@ abstract contract L1ArbitrumMessenger {
);
}
+ /**
+ * @notice Send a transaction to L2 with individual gas parameters
+ * @param _inbox Address of the inbox contract
+ * @param _to Destination address on L2
+ * @param _user Address that will be credited as the sender
+ * @param _l1CallValue ETH value to send with the L1 transaction
+ * @param _l2CallValue ETH value to send with the L2 transaction
+ * @param _maxSubmissionCost Maximum cost for submitting the transaction
+ * @param _maxGas Maximum gas for the L2 transaction
+ * @param _gasPriceBid Gas price bid for the L2 transaction
+ * @param _data Calldata for the L2 transaction
+ * @return Sequence number of the retryable ticket
+ */
function sendTxToL2(
address _inbox,
address _to,
@@ -88,11 +130,21 @@ abstract contract L1ArbitrumMessenger {
return seqNum;
}
+ /**
+ * @notice Get the bridge contract from an inbox
+ * @param _inbox Address of the inbox contract
+ * @return Bridge contract interface
+ */
function getBridge(address _inbox) internal view virtual returns (IBridge) {
return IInbox(_inbox).bridge();
}
- /// @dev the l2ToL1Sender behaves as the tx.origin, the msg.sender should be validated to protect against reentrancies
+ /**
+ * @notice Get the L2 to L1 sender address from the outbox
+ * @dev the l2ToL1Sender behaves as the tx.origin, the msg.sender should be validated to protect against reentrancies
+ * @param _inbox Address of the inbox contract
+ * @return Address of the L2 to L1 sender
+ */
function getL2ToL1Sender(address _inbox) internal view virtual returns (address) {
IOutbox outbox = IOutbox(getBridge(_inbox).activeOutbox());
address l2ToL1Sender = outbox.l2ToL1Sender();
diff --git a/packages/contracts/contracts/arbitrum/L2ArbitrumMessenger.sol b/packages/contracts/contracts/arbitrum/L2ArbitrumMessenger.sol
index e34a29262..ac6774748 100644
--- a/packages/contracts/contracts/arbitrum/L2ArbitrumMessenger.sol
+++ b/packages/contracts/contracts/arbitrum/L2ArbitrumMessenger.sol
@@ -25,15 +25,35 @@
pragma solidity ^0.7.6;
-import "arbos-precompiles/arbos/builtin/ArbSys.sol";
+import { ArbSys } from "arbos-precompiles/arbos/builtin/ArbSys.sol";
-/// @notice L2 utility contract to assist with L1 <=> L2 interactions
-/// @dev this is an abstract contract instead of library so the functions can be easily overridden when testing
+/**
+ * @title L2 Arbitrum Messenger
+ * @author Edge & Node
+ * @notice L2 utility contract to assist with L1 <=> L2 interactions
+ * @dev this is an abstract contract instead of library so the functions can be easily overridden when testing
+ */
abstract contract L2ArbitrumMessenger {
+ /// @dev Address of the ArbSys precompile
address internal constant ARB_SYS_ADDRESS = address(100);
+ /**
+ * @notice Emitted when a transaction is sent to L1
+ * @param _from Address sending the transaction
+ * @param _to Address receiving the transaction on L1
+ * @param _id ID of the L2 to L1 message
+ * @param _data Transaction data
+ */
event TxToL1(address indexed _from, address indexed _to, uint256 indexed _id, bytes _data);
+ /**
+ * @notice Send a transaction from L2 to L1
+ * @param _l1CallValue ETH value to send with the L1 transaction
+ * @param _from Address that is sending the transaction
+ * @param _to Destination address on L1
+ * @param _data Calldata for the L1 transaction
+ * @return ID of the L2 to L1 message
+ */
function sendTxToL1(
uint256 _l1CallValue,
address _from,
diff --git a/packages/contracts/contracts/bancor/BancorFormula.sol b/packages/contracts/contracts/bancor/BancorFormula.sol
index 689eebaba..0d221be56 100644
--- a/packages/contracts/contracts/bancor/BancorFormula.sol
+++ b/packages/contracts/contracts/bancor/BancorFormula.sol
@@ -2,35 +2,55 @@
pragma solidity ^0.7.6;
-import "@openzeppelin/contracts/math/SafeMath.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable function-max-lines, gas-increment-by-one, gas-strict-inequalities
+import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol";
+
+/**
+ * @title Bancor Formula Contract
+ * @author Edge & Node
+ * @notice Contract implementing Bancor's bonding curve formula for token conversion
+ */
contract BancorFormula {
using SafeMath for uint256;
- uint16 public constant version = 6;
+ /// @notice Version of the Bancor formula implementation
+ uint16 public constant version = 6; // solhint-disable-line const-name-snakecase
+ /// @dev Constant representing the value 1
uint256 private constant ONE = 1;
+ /// @dev Maximum ratio value (100% in parts per million)
uint32 private constant MAX_RATIO = 1000000;
+ /// @dev Minimum precision for calculations
uint8 private constant MIN_PRECISION = 32;
+ /// @dev Maximum precision for calculations
uint8 private constant MAX_PRECISION = 127;
/**
* @dev Auto-generated via 'PrintIntScalingFactors.py'
*/
+ /// @dev Fixed point representation of 1 (2^127)
uint256 private constant FIXED_1 = 0x080000000000000000000000000000000;
+ /// @dev Fixed point representation of 2 (2^128)
uint256 private constant FIXED_2 = 0x100000000000000000000000000000000;
+ /// @dev Maximum number for calculations (2^129)
uint256 private constant MAX_NUM = 0x200000000000000000000000000000000;
/**
* @dev Auto-generated via 'PrintLn2ScalingFactors.py'
*/
+ /// @dev Natural logarithm of 2 numerator for fixed point calculations
uint256 private constant LN2_NUMERATOR = 0x3f80fe03f80fe03f80fe03f80fe03f8;
+ /// @dev Natural logarithm of 2 denominator for fixed point calculations
uint256 private constant LN2_DENOMINATOR = 0x5b9de1d10bf4103d647b0955897ba80;
/**
* @dev Auto-generated via 'PrintFunctionOptimalLog.py' and 'PrintFunctionOptimalExp.py'
*/
+ /// @dev Maximum value for optimal logarithm calculation
uint256 private constant OPT_LOG_MAX_VAL = 0x15bf0a8b1457695355fb8ac404e7a79e3;
+ /// @dev Maximum value for optimal exponentiation calculation
uint256 private constant OPT_EXP_MAX_VAL = 0x800000000000000000000000000000000;
/**
@@ -38,6 +58,7 @@ contract BancorFormula {
*/
uint256[128] private maxExpArray;
+ /// @notice Initialize the Bancor formula with maximum exponent array values
constructor() {
// maxExpArray[ 0] = 0x6bffffffffffffffffffffffffffffffff;
// maxExpArray[ 1] = 0x67ffffffffffffffffffffffffffffffff;
@@ -170,7 +191,7 @@ contract BancorFormula {
}
/**
- * @dev given a token supply, reserve balance, ratio and a deposit amount (in the reserve token),
+ * @notice Given a token supply, reserve balance, ratio and a deposit amount (in the reserve token),
* calculates the return for a given conversion (in the main token)
*
* Formula:
@@ -210,7 +231,7 @@ contract BancorFormula {
}
/**
- * @dev given a token supply, reserve balance, ratio and a sell amount (in the main token),
+ * @notice Given a token supply, reserve balance, ratio and a sell amount (in the main token),
* calculates the return for a given conversion (in the reserve token)
*
* Formula:
@@ -258,7 +279,7 @@ contract BancorFormula {
}
/**
- * @dev given two reserve balances/ratios and a sell amount (in the first reserve token),
+ * @notice Given two reserve balances/ratios and a sell amount (in the first reserve token),
* calculates the return for a conversion from the first reserve token to the second reserve token (in the second reserve token)
* note that prior to version 4, you should use 'calculateCrossConnectorReturn' instead
*
@@ -304,7 +325,7 @@ contract BancorFormula {
}
/**
- * @dev given a smart token supply, reserve balance, total ratio and an amount of requested smart tokens,
+ * @notice Given a smart token supply, reserve balance, total ratio and an amount of requested smart tokens,
* calculates the amount of reserve tokens required for purchasing the given amount of smart tokens
*
* Formula:
@@ -341,7 +362,7 @@ contract BancorFormula {
}
/**
- * @dev given a smart token supply, reserve balance, total ratio and an amount of smart tokens to liquidate,
+ * @notice Given a smart token supply, reserve balance, total ratio and an amount of smart tokens to liquidate,
* calculates the amount of reserve tokens received for selling the given amount of smart tokens
*
* Formula:
@@ -384,7 +405,7 @@ contract BancorFormula {
}
/**
- * @dev General Description:
+ * @notice General Description:
* Determine a value of precision.
* Calculate an integer approximation of (_baseN / _baseD) ^ (_expN / _expD) * 2 ^ precision.
* Return the result along with the precision used.
@@ -400,6 +421,12 @@ contract BancorFormula {
* This allows us to compute "base ^ exp" with maximum accuracy and without exceeding 256 bits in any of the intermediate computations.
* This functions assumes that "_expN < 2 ^ 256 / log(MAX_NUM - 1)", otherwise the multiplication should be replaced with a "safeMul".
* Since we rely on unsigned-integer arithmetic and "base < 1" ==> "log(base) < 0", this function does not support "_baseN < _baseD".
+ * @param _baseN Base numerator
+ * @param _baseD Base denominator
+ * @param _expN Exponent numerator
+ * @param _expD Exponent denominator
+ * @return result The computed power result
+ * @return precision The precision used in the calculation
*/
function power(uint256 _baseN, uint256 _baseD, uint32 _expN, uint32 _expD) internal view returns (uint256, uint8) {
require(_baseN < MAX_NUM);
@@ -422,8 +449,10 @@ contract BancorFormula {
}
/**
- * @dev computes log(x / FIXED_1) * FIXED_1.
+ * @notice Computes log(x / FIXED_1) * FIXED_1.
* This functions assumes that "x >= FIXED_1", because the output would be negative otherwise.
+ * @param x The input value (must be >= FIXED_1)
+ * @return The computed logarithm
*/
function generalLog(uint256 x) internal pure returns (uint256) {
uint256 res = 0;
@@ -450,7 +479,9 @@ contract BancorFormula {
}
/**
- * @dev computes the largest integer smaller than or equal to the binary logarithm of the input.
+ * @notice Computes the largest integer smaller than or equal to the binary logarithm of the input.
+ * @param _n The input value
+ * @return The floor of the binary logarithm
*/
function floorLog2(uint256 _n) internal pure returns (uint8) {
uint8 res = 0;
@@ -475,9 +506,11 @@ contract BancorFormula {
}
/**
- * @dev the global "maxExpArray" is sorted in descending order, and therefore the following statements are equivalent:
+ * @notice The global "maxExpArray" is sorted in descending order, and therefore the following statements are equivalent:
* - This function finds the position of [the smallest value in "maxExpArray" larger than or equal to "x"]
* - This function finds the highest position of [a value in "maxExpArray" larger than or equal to "x"]
+ * @param _x The value to find position for
+ * @return The position in the maxExpArray
*/
function findPositionInMaxExpArray(uint256 _x) internal view returns (uint8) {
uint8 lo = MIN_PRECISION;
@@ -497,11 +530,14 @@ contract BancorFormula {
}
/**
- * @dev this function can be auto-generated by the script 'PrintFunctionGeneralExp.py'.
+ * @notice This function can be auto-generated by the script 'PrintFunctionGeneralExp.py'.
* it approximates "e ^ x" via maclaurin summation: "(x^0)/0! + (x^1)/1! + ... + (x^n)/n!".
* it returns "e ^ (x / 2 ^ precision) * 2 ^ precision", that is, the result is upshifted for accuracy.
* the global "maxExpArray" maps each "precision" to "((maximumExponent + 1) << (MAX_PRECISION - precision)) - 1".
* the maximum permitted value for "x" is therefore given by "maxExpArray[precision] >> (MAX_PRECISION - precision)".
+ * @param _x The exponent value
+ * @param _precision The precision to use
+ * @return The computed exponential result
*/
function generalExp(uint256 _x, uint8 _precision) internal pure returns (uint256) {
uint256 xi = _x;
@@ -576,7 +612,7 @@ contract BancorFormula {
}
/**
- * @dev computes log(x / FIXED_1) * FIXED_1
+ * @notice Computes log(x / FIXED_1) * FIXED_1
* Input range: FIXED_1 <= x <= LOG_EXP_MAX_VAL - 1
* Auto-generated via 'PrintFunctionOptimalLog.py'
* Detailed description:
@@ -585,6 +621,8 @@ contract BancorFormula {
* - The natural logarithm of r is calculated via Taylor series for log(1 + x), where x = r - 1
* - The natural logarithm of the input is calculated by summing up the intermediate results above
* - For example: log(250) = log(e^4 * e^1 * e^0.5 * 1.021692859) = 4 + 1 + 0.5 + log(1 + 0.021692859)
+ * @param x The input value
+ * @return The computed logarithm
*/
function optimalLog(uint256 x) internal pure returns (uint256) {
uint256 res = 0;
@@ -648,7 +686,7 @@ contract BancorFormula {
}
/**
- * @dev computes e ^ (x / FIXED_1) * FIXED_1
+ * @notice Computes e ^ (x / FIXED_1) * FIXED_1
* input range: 0 <= x <= OPT_EXP_MAX_VAL - 1
* auto-generated via 'PrintFunctionOptimalExp.py'
* Detailed description:
@@ -657,6 +695,8 @@ contract BancorFormula {
* - The exponentiation of r is calculated via Taylor series for e^x, where x = r
* - The exponentiation of the input is calculated by multiplying the intermediate results above
* - For example: e^5.521692859 = e^(4 + 1 + 0.5 + 0.021692859) = e^4 * e^1 * e^0.5 * e^0.021692859
+ * @param x The input value
+ * @return The computed exponential result
*/
function optimalExp(uint256 x) internal pure returns (uint256) {
uint256 res = 0;
@@ -724,7 +764,13 @@ contract BancorFormula {
}
/**
- * @dev deprecated, backward compatibility
+ * @notice Deprecated function for backward compatibility
+ * @param _fromConnectorBalance input connector balance
+ * @param _fromConnectorWeight input connector weight
+ * @param _toConnectorBalance output connector balance
+ * @param _toConnectorWeight output connector weight
+ * @param _amount input connector amount
+ * @return output connector amount
*/
function calculateCrossConnectorReturn(
uint256 _fromConnectorBalance,
diff --git a/packages/contracts/contracts/base/IMulticall.sol b/packages/contracts/contracts/base/IMulticall.sol
deleted file mode 100644
index 10f7fa469..000000000
--- a/packages/contracts/contracts/base/IMulticall.sol
+++ /dev/null
@@ -1,17 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-pragma abicoder v2;
-
-/**
- * @title Multicall interface
- * @notice Enables calling multiple methods in a single call to the contract
- */
-interface IMulticall {
- /**
- * @notice Call multiple functions in the current contract and return the data from all of them if they all succeed
- * @param data The encoded function data for each of the calls to make to this contract
- * @return results The results from each of the calls passed in via data
- */
- function multicall(bytes[] calldata data) external returns (bytes[] memory results);
-}
diff --git a/packages/contracts/contracts/base/Multicall.sol b/packages/contracts/contracts/base/Multicall.sol
index 49111840d..808f7695f 100644
--- a/packages/contracts/contracts/base/Multicall.sol
+++ b/packages/contracts/contracts/base/Multicall.sol
@@ -3,13 +3,17 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
-import "./IMulticall.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-increment-by-one
+
+import { IMulticall } from "@graphprotocol/interfaces/contracts/contracts/base/IMulticall.sol";
// Inspired by https://github.com/Uniswap/uniswap-v3-periphery/blob/main/contracts/base/Multicall.sol
// Note: Removed payable from the multicall
/**
* @title Multicall
+ * @author Edge & Node
* @notice Enables calling multiple methods in a single call to the contract
*/
abstract contract Multicall is IMulticall {
@@ -17,11 +21,12 @@ abstract contract Multicall is IMulticall {
function multicall(bytes[] calldata data) external override returns (bytes[] memory results) {
results = new bytes[](data.length);
for (uint256 i = 0; i < data.length; i++) {
- (bool success, bytes memory result) = address(this).delegatecall(data[i]);
+ (bool success, bytes memory result) = address(this).delegatecall(data[i]); // solhint-disable-line avoid-low-level-calls
if (!success) {
// Next 5 lines from https://ethereum.stackexchange.com/a/83577
if (result.length < 68) revert();
+ // solhint-disable-next-line no-inline-assembly
assembly {
result := add(result, 0x04)
}
diff --git a/packages/contracts/contracts/curation/Curation.sol b/packages/contracts/contracts/curation/Curation.sol
index 827c230b7..e7aac2cc2 100644
--- a/packages/contracts/contracts/curation/Curation.sol
+++ b/packages/contracts/contracts/curation/Curation.sol
@@ -3,6 +3,9 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-indexed-events, gas-small-strings, gas-strict-inequalities
+
import { AddressUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/AddressUpgradeable.sol";
import { SafeMathUpgradeable } from "@openzeppelin/contracts-upgradeable/math/SafeMathUpgradeable.sol";
import { ClonesUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/ClonesUpgradeable.sol";
@@ -10,15 +13,16 @@ import { ClonesUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/Clo
import { BancorFormula } from "../bancor/BancorFormula.sol";
import { GraphUpgradeable } from "../upgrades/GraphUpgradeable.sol";
import { TokenUtils } from "../utils/TokenUtils.sol";
-import { IRewardsManager } from "../rewards/IRewardsManager.sol";
+import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol";
import { Managed } from "../governance/Managed.sol";
-import { IGraphToken } from "../token/IGraphToken.sol";
+import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol";
import { CurationV2Storage } from "./CurationStorage.sol";
-import { IGraphCurationToken } from "./IGraphCurationToken.sol";
+import { IGraphCurationToken } from "@graphprotocol/interfaces/contracts/contracts/curation/IGraphCurationToken.sol";
/**
* @title Curation contract
- * @dev Allows curators to signal on subgraph deployments that might be relevant to indexers by
+ * @author Edge & Node
+ * @notice Allows curators to signal on subgraph deployments that might be relevant to indexers by
* staking Graph Tokens (GRT). Additionally, curators earn fees from the Query Market related to the
* subgraph deployment they curate.
* A curators deposit goes to a curation pool along with the deposits of other curators,
@@ -40,9 +44,14 @@ contract Curation is CurationV2Storage, GraphUpgradeable {
// -- Events --
/**
- * @dev Emitted when `curator` deposited `tokens` on `subgraphDeploymentID` as curation signal.
+ * @notice Emitted when `curator` deposited `tokens` on `subgraphDeploymentID` as curation signal.
* The `curator` receives `signal` amount according to the curation pool bonding curve.
* An amount of `curationTax` will be collected and burned.
+ * @param curator Address of the curator
+ * @param subgraphDeploymentID Subgraph deployment being signaled on
+ * @param tokens Amount of tokens deposited
+ * @param signal Amount of signal minted
+ * @param curationTax Amount of tokens burned as curation tax
*/
event Signalled(
address indexed curator,
@@ -53,14 +62,20 @@ contract Curation is CurationV2Storage, GraphUpgradeable {
);
/**
- * @dev Emitted when `curator` burned `signal` for a `subgraphDeploymentID`.
+ * @notice Emitted when `curator` burned `signal` for a `subgraphDeploymentID`.
* The curator will receive `tokens` according to the value of the bonding curve.
+ * @param curator Address of the curator
+ * @param subgraphDeploymentID Subgraph deployment being signaled on
+ * @param tokens Amount of tokens received
+ * @param signal Amount of signal burned
*/
event Burned(address indexed curator, bytes32 indexed subgraphDeploymentID, uint256 tokens, uint256 signal);
/**
- * @dev Emitted when `tokens` amount were collected for `subgraphDeploymentID` as part of fees
+ * @notice Emitted when `tokens` amount were collected for `subgraphDeploymentID` as part of fees
* distributed by an indexer from query fees received from state channels.
+ * @param subgraphDeploymentID Subgraph deployment that collected fees
+ * @param tokens Amount of tokens collected as fees
*/
event Collected(bytes32 indexed subgraphDeploymentID, uint256 tokens);
@@ -94,8 +109,7 @@ contract Curation is CurationV2Storage, GraphUpgradeable {
}
/**
- * @dev Set the default reserve ratio percentage for a curation pool.
- * @notice Update the default reserve ratio to `_defaultReserveRatio`
+ * @notice Set the default reserve ratio percentage for a curation pool.
* @param _defaultReserveRatio Reserve ratio (in PPM)
*/
function setDefaultReserveRatio(uint32 _defaultReserveRatio) external override onlyGovernor {
@@ -103,8 +117,7 @@ contract Curation is CurationV2Storage, GraphUpgradeable {
}
/**
- * @dev Set the minimum deposit amount for curators.
- * @notice Update the minimum deposit amount to `_minimumCurationDeposit`
+ * @notice Set the minimum deposit amount for curators.
* @param _minimumCurationDeposit Minimum amount of tokens required deposit
*/
function setMinimumCurationDeposit(uint256 _minimumCurationDeposit) external override onlyGovernor {
@@ -207,7 +220,6 @@ contract Curation is CurationV2Storage, GraphUpgradeable {
}
/**
- * @dev Return an amount of signal to get tokens back.
* @notice Burn _signal from the SubgraphDeployment curation pool
* @param _subgraphDeploymentID SubgraphDeployment the curator is returning signal
* @param _signalIn Amount of signal to return
@@ -313,7 +325,7 @@ contract Curation is CurationV2Storage, GraphUpgradeable {
}
/**
- * @dev Calculate amount of signal that can be bought with tokens in a curation pool.
+ * @notice Calculate amount of signal that can be bought with tokens in a curation pool.
* @param _subgraphDeploymentID Subgraph deployment to mint signal
* @param _tokensIn Amount of tokens used to mint signal
* @return Amount of signal that can be bought with tokens
@@ -367,7 +379,6 @@ contract Curation is CurationV2Storage, GraphUpgradeable {
}
/**
- * @dev Internal: Set the default reserve ratio percentage for a curation pool.
* @notice Update the default reserver ratio to `_defaultReserveRatio`
* @param _defaultReserveRatio Reserve ratio (in PPM)
*/
@@ -381,7 +392,6 @@ contract Curation is CurationV2Storage, GraphUpgradeable {
}
/**
- * @dev Internal: Set the minimum deposit amount for curators.
* @notice Update the minimum deposit amount to `_minimumCurationDeposit`
* @param _minimumCurationDeposit Minimum amount of tokens required deposit
*/
@@ -393,7 +403,7 @@ contract Curation is CurationV2Storage, GraphUpgradeable {
}
/**
- * @dev Internal: Set the curation tax percentage (in PPM) to charge when a curator deposits GRT tokens.
+ * @notice Internal: Set the curation tax percentage (in PPM) to charge when a curator deposits GRT tokens.
* @param _percentage Curation tax charged when depositing GRT tokens in PPM
*/
function _setCurationTaxPercentage(uint32 _percentage) private {
@@ -404,7 +414,7 @@ contract Curation is CurationV2Storage, GraphUpgradeable {
}
/**
- * @dev Internal: Set the master copy to use as clones for the curation token.
+ * @notice Internal: Set the master copy to use as clones for the curation token.
* @param _curationTokenMaster Address of implementation contract to use for curation tokens
*/
function _setCurationTokenMaster(address _curationTokenMaster) private {
@@ -416,7 +426,7 @@ contract Curation is CurationV2Storage, GraphUpgradeable {
}
/**
- * @dev Triggers an update of rewards due to a change in signal.
+ * @notice Triggers an update of rewards due to a change in signal.
* @param _subgraphDeploymentID Subgraph deployment updated
*/
function _updateRewards(bytes32 _subgraphDeploymentID) private {
diff --git a/packages/contracts/contracts/curation/CurationStorage.sol b/packages/contracts/contracts/curation/CurationStorage.sol
index 12f5b255b..67b302bfe 100644
--- a/packages/contracts/contracts/curation/CurationStorage.sol
+++ b/packages/contracts/contracts/curation/CurationStorage.sol
@@ -1,16 +1,21 @@
// SPDX-License-Identifier: GPL-2.0-or-later
+// solhint-disable one-contract-per-file
pragma solidity ^0.7.6;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable named-parameters-mapping
+
import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/Initializable.sol";
-import { ICuration } from "./ICuration.sol";
-import { IGraphCurationToken } from "./IGraphCurationToken.sol";
+import { ICuration } from "@graphprotocol/interfaces/contracts/contracts/curation/ICuration.sol";
+import { IGraphCurationToken } from "@graphprotocol/interfaces/contracts/contracts/curation/IGraphCurationToken.sol";
import { Managed } from "../governance/Managed.sol";
/**
* @title Curation Storage version 1
- * @dev This contract holds the first version of the storage variables
+ * @author Edge & Node
+ * @notice This contract holds the first version of the storage variables
* for the Curation and L2Curation contracts.
* When adding new variables, create a new version that inherits this and update
* the contracts to use the new version instead.
@@ -21,6 +26,9 @@ abstract contract CurationV1Storage is Managed, ICuration {
/**
* @dev CurationPool structure that holds the pool's state
* for a particular subgraph deployment.
+ * @param tokens GRT Tokens stored as reserves for the subgraph deployment
+ * @param reserveRatio Ratio for the bonding curve, unused and deprecated in L2 where it will always be 100% but appear as 0
+ * @param gcs Curation token contract for this curation pool
*/
struct CurationPool {
uint256 tokens; // GRT Tokens stored as reserves for the subgraph deployment
@@ -30,35 +38,36 @@ abstract contract CurationV1Storage is Managed, ICuration {
// -- State --
- /// Tax charged when curators deposit funds.
+ /// @notice Tax charged when curators deposit funds.
/// Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%)
uint32 public override curationTaxPercentage;
- /// Default reserve ratio to configure curator shares bonding curve
+ /// @notice Default reserve ratio to configure curator shares bonding curve
/// Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%).
/// Unused in L2.
uint32 public defaultReserveRatio;
- /// Master copy address that holds implementation of curation token.
+ /// @notice Master copy address that holds implementation of curation token.
/// @dev This is used as the target for GraphCurationToken clones.
address public curationTokenMaster;
- /// Minimum amount allowed to be deposited by curators to initialize a pool
+ /// @notice Minimum amount allowed to be deposited by curators to initialize a pool
/// @dev This is the `startPoolBalance` for the bonding curve
uint256 public minimumCurationDeposit;
- /// Bonding curve library
+ /// @notice Bonding curve library
/// Unused in L2.
address public bondingCurve;
- /// @dev Mapping of subgraphDeploymentID => CurationPool
+ /// @notice Mapping of subgraphDeploymentID => CurationPool
/// There is only one CurationPool per SubgraphDeploymentID
mapping(bytes32 => CurationPool) public pools;
}
/**
* @title Curation Storage version 2
- * @dev This contract holds the second version of the storage variables
+ * @author Edge & Node
+ * @notice This contract holds the second version of the storage variables
* for the Curation and L2Curation contracts.
* It doesn't add new variables at this contract's level, but adds the Initializable
* contract to the inheritance chain, which includes storage variables.
@@ -71,6 +80,8 @@ abstract contract CurationV2Storage is CurationV1Storage, Initializable {
/**
* @title Curation Storage version 3
+ * @author Edge & Node
+ * @notice This contract holds the third version of the storage variables for the Curation and L2Curation contracts
* @dev This contract holds the third version of the storage variables
* for the Curation and L2Curation contracts.
* It adds a new variable subgraphService to the storage.
@@ -78,6 +89,6 @@ abstract contract CurationV2Storage is CurationV1Storage, Initializable {
* the contracts to use the new version instead.
*/
abstract contract CurationV3Storage is CurationV2Storage {
- // Address of the subgraph service
+ /// @notice Address of the subgraph service
address public subgraphService;
}
diff --git a/packages/contracts/contracts/curation/GraphCurationToken.sol b/packages/contracts/contracts/curation/GraphCurationToken.sol
index 78b721e1b..108cc0680 100644
--- a/packages/contracts/contracts/curation/GraphCurationToken.sol
+++ b/packages/contracts/contracts/curation/GraphCurationToken.sol
@@ -2,13 +2,14 @@
pragma solidity ^0.7.6;
-import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
+import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
-import "../governance/Governed.sol";
+import { Governed } from "../governance/Governed.sol";
/**
* @title GraphCurationToken contract
- * @dev This is the implementation of the Curation ERC20 token (GCS).
+ * @author Edge & Node
+ * @notice This is the implementation of the Curation ERC20 token (GCS).
*
* GCS are created for each subgraph deployment curated in the Curation contract.
* The Curation contract is the owner of GCS tokens and the only one allowed to mint or
@@ -20,7 +21,7 @@ import "../governance/Governed.sol";
*/
contract GraphCurationToken is ERC20Upgradeable, Governed {
/**
- * @dev Graph Curation Token Contract initializer.
+ * @notice Graph Curation Token Contract initializer.
* @param _owner Address of the contract issuing this token
*/
function initialize(address _owner) external initializer {
@@ -29,7 +30,7 @@ contract GraphCurationToken is ERC20Upgradeable, Governed {
}
/**
- * @dev Mint new tokens.
+ * @notice Mint new tokens.
* @param _to Address to send the newly minted tokens
* @param _amount Amount of tokens to mint
*/
@@ -38,7 +39,7 @@ contract GraphCurationToken is ERC20Upgradeable, Governed {
}
/**
- * @dev Burn tokens from an address.
+ * @notice Burn tokens from an address.
* @param _account Address from where tokens will be burned
* @param _amount Amount of tokens to burn
*/
diff --git a/packages/contracts/contracts/curation/ICuration.sol b/packages/contracts/contracts/curation/ICuration.sol
deleted file mode 100644
index 4f2c2bac5..000000000
--- a/packages/contracts/contracts/curation/ICuration.sol
+++ /dev/null
@@ -1,123 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-/**
- * @title Curation Interface
- * @dev Interface for the Curation contract (and L2Curation too)
- */
-interface ICuration {
- // -- Configuration --
-
- /**
- * @notice Update the default reserve ratio to `_defaultReserveRatio`
- * @param _defaultReserveRatio Reserve ratio (in PPM)
- */
- function setDefaultReserveRatio(uint32 _defaultReserveRatio) external;
-
- /**
- * @notice Update the minimum deposit amount needed to intialize a new subgraph
- * @param _minimumCurationDeposit Minimum amount of tokens required deposit
- */
- function setMinimumCurationDeposit(uint256 _minimumCurationDeposit) external;
-
- /**
- * @notice Set the curation tax percentage to charge when a curator deposits GRT tokens.
- * @param _percentage Curation tax percentage charged when depositing GRT tokens
- */
- function setCurationTaxPercentage(uint32 _percentage) external;
-
- /**
- * @notice Set the master copy to use as clones for the curation token.
- * @param _curationTokenMaster Address of implementation contract to use for curation tokens
- */
- function setCurationTokenMaster(address _curationTokenMaster) external;
-
- // -- Curation --
-
- /**
- * @notice Deposit Graph Tokens in exchange for signal of a SubgraphDeployment curation pool.
- * @param _subgraphDeploymentID Subgraph deployment pool from where to mint signal
- * @param _tokensIn Amount of Graph Tokens to deposit
- * @param _signalOutMin Expected minimum amount of signal to receive
- * @return Amount of signal minted
- * @return Amount of curation tax burned
- */
- function mint(
- bytes32 _subgraphDeploymentID,
- uint256 _tokensIn,
- uint256 _signalOutMin
- ) external returns (uint256, uint256);
-
- /**
- * @notice Burn _signal from the SubgraphDeployment curation pool
- * @param _subgraphDeploymentID SubgraphDeployment the curator is returning signal
- * @param _signalIn Amount of signal to return
- * @param _tokensOutMin Expected minimum amount of tokens to receive
- * @return Tokens returned
- */
- function burn(bytes32 _subgraphDeploymentID, uint256 _signalIn, uint256 _tokensOutMin) external returns (uint256);
-
- /**
- * @notice Assign Graph Tokens collected as curation fees to the curation pool reserve.
- * @param _subgraphDeploymentID SubgraphDeployment where funds should be allocated as reserves
- * @param _tokens Amount of Graph Tokens to add to reserves
- */
- function collect(bytes32 _subgraphDeploymentID, uint256 _tokens) external;
-
- // -- Getters --
-
- /**
- * @notice Check if any GRT tokens are deposited for a SubgraphDeployment.
- * @param _subgraphDeploymentID SubgraphDeployment to check if curated
- * @return True if curated, false otherwise
- */
- function isCurated(bytes32 _subgraphDeploymentID) external view returns (bool);
-
- /**
- * @notice Get the amount of signal a curator has in a curation pool.
- * @param _curator Curator owning the signal tokens
- * @param _subgraphDeploymentID Subgraph deployment curation pool
- * @return Amount of signal owned by a curator for the subgraph deployment
- */
- function getCuratorSignal(address _curator, bytes32 _subgraphDeploymentID) external view returns (uint256);
-
- /**
- * @notice Get the amount of signal in a curation pool.
- * @param _subgraphDeploymentID Subgraph deployment curation pool
- * @return Amount of signal minted for the subgraph deployment
- */
- function getCurationPoolSignal(bytes32 _subgraphDeploymentID) external view returns (uint256);
-
- /**
- * @notice Get the amount of token reserves in a curation pool.
- * @param _subgraphDeploymentID Subgraph deployment curation pool
- * @return Amount of token reserves in the curation pool
- */
- function getCurationPoolTokens(bytes32 _subgraphDeploymentID) external view returns (uint256);
-
- /**
- * @notice Calculate amount of signal that can be bought with tokens in a curation pool.
- * This function considers and excludes the deposit tax.
- * @param _subgraphDeploymentID Subgraph deployment to mint signal
- * @param _tokensIn Amount of tokens used to mint signal
- * @return Amount of signal that can be bought
- * @return Amount of tokens that will be burned as curation tax
- */
- function tokensToSignal(bytes32 _subgraphDeploymentID, uint256 _tokensIn) external view returns (uint256, uint256);
-
- /**
- * @notice Calculate number of tokens to get when burning signal from a curation pool.
- * @param _subgraphDeploymentID Subgraph deployment to burn signal
- * @param _signalIn Amount of signal to burn
- * @return Amount of tokens to get for the specified amount of signal
- */
- function signalToTokens(bytes32 _subgraphDeploymentID, uint256 _signalIn) external view returns (uint256);
-
- /**
- * @notice Tax charged when curators deposit funds.
- * Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%)
- * @return Curation tax percentage expressed in PPM
- */
- function curationTaxPercentage() external view returns (uint32);
-}
diff --git a/packages/contracts/contracts/curation/IGraphCurationToken.sol b/packages/contracts/contracts/curation/IGraphCurationToken.sol
deleted file mode 100644
index 43679aba6..000000000
--- a/packages/contracts/contracts/curation/IGraphCurationToken.sol
+++ /dev/null
@@ -1,13 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6;
-
-import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol";
-
-interface IGraphCurationToken is IERC20Upgradeable {
- function initialize(address _owner) external;
-
- function burnFrom(address _account, uint256 _amount) external;
-
- function mint(address _to, uint256 _amount) external;
-}
diff --git a/packages/contracts/contracts/discovery/GNS.sol b/packages/contracts/contracts/discovery/GNS.sol
index 3cbb9ca8a..384bd3b66 100644
--- a/packages/contracts/contracts/discovery/GNS.sol
+++ b/packages/contracts/contracts/discovery/GNS.sol
@@ -3,22 +3,26 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable function-max-lines, gas-indexed-events, gas-small-strings, gas-strict-inequalities
+
import { SafeMathUpgradeable } from "@openzeppelin/contracts-upgradeable/math/SafeMathUpgradeable.sol";
import { AddressUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/AddressUpgradeable.sol";
import { Multicall } from "../base/Multicall.sol";
import { GraphUpgradeable } from "../upgrades/GraphUpgradeable.sol";
import { TokenUtils } from "../utils/TokenUtils.sol";
-import { ICuration } from "../curation/ICuration.sol";
+import { ICuration } from "@graphprotocol/interfaces/contracts/contracts/curation/ICuration.sol";
import { Managed } from "../governance/Managed.sol";
-import { ISubgraphNFT } from "./ISubgraphNFT.sol";
+import { ISubgraphNFT } from "@graphprotocol/interfaces/contracts/contracts/discovery/ISubgraphNFT.sol";
-import { IGNS } from "./IGNS.sol";
+import { IGNS } from "@graphprotocol/interfaces/contracts/contracts/discovery/IGNS.sol";
import { GNSV3Storage } from "./GNSStorage.sol";
/**
* @title GNS
- * @dev The Graph Name System contract provides a decentralized naming system for subgraphs
+ * @author Edge & Node
+ * @notice The Graph Name System contract provides a decentralized naming system for subgraphs
* used in the scope of the Graph Network. It translates Subgraphs into Subgraph Versions.
* Each version is associated with a Subgraph Deployment. The contract has no knowledge of
* human-readable names. All human readable names emitted in events.
@@ -34,15 +38,21 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
uint32 private constant MAX_PPM = 1000000;
/// @dev Equates to Connector weight on bancor formula to be CW = 1
+ // solhint-disable-next-line immutable-vars-naming
uint32 internal immutable fixedReserveRatio = MAX_PPM;
// -- Events --
- /// @dev Emitted when the subgraph NFT contract is updated
+ /// @notice Emitted when the subgraph NFT contract is updated
+ /// @param subgraphNFT Address of the new subgraph NFT contract
event SubgraphNFTUpdated(address subgraphNFT);
/**
- * @dev Emitted when graph account sets its default name
+ * @notice Emitted when graph account sets its default name
+ * @param graphAccount Address of the graph account
+ * @param nameSystem Name system identifier (only ENS for now)
+ * @param nameIdentifier Name identifier in the name system
+ * @param name Human-readable name
*/
event SetDefaultName(
address indexed graphAccount,
@@ -52,12 +62,17 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
);
/**
- * @dev Emitted when the subgraph metadata is updated.
+ * @notice Emitted when the subgraph metadata is updated.
+ * @param subgraphID ID of the subgraph
+ * @param subgraphMetadata IPFS hash of the subgraph metadata
*/
event SubgraphMetadataUpdated(uint256 indexed subgraphID, bytes32 subgraphMetadata);
/**
- * @dev Emitted when a subgraph version is updated.
+ * @notice Emitted when a subgraph version is updated.
+ * @param subgraphID ID of the subgraph
+ * @param subgraphDeploymentID Subgraph deployment ID for the new version
+ * @param versionMetadata IPFS hash of the version metadata
*/
event SubgraphVersionUpdated(
uint256 indexed subgraphID,
@@ -66,7 +81,12 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
);
/**
- * @dev Emitted when a curator mints signal.
+ * @notice Emitted when a curator mints signal.
+ * @param subgraphID ID of the subgraph
+ * @param curator Address of the curator
+ * @param nSignalCreated Amount of name signal created
+ * @param vSignalCreated Amount of version signal created
+ * @param tokensDeposited Amount of tokens deposited
*/
event SignalMinted(
uint256 indexed subgraphID,
@@ -77,7 +97,12 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
);
/**
- * @dev Emitted when a curator burns signal.
+ * @notice Emitted when a curator burns signal.
+ * @param subgraphID ID of the subgraph
+ * @param curator Address of the curator
+ * @param nSignalBurnt Amount of name signal burned
+ * @param vSignalBurnt Amount of version signal burned
+ * @param tokensReceived Amount of tokens received
*/
event SignalBurned(
uint256 indexed subgraphID,
@@ -88,7 +113,11 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
);
/**
- * @dev Emitted when a curator transfers signal.
+ * @notice Emitted when a curator transfers signal.
+ * @param subgraphID ID of the subgraph
+ * @param from Address transferring the signal
+ * @param to Address receiving the signal
+ * @param nSignalTransferred Amount of name signal transferred
*/
event SignalTransferred(
uint256 indexed subgraphID,
@@ -98,14 +127,21 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
);
/**
- * @dev Emitted when a subgraph is created.
+ * @notice Emitted when a subgraph is created.
+ * @param subgraphID ID of the subgraph
+ * @param subgraphDeploymentID Subgraph deployment ID
+ * @param reserveRatio Reserve ratio for the bonding curve
*/
event SubgraphPublished(uint256 indexed subgraphID, bytes32 indexed subgraphDeploymentID, uint32 reserveRatio);
/**
- * @dev Emitted when a subgraph is upgraded to point to a new
+ * @notice Emitted when a subgraph is upgraded to point to a new
* subgraph deployment, burning all the old vSignal and depositing the GRT into the
* new vSignal curve.
+ * @param subgraphID ID of the subgraph
+ * @param vSignalCreated Amount of version signal created in the new deployment
+ * @param tokensSignalled Amount of tokens signalled in the new deployment
+ * @param subgraphDeploymentID New subgraph deployment ID
*/
event SubgraphUpgraded(
uint256 indexed subgraphID,
@@ -115,29 +151,39 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
);
/**
- * @dev Emitted when a subgraph is deprecated.
+ * @notice Emitted when a subgraph is deprecated.
+ * @param subgraphID ID of the subgraph
+ * @param withdrawableGRT Amount of GRT available for withdrawal
*/
event SubgraphDeprecated(uint256 indexed subgraphID, uint256 withdrawableGRT);
/**
- * @dev Emitted when a curator withdraws GRT from a deprecated subgraph
+ * @notice Emitted when a curator withdraws GRT from a deprecated subgraph
+ * @param subgraphID ID of the subgraph
+ * @param curator Address of the curator
+ * @param nSignalBurnt Amount of name signal burned
+ * @param withdrawnGRT Amount of GRT withdrawn
*/
event GRTWithdrawn(uint256 indexed subgraphID, address indexed curator, uint256 nSignalBurnt, uint256 withdrawnGRT);
/**
- * @dev Emitted when the counterpart (L1/L2) GNS address is updated
+ * @notice Emitted when the counterpart (L1/L2) GNS address is updated
+ * @param _counterpart Address of the counterpart GNS contract
*/
event CounterpartGNSAddressUpdated(address _counterpart);
// -- Modifiers --
/**
- * @dev Emitted when a legacy subgraph is claimed
+ * @notice Emitted when a legacy subgraph is claimed
+ * @param graphAccount Address of the graph account that created the subgraph
+ * @param subgraphNumber Sequence number of the subgraph
*/
event LegacySubgraphClaimed(address indexed graphAccount, uint256 subgraphNumber);
/**
- * @dev Modifier that allows only a subgraph operator to be the caller
+ * @notice Modifier that allows only a subgraph operator to be the caller
+ * @param _subgraphID ID of the subgraph to check authorization for
*/
modifier onlySubgraphAuth(uint256 _subgraphID) {
require(ownerOf(_subgraphID) == msg.sender, "GNS: Must be authorized");
@@ -160,7 +206,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @notice Approve curation contract to pull funds.
+ * @inheritdoc IGNS
*/
function approveAll() external override {
graphToken().approve(address(curation()), type(uint256).max);
@@ -169,9 +215,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
// -- Config --
/**
- * @notice Set the owner fee percentage. This is used to prevent a subgraph owner to drain all
- * the name curators tokens while upgrading or deprecating and is configurable in parts per million.
- * @param _ownerTaxPercentage Owner tax percentage
+ * @inheritdoc IGNS
*/
function setOwnerTaxPercentage(uint32 _ownerTaxPercentage) external override onlyGovernor {
_setOwnerTaxPercentage(_ownerTaxPercentage);
@@ -200,11 +244,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
// -- Actions --
/**
- * @notice Allows a graph account to set a default name
- * @param _graphAccount Account that is setting its name
- * @param _nameSystem Name system account already has ownership of a name in
- * @param _nameIdentifier The unique identifier that is used to identify the name in the system
- * @param _name The name being set as default
+ * @inheritdoc IGNS
*/
function setDefaultName(
address _graphAccount,
@@ -217,9 +257,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @notice Allows a subgraph owner to update the metadata of a subgraph they have published
- * @param _subgraphID Subgraph ID
- * @param _subgraphMetadata IPFS hash for the subgraph metadata
+ * @inheritdoc IGNS
*/
function updateSubgraphMetadata(
uint256 _subgraphID,
@@ -229,10 +267,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @notice Publish a new subgraph.
- * @param _subgraphDeploymentID Subgraph deployment for the subgraph
- * @param _versionMetadata IPFS hash for the subgraph version metadata
- * @param _subgraphMetadata IPFS hash for the subgraph metadata
+ * @inheritdoc IGNS
*/
function publishNewSubgraph(
bytes32 _subgraphDeploymentID,
@@ -261,10 +296,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @notice Publish a new version of an existing subgraph.
- * @param _subgraphID Subgraph ID
- * @param _subgraphDeploymentID Subgraph deployment ID of the new version
- * @param _versionMetadata IPFS hash for the subgraph version metadata
+ * @inheritdoc IGNS
*/
function publishNewVersion(
uint256 _subgraphID,
@@ -322,10 +354,10 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @notice Deprecate a subgraph. The bonding curve is destroyed, the vSignal is burned, and the GNS
+ * @inheritdoc IGNS
+ * @notice The bonding curve is destroyed, the vSignal is burned, and the GNS
* contract holds the GRT from burning the vSignal, which all curators can withdraw manually.
* Can only be done by the subgraph owner.
- * @param _subgraphID Subgraph ID
*/
function deprecateSubgraph(uint256 _subgraphID) external override notPaused onlySubgraphAuth(_subgraphID) {
// Subgraph check
@@ -350,10 +382,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @notice Deposit GRT into a subgraph and mint signal.
- * @param _subgraphID Subgraph ID
- * @param _tokensIn The amount of tokens the nameCurator wants to deposit
- * @param _nSignalOutMin Expected minimum amount of name signal to receive
+ * @inheritdoc IGNS
*/
function mintSignal(
uint256 _subgraphID,
@@ -383,10 +412,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @notice Burn signal for a subgraph and return the GRT.
- * @param _subgraphID Subgraph ID
- * @param _nSignal The amount of nSignal the nameCurator wants to burn
- * @param _tokensOutMin Expected minimum amount of tokens to receive
+ * @inheritdoc IGNS
*/
function burnSignal(
uint256 _subgraphID,
@@ -543,7 +569,9 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
* @notice Calculate subgraph signal to be returned for an amount of tokens.
* @param _subgraphID Subgraph ID
* @param _tokensIn Tokens being exchanged for subgraph signal
- * @return Amount of subgraph signal and curation tax
+ * @return nSignalOut Amount of name signal minted
+ * @return curationTax Amount of curation tax charged
+ * @return vSignalOut Amount of version signal minted
*/
function tokensToNSignal(
uint256 _subgraphID,
@@ -562,7 +590,8 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
* @notice Calculate tokens returned for an amount of subgraph signal.
* @param _subgraphID Subgraph ID
* @param _nSignalIn Subgraph signal being exchanged for tokens
- * @return Amount of tokens returned for an amount of subgraph signal
+ * @return vSignalOut Amount of version signal burned
+ * @return tokensOut Amount of tokens returned
*/
function nSignalToTokens(uint256 _subgraphID, uint256 _nSignalIn) public view override returns (uint256, uint256) {
// Get subgraph or revert if not published
@@ -574,10 +603,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @notice Calculate subgraph signal to be returned for an amount of subgraph deployment signal.
- * @param _subgraphID Subgraph ID
- * @param _vSignalIn Amount of subgraph deployment signal to exchange for subgraph signal
- * @return Amount of subgraph signal that can be bought
+ * @inheritdoc IGNS
*/
function vSignalToNSignal(uint256 _subgraphID, uint256 _vSignalIn) public view override returns (uint256) {
SubgraphData storage subgraphData = _getSubgraphData(_subgraphID);
@@ -591,10 +617,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @notice Calculate subgraph deployment signal to be returned for an amount of subgraph signal.
- * @param _subgraphID Subgraph ID
- * @param _nSignalIn Subgraph signal being exchanged for subgraph deployment signal
- * @return Amount of subgraph deployment signal that can be returned
+ * @inheritdoc IGNS
*/
function nSignalToVSignal(uint256 _subgraphID, uint256 _nSignalIn) public view override returns (uint256) {
SubgraphData storage subgraphData = _getSubgraphData(_subgraphID);
@@ -602,29 +625,21 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @notice Get the amount of subgraph signal a curator has.
- * @param _subgraphID Subgraph ID
- * @param _curator Curator address
- * @return Amount of subgraph signal owned by a curator
+ * @inheritdoc IGNS
*/
function getCuratorSignal(uint256 _subgraphID, address _curator) public view override returns (uint256) {
return _getSubgraphData(_subgraphID).curatorNSignal[_curator];
}
/**
- * @notice Return whether a subgraph is published.
- * @param _subgraphID Subgraph ID
- * @return Return true if subgraph is currently published
+ * @inheritdoc IGNS
*/
function isPublished(uint256 _subgraphID) public view override returns (bool) {
return _isPublished(_getSubgraphData(_subgraphID));
}
/**
- * @notice Returns account and sequence ID for a legacy subgraph (created before subgraph NFTs).
- * @param _subgraphID Subgraph ID
- * @return account Account that created the subgraph (or 0 if it's not a legacy subgraph)
- * @return seqID Sequence number for the subgraph
+ * @inheritdoc IGNS
*/
function getLegacySubgraphKey(uint256 _subgraphID) public view override returns (address account, uint256 seqID) {
LegacySubgraphKey storage legacySubgraphKey = legacySubgraphKeys[_subgraphID];
@@ -633,16 +648,14 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @notice Return the owner of a subgraph.
- * @param _tokenID Subgraph ID
- * @return Owner address
+ * @inheritdoc IGNS
*/
function ownerOf(uint256 _tokenID) public view override returns (address) {
return subgraphNFT.ownerOf(_tokenID);
}
/**
- * @dev Calculate tax that owner will have to cover for upgrading or deprecating.
+ * @notice Calculate tax that owner will have to cover for upgrading or deprecating.
* @param _tokens Tokens that were received from deprecating the old subgraph
* @param _owner Subgraph owner
* @param _curationTaxPercentage Tax percentage on curation deposits from Curation contract
@@ -689,8 +702,9 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @dev Return the next subgraphID given the account that is creating the subgraph.
+ * @notice Return the next subgraphID given the account that is creating the subgraph.
* NOTE: This function updates the sequence ID for the account
+ * @param _account The account creating the subgraph
* @return Sequence ID for the account
*/
function _nextSubgraphID(address _account) internal returns (uint256) {
@@ -698,8 +712,9 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @dev Return a new consecutive sequence ID for an account and update to the next value.
+ * @notice Return a new consecutive sequence ID for an account and update to the next value.
* NOTE: This function updates the sequence ID for the account
+ * @param _account The account to get the next sequence ID for
* @return Sequence ID for the account
*/
function _nextAccountSeqID(address _account) internal returns (uint256) {
@@ -709,7 +724,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @dev Mint the NFT for the subgraph.
+ * @notice Mint the NFT for the subgraph.
* @param _owner Owner address
* @param _tokenID Subgraph ID
*/
@@ -718,7 +733,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @dev Burn the NFT for the subgraph.
+ * @notice Burn the NFT for the subgraph.
* @param _tokenID Subgraph ID
*/
function _burnNFT(uint256 _tokenID) internal {
@@ -726,7 +741,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @dev Set the subgraph metadata.
+ * @notice Set the subgraph metadata.
* @param _tokenID Subgraph ID
* @param _subgraphMetadata IPFS hash of the subgraph metadata
*/
@@ -739,7 +754,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @dev Get subgraph data.
+ * @notice Get subgraph data.
* This function will first look for a v1 subgraph and return it if found.
* @param _subgraphID Subgraph ID
* @return Subgraph Data
@@ -755,7 +770,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @dev Return whether a subgraph is published.
+ * @notice Return whether a subgraph is published.
* @param _subgraphData Subgraph Data
* @return Return true if subgraph is currently published
*/
@@ -764,7 +779,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @dev Return the subgraph data or revert if not published or deprecated.
+ * @notice Return the subgraph data or revert if not published or deprecated.
* @param _subgraphID Subgraph ID
* @return Subgraph Data
*/
@@ -775,9 +790,11 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @dev Build a subgraph ID based on the account creating it and a sequence number for that account.
+ * @notice Build a subgraph ID based on the account creating it and a sequence number for that account.
* Only used for legacy subgraphs being migrated, as new ones will also use the chainid.
* Subgraph ID is the keccak hash of account+seqID
+ * @param _account The account creating the subgraph
+ * @param _seqID The sequence ID for the account
* @return Subgraph ID
*/
function _buildLegacySubgraphID(address _account, uint256 _seqID) internal pure returns (uint256) {
@@ -785,8 +802,10 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @dev Build a subgraph ID based on the account creating it and a sequence number for that account.
+ * @notice Build a subgraph ID based on the account creating it and a sequence number for that account.
* Subgraph ID is the keccak hash of account+seqID
+ * @param _account The account creating the subgraph
+ * @param _seqID The sequence ID for the account
* @return Subgraph ID
*/
function _buildSubgraphID(address _account, uint256 _seqID) internal pure returns (uint256) {
@@ -800,7 +819,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @dev Internal: Set the owner tax percentage. This is used to prevent a subgraph owner to drain all
+ * @notice Internal: Set the owner tax percentage. This is used to prevent a subgraph owner to drain all
* the name curators tokens while upgrading or deprecating and is configurable in parts per million.
* @param _ownerTaxPercentage Owner tax percentage
*/
@@ -811,7 +830,7 @@ abstract contract GNS is GNSV3Storage, GraphUpgradeable, IGNS, Multicall {
}
/**
- * @dev Internal: Set the NFT registry contract
+ * @notice Internal: Set the NFT registry contract
* @param _subgraphNFT Address of the ERC721 contract
*/
function _setSubgraphNFT(address _subgraphNFT) private {
diff --git a/packages/contracts/contracts/discovery/GNSStorage.sol b/packages/contracts/contracts/discovery/GNSStorage.sol
index 80122c9ba..ca746ec29 100644
--- a/packages/contracts/contracts/discovery/GNSStorage.sol
+++ b/packages/contracts/contracts/discovery/GNSStorage.sol
@@ -1,23 +1,28 @@
// SPDX-License-Identifier: GPL-2.0-or-later
+// solhint-disable one-contract-per-file
pragma solidity ^0.7.6;
pragma abicoder v2;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable named-parameters-mapping
+
import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/Initializable.sol";
import { Managed } from "../governance/Managed.sol";
-import { IEthereumDIDRegistry } from "./erc1056/IEthereumDIDRegistry.sol";
-import { IGNS } from "./IGNS.sol";
-import { ISubgraphNFT } from "./ISubgraphNFT.sol";
+import { IEthereumDIDRegistry } from "@graphprotocol/interfaces/contracts/contracts/discovery/erc1056/IEthereumDIDRegistry.sol";
+import { IGNS } from "@graphprotocol/interfaces/contracts/contracts/discovery/IGNS.sol";
+import { ISubgraphNFT } from "@graphprotocol/interfaces/contracts/contracts/discovery/ISubgraphNFT.sol";
/**
* @title GNSV1Storage
+ * @author Edge & Node
* @notice This contract holds all the storage variables for the GNS contract, version 1
*/
abstract contract GNSV1Storage is Managed {
// -- State --
- /// Percentage of curation tax that must be paid by the owner, in parts per million.
+ /// @notice Percentage of curation tax that must be paid by the owner, in parts per million.
uint32 public ownerTaxPercentage;
/// @dev [DEPRECATED] Bonding curve formula.
@@ -29,11 +34,11 @@ abstract contract GNSV1Storage is Managed {
/// (graphAccountID, subgraphNumber) => subgraphDeploymentID
mapping(address => mapping(uint256 => bytes32)) internal legacySubgraphs;
- /// Every time an account creates a subgraph it increases a per-account sequence ID.
+ /// @notice Every time an account creates a subgraph it increases a per-account sequence ID.
/// account => seqID
mapping(address => uint256) public nextAccountSeqID;
- /// Stores all the signal deposited on a legacy subgraph.
+ /// @notice Stores all the signal deposited on a legacy subgraph.
/// (graphAccountID, subgraphNumber) => SubgraphData
mapping(address => mapping(uint256 => IGNS.SubgraphData)) public legacySubgraphData;
@@ -44,31 +49,33 @@ abstract contract GNSV1Storage is Managed {
/**
* @title GNSV2Storage
+ * @author Edge & Node
* @notice This contract holds all the storage variables for the GNS contract, version 2
*/
abstract contract GNSV2Storage is GNSV1Storage {
- /// Stores the account and seqID for a legacy subgraph that has been migrated.
+ /// @notice Stores the account and seqID for a legacy subgraph that has been migrated.
/// Use it whenever a legacy (v1) subgraph NFT was claimed to maintain compatibility.
/// Keep a reference from subgraphID => (graphAccount, subgraphNumber)
mapping(uint256 => IGNS.LegacySubgraphKey) public legacySubgraphKeys;
- /// Store data for all NFT-based (v2) subgraphs.
+ /// @notice Store data for all NFT-based (v2) subgraphs.
/// subgraphID => SubgraphData
mapping(uint256 => IGNS.SubgraphData) public subgraphs;
- /// Contract that represents subgraph ownership through an NFT
+ /// @notice Contract that represents subgraph ownership through an NFT
ISubgraphNFT public subgraphNFT;
}
/**
* @title GNSV3Storage
+ * @author Edge & Node
* @notice This contract holds all the storage variables for the base GNS contract, version 3.
* @dev Note that this is the first version that includes a storage gap - if adding
* future versions, make sure to move the gap to the new version and
* reduce the size of the gap accordingly.
*/
abstract contract GNSV3Storage is GNSV2Storage, Initializable {
- /// Address of the counterpart GNS contract (L1GNS/L2GNS)
+ /// @notice Address of the counterpart GNS contract (L1GNS/L2GNS)
address public counterpartGNSAddress;
/// @dev Gap to allow adding variables in future upgrades (since L1GNS and L2GNS have their own storage as well)
uint256[50] private __gap;
diff --git a/packages/contracts/contracts/discovery/IGNS.sol b/packages/contracts/contracts/discovery/IGNS.sol
deleted file mode 100644
index 70b366d9b..000000000
--- a/packages/contracts/contracts/discovery/IGNS.sol
+++ /dev/null
@@ -1,218 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-/**
- * @title Interface for GNS
- */
-interface IGNS {
- // -- Pool --
-
- /**
- * @dev The SubgraphData struct holds information about subgraphs
- * and their signal; both nSignal (i.e. name signal at the GNS level)
- * and vSignal (i.e. version signal at the Curation contract level)
- */
- struct SubgraphData {
- uint256 vSignal; // The token of the subgraph-deployment bonding curve
- uint256 nSignal; // The token of the subgraph bonding curve
- mapping(address => uint256) curatorNSignal;
- bytes32 subgraphDeploymentID;
- uint32 __DEPRECATED_reserveRatio; // solhint-disable-line var-name-mixedcase
- bool disabled;
- uint256 withdrawableGRT;
- }
-
- /**
- * @dev The LegacySubgraphKey struct holds the account and sequence ID
- * used to generate subgraph IDs in legacy subgraphs.
- */
- struct LegacySubgraphKey {
- address account;
- uint256 accountSeqID;
- }
-
- // -- Configuration --
-
- /**
- * @notice Approve curation contract to pull funds.
- */
- function approveAll() external;
-
- /**
- * @notice Set the owner fee percentage. This is used to prevent a subgraph owner to drain all
- * the name curators tokens while upgrading or deprecating and is configurable in parts per million.
- * @param _ownerTaxPercentage Owner tax percentage
- */
- function setOwnerTaxPercentage(uint32 _ownerTaxPercentage) external;
-
- // -- Publishing --
-
- /**
- * @notice Allows a graph account to set a default name
- * @param _graphAccount Account that is setting its name
- * @param _nameSystem Name system account already has ownership of a name in
- * @param _nameIdentifier The unique identifier that is used to identify the name in the system
- * @param _name The name being set as default
- */
- function setDefaultName(
- address _graphAccount,
- uint8 _nameSystem,
- bytes32 _nameIdentifier,
- string calldata _name
- ) external;
-
- /**
- * @notice Allows a subgraph owner to update the metadata of a subgraph they have published
- * @param _subgraphID Subgraph ID
- * @param _subgraphMetadata IPFS hash for the subgraph metadata
- */
- function updateSubgraphMetadata(uint256 _subgraphID, bytes32 _subgraphMetadata) external;
-
- /**
- * @notice Publish a new subgraph.
- * @param _subgraphDeploymentID Subgraph deployment for the subgraph
- * @param _versionMetadata IPFS hash for the subgraph version metadata
- * @param _subgraphMetadata IPFS hash for the subgraph metadata
- */
- function publishNewSubgraph(
- bytes32 _subgraphDeploymentID,
- bytes32 _versionMetadata,
- bytes32 _subgraphMetadata
- ) external;
-
- /**
- * @notice Publish a new version of an existing subgraph.
- * @param _subgraphID Subgraph ID
- * @param _subgraphDeploymentID Subgraph deployment ID of the new version
- * @param _versionMetadata IPFS hash for the subgraph version metadata
- */
- function publishNewVersion(uint256 _subgraphID, bytes32 _subgraphDeploymentID, bytes32 _versionMetadata) external;
-
- /**
- * @notice Deprecate a subgraph. The bonding curve is destroyed, the vSignal is burned, and the GNS
- * contract holds the GRT from burning the vSignal, which all curators can withdraw manually.
- * Can only be done by the subgraph owner.
- * @param _subgraphID Subgraph ID
- */
- function deprecateSubgraph(uint256 _subgraphID) external;
-
- // -- Curation --
-
- /**
- * @notice Deposit GRT into a subgraph and mint signal.
- * @param _subgraphID Subgraph ID
- * @param _tokensIn The amount of tokens the nameCurator wants to deposit
- * @param _nSignalOutMin Expected minimum amount of name signal to receive
- */
- function mintSignal(uint256 _subgraphID, uint256 _tokensIn, uint256 _nSignalOutMin) external;
-
- /**
- * @notice Burn signal for a subgraph and return the GRT.
- * @param _subgraphID Subgraph ID
- * @param _nSignal The amount of nSignal the nameCurator wants to burn
- * @param _tokensOutMin Expected minimum amount of tokens to receive
- */
- function burnSignal(uint256 _subgraphID, uint256 _nSignal, uint256 _tokensOutMin) external;
-
- /**
- * @notice Move subgraph signal from sender to `_recipient`
- * @param _subgraphID Subgraph ID
- * @param _recipient Address to send the signal to
- * @param _amount The amount of nSignal to transfer
- */
- function transferSignal(uint256 _subgraphID, address _recipient, uint256 _amount) external;
-
- /**
- * @notice Withdraw tokens from a deprecated subgraph.
- * When the subgraph is deprecated, any curator can call this function and
- * withdraw the GRT they are entitled for its original deposit
- * @param _subgraphID Subgraph ID
- */
- function withdraw(uint256 _subgraphID) external;
-
- // -- Getters --
-
- /**
- * @notice Return the owner of a subgraph.
- * @param _tokenID Subgraph ID
- * @return Owner address
- */
- function ownerOf(uint256 _tokenID) external view returns (address);
-
- /**
- * @notice Return the total signal on the subgraph.
- * @param _subgraphID Subgraph ID
- * @return Total signal on the subgraph
- */
- function subgraphSignal(uint256 _subgraphID) external view returns (uint256);
-
- /**
- * @notice Return the total tokens on the subgraph at current value.
- * @param _subgraphID Subgraph ID
- * @return Total tokens on the subgraph
- */
- function subgraphTokens(uint256 _subgraphID) external view returns (uint256);
-
- /**
- * @notice Calculate subgraph signal to be returned for an amount of tokens.
- * @param _subgraphID Subgraph ID
- * @param _tokensIn Tokens being exchanged for subgraph signal
- * @return Amount of subgraph signal and curation tax
- */
- function tokensToNSignal(uint256 _subgraphID, uint256 _tokensIn) external view returns (uint256, uint256, uint256);
-
- /**
- * @notice Calculate tokens returned for an amount of subgraph signal.
- * @param _subgraphID Subgraph ID
- * @param _nSignalIn Subgraph signal being exchanged for tokens
- * @return Amount of tokens returned for an amount of subgraph signal
- */
- function nSignalToTokens(uint256 _subgraphID, uint256 _nSignalIn) external view returns (uint256, uint256);
-
- /**
- * @notice Calculate subgraph signal to be returned for an amount of subgraph deployment signal.
- * @param _subgraphID Subgraph ID
- * @param _vSignalIn Amount of subgraph deployment signal to exchange for subgraph signal
- * @return Amount of subgraph signal that can be bought
- */
- function vSignalToNSignal(uint256 _subgraphID, uint256 _vSignalIn) external view returns (uint256);
-
- /**
- * @notice Calculate subgraph deployment signal to be returned for an amount of subgraph signal.
- * @param _subgraphID Subgraph ID
- * @param _nSignalIn Subgraph signal being exchanged for subgraph deployment signal
- * @return Amount of subgraph deployment signal that can be returned
- */
- function nSignalToVSignal(uint256 _subgraphID, uint256 _nSignalIn) external view returns (uint256);
-
- /**
- * @notice Get the amount of subgraph signal a curator has.
- * @param _subgraphID Subgraph ID
- * @param _curator Curator address
- * @return Amount of subgraph signal owned by a curator
- */
- function getCuratorSignal(uint256 _subgraphID, address _curator) external view returns (uint256);
-
- /**
- * @notice Return whether a subgraph is published.
- * @param _subgraphID Subgraph ID
- * @return Return true if subgraph is currently published
- */
- function isPublished(uint256 _subgraphID) external view returns (bool);
-
- /**
- * @notice Return whether a subgraph is a legacy subgraph (created before subgraph NFTs).
- * @param _subgraphID Subgraph ID
- * @return Return true if subgraph is a legacy subgraph
- */
- function isLegacySubgraph(uint256 _subgraphID) external view returns (bool);
-
- /**
- * @notice Returns account and sequence ID for a legacy subgraph (created before subgraph NFTs).
- * @param _subgraphID Subgraph ID
- * @return account Account that created the subgraph (or 0 if it's not a legacy subgraph)
- * @return seqID Sequence number for the subgraph
- */
- function getLegacySubgraphKey(uint256 _subgraphID) external view returns (address account, uint256 seqID);
-}
diff --git a/packages/contracts/contracts/discovery/IServiceRegistry.sol b/packages/contracts/contracts/discovery/IServiceRegistry.sol
deleted file mode 100644
index 724f7bebe..000000000
--- a/packages/contracts/contracts/discovery/IServiceRegistry.sol
+++ /dev/null
@@ -1,20 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-interface IServiceRegistry {
- struct IndexerService {
- string url;
- string geohash;
- }
-
- function register(string calldata _url, string calldata _geohash) external;
-
- function registerFor(address _indexer, string calldata _url, string calldata _geohash) external;
-
- function unregister() external;
-
- function unregisterFor(address _indexer) external;
-
- function isRegistered(address _indexer) external view returns (bool);
-}
diff --git a/packages/contracts/contracts/discovery/ISubgraphNFT.sol b/packages/contracts/contracts/discovery/ISubgraphNFT.sol
deleted file mode 100644
index 6cef69297..000000000
--- a/packages/contracts/contracts/discovery/ISubgraphNFT.sol
+++ /dev/null
@@ -1,25 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
-
-interface ISubgraphNFT is IERC721 {
- // -- Config --
-
- function setMinter(address _minter) external;
-
- function setTokenDescriptor(address _tokenDescriptor) external;
-
- function setBaseURI(string memory _baseURI) external;
-
- // -- Actions --
-
- function mint(address _to, uint256 _tokenId) external;
-
- function burn(uint256 _tokenId) external;
-
- function setSubgraphMetadata(uint256 _tokenId, bytes32 _subgraphMetadata) external;
-
- function tokenURI(uint256 _tokenId) external view returns (string memory);
-}
diff --git a/packages/contracts/contracts/discovery/ISubgraphNFTDescriptor.sol b/packages/contracts/contracts/discovery/ISubgraphNFTDescriptor.sol
deleted file mode 100644
index cd0785dcb..000000000
--- a/packages/contracts/contracts/discovery/ISubgraphNFTDescriptor.sol
+++ /dev/null
@@ -1,20 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6;
-
-/// @title Describes subgraph NFT tokens via URI
-interface ISubgraphNFTDescriptor {
- /// @notice Produces the URI describing a particular token ID for a Subgraph
- /// @dev Note this URI may be data: URI with the JSON contents directly inlined
- /// @param _minter Address of the allowed minter
- /// @param _tokenId The ID of the subgraph NFT for which to produce a description, which may not be valid
- /// @param _baseURI The base URI that could be prefixed to the final URI
- /// @param _subgraphMetadata Subgraph metadata set for the subgraph
- /// @return The URI of the ERC721-compliant metadata
- function tokenURI(
- address _minter,
- uint256 _tokenId,
- string calldata _baseURI,
- bytes32 _subgraphMetadata
- ) external view returns (string memory);
-}
diff --git a/packages/contracts/contracts/discovery/L1GNS.sol b/packages/contracts/contracts/discovery/L1GNS.sol
index 31e9b0fb3..3441d05fa 100644
--- a/packages/contracts/contracts/discovery/L1GNS.sol
+++ b/packages/contracts/contracts/discovery/L1GNS.sol
@@ -7,14 +7,15 @@ import { SafeMathUpgradeable } from "@openzeppelin/contracts-upgradeable/math/Sa
import { GNS } from "./GNS.sol";
-import { ITokenGateway } from "../arbitrum/ITokenGateway.sol";
-import { IL2GNS } from "../l2/discovery/IL2GNS.sol";
-import { IGraphToken } from "../token/IGraphToken.sol";
+import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol";
+import { IL2GNS } from "@graphprotocol/interfaces/contracts/contracts/l2/discovery/IL2GNS.sol";
+import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol";
import { L1GNSV1Storage } from "./L1GNSStorage.sol";
/**
* @title L1GNS
- * @dev The Graph Name System contract provides a decentralized naming system for subgraphs
+ * @author Edge & Node
+ * @notice The Graph Name System contract provides a decentralized naming system for subgraphs
* used in the scope of the Graph Network. It translates Subgraphs into Subgraph Versions.
* Each version is associated with a Subgraph Deployment. The contract has no knowledge of
* human-readable names. All human readable names emitted in events.
@@ -25,7 +26,13 @@ import { L1GNSV1Storage } from "./L1GNSStorage.sol";
contract L1GNS is GNS, L1GNSV1Storage {
using SafeMathUpgradeable for uint256;
- /// @dev Emitted when a subgraph was sent to L2 through the bridge
+ /**
+ * @notice Emitted when a subgraph was sent to L2 through the bridge
+ * @param _subgraphID ID of the subgraph being transferred
+ * @param _l1Owner Address of the subgraph owner on L1
+ * @param _l2Owner Address that will own the subgraph on L2
+ * @param _tokens Amount of tokens transferred with the subgraph
+ */
event SubgraphSentToL2(
uint256 indexed _subgraphID,
address indexed _l1Owner,
@@ -33,7 +40,13 @@ contract L1GNS is GNS, L1GNSV1Storage {
uint256 _tokens
);
- /// @dev Emitted when a curator's balance for a subgraph was sent to L2
+ /**
+ * @notice Emitted when a curator's balance for a subgraph was sent to L2
+ * @param _subgraphID ID of the subgraph
+ * @param _l1Curator Address of the curator on L1
+ * @param _l2Beneficiary Address that will receive the tokens on L2
+ * @param _tokens Amount of tokens transferred
+ */
event CuratorBalanceSentToL2(
uint256 indexed _subgraphID,
address indexed _l1Curator,
@@ -42,10 +55,10 @@ contract L1GNS is GNS, L1GNSV1Storage {
);
/**
- * @notice Send a subgraph's data and tokens to L2.
- * Use the Arbitrum SDK to estimate the L2 retryable ticket parameters.
+ * @notice Send a subgraph's data and tokens to L2
+ * @dev Use the Arbitrum SDK to estimate the L2 retryable ticket parameters.
* Note that any L2 gas/fee refunds will be lost, so the function only accepts
- * the exact amount of ETH to cover _maxSubmissionCost + _maxGas * _gasPriceBid.
+ * the exact amount of ETH to cover _maxSubmissionCost + _maxGas * _gasPriceBid
* @param _subgraphID Subgraph ID
* @param _l2Owner Address that will own the subgraph in L2 (could be the L1 owner, but could be different if the L1 owner is an L1 contract)
* @param _maxGas Max gas to use for the L2 retryable ticket
diff --git a/packages/contracts/contracts/discovery/L1GNSStorage.sol b/packages/contracts/contracts/discovery/L1GNSStorage.sol
index 557814513..72af676f2 100644
--- a/packages/contracts/contracts/discovery/L1GNSStorage.sol
+++ b/packages/contracts/contracts/discovery/L1GNSStorage.sol
@@ -1,16 +1,20 @@
// SPDX-License-Identifier: GPL-2.0-or-later
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable named-parameters-mapping
+
pragma solidity ^0.7.6;
pragma abicoder v2;
/**
* @title L1GNSV1Storage
+ * @author Edge & Node
* @notice This contract holds all the L1-specific storage variables for the L1GNS contract, version 1
* @dev When adding new versions, make sure to move the gap to the new version and
* reduce the size of the gap accordingly.
*/
abstract contract L1GNSV1Storage {
- /// True for subgraph IDs that have been transferred to L2
+ /// @notice True for subgraph IDs that have been transferred to L2
mapping(uint256 => bool) public subgraphTransferredToL2;
/// @dev Storage gap to keep storage slots fixed in future versions
uint256[50] private __gap;
diff --git a/packages/contracts/contracts/discovery/ServiceRegistry.sol b/packages/contracts/contracts/discovery/ServiceRegistry.sol
index 1eb1393d3..32db1fe1b 100644
--- a/packages/contracts/contracts/discovery/ServiceRegistry.sol
+++ b/packages/contracts/contracts/discovery/ServiceRegistry.sol
@@ -3,58 +3,68 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
-import "../governance/Managed.sol";
-import "../upgrades/GraphUpgradeable.sol";
+import { Managed } from "../governance/Managed.sol";
+import { GraphUpgradeable } from "../upgrades/GraphUpgradeable.sol";
-import "./ServiceRegistryStorage.sol";
-import "./IServiceRegistry.sol";
+import { ServiceRegistryV1Storage } from "./ServiceRegistryStorage.sol";
+import { IServiceRegistry } from "@graphprotocol/interfaces/contracts/contracts/discovery/IServiceRegistry.sol";
/**
* @title ServiceRegistry contract
- * @dev This contract supports the service discovery process by allowing indexers to
+ * @author Edge & Node
+ * @notice This contract supports the service discovery process by allowing indexers to
* register their service url and any other relevant information.
*/
contract ServiceRegistry is ServiceRegistryV1Storage, GraphUpgradeable, IServiceRegistry {
// -- Events --
+ /**
+ * @notice Emitted when an indexer registers their service
+ * @param indexer Address of the indexer
+ * @param url URL of the indexer service
+ * @param geohash Geohash of the indexer service location
+ */
event ServiceRegistered(address indexed indexer, string url, string geohash);
+
+ /**
+ * @notice Emitted when an indexer unregisters their service
+ * @param indexer Address of the indexer
+ */
event ServiceUnregistered(address indexed indexer);
/**
- * @dev Check if the caller is authorized (indexer or operator)
+ * @notice Check if the caller is authorized (indexer or operator)
+ * @param _indexer Address of the indexer to check authorization for
+ * @return True if the caller is authorized, false otherwise
*/
function _isAuth(address _indexer) internal view returns (bool) {
return msg.sender == _indexer || staking().isOperator(msg.sender, _indexer) == true;
}
/**
- * @dev Initialize this contract.
+ * @notice Initialize this contract.
+ * @param _controller Address of the controller contract
*/
function initialize(address _controller) external onlyImpl {
Managed._initialize(_controller);
}
/**
- * @dev Register an indexer service
- * @param _url URL of the indexer service
- * @param _geohash Geohash of the indexer service location
+ * @inheritdoc IServiceRegistry
*/
function register(string calldata _url, string calldata _geohash) external override {
_register(msg.sender, _url, _geohash);
}
/**
- * @dev Register an indexer service
- * @param _indexer Address of the indexer
- * @param _url URL of the indexer service
- * @param _geohash Geohash of the indexer service location
+ * @inheritdoc IServiceRegistry
*/
function registerFor(address _indexer, string calldata _url, string calldata _geohash) external override {
_register(_indexer, _url, _geohash);
}
/**
- * @dev Internal: Register an indexer service
+ * @notice Internal: Register an indexer service
* @param _indexer Address of the indexer
* @param _url URL of the indexer service
* @param _geohash Geohash of the indexer service location
@@ -69,22 +79,21 @@ contract ServiceRegistry is ServiceRegistryV1Storage, GraphUpgradeable, IService
}
/**
- * @dev Unregister an indexer service
+ * @inheritdoc IServiceRegistry
*/
function unregister() external override {
_unregister(msg.sender);
}
/**
- * @dev Unregister an indexer service
- * @param _indexer Address of the indexer
+ * @inheritdoc IServiceRegistry
*/
function unregisterFor(address _indexer) external override {
_unregister(_indexer);
}
/**
- * @dev Unregister an indexer service
+ * @notice Unregister an indexer service
* @param _indexer Address of the indexer
*/
function _unregister(address _indexer) private {
@@ -96,8 +105,7 @@ contract ServiceRegistry is ServiceRegistryV1Storage, GraphUpgradeable, IService
}
/**
- * @dev Return the registration status of an indexer service
- * @return True if the indexer service is registered
+ * @inheritdoc IServiceRegistry
*/
function isRegistered(address _indexer) public view override returns (bool) {
return bytes(services[_indexer].url).length > 0;
diff --git a/packages/contracts/contracts/discovery/ServiceRegistryStorage.sol b/packages/contracts/contracts/discovery/ServiceRegistryStorage.sol
index 1cd484970..4ad8a7359 100644
--- a/packages/contracts/contracts/discovery/ServiceRegistryStorage.sol
+++ b/packages/contracts/contracts/discovery/ServiceRegistryStorage.sol
@@ -1,13 +1,22 @@
// SPDX-License-Identifier: GPL-2.0-or-later
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable named-parameters-mapping
+
pragma solidity ^0.7.6;
-import "../governance/Managed.sol";
+import { Managed } from "../governance/Managed.sol";
-import "./IServiceRegistry.sol";
+import { IServiceRegistry } from "@graphprotocol/interfaces/contracts/contracts/discovery/IServiceRegistry.sol";
+/**
+ * @title Service Registry Storage V1
+ * @author Edge & Node
+ * @notice Storage contract for the Service Registry
+ */
contract ServiceRegistryV1Storage is Managed {
// -- State --
+ /// @notice Mapping of indexer addresses to their service information
mapping(address => IServiceRegistry.IndexerService) public services;
}
diff --git a/packages/contracts/contracts/discovery/SubgraphNFT.sol b/packages/contracts/contracts/discovery/SubgraphNFT.sol
index 3c514718c..22fc307c0 100644
--- a/packages/contracts/contracts/discovery/SubgraphNFT.sol
+++ b/packages/contracts/contracts/discovery/SubgraphNFT.sol
@@ -2,35 +2,66 @@
pragma solidity ^0.7.6;
-import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
-import "@openzeppelin/contracts/utils/Address.sol";
-
-import "../governance/Governed.sol";
-import "../libraries/HexStrings.sol";
-import "./ISubgraphNFT.sol";
-import "./ISubgraphNFTDescriptor.sol";
-
-/// @title NFT that represents ownership of a Subgraph
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-calldata-parameters, gas-indexed-events, gas-small-strings
+// solhint-disable named-parameters-mapping
+
+import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
+import { Address } from "@openzeppelin/contracts/utils/Address.sol";
+
+import { Governed } from "../governance/Governed.sol";
+import { HexStrings } from "../libraries/HexStrings.sol";
+import { ISubgraphNFT } from "@graphprotocol/interfaces/contracts/contracts/discovery/ISubgraphNFT.sol";
+import { ISubgraphNFTDescriptor } from "@graphprotocol/interfaces/contracts/contracts/discovery/ISubgraphNFTDescriptor.sol";
+
+/**
+ * @title NFT that represents ownership of a Subgraph
+ * @author Edge & Node
+ * @notice NFT that represents ownership of a Subgraph
+ */
contract SubgraphNFT is Governed, ERC721, ISubgraphNFT {
// -- State --
+ /// @notice Address of the minter contract
address public minter;
+ /// @notice Address of the token descriptor contract
ISubgraphNFTDescriptor public tokenDescriptor;
+ /// @dev Mapping from token ID to subgraph metadata hash
mapping(uint256 => bytes32) private _subgraphMetadataHashes;
// -- Events --
+ /**
+ * @notice Emitted when the minter address is updated
+ * @param minter Address of the new minter
+ */
event MinterUpdated(address minter);
+
+ /**
+ * @notice Emitted when the token descriptor is updated
+ * @param tokenDescriptor Address of the new token descriptor
+ */
event TokenDescriptorUpdated(address tokenDescriptor);
+
+ /**
+ * @notice Emitted when subgraph metadata is updated
+ * @param tokenID ID of the token
+ * @param subgraphURI IPFS hash of the subgraph metadata
+ */
event SubgraphMetadataUpdated(uint256 indexed tokenID, bytes32 subgraphURI);
// -- Modifiers --
+ /// @dev Modifier to restrict access to minter only
modifier onlyMinter() {
require(msg.sender == minter, "Must be a minter");
_;
}
+ /**
+ * @notice Constructor for the SubgraphNFT contract
+ * @param _governor Address that will have governance privileges
+ */
constructor(address _governor) ERC721("Subgraph", "SG") {
_initialize(_governor);
}
@@ -38,9 +69,7 @@ contract SubgraphNFT is Governed, ERC721, ISubgraphNFT {
// -- Config --
/**
- * @notice Set the minter allowed to perform actions on the NFT.
- * @dev Minter can mint, burn and update the metadata
- * @param _minter Address of the allowed minter
+ * @inheritdoc ISubgraphNFT
*/
function setMinter(address _minter) external override onlyGovernor {
_setMinter(_minter);
@@ -57,16 +86,14 @@ contract SubgraphNFT is Governed, ERC721, ISubgraphNFT {
}
/**
- * @notice Set the token descriptor contract.
- * @dev Token descriptor can be zero. If set, it must be a contract.
- * @param _tokenDescriptor Address of the contract that creates the NFT token URI
+ * @inheritdoc ISubgraphNFT
*/
function setTokenDescriptor(address _tokenDescriptor) external override onlyGovernor {
_setTokenDescriptor(_tokenDescriptor);
}
/**
- * @dev Internal: Set the token descriptor contract used to create the ERC-721 metadata URI.
+ * @notice Internal: Set the token descriptor contract used to create the ERC-721 metadata URI.
* @param _tokenDescriptor Address of the contract that creates the NFT token URI
*/
function _setTokenDescriptor(address _tokenDescriptor) internal {
@@ -79,9 +106,7 @@ contract SubgraphNFT is Governed, ERC721, ISubgraphNFT {
}
/**
- * @notice Set the base URI.
- * @dev Can be set to empty.
- * @param _baseURI Base URI to use to build the token URI
+ * @inheritdoc ISubgraphNFT
*/
function setBaseURI(string memory _baseURI) external override onlyGovernor {
_setBaseURI(_baseURI);
@@ -90,29 +115,21 @@ contract SubgraphNFT is Governed, ERC721, ISubgraphNFT {
// -- Minter actions --
/**
- * @notice Mint `_tokenId` and transfers it to `_to`.
- * @dev `tokenId` must not exist and `to` cannot be the zero address.
- * @param _to Address receiving the minted NFT
- * @param _tokenId ID of the NFT
+ * @inheritdoc ISubgraphNFT
*/
function mint(address _to, uint256 _tokenId) external override onlyMinter {
_mint(_to, _tokenId);
}
/**
- * @notice Burn `_tokenId`.
- * @dev The approval is cleared when the token is burned.
- * @param _tokenId ID of the NFT
+ * @inheritdoc ISubgraphNFT
*/
function burn(uint256 _tokenId) external override onlyMinter {
_burn(_tokenId);
}
/**
- * @notice Set the metadata for a subgraph represented by `_tokenId`.
- * @dev `_tokenId` must exist.
- * @param _tokenId ID of the NFT
- * @param _subgraphMetadata IPFS hash for the metadata
+ * @inheritdoc ISubgraphNFT
*/
function setSubgraphMetadata(uint256 _tokenId, bytes32 _subgraphMetadata) external override onlyMinter {
require(_exists(_tokenId), "ERC721Metadata: URI set of nonexistent token");
diff --git a/packages/contracts/contracts/discovery/SubgraphNFTDescriptor.sol b/packages/contracts/contracts/discovery/SubgraphNFTDescriptor.sol
index 81f6da696..a4f5a5080 100644
--- a/packages/contracts/contracts/discovery/SubgraphNFTDescriptor.sol
+++ b/packages/contracts/contracts/discovery/SubgraphNFTDescriptor.sol
@@ -2,10 +2,14 @@
pragma solidity ^0.7.6;
-import "../libraries/Base58Encoder.sol";
-import "./ISubgraphNFTDescriptor.sol";
+import { Base58Encoder } from "../libraries/Base58Encoder.sol";
+import { ISubgraphNFTDescriptor } from "@graphprotocol/interfaces/contracts/contracts/discovery/ISubgraphNFTDescriptor.sol";
-/// @title Describes subgraph NFT tokens via URI
+/**
+ * @title Describes subgraph NFT tokens via URI
+ * @author Edge & Node
+ * @notice Describes subgraph NFT tokens via URI
+ */
contract SubgraphNFTDescriptor is ISubgraphNFTDescriptor {
/// @inheritdoc ISubgraphNFTDescriptor
function tokenURI(
diff --git a/packages/contracts/contracts/discovery/erc1056/EthereumDIDRegistry.sol b/packages/contracts/contracts/discovery/erc1056/EthereumDIDRegistry.sol
index e8545dd4a..76c1a41f9 100644
--- a/packages/contracts/contracts/discovery/erc1056/EthereumDIDRegistry.sol
+++ b/packages/contracts/contracts/discovery/erc1056/EthereumDIDRegistry.sol
@@ -12,19 +12,51 @@ As well as all testnets
pragma solidity ^0.7.6;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-increment-by-one, gas-indexed-events, gas-small-strings
+// solhint-disable named-parameters-mapping
+
+/**
+ * @title Ethereum DID Registry
+ * @author Edge & Node
+ * @notice Registry for Ethereum Decentralized Identifiers (DIDs)
+ */
contract EthereumDIDRegistry {
+ /// @notice Mapping of identity addresses to their owners
mapping(address => address) public owners;
+ /// @notice Mapping of identity addresses to delegate types to delegate addresses to validity periods
mapping(address => mapping(bytes32 => mapping(address => uint256))) public delegates;
+ /// @notice Mapping of identity addresses to their last change block numbers
mapping(address => uint256) public changed;
+ /// @notice Mapping of identity addresses to their nonce values
mapping(address => uint256) public nonce;
+ /**
+ * @notice Modifier to restrict access to identity owners only
+ * @param identity The identity address
+ * @param actor The address performing the action
+ */
modifier onlyOwner(address identity, address actor) {
require(actor == identityOwner(identity), "Caller must be the identity owner");
_;
}
+ /**
+ * @notice Emitted when a DID owner is changed
+ * @param identity The identity address
+ * @param owner The new owner address
+ * @param previousChange Block number of the previous change
+ */
event DIDOwnerChanged(address indexed identity, address owner, uint256 previousChange);
+ /**
+ * @notice Emitted when a DID delegate is changed
+ * @param identity The identity address
+ * @param delegateType The type of delegate
+ * @param delegate The delegate address
+ * @param validTo Timestamp until which the delegate is valid
+ * @param previousChange Block number of the previous change
+ */
event DIDDelegateChanged(
address indexed identity,
bytes32 delegateType,
@@ -33,6 +65,14 @@ contract EthereumDIDRegistry {
uint256 previousChange
);
+ /**
+ * @notice Emitted when a DID attribute is changed
+ * @param identity The identity address
+ * @param name The attribute name
+ * @param value The attribute value
+ * @param validTo Timestamp until which the attribute is valid
+ * @param previousChange Block number of the previous change
+ */
event DIDAttributeChanged(
address indexed identity,
bytes32 name,
@@ -41,6 +81,11 @@ contract EthereumDIDRegistry {
uint256 previousChange
);
+ /**
+ * @notice Get the owner of an identity
+ * @param identity The identity address
+ * @return The address of the identity owner
+ */
function identityOwner(address identity) public view returns (address) {
address owner = owners[identity];
if (owner != address(0)) {
@@ -49,6 +94,15 @@ contract EthereumDIDRegistry {
return identity;
}
+ /**
+ * @notice Verify signature and return signer address
+ * @param identity The identity address
+ * @param sigV Recovery ID of the signature
+ * @param sigR R component of the signature
+ * @param sigS S component of the signature
+ * @param hash Hash that was signed
+ * @return The address of the signer
+ */
function checkSignature(
address identity,
uint8 sigV,
@@ -62,22 +116,48 @@ contract EthereumDIDRegistry {
return signer;
}
+ /**
+ * @notice Check if a delegate is valid for an identity
+ * @param identity The identity address
+ * @param delegateType The type of delegate
+ * @param delegate The delegate address
+ * @return True if the delegate is valid, false otherwise
+ */
function validDelegate(address identity, bytes32 delegateType, address delegate) public view returns (bool) {
uint256 validity = delegates[identity][keccak256(abi.encode(delegateType))][delegate];
/* solium-disable-next-line security/no-block-members*/
return (validity > block.timestamp);
}
+ /**
+ * @notice Internal function to change the owner of an identity
+ * @param identity The identity address
+ * @param actor The address performing the action
+ * @param newOwner The new owner address
+ */
function changeOwner(address identity, address actor, address newOwner) internal onlyOwner(identity, actor) {
owners[identity] = newOwner;
emit DIDOwnerChanged(identity, newOwner, changed[identity]);
changed[identity] = block.number;
}
+ /**
+ * @notice Change the owner of an identity
+ * @param identity The identity address
+ * @param newOwner The new owner address
+ */
function changeOwner(address identity, address newOwner) public {
changeOwner(identity, msg.sender, newOwner);
}
+ /**
+ * @notice Change the owner of an identity using a signed message
+ * @param identity The identity address
+ * @param sigV Recovery ID of the signature
+ * @param sigR R component of the signature
+ * @param sigS S component of the signature
+ * @param newOwner The new owner address
+ */
function changeOwnerSigned(address identity, uint8 sigV, bytes32 sigR, bytes32 sigS, address newOwner) public {
bytes32 hash = keccak256(
abi.encodePacked(
@@ -93,6 +173,14 @@ contract EthereumDIDRegistry {
changeOwner(identity, checkSignature(identity, sigV, sigR, sigS, hash), newOwner);
}
+ /**
+ * @notice Internal function to add a delegate for an identity
+ * @param identity The identity address
+ * @param actor The address performing the action
+ * @param delegateType The type of delegate
+ * @param delegate The delegate address
+ * @param validity The validity period in seconds
+ */
function addDelegate(
address identity,
address actor,
@@ -113,10 +201,27 @@ contract EthereumDIDRegistry {
changed[identity] = block.number;
}
+ /**
+ * @notice Add a delegate for an identity
+ * @param identity The identity to add a delegate for
+ * @param delegateType The type of delegate
+ * @param delegate The address of the delegate
+ * @param validity The validity period in seconds
+ */
function addDelegate(address identity, bytes32 delegateType, address delegate, uint256 validity) public {
addDelegate(identity, msg.sender, delegateType, delegate, validity);
}
+ /**
+ * @notice Add a delegate for an identity using a signed message
+ * @param identity The identity to add a delegate for
+ * @param sigV The recovery id of the signature
+ * @param sigR The r component of the signature
+ * @param sigS The s component of the signature
+ * @param delegateType The type of delegate
+ * @param delegate The address of the delegate
+ * @param validity The validity period in seconds
+ */
function addDelegateSigned(
address identity,
uint8 sigV,
@@ -142,6 +247,13 @@ contract EthereumDIDRegistry {
addDelegate(identity, checkSignature(identity, sigV, sigR, sigS, hash), delegateType, delegate, validity);
}
+ /**
+ * @notice Internal function to revoke a delegate for an identity
+ * @param identity The identity address
+ * @param actor The address performing the action
+ * @param delegateType The type of delegate
+ * @param delegate The delegate address
+ */
function revokeDelegate(
address identity,
address actor,
@@ -155,10 +267,25 @@ contract EthereumDIDRegistry {
changed[identity] = block.number;
}
+ /**
+ * @notice Revoke a delegate for an identity
+ * @param identity The identity to revoke a delegate for
+ * @param delegateType The type of delegate
+ * @param delegate The address of the delegate
+ */
function revokeDelegate(address identity, bytes32 delegateType, address delegate) public {
revokeDelegate(identity, msg.sender, delegateType, delegate);
}
+ /**
+ * @notice Revoke a delegate for an identity using a signed message
+ * @param identity The identity to revoke a delegate for
+ * @param sigV The recovery id of the signature
+ * @param sigR The r component of the signature
+ * @param sigS The s component of the signature
+ * @param delegateType The type of delegate
+ * @param delegate The address of the delegate
+ */
function revokeDelegateSigned(
address identity,
uint8 sigV,
@@ -182,6 +309,14 @@ contract EthereumDIDRegistry {
revokeDelegate(identity, checkSignature(identity, sigV, sigR, sigS, hash), delegateType, delegate);
}
+ /**
+ * @notice Internal function to set an attribute for an identity
+ * @param identity The identity address
+ * @param actor The address performing the action
+ * @param name The attribute name
+ * @param value The attribute value
+ * @param validity The validity period in seconds
+ */
function setAttribute(
address identity,
address actor,
@@ -194,10 +329,27 @@ contract EthereumDIDRegistry {
changed[identity] = block.number;
}
+ /**
+ * @notice Set an attribute for an identity
+ * @param identity The identity to set an attribute for
+ * @param name The name of the attribute
+ * @param value The value of the attribute
+ * @param validity The validity period in seconds
+ */
function setAttribute(address identity, bytes32 name, bytes memory value, uint256 validity) public {
setAttribute(identity, msg.sender, name, value, validity);
}
+ /**
+ * @notice Set an attribute for an identity using a signed message
+ * @param identity The identity to set an attribute for
+ * @param sigV The recovery id of the signature
+ * @param sigR The r component of the signature
+ * @param sigS The s component of the signature
+ * @param name The name of the attribute
+ * @param value The value of the attribute
+ * @param validity The validity period in seconds
+ */
function setAttributeSigned(
address identity,
uint8 sigV,
@@ -223,6 +375,13 @@ contract EthereumDIDRegistry {
setAttribute(identity, checkSignature(identity, sigV, sigR, sigS, hash), name, value, validity);
}
+ /**
+ * @notice Internal function to revoke an attribute for an identity
+ * @param identity The identity address
+ * @param actor The address performing the action
+ * @param name The attribute name
+ * @param value The attribute value
+ */
function revokeAttribute(
address identity,
address actor,
@@ -233,10 +392,25 @@ contract EthereumDIDRegistry {
changed[identity] = block.number;
}
+ /**
+ * @notice Revoke an attribute for an identity
+ * @param identity The identity to revoke an attribute for
+ * @param name The name of the attribute
+ * @param value The value of the attribute
+ */
function revokeAttribute(address identity, bytes32 name, bytes memory value) public {
revokeAttribute(identity, msg.sender, name, value);
}
+ /**
+ * @notice Revoke an attribute for an identity using a signed message
+ * @param identity The identity to revoke an attribute for
+ * @param sigV The recovery id of the signature
+ * @param sigR The r component of the signature
+ * @param sigS The s component of the signature
+ * @param name The name of the attribute
+ * @param value The value of the attribute
+ */
function revokeAttributeSigned(
address identity,
uint8 sigV,
diff --git a/packages/contracts/contracts/discovery/erc1056/IEthereumDIDRegistry.sol b/packages/contracts/contracts/discovery/erc1056/IEthereumDIDRegistry.sol
deleted file mode 100644
index 8de69f304..000000000
--- a/packages/contracts/contracts/discovery/erc1056/IEthereumDIDRegistry.sol
+++ /dev/null
@@ -1,9 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-
-pragma solidity ^0.7.6;
-
-interface IEthereumDIDRegistry {
- function identityOwner(address identity) external view returns (address);
-
- function setAttribute(address identity, bytes32 name, bytes calldata value, uint256 validity) external;
-}
diff --git a/packages/contracts/contracts/disputes/DisputeManager.sol b/packages/contracts/contracts/disputes/DisputeManager.sol
index 013a21b03..9ef426453 100644
--- a/packages/contracts/contracts/disputes/DisputeManager.sol
+++ b/packages/contracts/contracts/disputes/DisputeManager.sol
@@ -3,18 +3,23 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
-import "@openzeppelin/contracts/math/SafeMath.sol";
-import "@openzeppelin/contracts/cryptography/ECDSA.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-small-strings, gas-strict-inequalities
-import "../governance/Managed.sol";
-import "../upgrades/GraphUpgradeable.sol";
-import "../utils/TokenUtils.sol";
+import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol";
+import { ECDSA } from "@openzeppelin/contracts/cryptography/ECDSA.sol";
-import "./DisputeManagerStorage.sol";
-import "./IDisputeManager.sol";
+import { Managed } from "../governance/Managed.sol";
+import { GraphUpgradeable } from "../upgrades/GraphUpgradeable.sol";
+import { TokenUtils } from "../utils/TokenUtils.sol";
+import { IStaking } from "@graphprotocol/interfaces/contracts/contracts/staking/IStaking.sol";
-/*
+import { DisputeManagerV1Storage } from "./DisputeManagerStorage.sol";
+import { IDisputeManager } from "@graphprotocol/interfaces/contracts/contracts/disputes/IDisputeManager.sol";
+
+/**
* @title DisputeManager
+ * @author Edge & Node
* @notice Provides a way to align the incentives of participants by having slashing as deterrent
* for incorrect behaviour.
*
@@ -41,39 +46,61 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
// -- EIP-712 --
+ /// @dev EIP-712 domain type hash for signature verification
bytes32 private constant DOMAIN_TYPE_HASH =
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)");
+ /// @dev EIP-712 domain name hash
bytes32 private constant DOMAIN_NAME_HASH = keccak256("Graph Protocol");
+ /// @dev EIP-712 domain version hash
bytes32 private constant DOMAIN_VERSION_HASH = keccak256("0");
+ /// @dev EIP-712 domain salt for uniqueness
bytes32 private constant DOMAIN_SALT = 0xa070ffb1cd7409649bf77822cce74495468e06dbfaef09556838bf188679b9c2;
+ /// @dev EIP-712 receipt type hash for attestation verification
bytes32 private constant RECEIPT_TYPE_HASH =
keccak256("Receipt(bytes32 requestCID,bytes32 responseCID,bytes32 subgraphDeploymentID)");
// -- Constants --
- // Attestation size is the sum of the receipt (96) + signature (65)
+ /// @dev Total size of attestation in bytes (receipt + signature)
uint256 private constant ATTESTATION_SIZE_BYTES = RECEIPT_SIZE_BYTES + SIG_SIZE_BYTES;
+ /// @dev Size of receipt in bytes
uint256 private constant RECEIPT_SIZE_BYTES = 96;
+ /// @dev Length of signature R component in bytes
uint256 private constant SIG_R_LENGTH = 32;
+ /// @dev Length of signature S component in bytes
uint256 private constant SIG_S_LENGTH = 32;
+ /// @dev Length of signature V component in bytes
uint256 private constant SIG_V_LENGTH = 1;
+ /// @dev Offset of signature R component in attestation data
uint256 private constant SIG_R_OFFSET = RECEIPT_SIZE_BYTES;
+ /// @dev Offset of signature S component in attestation data
uint256 private constant SIG_S_OFFSET = RECEIPT_SIZE_BYTES + SIG_R_LENGTH;
+ /// @dev Offset of signature V component in attestation data
uint256 private constant SIG_V_OFFSET = RECEIPT_SIZE_BYTES + SIG_R_LENGTH + SIG_S_LENGTH;
+ /// @dev Total size of signature in bytes
uint256 private constant SIG_SIZE_BYTES = SIG_R_LENGTH + SIG_S_LENGTH + SIG_V_LENGTH;
+ /// @dev Length of uint8 type in bytes
uint256 private constant UINT8_BYTE_LENGTH = 1;
+ /// @dev Length of bytes32 type in bytes
uint256 private constant BYTES32_BYTE_LENGTH = 32;
+ /// @dev Maximum percentage in parts per million (100%)
uint256 private constant MAX_PPM = 1000000; // 100% in parts per million
// -- Events --
/**
- * @dev Emitted when a query dispute is created for `subgraphDeploymentID` and `indexer`
+ * @notice Emitted when a query dispute is created for `subgraphDeploymentID` and `indexer`
* by `fisherman`.
* The event emits the amount of `tokens` deposited by the fisherman and `attestation` submitted.
+ * @param disputeID ID of the dispute
+ * @param indexer Address of the indexer being disputed
+ * @param fisherman Address of the fisherman creating the dispute
+ * @param tokens Amount of tokens deposited by the fisherman
+ * @param subgraphDeploymentID Subgraph deployment ID being disputed
+ * @param attestation Attestation data submitted
*/
event QueryDisputeCreated(
bytes32 indexed disputeID,
@@ -85,9 +112,14 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
);
/**
- * @dev Emitted when an indexing dispute is created for `allocationID` and `indexer`
+ * @notice Emitted when an indexing dispute is created for `allocationID` and `indexer`
* by `fisherman`.
* The event emits the amount of `tokens` deposited by the fisherman.
+ * @param disputeID ID of the dispute
+ * @param indexer Address of the indexer being disputed
+ * @param fisherman Address of the fisherman creating the dispute
+ * @param tokens Amount of tokens deposited by the fisherman
+ * @param allocationID Allocation ID being disputed
*/
event IndexingDisputeCreated(
bytes32 indexed disputeID,
@@ -98,8 +130,12 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
);
/**
- * @dev Emitted when arbitrator accepts a `disputeID` to `indexer` created by `fisherman`.
+ * @notice Emitted when arbitrator accepts a `disputeID` to `indexer` created by `fisherman`.
* The event emits the amount `tokens` transferred to the fisherman, the deposit plus reward.
+ * @param disputeID ID of the dispute
+ * @param indexer Address of the indexer being disputed
+ * @param fisherman Address of the fisherman who created the dispute
+ * @param tokens Amount of tokens transferred to the fisherman (deposit plus reward)
*/
event DisputeAccepted(
bytes32 indexed disputeID,
@@ -109,8 +145,12 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
);
/**
- * @dev Emitted when arbitrator rejects a `disputeID` for `indexer` created by `fisherman`.
+ * @notice Emitted when arbitrator rejects a `disputeID` for `indexer` created by `fisherman`.
* The event emits the amount `tokens` burned from the fisherman deposit.
+ * @param disputeID ID of the dispute
+ * @param indexer Address of the indexer being disputed
+ * @param fisherman Address of the fisherman who created the dispute
+ * @param tokens Amount of tokens burned from the fisherman deposit
*/
event DisputeRejected(
bytes32 indexed disputeID,
@@ -120,20 +160,29 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
);
/**
- * @dev Emitted when arbitrator draw a `disputeID` for `indexer` created by `fisherman`.
+ * @notice Emitted when arbitrator draw a `disputeID` for `indexer` created by `fisherman`.
* The event emits the amount `tokens` used as deposit and returned to the fisherman.
+ * @param disputeID ID of the dispute
+ * @param indexer Address of the indexer being disputed
+ * @param fisherman Address of the fisherman who created the dispute
+ * @param tokens Amount of tokens used as deposit and returned to the fisherman
*/
event DisputeDrawn(bytes32 indexed disputeID, address indexed indexer, address indexed fisherman, uint256 tokens);
/**
- * @dev Emitted when two disputes are in conflict to link them.
+ * @notice Emitted when two disputes are in conflict to link them.
* This event will be emitted after each DisputeCreated event is emitted
* for each of the individual disputes.
+ * @param disputeID1 ID of the first dispute
+ * @param disputeID2 ID of the second dispute
*/
event DisputeLinked(bytes32 indexed disputeID1, bytes32 indexed disputeID2);
// -- Modifiers --
+ /**
+ * @notice Internal function to check if the caller is the arbitrator
+ */
function _onlyArbitrator() internal view {
require(msg.sender == arbitrator, "Caller is not the Arbitrator");
}
@@ -146,6 +195,10 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
_;
}
+ /**
+ * @dev Check if the dispute exists and is pending
+ * @param _disputeID ID of the dispute to check
+ */
modifier onlyPendingDispute(bytes32 _disputeID) {
require(isDisputeCreated(_disputeID), "Dispute does not exist");
require(disputes[_disputeID].status == IDisputeManager.DisputeStatus.Pending, "Dispute must be pending");
@@ -155,7 +208,8 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
// -- Functions --
/**
- * @dev Initialize this contract.
+ * @notice Initialize this contract.
+ * @param _controller Controller address
* @param _arbitrator Arbitrator role
* @param _minimumDeposit Minimum deposit required to create a Dispute
* @param _fishermanRewardPercentage Percent of slashed funds for fisherman (ppm)
@@ -192,16 +246,13 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Set the arbitrator address.
- * @notice Update the arbitrator to `_arbitrator`
- * @param _arbitrator The address of the arbitration contract or party
+ * @inheritdoc IDisputeManager
*/
function setArbitrator(address _arbitrator) external override onlyGovernor {
_setArbitrator(_arbitrator);
}
/**
- * @dev Internal: Set the arbitrator address.
* @notice Update the arbitrator to `_arbitrator`
* @param _arbitrator The address of the arbitration contract or party
*/
@@ -212,16 +263,13 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Set the minimum deposit required to create a dispute.
- * @notice Update the minimum deposit to `_minimumDeposit` Graph Tokens
- * @param _minimumDeposit The minimum deposit in Graph Tokens
+ * @inheritdoc IDisputeManager
*/
function setMinimumDeposit(uint256 _minimumDeposit) external override onlyGovernor {
_setMinimumDeposit(_minimumDeposit);
}
/**
- * @dev Internal: Set the minimum deposit required to create a dispute.
* @notice Update the minimum deposit to `_minimumDeposit` Graph Tokens
* @param _minimumDeposit The minimum deposit in Graph Tokens
*/
@@ -232,17 +280,14 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Set the percent reward that the fisherman gets when slashing occurs.
- * @notice Update the reward percentage to `_percentage`
- * @param _percentage Reward as a percentage of indexer stake
+ * @inheritdoc IDisputeManager
*/
function setFishermanRewardPercentage(uint32 _percentage) external override onlyGovernor {
_setFishermanRewardPercentage(_percentage);
}
/**
- * @dev Internal: Set the percent reward that the fisherman gets when slashing occurs.
- * @notice Update the reward percentage to `_percentage`
+ * @notice Set the percent reward that the fisherman gets when slashing occurs.
* @param _percentage Reward as a percentage of indexer stake
*/
function _setFishermanRewardPercentage(uint32 _percentage) private {
@@ -253,16 +298,14 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Set the percentage used for slashing indexers.
- * @param _qryPercentage Percentage slashing for query disputes
- * @param _idxPercentage Percentage slashing for indexing disputes
+ * @inheritdoc IDisputeManager
*/
function setSlashingPercentage(uint32 _qryPercentage, uint32 _idxPercentage) external override onlyGovernor {
_setSlashingPercentage(_qryPercentage, _idxPercentage);
}
/**
- * @dev Internal: Set the percentage used for slashing indexers.
+ * @notice Internal: Set the percentage used for slashing indexers.
* @param _qryPercentage Percentage slashing for query disputes
* @param _idxPercentage Percentage slashing for indexing disputes
*/
@@ -279,21 +322,16 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Return whether a dispute exists or not.
- * @notice Return if dispute with ID `_disputeID` exists
- * @param _disputeID True if dispute already exists
+ * @inheritdoc IDisputeManager
*/
function isDisputeCreated(bytes32 _disputeID) public view override returns (bool) {
return disputes[_disputeID].status != DisputeStatus.Null;
}
/**
- * @dev Get the message hash that an indexer used to sign the receipt.
- * Encodes a receipt using a domain separator, as described on
+ * @inheritdoc IDisputeManager
+ * @dev Encodes a receipt using a domain separator, as described on
* https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md#specification.
- * @notice Return the message hash used to sign the receipt
- * @param _receipt Receipt returned by indexer and submitted by fisherman
- * @return Message hash used to sign the receipt
*/
function encodeHashReceipt(Receipt memory _receipt) public view override returns (bytes32) {
return
@@ -314,11 +352,8 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Returns if two attestations are conflicting.
- * Everything must match except for the responseID.
- * @param _attestation1 Attestation
- * @param _attestation2 Attestation
- * @return True if the two attestations are conflicting
+ * @inheritdoc IDisputeManager
+ * @dev Everything must match except for the responseID.
*/
function areConflictingAttestations(
Attestation memory _attestation1,
@@ -330,9 +365,7 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Returns the indexer that signed an attestation.
- * @param _attestation Attestation
- * @return Indexer address
+ * @inheritdoc IDisputeManager
*/
function getAttestationIndexer(Attestation memory _attestation) public view override returns (address) {
// Get attestation signer. Indexers signs with the allocationID
@@ -348,11 +381,9 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Create a query dispute for the arbitrator to resolve.
- * This function is called by a fisherman that will need to `_deposit` at
+ * @inheritdoc IDisputeManager
+ * @dev This function is called by a fisherman that will need to `_deposit` at
* least `minimumDeposit` GRT tokens.
- * @param _attestationData Attestation bytes submitted by the fisherman
- * @param _deposit Amount of tokens staked as deposit
*/
function createQueryDispute(bytes calldata _attestationData, uint256 _deposit) external override returns (bytes32) {
// Get funds from submitter
@@ -369,16 +400,7 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Create query disputes for two conflicting attestations.
- * A conflicting attestation is a proof presented by two different indexers
- * where for the same request on a subgraph the response is different.
- * For this type of dispute the submitter is not required to present a deposit
- * as one of the attestation is considered to be right.
- * Two linked disputes will be created and if the arbitrator resolve one, the other
- * one will be automatically resolved.
- * @param _attestationData1 First attestation data submitted
- * @param _attestationData2 Second attestation data submitted
- * @return DisputeID1, DisputeID2
+ * @inheritdoc IDisputeManager
*/
function createQueryDisputeConflict(
bytes calldata _attestationData1,
@@ -409,7 +431,7 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Create a query dispute passing the parsed attestation.
+ * @notice Create a query dispute passing the parsed attestation.
* To be used in createQueryDispute() and createQueryDisputeConflict()
* to avoid calling parseAttestation() multiple times
* `_attestationData` is only passed to be emitted
@@ -472,8 +494,7 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
* The disputes are created in reference to an allocationID
* This function is called by a challenger that will need to `_deposit` at
* least `minimumDeposit` GRT tokens.
- * @param _allocationID The allocation to dispute
- * @param _deposit Amount of tokens staked as deposit
+ * @inheritdoc IDisputeManager
*/
function createIndexingDispute(address _allocationID, uint256 _deposit) external override returns (bytes32) {
// Get funds from submitter
@@ -484,12 +505,12 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Create indexing dispute internal function.
+ * @notice Create indexing dispute internal function.
* @param _fisherman The challenger creating the dispute
* @param _deposit Amount of tokens staked as deposit
* @param _allocationID Allocation disputed
+ * @return disputeID The ID of the created dispute
*/
-
function _createIndexingDisputeWithAllocation(
address _fisherman,
uint256 _deposit,
@@ -525,12 +546,10 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev The arbitrator accepts a dispute as being valid.
- * This function will revert if the indexer is not slashable, whether because it does not have
+ * @dev This function will revert if the indexer is not slashable, whether because it does not have
* any stake available or the slashing percentage is configured to be zero. In those cases
* a dispute must be resolved using drawDispute or rejectDispute.
- * @notice Accept a dispute with ID `_disputeID`
- * @param _disputeID ID of the dispute to be accepted
+ * @inheritdoc IDisputeManager
*/
function acceptDispute(bytes32 _disputeID) external override onlyArbitrator onlyPendingDispute(_disputeID) {
Dispute storage dispute = disputes[_disputeID];
@@ -552,9 +571,7 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev The arbitrator rejects a dispute as being invalid.
- * @notice Reject a dispute with ID `_disputeID`
- * @param _disputeID ID of the dispute to be rejected
+ * @inheritdoc IDisputeManager
*/
function rejectDispute(bytes32 _disputeID) public override onlyArbitrator onlyPendingDispute(_disputeID) {
Dispute storage dispute = disputes[_disputeID];
@@ -575,9 +592,7 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev The arbitrator draws dispute.
- * @notice Ignore a dispute with ID `_disputeID`
- * @param _disputeID ID of the dispute to be disregarded
+ * @inheritdoc IDisputeManager
*/
function drawDispute(bytes32 _disputeID) external override onlyArbitrator onlyPendingDispute(_disputeID) {
Dispute storage dispute = disputes[_disputeID];
@@ -595,7 +610,7 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Returns whether the dispute is for a conflicting attestation or not.
+ * @notice Returns whether the dispute is for a conflicting attestation or not.
* @param _dispute Dispute
* @return True conflicting attestation dispute
*/
@@ -606,7 +621,7 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Resolve the conflicting dispute if there is any for the one passed to this function.
+ * @notice Resolve the conflicting dispute if there is any for the one passed to this function.
* @param _dispute Dispute
* @return True if resolved
*/
@@ -621,7 +636,7 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Pull deposit from submitter account.
+ * @notice Pull deposit from submitter account.
* @param _deposit Amount of tokens to deposit
*/
function _pullSubmitterDeposit(uint256 _deposit) private {
@@ -633,7 +648,7 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Make the staking contract slash the indexer and reward the challenger.
+ * @notice Make the staking contract slash the indexer and reward the challenger.
* Give the challenger a reward equal to the fishermanRewardPercentage of slashed amount
* @param _indexer Address of the indexer
* @param _challenger Address of the challenger
@@ -664,7 +679,7 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Return the slashing percentage for the dispute type.
+ * @notice Return the slashing percentage for the dispute type.
* @param _disputeType Dispute type
* @return Slashing percentage to use for the dispute type
*/
@@ -675,7 +690,7 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Recover the signer address of the `_attestation`.
+ * @notice Recover the signer address of the `_attestation`.
* @param _attestation The attestation struct
* @return Signer address
*/
@@ -694,11 +709,12 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Get the running network chain ID
+ * @notice Get the running network chain ID
* @return The chain ID
*/
function _getChainID() private pure returns (uint256) {
uint256 id;
+ // solhint-disable-next-line no-inline-assembly
assembly {
id := chainid()
}
@@ -706,7 +722,8 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Parse the bytes attestation into a struct from `_data`.
+ * @notice Parse the bytes attestation into a struct from `_data`.
+ * @param _data The bytes data to parse into an attestation
* @return Attestation struct
*/
function _parseAttestation(bytes memory _data) private pure returns (Attestation memory) {
@@ -729,13 +746,16 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Parse a uint8 from `_bytes` starting at offset `_start`.
+ * @notice Parse a uint8 from `_bytes` starting at offset `_start`.
+ * @param _bytes The bytes array to parse from
+ * @param _start The starting offset in the bytes array
* @return uint8 value
*/
function _toUint8(bytes memory _bytes, uint256 _start) private pure returns (uint8) {
require(_bytes.length >= (_start + UINT8_BYTE_LENGTH), "Bytes: out of bounds");
uint8 tempUint;
+ // solhint-disable-next-line no-inline-assembly
assembly {
tempUint := mload(add(add(_bytes, 0x1), _start))
}
@@ -744,13 +764,16 @@ contract DisputeManager is DisputeManagerV1Storage, GraphUpgradeable, IDisputeMa
}
/**
- * @dev Parse a bytes32 from `_bytes` starting at offset `_start`.
+ * @notice Parse a bytes32 from `_bytes` starting at offset `_start`.
+ * @param _bytes The bytes array to parse from
+ * @param _start The starting offset in the bytes array
* @return bytes32 value
*/
function _toBytes32(bytes memory _bytes, uint256 _start) private pure returns (bytes32) {
require(_bytes.length >= (_start + BYTES32_BYTE_LENGTH), "Bytes: out of bounds");
bytes32 tempBytes32;
+ // solhint-disable-next-line no-inline-assembly
assembly {
tempBytes32 := mload(add(add(_bytes, 0x20), _start))
}
diff --git a/packages/contracts/contracts/disputes/DisputeManagerStorage.sol b/packages/contracts/contracts/disputes/DisputeManagerStorage.sol
index 4df6e0ae6..f2ad2b7c3 100644
--- a/packages/contracts/contracts/disputes/DisputeManagerStorage.sol
+++ b/packages/contracts/contracts/disputes/DisputeManagerStorage.sol
@@ -1,34 +1,44 @@
// SPDX-License-Identifier: GPL-2.0-or-later
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable named-parameters-mapping
+
pragma solidity ^0.7.6;
-import "../governance/Managed.sol";
+import { Managed } from "../governance/Managed.sol";
-import "./IDisputeManager.sol";
+import { IDisputeManager } from "@graphprotocol/interfaces/contracts/contracts/disputes/IDisputeManager.sol";
+/**
+ * @title Dispute Manager Storage V1
+ * @author Edge & Node
+ * @notice Storage contract for the Dispute Manager
+ */
contract DisputeManagerV1Storage is Managed {
// -- State --
- bytes32 internal DOMAIN_SEPARATOR;
+ /// @dev Domain separator for EIP-712 signature verification
+ bytes32 internal DOMAIN_SEPARATOR; // solhint-disable-line var-name-mixedcase
- // The arbitrator is solely in control of arbitrating disputes
+ /// @notice The arbitrator is solely in control of arbitrating disputes
address public arbitrator;
- // Minimum deposit required to create a Dispute
+ /// @notice Minimum deposit required to create a Dispute
uint256 public minimumDeposit;
// -- Slot 0xf
- // Percentage of indexer slashed funds to assign as a reward to fisherman in successful dispute
- // Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%)
+ /// @notice Percentage of indexer slashed funds to assign as a reward to fisherman in successful dispute
+ /// Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%)
uint32 public fishermanRewardPercentage;
- // Percentage of indexer stake to slash on disputes
- // Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%)
+ /// @notice Percentage of indexer stake to slash on disputes
+ /// Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%)
uint32 public qrySlashingPercentage;
+ /// @notice Percentage of indexer stake to slash on disputes
uint32 public idxSlashingPercentage;
// -- Slot 0x10
- // Disputes created : disputeID => Dispute
- // disputeID - check creation functions to see how disputeID is built
+ /// @notice Disputes created : disputeID => Dispute
+ /// @dev disputeID - check creation functions to see how disputeID is built
mapping(bytes32 => IDisputeManager.Dispute) public disputes;
}
diff --git a/packages/contracts/contracts/disputes/IDisputeManager.sol b/packages/contracts/contracts/disputes/IDisputeManager.sol
deleted file mode 100644
index e42386941..000000000
--- a/packages/contracts/contracts/disputes/IDisputeManager.sol
+++ /dev/null
@@ -1,91 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity >=0.6.12 <0.8.0 || 0.8.27;
-pragma abicoder v2;
-
-interface IDisputeManager {
- // -- Dispute --
-
- enum DisputeType {
- Null,
- IndexingDispute,
- QueryDispute
- }
-
- enum DisputeStatus {
- Null,
- Accepted,
- Rejected,
- Drawn,
- Pending
- }
-
- // Disputes contain info necessary for the Arbitrator to verify and resolve
- struct Dispute {
- address indexer;
- address fisherman;
- uint256 deposit;
- bytes32 relatedDisputeID;
- DisputeType disputeType;
- DisputeStatus status;
- }
-
- // -- Attestation --
-
- // Receipt content sent from indexer in response to request
- struct Receipt {
- bytes32 requestCID;
- bytes32 responseCID;
- bytes32 subgraphDeploymentID;
- }
-
- // Attestation sent from indexer in response to a request
- struct Attestation {
- bytes32 requestCID;
- bytes32 responseCID;
- bytes32 subgraphDeploymentID;
- bytes32 r;
- bytes32 s;
- uint8 v;
- }
-
- // -- Configuration --
-
- function setArbitrator(address _arbitrator) external;
-
- function setMinimumDeposit(uint256 _minimumDeposit) external;
-
- function setFishermanRewardPercentage(uint32 _percentage) external;
-
- function setSlashingPercentage(uint32 _qryPercentage, uint32 _idxPercentage) external;
-
- // -- Getters --
-
- function isDisputeCreated(bytes32 _disputeID) external view returns (bool);
-
- function encodeHashReceipt(Receipt memory _receipt) external view returns (bytes32);
-
- function areConflictingAttestations(
- Attestation memory _attestation1,
- Attestation memory _attestation2
- ) external pure returns (bool);
-
- function getAttestationIndexer(Attestation memory _attestation) external view returns (address);
-
- // -- Dispute --
-
- function createQueryDispute(bytes calldata _attestationData, uint256 _deposit) external returns (bytes32);
-
- function createQueryDisputeConflict(
- bytes calldata _attestationData1,
- bytes calldata _attestationData2
- ) external returns (bytes32, bytes32);
-
- function createIndexingDispute(address _allocationID, uint256 _deposit) external returns (bytes32);
-
- function acceptDispute(bytes32 _disputeID) external;
-
- function rejectDispute(bytes32 _disputeID) external;
-
- function drawDispute(bytes32 _disputeID) external;
-}
diff --git a/packages/contracts/contracts/epochs/EpochManager.sol b/packages/contracts/contracts/epochs/EpochManager.sol
index 281b63896..d69002794 100644
--- a/packages/contracts/contracts/epochs/EpochManager.sol
+++ b/packages/contracts/contracts/epochs/EpochManager.sol
@@ -2,27 +2,45 @@
pragma solidity ^0.7.6;
-import "@openzeppelin/contracts/math/SafeMath.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-indexed-events, gas-small-strings, gas-strict-inequalities
-import "../upgrades/GraphUpgradeable.sol";
+import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol";
-import "./EpochManagerStorage.sol";
-import "./IEpochManager.sol";
+import { GraphUpgradeable } from "../upgrades/GraphUpgradeable.sol";
+import { Managed } from "../governance/Managed.sol";
+
+import { EpochManagerV1Storage } from "./EpochManagerStorage.sol";
+import { IEpochManager } from "@graphprotocol/interfaces/contracts/contracts/epochs/IEpochManager.sol";
/**
* @title EpochManager contract
- * @dev Produce epochs based on a number of blocks to coordinate contracts in the protocol.
+ * @author Edge & Node
+ * @notice Produce epochs based on a number of blocks to coordinate contracts in the protocol.
*/
contract EpochManager is EpochManagerV1Storage, GraphUpgradeable, IEpochManager {
using SafeMath for uint256;
// -- Events --
+ /**
+ * @notice Emitted when an epoch is run
+ * @param epoch The epoch number that was run
+ * @param caller Address that called runEpoch()
+ */
event EpochRun(uint256 indexed epoch, address caller);
+
+ /**
+ * @notice Emitted when the epoch length is updated
+ * @param epoch The epoch when the length was updated
+ * @param epochLength The new epoch length in blocks
+ */
event EpochLengthUpdate(uint256 indexed epoch, uint256 epochLength);
/**
- * @dev Initialize this contract.
+ * @notice Initialize this contract.
+ * @param _controller Address of the Controller contract
+ * @param _epochLength Length of each epoch in blocks
*/
function initialize(address _controller, uint256 _epochLength) external onlyImpl {
require(_epochLength > 0, "Epoch length cannot be 0");
@@ -39,9 +57,7 @@ contract EpochManager is EpochManagerV1Storage, GraphUpgradeable, IEpochManager
}
/**
- * @dev Set the epoch length.
- * @notice Set epoch length to `_epochLength` blocks
- * @param _epochLength Epoch length in blocks
+ * @inheritdoc IEpochManager
*/
function setEpochLength(uint256 _epochLength) external override onlyGovernor {
require(_epochLength > 0, "Epoch length cannot be 0");
@@ -55,8 +71,7 @@ contract EpochManager is EpochManagerV1Storage, GraphUpgradeable, IEpochManager
}
/**
- * @dev Run a new epoch, should be called once at the start of any epoch.
- * @notice Perform state changes for the current epoch
+ * @inheritdoc IEpochManager
*/
function runEpoch() external override {
// Check if already called for the current epoch
@@ -70,24 +85,21 @@ contract EpochManager is EpochManagerV1Storage, GraphUpgradeable, IEpochManager
}
/**
- * @dev Return true if the current epoch has already run.
- * @return Return true if current epoch is the last epoch that has run
+ * @inheritdoc IEpochManager
*/
function isCurrentEpochRun() public view override returns (bool) {
return lastRunEpoch == currentEpoch();
}
/**
- * @dev Return current block number.
- * @return Block number
+ * @inheritdoc IEpochManager
*/
function blockNum() public view override returns (uint256) {
return block.number;
}
/**
- * @dev Return blockhash for a block.
- * @return BlockHash for `_block` number
+ * @inheritdoc IEpochManager
*/
function blockHash(uint256 _block) external view override returns (bytes32) {
uint256 currentBlock = blockNum();
@@ -99,33 +111,28 @@ contract EpochManager is EpochManagerV1Storage, GraphUpgradeable, IEpochManager
}
/**
- * @dev Return the current epoch, it may have not been run yet.
- * @return The current epoch based on epoch length
+ * @inheritdoc IEpochManager
*/
function currentEpoch() public view override returns (uint256) {
return lastLengthUpdateEpoch.add(epochsSinceUpdate());
}
/**
- * @dev Return block where the current epoch started.
- * @return The block number when the current epoch started
+ * @inheritdoc IEpochManager
*/
function currentEpochBlock() public view override returns (uint256) {
return lastLengthUpdateBlock.add(epochsSinceUpdate().mul(epochLength));
}
/**
- * @dev Return the number of blocks that passed since current epoch started.
- * @return Blocks that passed since start of epoch
+ * @inheritdoc IEpochManager
*/
function currentEpochBlockSinceStart() external view override returns (uint256) {
return blockNum() - currentEpochBlock();
}
/**
- * @dev Return the number of epoch that passed since another epoch.
- * @param _epoch Epoch to use as since epoch value
- * @return Number of epochs and current epoch
+ * @inheritdoc IEpochManager
*/
function epochsSince(uint256 _epoch) external view override returns (uint256) {
uint256 epoch = currentEpoch();
@@ -133,8 +140,7 @@ contract EpochManager is EpochManagerV1Storage, GraphUpgradeable, IEpochManager
}
/**
- * @dev Return number of epochs passed since last epoch length update.
- * @return The number of epoch that passed since last epoch length update
+ * @inheritdoc IEpochManager
*/
function epochsSinceUpdate() public view override returns (uint256) {
return blockNum().sub(lastLengthUpdateBlock).div(epochLength);
diff --git a/packages/contracts/contracts/epochs/EpochManagerStorage.sol b/packages/contracts/contracts/epochs/EpochManagerStorage.sol
index 5f8599434..894f34a46 100644
--- a/packages/contracts/contracts/epochs/EpochManagerStorage.sol
+++ b/packages/contracts/contracts/epochs/EpochManagerStorage.sol
@@ -2,18 +2,24 @@
pragma solidity ^0.7.6;
-import "../governance/Managed.sol";
+import { Managed } from "../governance/Managed.sol";
+/**
+ * @title Epoch Manager Storage V1
+ * @author Edge & Node
+ * @notice Storage contract for the Epoch Manager
+ */
contract EpochManagerV1Storage is Managed {
// -- State --
- // Epoch length in blocks
+ /// @notice Epoch length in blocks
uint256 public epochLength;
- // Epoch that was last run
+ /// @notice Epoch that was last run
uint256 public lastRunEpoch;
- // Block and epoch when epoch length was last updated
+ /// @notice Epoch when epoch length was last updated
uint256 public lastLengthUpdateEpoch;
+ /// @notice Block when epoch length was last updated
uint256 public lastLengthUpdateBlock;
}
diff --git a/packages/contracts/contracts/epochs/IEpochManager.sol b/packages/contracts/contracts/epochs/IEpochManager.sol
deleted file mode 100644
index c65280d59..000000000
--- a/packages/contracts/contracts/epochs/IEpochManager.sol
+++ /dev/null
@@ -1,31 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-interface IEpochManager {
- // -- Configuration --
-
- function setEpochLength(uint256 _epochLength) external;
-
- // -- Epochs
-
- function runEpoch() external;
-
- // -- Getters --
-
- function isCurrentEpochRun() external view returns (bool);
-
- function blockNum() external view returns (uint256);
-
- function blockHash(uint256 _block) external view returns (bytes32);
-
- function currentEpoch() external view returns (uint256);
-
- function currentEpochBlock() external view returns (uint256);
-
- function currentEpochBlockSinceStart() external view returns (uint256);
-
- function epochsSince(uint256 _epoch) external view returns (uint256);
-
- function epochsSinceUpdate() external view returns (uint256);
-}
diff --git a/packages/contracts/contracts/gateway/BridgeEscrow.sol b/packages/contracts/contracts/gateway/BridgeEscrow.sol
index 73bc0a3d7..d3b50edc8 100644
--- a/packages/contracts/contracts/gateway/BridgeEscrow.sol
+++ b/packages/contracts/contracts/gateway/BridgeEscrow.sol
@@ -9,7 +9,8 @@ import { Managed } from "../governance/Managed.sol";
/**
* @title Bridge Escrow
- * @dev This contracts acts as a gateway for an L2 bridge (or several). It simply holds GRT and has
+ * @author Edge & Node
+ * @notice This contracts acts as a gateway for an L2 bridge (or several). It simply holds GRT and has
* a set of spenders that can transfer the tokens; the L1 side of each L2 bridge has to be
* approved as a spender.
*/
diff --git a/packages/contracts/contracts/gateway/GraphTokenGateway.sol b/packages/contracts/contracts/gateway/GraphTokenGateway.sol
index fb992afc2..81edb9922 100644
--- a/packages/contracts/contracts/gateway/GraphTokenGateway.sol
+++ b/packages/contracts/contracts/gateway/GraphTokenGateway.sol
@@ -3,13 +3,14 @@
pragma solidity ^0.7.6;
import { GraphUpgradeable } from "../upgrades/GraphUpgradeable.sol";
-import { ITokenGateway } from "../arbitrum/ITokenGateway.sol";
+import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol";
import { Pausable } from "../governance/Pausable.sol";
import { Managed } from "../governance/Managed.sol";
/**
* @title L1/L2 Graph Token Gateway
- * @dev This includes everything that's shared between the L1 and L2 sides of the bridge.
+ * @author Edge & Node
+ * @notice This includes everything that's shared between the L1 and L2 sides of the bridge.
*/
abstract contract GraphTokenGateway is GraphUpgradeable, Pausable, Managed, ITokenGateway {
/// @dev Storage gap added in case we need to add state variables to this contract
@@ -52,7 +53,7 @@ abstract contract GraphTokenGateway is GraphUpgradeable, Pausable, Managed, ITok
}
/**
- * @dev Override the default pausing from Managed to allow pausing this
+ * @notice Override the default pausing from Managed to allow pausing this
* particular contract instead of pausing from the Controller.
*/
function _notPaused() internal view override {
@@ -60,7 +61,7 @@ abstract contract GraphTokenGateway is GraphUpgradeable, Pausable, Managed, ITok
}
/**
- * @dev Runs state validation before unpausing, reverts if
+ * @notice Runs state validation before unpausing, reverts if
* something is not set properly
*/
function _checksBeforeUnpause() internal view virtual;
diff --git a/packages/contracts/contracts/gateway/ICallhookReceiver.sol b/packages/contracts/contracts/gateway/ICallhookReceiver.sol
deleted file mode 100644
index 8d003cb76..000000000
--- a/packages/contracts/contracts/gateway/ICallhookReceiver.sol
+++ /dev/null
@@ -1,19 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-/**
- * @title Interface for contracts that can receive callhooks through the Arbitrum GRT bridge
- * @dev Any contract that can receive a callhook on L2, sent through the bridge from L1, must
- * be allowlisted by the governor, but also implement this interface that contains
- * the function that will actually be called by the L2GraphTokenGateway.
- */
-pragma solidity ^0.7.6 || 0.8.27;
-
-interface ICallhookReceiver {
- /**
- * @notice Receive tokens with a callhook from the bridge
- * @param _from Token sender in L1
- * @param _amount Amount of tokens that were transferred
- * @param _data ABI-encoded callhook data
- */
- function onTokenTransfer(address _from, uint256 _amount, bytes calldata _data) external;
-}
diff --git a/packages/contracts/contracts/gateway/L1GraphTokenGateway.sol b/packages/contracts/contracts/gateway/L1GraphTokenGateway.sol
index 7fad927ad..d9216b956 100644
--- a/packages/contracts/contracts/gateway/L1GraphTokenGateway.sol
+++ b/packages/contracts/contracts/gateway/L1GraphTokenGateway.sol
@@ -3,22 +3,27 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-indexed-events, gas-strict-inequalities
+// solhint-disable named-parameters-mapping
+
import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/Initializable.sol";
import { AddressUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/AddressUpgradeable.sol";
import { SafeMathUpgradeable } from "@openzeppelin/contracts-upgradeable/math/SafeMathUpgradeable.sol";
import { L1ArbitrumMessenger } from "../arbitrum/L1ArbitrumMessenger.sol";
-import { IBridge } from "../arbitrum/IBridge.sol";
-import { IInbox } from "../arbitrum/IInbox.sol";
-import { IOutbox } from "../arbitrum/IOutbox.sol";
-import { ITokenGateway } from "../arbitrum/ITokenGateway.sol";
+import { IBridge } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/IBridge.sol";
+import { IInbox } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/IInbox.sol";
+import { IOutbox } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/IOutbox.sol";
+import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol";
import { Managed } from "../governance/Managed.sol";
import { GraphTokenGateway } from "./GraphTokenGateway.sol";
-import { IGraphToken } from "../token/IGraphToken.sol";
+import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol";
/**
* @title L1 Graph Token Gateway Contract
- * @dev Provides the L1 side of the Ethereum-Arbitrum GRT bridge. Sends GRT to the L2 chain
+ * @author Edge & Node
+ * @notice Provides the L1 side of the Ethereum-Arbitrum GRT bridge. Sends GRT to the L2 chain
* by escrowing them and sending a message to the L2 gateway, and receives tokens from L2 by
* releasing them from escrow.
* Based on Offchain Labs' reference implementation and Livepeer's arbitrum-lpt-bridge
@@ -28,28 +33,35 @@ import { IGraphToken } from "../token/IGraphToken.sol";
contract L1GraphTokenGateway is Initializable, GraphTokenGateway, L1ArbitrumMessenger {
using SafeMathUpgradeable for uint256;
- /// Address of the Graph Token contract on L2
+ /// @notice Address of the Graph Token contract on L2
address public l2GRT;
- /// Address of the Arbitrum Inbox
+ /// @notice Address of the Arbitrum Inbox
address public inbox;
- /// Address of the Arbitrum Gateway Router on L1
+ /// @notice Address of the Arbitrum Gateway Router on L1
address public l1Router;
- /// Address of the L2GraphTokenGateway on L2 that is the counterpart of this gateway
+ /// @notice Address of the L2GraphTokenGateway on L2 that is the counterpart of this gateway
address public l2Counterpart;
- /// Address of the BridgeEscrow contract that holds the GRT in the bridge
+ /// @notice Address of the BridgeEscrow contract that holds the GRT in the bridge
address public escrow;
- /// Addresses for which this mapping is true are allowed to send callhooks in outbound transfers
+ /// @notice Addresses for which this mapping is true are allowed to send callhooks in outbound transfers
mapping(address => bool) public callhookAllowlist;
- /// Total amount minted from L2
+ /// @notice Total amount minted from L2
uint256 public totalMintedFromL2;
- /// Accumulated allowance for tokens minted from L2 at lastL2MintAllowanceUpdateBlock
+ /// @notice Accumulated allowance for tokens minted from L2 at lastL2MintAllowanceUpdateBlock
uint256 public accumulatedL2MintAllowanceSnapshot;
- /// Block at which new L2 allowance starts accumulating
+ /// @notice Block at which new L2 allowance starts accumulating
uint256 public lastL2MintAllowanceUpdateBlock;
- /// New L2 mint allowance per block
+ /// @notice New L2 mint allowance per block
uint256 public l2MintAllowancePerBlock;
- /// Emitted when an outbound transfer is initiated, i.e. tokens are deposited from L1 to L2
+ /**
+ * @notice Emitted when an outbound transfer is initiated, i.e. tokens are deposited from L1 to L2
+ * @param l1Token Address of the L1 token being transferred
+ * @param from Address sending the tokens on L1
+ * @param to Address receiving the tokens on L2
+ * @param sequenceNumber Sequence number of the retryable ticket
+ * @param amount Amount of tokens transferred
+ */
event DepositInitiated(
address l1Token,
address indexed from,
@@ -58,7 +70,14 @@ contract L1GraphTokenGateway is Initializable, GraphTokenGateway, L1ArbitrumMess
uint256 amount
);
- /// Emitted when an incoming transfer is finalized, i.e tokens are withdrawn from L2 to L1
+ /**
+ * @notice Emitted when an incoming transfer is finalized, i.e tokens are withdrawn from L2 to L1
+ * @param l1Token Address of the L1 token being transferred
+ * @param from Address sending the tokens on L2
+ * @param to Address receiving the tokens on L1
+ * @param exitNum Exit number (always 0 for this contract)
+ * @param amount Amount of tokens transferred
+ */
event WithdrawalFinalized(
address l1Token,
address indexed from,
@@ -67,25 +86,58 @@ contract L1GraphTokenGateway is Initializable, GraphTokenGateway, L1ArbitrumMess
uint256 amount
);
- /// Emitted when the Arbitrum Inbox and Gateway Router addresses have been updated
+ /**
+ * @notice Emitted when the Arbitrum Inbox and Gateway Router addresses have been updated
+ * @param inbox Address of the Arbitrum Inbox
+ * @param l1Router Address of the L1 Gateway Router
+ */
event ArbitrumAddressesSet(address inbox, address l1Router);
- /// Emitted when the L2 GRT address has been updated
+
+ /**
+ * @notice Emitted when the L2 GRT address has been updated
+ * @param l2GRT Address of the L2 GRT contract
+ */
event L2TokenAddressSet(address l2GRT);
- /// Emitted when the counterpart L2GraphTokenGateway address has been updated
+
+ /**
+ * @notice Emitted when the counterpart L2GraphTokenGateway address has been updated
+ * @param l2Counterpart Address of the L2 counterpart gateway
+ */
event L2CounterpartAddressSet(address l2Counterpart);
- /// Emitted when the escrow address has been updated
+ /**
+ * @notice Emitted when the escrow address has been updated
+ * @param escrow Address of the escrow contract
+ */
event EscrowAddressSet(address escrow);
- /// Emitted when an address is added to the callhook allowlist
+
+ /**
+ * @notice Emitted when an address is added to the callhook allowlist
+ * @param newAllowlisted Address added to the allowlist
+ */
event AddedToCallhookAllowlist(address newAllowlisted);
- /// Emitted when an address is removed from the callhook allowlist
+
+ /**
+ * @notice Emitted when an address is removed from the callhook allowlist
+ * @param notAllowlisted Address removed from the allowlist
+ */
event RemovedFromCallhookAllowlist(address notAllowlisted);
- /// Emitted when the L2 mint allowance per block is updated
+
+ /**
+ * @notice Emitted when the L2 mint allowance per block is updated
+ * @param accumulatedL2MintAllowanceSnapshot Accumulated allowance snapshot at update block
+ * @param l2MintAllowancePerBlock New allowance per block
+ * @param lastL2MintAllowanceUpdateBlock Block number when allowance was updated
+ */
event L2MintAllowanceUpdated(
uint256 accumulatedL2MintAllowanceSnapshot,
uint256 l2MintAllowancePerBlock,
uint256 lastL2MintAllowanceUpdateBlock
);
- /// Emitted when tokens are minted due to an incoming transfer from L2
+
+ /**
+ * @notice Emitted when tokens are minted due to an incoming transfer from L2
+ * @param amount Amount of tokens minted
+ */
event TokensMintedFromL2(uint256 amount);
/**
@@ -199,7 +251,7 @@ contract L1GraphTokenGateway is Initializable, GraphTokenGateway, L1ArbitrumMess
}
/**
- * @dev Updates the L2 mint allowance per block
+ * @notice Updates the L2 mint allowance per block
* It is meant to be called _after_ the issuancePerBlock is updated in L2.
* The caller should provide the new issuance per block and the block at which it was updated,
* the function will automatically compute the values so that the bridge's mint allowance
@@ -221,7 +273,7 @@ contract L1GraphTokenGateway is Initializable, GraphTokenGateway, L1ArbitrumMess
}
/**
- * @dev Manually sets the parameters used to compute the L2 mint allowance
+ * @notice Manually sets the parameters used to compute the L2 mint allowance
* The use of this function is not recommended, use updateL2MintAllowance instead;
* this one is only meant to be used as a backup recovery if a previous call to
* updateL2MintAllowance was done with incorrect values.
@@ -246,10 +298,7 @@ contract L1GraphTokenGateway is Initializable, GraphTokenGateway, L1ArbitrumMess
}
/**
- * @notice Creates and sends a retryable ticket to transfer GRT to L2 using the Arbitrum Inbox.
- * The tokens are escrowed by the gateway until they are withdrawn back to L1.
- * The ticket must be redeemed on L2 to receive tokens at the specified address.
- * Note that the caller must previously allow the gateway to spend the specified amount of GRT.
+ * @inheritdoc ITokenGateway
* @dev maxGas and gasPriceBid must be set using Arbitrum's NodeInterface.estimateRetryableTicket method.
* Also note that allowlisted senders (some protocol contracts) can include additional calldata
* for a callhook to be executed on the L2 side when the tokens are received. In this case, the L2 transaction
@@ -257,13 +306,6 @@ contract L1GraphTokenGateway is Initializable, GraphTokenGateway, L1ArbitrumMess
* never succeeds. This requires extra care when adding contracts to the allowlist, but is necessary to ensure that
* the tickets can be retried in the case of a temporary failure, and to ensure the atomicity of callhooks
* with token transfers.
- * @param _l1Token L1 Address of the GRT contract (needed for compatibility with Arbitrum Gateway Router)
- * @param _to Recipient address on L2
- * @param _amount Amount of tokens to transfer
- * @param _maxGas Gas limit for L2 execution of the ticket
- * @param _gasPriceBid Price per gas on L2
- * @param _data Encoded maxSubmissionCost and sender address along with additional calldata
- * @return Sequence number of the retryable ticket created by Inbox
*/
function outboundTransfer(
address _l1Token,
@@ -304,15 +346,10 @@ contract L1GraphTokenGateway is Initializable, GraphTokenGateway, L1ArbitrumMess
}
/**
- * @notice Receives withdrawn tokens from L2
- * The equivalent tokens are released from escrow and sent to the destination.
+ * @inheritdoc ITokenGateway
* @dev can only accept transactions coming from the L2 GRT Gateway.
* The last parameter is unused but kept for compatibility with Arbitrum gateways,
* and the encoded exitNum is assumed to be 0.
- * @param _l1Token L1 Address of the GRT contract (needed for compatibility with Arbitrum Gateway Router)
- * @param _from Address of the sender
- * @param _to Recipient address on L1
- * @param _amount Amount of tokens transferred
*/
function finalizeInboundTransfer(
address _l1Token,
@@ -335,10 +372,8 @@ contract L1GraphTokenGateway is Initializable, GraphTokenGateway, L1ArbitrumMess
}
/**
- * @notice Calculate the L2 address of a bridged token
+ * @inheritdoc ITokenGateway
* @dev In our case, this would only work for GRT.
- * @param _l1ERC20 address of L1 GRT contract
- * @return L2 address of the bridged GRT token
*/
function calculateL2TokenAddress(address _l1ERC20) external view override returns (address) {
IGraphToken token = graphToken();
@@ -387,10 +422,8 @@ contract L1GraphTokenGateway is Initializable, GraphTokenGateway, L1ArbitrumMess
);
}
- /**
- * @dev Runs state validation before unpausing, reverts if
- * something is not set properly
- */
+ /// @inheritdoc GraphTokenGateway
+ // solhint-disable-next-line use-natspec
function _checksBeforeUnpause() internal view override {
require(inbox != address(0), "INBOX_NOT_SET");
require(l1Router != address(0), "ROUTER_NOT_SET");
@@ -425,7 +458,7 @@ contract L1GraphTokenGateway is Initializable, GraphTokenGateway, L1ArbitrumMess
}
/**
- * @dev Get the accumulated L2 mint allowance at a particular block number
+ * @notice Get the accumulated L2 mint allowance at a particular block number
* @param _blockNum Block at which allowance will be computed
* @return The accumulated GRT amount that can be minted from L2 at the specified block
*/
@@ -438,7 +471,7 @@ contract L1GraphTokenGateway is Initializable, GraphTokenGateway, L1ArbitrumMess
}
/**
- * @dev Mint new L1 tokens coming from L2
+ * @notice Mint new L1 tokens coming from L2
* This will check if the amount to mint is within the L2's mint allowance, and revert otherwise.
* The tokens will be sent to the bridge escrow (from where they will then be sent to the destinatary
* of the current inbound transfer).
@@ -454,7 +487,7 @@ contract L1GraphTokenGateway is Initializable, GraphTokenGateway, L1ArbitrumMess
}
/**
- * @dev Check if minting a certain amount of tokens from L2 is within allowance
+ * @notice Check if minting a certain amount of tokens from L2 is within allowance
* @param _amount Number of tokens that would be minted
* @return true if minting those tokens is allowed, or false if it would be over allowance
*/
diff --git a/packages/contracts/contracts/governance/Controller.sol b/packages/contracts/contracts/governance/Controller.sol
index 707a27fff..af9c78bd8 100644
--- a/packages/contracts/contracts/governance/Controller.sol
+++ b/packages/contracts/contracts/governance/Controller.sol
@@ -1,22 +1,33 @@
// SPDX-License-Identifier: GPL-2.0-or-later
-pragma solidity ^0.7.6 || 0.8.27;
+pragma solidity ^0.7.6 || ^0.8.27;
-import { IController } from "./IController.sol";
-import { IManaged } from "./IManaged.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-indexed-events, gas-small-strings
+// solhint-disable named-parameters-mapping
+
+/* solhint-disable gas-custom-errors */ // Cannot use custom errors with 0.7.6
+
+import { IController } from "@graphprotocol/interfaces/contracts/contracts/governance/IController.sol";
+import { IManaged } from "@graphprotocol/interfaces/contracts/contracts/governance/IManaged.sol";
import { Governed } from "./Governed.sol";
import { Pausable } from "./Pausable.sol";
/**
* @title Graph Controller contract
- * @dev Controller is a registry of contracts for convenience. Inspired by Livepeer:
+ * @author Edge & Node
+ * @notice Controller is a registry of contracts for convenience. Inspired by Livepeer:
* https://github.com/livepeer/protocol/blob/streamflow/contracts/Controller.sol
*/
contract Controller is Governed, Pausable, IController {
/// @dev Track contract ids to contract proxy address
mapping(bytes32 => address) private _registry;
- /// Emitted when the proxy address for a protocol contract has been set
+ /**
+ * @notice Emitted when the proxy address for a protocol contract has been set
+ * @param id Contract identifier
+ * @param contractAddress Address of the contract proxy
+ */
event SetContractProxy(bytes32 indexed id, address contractAddress);
/**
@@ -37,7 +48,7 @@ contract Controller is Governed, Pausable, IController {
}
/**
- * @notice Getter to access governor
+ * @inheritdoc IController
*/
function getGovernor() external view override returns (address) {
return governor;
@@ -46,9 +57,7 @@ contract Controller is Governed, Pausable, IController {
// -- Registry --
/**
- * @notice Register contract id and mapped address
- * @param _id Contract id (keccak256 hash of contract name)
- * @param _contractAddress Contract address
+ * @inheritdoc IController
*/
function setContractProxy(bytes32 _id, address _contractAddress) external override onlyGovernor {
require(_contractAddress != address(0), "Contract address must be set");
@@ -57,8 +66,7 @@ contract Controller is Governed, Pausable, IController {
}
/**
- * @notice Unregister a contract address
- * @param _id Contract id (keccak256 hash of contract name)
+ * @inheritdoc IController
*/
function unsetContractProxy(bytes32 _id) external override onlyGovernor {
_registry[_id] = address(0);
@@ -66,18 +74,14 @@ contract Controller is Governed, Pausable, IController {
}
/**
- * @notice Get contract proxy address by its id
- * @param _id Contract id
- * @return Address of the proxy contract for the provided id
+ * @inheritdoc IController
*/
function getContractProxy(bytes32 _id) external view override returns (address) {
return _registry[_id];
}
/**
- * @notice Update contract's controller
- * @param _id Contract id (keccak256 hash of contract name)
- * @param _controller Controller address
+ * @inheritdoc IController
*/
function updateController(bytes32 _id, address _controller) external override onlyGovernor {
require(_controller != address(0), "Controller must be set");
@@ -96,17 +100,15 @@ contract Controller is Governed, Pausable, IController {
}
/**
- * @notice Change the paused state of the contract
- * Full pause most of protocol functions
- * @param _toPause True if the contracts should be paused, false otherwise
+ * @inheritdoc IController
+ * @dev Full pause most of protocol functions
*/
function setPaused(bool _toPause) external override onlyGovernorOrGuardian {
_setPaused(_toPause);
}
/**
- * @notice Change the Pause Guardian
- * @param _newPauseGuardian The address of the new Pause Guardian
+ * @inheritdoc IController
*/
function setPauseGuardian(address _newPauseGuardian) external override onlyGovernor {
require(_newPauseGuardian != address(0), "PauseGuardian must be set");
@@ -114,16 +116,14 @@ contract Controller is Governed, Pausable, IController {
}
/**
- * @notice Getter to access paused
- * @return True if the contracts are paused, false otherwise
+ * @inheritdoc IController
*/
function paused() external view override returns (bool) {
return _paused;
}
/**
- * @notice Getter to access partial pause status
- * @return True if the contracts are partially paused, false otherwise
+ * @inheritdoc IController
*/
function partialPaused() external view override returns (bool) {
return _partialPaused;
diff --git a/packages/contracts/contracts/governance/Governed.sol b/packages/contracts/contracts/governance/Governed.sol
index 76a3247dd..6a31cffea 100644
--- a/packages/contracts/contracts/governance/Governed.sol
+++ b/packages/contracts/contracts/governance/Governed.sol
@@ -1,24 +1,40 @@
// SPDX-License-Identifier: GPL-2.0-or-later
-pragma solidity ^0.7.6 || 0.8.27;
+pragma solidity ^0.7.6 || ^0.8.27;
+
+/* solhint-disable gas-custom-errors */ // Cannot use custom errors with 0.7.6
/**
* @title Graph Governance contract
- * @dev All contracts that will be owned by a Governor entity should extend this contract.
+ * @author Edge & Node
+ * @notice All contracts that will be owned by a Governor entity should extend this contract.
*/
abstract contract Governed {
// -- State --
- /// Address of the governor
+ /**
+ * @notice Address of the governor
+ */
address public governor;
- /// Address of the new governor that is pending acceptance
+ /**
+ * @notice Address of the new governor that is pending acceptance
+ */
address public pendingGovernor;
// -- Events --
- /// Emitted when a new owner/governor has been set, but is pending acceptance
+ /**
+ * @notice Emitted when a new owner/governor has been set, but is pending acceptance
+ * @param from Previous pending governor address
+ * @param to New pending governor address
+ */
event NewPendingOwnership(address indexed from, address indexed to);
- /// Emitted when a new owner/governor has accepted their role
+
+ /**
+ * @notice Emitted when a new owner/governor has accepted their role
+ * @param from Previous governor address
+ * @param to New governor address
+ */
event NewOwnership(address indexed from, address indexed to);
/**
@@ -30,7 +46,7 @@ abstract contract Governed {
}
/**
- * @dev Initialize the governor for this contract
+ * @notice Initialize the governor for this contract
* @param _initGovernor Address of the governor
*/
function _initialize(address _initGovernor) internal {
diff --git a/packages/contracts/contracts/governance/IController.sol b/packages/contracts/contracts/governance/IController.sol
deleted file mode 100644
index 6ab72010e..000000000
--- a/packages/contracts/contracts/governance/IController.sol
+++ /dev/null
@@ -1,29 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-interface IController {
- function getGovernor() external view returns (address);
-
- // -- Registry --
-
- function setContractProxy(bytes32 _id, address _contractAddress) external;
-
- function unsetContractProxy(bytes32 _id) external;
-
- function updateController(bytes32 _id, address _controller) external;
-
- function getContractProxy(bytes32 _id) external view returns (address);
-
- // -- Pausing --
-
- function setPartialPaused(bool _partialPaused) external;
-
- function setPaused(bool _paused) external;
-
- function setPauseGuardian(address _newPauseGuardian) external;
-
- function paused() external view returns (bool);
-
- function partialPaused() external view returns (bool);
-}
diff --git a/packages/contracts/contracts/governance/IManaged.sol b/packages/contracts/contracts/governance/IManaged.sol
deleted file mode 100644
index ff6625d81..000000000
--- a/packages/contracts/contracts/governance/IManaged.sol
+++ /dev/null
@@ -1,32 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-import { IController } from "./IController.sol";
-
-/**
- * @title Managed Interface
- * @dev Interface for contracts that can be managed by a controller.
- */
-interface IManaged {
- /**
- * @notice Set the controller that manages this contract
- * @dev Only the current controller can set a new controller
- * @param _controller Address of the new controller
- */
- function setController(address _controller) external;
-
- /**
- * @notice Sync protocol contract addresses from the Controller registry
- * @dev This function will cache all the contracts using the latest addresses.
- * Anyone can call the function whenever a Proxy contract change in the
- * controller to ensure the protocol is using the latest version.
- */
- function syncAllContracts() external;
-
- /**
- * @notice Get the Controller that manages this contract
- * @return The Controller as an IController interface
- */
- function controller() external view returns (IController);
-}
diff --git a/packages/contracts/contracts/governance/Managed.sol b/packages/contracts/contracts/governance/Managed.sol
index 9b0ea29c8..c4718a1e6 100644
--- a/packages/contracts/contracts/governance/Managed.sol
+++ b/packages/contracts/contracts/governance/Managed.sol
@@ -2,21 +2,26 @@
pragma solidity ^0.7.6;
-import { IController } from "./IController.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-indexed-events
+// solhint-disable named-parameters-mapping
-import { ICuration } from "../curation/ICuration.sol";
-import { IEpochManager } from "../epochs/IEpochManager.sol";
-import { IRewardsManager } from "../rewards/IRewardsManager.sol";
-import { IStaking } from "../staking/IStaking.sol";
-import { IGraphToken } from "../token/IGraphToken.sol";
-import { ITokenGateway } from "../arbitrum/ITokenGateway.sol";
-import { IGNS } from "../discovery/IGNS.sol";
+import { IController } from "@graphprotocol/interfaces/contracts/contracts/governance/IController.sol";
-import { IManaged } from "./IManaged.sol";
+import { ICuration } from "@graphprotocol/interfaces/contracts/contracts/curation/ICuration.sol";
+import { IEpochManager } from "@graphprotocol/interfaces/contracts/contracts/epochs/IEpochManager.sol";
+import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol";
+import { IStaking } from "@graphprotocol/interfaces/contracts/contracts/staking/IStaking.sol";
+import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol";
+import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol";
+import { IGNS } from "@graphprotocol/interfaces/contracts/contracts/discovery/IGNS.sol";
+
+import { IManaged } from "@graphprotocol/interfaces/contracts/contracts/governance/IManaged.sol";
/**
* @title Graph Managed contract
- * @dev The Managed contract provides an interface to interact with the Controller.
+ * @author Edge & Node
+ * @notice The Managed contract provides an interface to interact with the Controller.
* It also provides local caching for contract addresses. This mechanism relies on calling the
* public `syncAllContracts()` function whenever a contract changes in the controller.
*
@@ -26,7 +31,9 @@ import { IManaged } from "./IManaged.sol";
abstract contract Managed is IManaged {
// -- State --
- /// Controller that manages this contract
+ /**
+ * @inheritdoc IManaged
+ */
IController public override controller;
/// @dev Cache for the addresses of the contracts retrieved from the controller
mapping(bytes32 => address) private _addressCache;
@@ -34,28 +41,46 @@ abstract contract Managed is IManaged {
uint256[10] private __gap;
// Immutables
+ /// @dev Contract name hash for Curation contract
bytes32 private immutable CURATION = keccak256("Curation");
+ /// @dev Contract name hash for EpochManager contract
bytes32 private immutable EPOCH_MANAGER = keccak256("EpochManager");
+ /// @dev Contract name hash for RewardsManager contract
bytes32 private immutable REWARDS_MANAGER = keccak256("RewardsManager");
+ /// @dev Contract name hash for Staking contract
bytes32 private immutable STAKING = keccak256("Staking");
+ /// @dev Contract name hash for GraphToken contract
bytes32 private immutable GRAPH_TOKEN = keccak256("GraphToken");
+ /// @dev Contract name hash for GraphTokenGateway contract
bytes32 private immutable GRAPH_TOKEN_GATEWAY = keccak256("GraphTokenGateway");
+ /// @dev Contract name hash for GNS contract
bytes32 private immutable GNS = keccak256("GNS");
// -- Events --
- /// Emitted when a contract parameter has been updated
+ /**
+ * @notice Emitted when a contract parameter has been updated
+ * @param param Name of the parameter that was updated
+ */
event ParameterUpdated(string param);
- /// Emitted when the controller address has been set
+
+ /**
+ * @notice Emitted when the controller address has been set
+ * @param controller Address of the new controller
+ */
event SetController(address controller);
- /// Emitted when contract with `nameHash` is synced to `contractAddress`.
+ /**
+ * @notice Emitted when contract with `nameHash` is synced to `contractAddress`.
+ * @param nameHash Hash of the contract name
+ * @param contractAddress Address of the synced contract
+ */
event ContractSynced(bytes32 indexed nameHash, address contractAddress);
// -- Modifiers --
/**
- * @dev Revert if the controller is paused or partially paused
+ * @notice Revert if the controller is paused or partially paused
*/
function _notPartialPaused() internal view {
require(!controller.paused(), "Paused");
@@ -63,21 +88,21 @@ abstract contract Managed is IManaged {
}
/**
- * @dev Revert if the controller is paused
+ * @notice Revert if the controller is paused
*/
function _notPaused() internal view virtual {
require(!controller.paused(), "Paused");
}
/**
- * @dev Revert if the caller is not the governor
+ * @notice Revert if the caller is not the governor
*/
function _onlyGovernor() internal view {
require(msg.sender == controller.getGovernor(), "Only Controller governor");
}
/**
- * @dev Revert if the caller is not the Controller
+ * @notice Revert if the caller is not the Controller
*/
function _onlyController() internal view {
require(msg.sender == address(controller), "Caller must be Controller");
@@ -118,7 +143,7 @@ abstract contract Managed is IManaged {
// -- Functions --
/**
- * @dev Initialize a Managed contract
+ * @notice Initialize a Managed contract
* @param _controller Address for the Controller that manages this contract
*/
function _initialize(address _controller) internal {
@@ -126,15 +151,14 @@ abstract contract Managed is IManaged {
}
/**
- * @notice Set Controller. Only callable by current controller.
- * @param _controller Controller contract address
+ * @inheritdoc IManaged
*/
function setController(address _controller) external override onlyController {
_setController(_controller);
}
/**
- * @dev Set controller.
+ * @notice Set controller.
* @param _controller Controller contract address
*/
function _setController(address _controller) internal {
@@ -144,7 +168,7 @@ abstract contract Managed is IManaged {
}
/**
- * @dev Return Curation interface
+ * @notice Return Curation interface
* @return Curation contract registered with Controller
*/
function curation() internal view returns (ICuration) {
@@ -152,7 +176,7 @@ abstract contract Managed is IManaged {
}
/**
- * @dev Return EpochManager interface
+ * @notice Return EpochManager interface
* @return Epoch manager contract registered with Controller
*/
function epochManager() internal view returns (IEpochManager) {
@@ -160,7 +184,7 @@ abstract contract Managed is IManaged {
}
/**
- * @dev Return RewardsManager interface
+ * @notice Return RewardsManager interface
* @return Rewards manager contract registered with Controller
*/
function rewardsManager() internal view returns (IRewardsManager) {
@@ -168,7 +192,7 @@ abstract contract Managed is IManaged {
}
/**
- * @dev Return Staking interface
+ * @notice Return Staking interface
* @return Staking contract registered with Controller
*/
function staking() internal view returns (IStaking) {
@@ -176,7 +200,7 @@ abstract contract Managed is IManaged {
}
/**
- * @dev Return GraphToken interface
+ * @notice Return GraphToken interface
* @return Graph token contract registered with Controller
*/
function graphToken() internal view returns (IGraphToken) {
@@ -184,7 +208,7 @@ abstract contract Managed is IManaged {
}
/**
- * @dev Return GraphTokenGateway (L1 or L2) interface
+ * @notice Return GraphTokenGateway (L1 or L2) interface
* @return Graph token gateway contract registered with Controller
*/
function graphTokenGateway() internal view returns (ITokenGateway) {
@@ -192,7 +216,7 @@ abstract contract Managed is IManaged {
}
/**
- * @dev Return GNS (L1 or L2) interface.
+ * @notice Return GNS (L1 or L2) interface.
* @return Address of the GNS contract registered with Controller, as an IGNS interface.
*/
function gns() internal view returns (IGNS) {
@@ -200,7 +224,7 @@ abstract contract Managed is IManaged {
}
/**
- * @dev Resolve a contract address from the cache or the Controller if not found.
+ * @notice Resolve a contract address from the cache or the Controller if not found.
* @param _nameHash keccak256 hash of the contract name
* @return Address of the contract
*/
@@ -213,7 +237,7 @@ abstract contract Managed is IManaged {
}
/**
- * @dev Cache a contract address from the Controller registry.
+ * @notice Cache a contract address from the Controller registry.
* @param _nameHash keccak256 hash of the name of the contract to sync into the cache
*/
function _syncContract(bytes32 _nameHash) internal {
@@ -225,10 +249,7 @@ abstract contract Managed is IManaged {
}
/**
- * @notice Sync protocol contract addresses from the Controller registry
- * @dev This function will cache all the contracts using the latest addresses
- * Anyone can call the function whenever a Proxy contract change in the
- * controller to ensure the protocol is using the latest version
+ * @inheritdoc IManaged
*/
function syncAllContracts() external override {
_syncContract(CURATION);
diff --git a/packages/contracts/contracts/governance/Pausable.sol b/packages/contracts/contracts/governance/Pausable.sol
index 2bc1795cd..8f5614231 100644
--- a/packages/contracts/contracts/governance/Pausable.sol
+++ b/packages/contracts/contracts/governance/Pausable.sol
@@ -1,7 +1,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later
-pragma solidity ^0.7.6 || 0.8.27;
+pragma solidity ^0.7.6 || ^0.8.27;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-indexed-events
+
+/**
+ * @title Pausable Contract
+ * @author Edge & Node
+ * @notice Abstract contract that provides pause functionality for protocol operations
+ */
abstract contract Pausable {
/**
* @dev "Partial paused" pauses exit and enter functions for GRT, but not internal
@@ -13,24 +21,36 @@ abstract contract Pausable {
*/
bool internal _paused;
- /// Timestamp for the last time the partial pause was set
+ /// @notice Timestamp for the last time the partial pause was set
uint256 public lastPartialPauseTime;
- /// Timestamp for the last time the full pause was set
+ /// @notice Timestamp for the last time the full pause was set
uint256 public lastPauseTime;
- /// Pause guardian is a separate entity from the governor that can
+ /// @notice Pause guardian is a separate entity from the governor that can
/// pause and unpause the protocol, fully or partially
address public pauseGuardian;
- /// Emitted when the partial pause state changed
+ /**
+ * @notice Emitted when the partial pause state changed
+ * @param isPaused Whether the contract is partially paused
+ */
event PartialPauseChanged(bool isPaused);
- /// Emitted when the full pause state changed
+
+ /**
+ * @notice Emitted when the full pause state changed
+ * @param isPaused Whether the contract is fully paused
+ */
event PauseChanged(bool isPaused);
- /// Emitted when the pause guardian is changed
+
+ /**
+ * @notice Emitted when the pause guardian is changed
+ * @param oldPauseGuardian Address of the previous pause guardian
+ * @param pauseGuardian Address of the new pause guardian
+ */
event NewPauseGuardian(address indexed oldPauseGuardian, address indexed pauseGuardian);
/**
- * @dev Change the partial paused state of the contract
+ * @notice Change the partial paused state of the contract
* @param _toPartialPause New value for the partial pause state (true means the contracts will be partially paused)
*/
function _setPartialPaused(bool _toPartialPause) internal {
@@ -45,7 +65,7 @@ abstract contract Pausable {
}
/**
- * @dev Change the paused state of the contract
+ * @notice Change the paused state of the contract
* @param _toPause New value for the pause state (true means the contracts will be paused)
*/
function _setPaused(bool _toPause) internal {
@@ -60,7 +80,7 @@ abstract contract Pausable {
}
/**
- * @dev Change the Pause Guardian
+ * @notice Change the Pause Guardian
* @param newPauseGuardian The address of the new Pause Guardian
*/
function _setPauseGuardian(address newPauseGuardian) internal {
diff --git a/packages/contracts/contracts/l2/curation/IL2Curation.sol b/packages/contracts/contracts/l2/curation/IL2Curation.sol
deleted file mode 100644
index 7f93f9603..000000000
--- a/packages/contracts/contracts/l2/curation/IL2Curation.sol
+++ /dev/null
@@ -1,46 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-/**
- * @title Interface of the L2 Curation contract.
- */
-interface IL2Curation {
- /**
- * @notice Set the subgraph service address.
- * @param _subgraphService Address of the SubgraphService contract
- */
- function setSubgraphService(address _subgraphService) external;
-
- /**
- * @notice Deposit Graph Tokens in exchange for signal of a SubgraphDeployment curation pool.
- * @dev This function charges no tax and can only be called by GNS in specific scenarios (for now
- * only during an L1-L2 transfer).
- * @param _subgraphDeploymentID Subgraph deployment pool from where to mint signal
- * @param _tokensIn Amount of Graph Tokens to deposit
- * @return Signal minted
- */
- function mintTaxFree(bytes32 _subgraphDeploymentID, uint256 _tokensIn) external returns (uint256);
-
- /**
- * @notice Calculate amount of signal that can be bought with tokens in a curation pool,
- * without accounting for curation tax.
- * @param _subgraphDeploymentID Subgraph deployment for which to mint signal
- * @param _tokensIn Amount of tokens used to mint signal
- * @return Amount of signal that can be bought
- */
- function tokensToSignalNoTax(bytes32 _subgraphDeploymentID, uint256 _tokensIn) external view returns (uint256);
-
- /**
- * @notice Calculate the amount of tokens that would be recovered if minting signal with
- * the input tokens and then burning it. This can be used to compute rounding error.
- * This function does not account for curation tax.
- * @param _subgraphDeploymentID Subgraph deployment for which to mint signal
- * @param _tokensIn Amount of tokens used to mint signal
- * @return Amount of tokens that would be recovered after minting and burning signal
- */
- function tokensToSignalToTokensNoTax(
- bytes32 _subgraphDeploymentID,
- uint256 _tokensIn
- ) external view returns (uint256);
-}
diff --git a/packages/contracts/contracts/l2/curation/L2Curation.sol b/packages/contracts/contracts/l2/curation/L2Curation.sol
index 271545ea7..fd26bd2ac 100644
--- a/packages/contracts/contracts/l2/curation/L2Curation.sol
+++ b/packages/contracts/contracts/l2/curation/L2Curation.sol
@@ -3,22 +3,26 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-indexed-events, gas-small-strings, gas-strict-inequalities
+
import { AddressUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/AddressUpgradeable.sol";
import { SafeMathUpgradeable } from "@openzeppelin/contracts-upgradeable/math/SafeMathUpgradeable.sol";
import { ClonesUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/ClonesUpgradeable.sol";
import { GraphUpgradeable } from "../../upgrades/GraphUpgradeable.sol";
import { TokenUtils } from "../../utils/TokenUtils.sol";
-import { IRewardsManager } from "../../rewards/IRewardsManager.sol";
+import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol";
import { Managed } from "../../governance/Managed.sol";
-import { IGraphToken } from "../../token/IGraphToken.sol";
+import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol";
import { CurationV3Storage } from "../../curation/CurationStorage.sol";
-import { IGraphCurationToken } from "../../curation/IGraphCurationToken.sol";
-import { IL2Curation } from "./IL2Curation.sol";
+import { IGraphCurationToken } from "@graphprotocol/interfaces/contracts/contracts/curation/IGraphCurationToken.sol";
+import { IL2Curation } from "@graphprotocol/interfaces/contracts/contracts/l2/curation/IL2Curation.sol";
/**
* @title L2Curation contract
- * @dev Allows curators to signal on subgraph deployments that might be relevant to indexers by
+ * @author Edge & Node
+ * @notice Allows curators to signal on subgraph deployments that might be relevant to indexers by
* staking Graph Tokens (GRT). Additionally, curators earn fees from the Query Market related to the
* subgraph deployment they curate.
* A curators deposit goes to a curation pool along with the deposits of other curators,
@@ -38,14 +42,20 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation {
uint256 private constant SIGNAL_PER_MINIMUM_DEPOSIT = 1; // 1e-18 signal as 18 decimal number
/// @dev Reserve ratio for all subgraphs set to 100% for a flat bonding curve
+ // solhint-disable-next-line immutable-vars-naming
uint32 private immutable fixedReserveRatio = MAX_PPM;
// -- Events --
/**
- * @dev Emitted when `curator` deposited `tokens` on `subgraphDeploymentID` as curation signal.
+ * @notice Emitted when `curator` deposited `tokens` on `subgraphDeploymentID` as curation signal.
* The `curator` receives `signal` amount according to the curation pool bonding curve.
* An amount of `curationTax` will be collected and burned.
+ * @param curator Address of the curator
+ * @param subgraphDeploymentID Subgraph deployment being signaled on
+ * @param tokens Amount of tokens deposited
+ * @param signal Amount of signal minted
+ * @param curationTax Amount of tokens burned as curation tax
*/
event Signalled(
address indexed curator,
@@ -56,19 +66,26 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation {
);
/**
- * @dev Emitted when `curator` burned `signal` for a `subgraphDeploymentID`.
+ * @notice Emitted when `curator` burned `signal` for a `subgraphDeploymentID`.
* The curator will receive `tokens` according to the value of the bonding curve.
+ * @param curator Address of the curator
+ * @param subgraphDeploymentID Subgraph deployment being signaled on
+ * @param tokens Amount of tokens received
+ * @param signal Amount of signal burned
*/
event Burned(address indexed curator, bytes32 indexed subgraphDeploymentID, uint256 tokens, uint256 signal);
/**
- * @dev Emitted when `tokens` amount were collected for `subgraphDeploymentID` as part of fees
+ * @notice Emitted when `tokens` amount were collected for `subgraphDeploymentID` as part of fees
* distributed by an indexer from query fees received from state channels.
+ * @param subgraphDeploymentID Subgraph deployment that collected fees
+ * @param tokens Amount of tokens collected as fees
*/
event Collected(bytes32 indexed subgraphDeploymentID, uint256 tokens);
/**
- * @dev Emitted when the subgraph service is set.
+ * @notice Emitted when the subgraph service is set
+ * @param newSubgraphService Address of the new subgraph service
*/
event SubgraphServiceSet(address indexed newSubgraphService);
@@ -107,7 +124,8 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation {
* @notice Set the default reserve ratio - not implemented in L2
* @dev We only keep this for compatibility with ICuration
*/
- function setDefaultReserveRatio(uint32) external view override onlyGovernor {
+ // solhint-disable-next-line use-natspec
+ function setDefaultReserveRatio(uint32 /* _defaultReserveRatio */) external view override onlyGovernor {
revert("Not implemented in L2");
}
@@ -153,11 +171,8 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation {
* @param _tokens Amount of Graph Tokens to add to reserves
*/
function collect(bytes32 _subgraphDeploymentID, uint256 _tokens) external override {
- // Only SubgraphService or Staking contract are authorized as caller
- require(
- msg.sender == subgraphService || msg.sender == address(staking()),
- "Caller must be the subgraph service or staking contract"
- );
+ // Only SubgraphService is authorized as caller
+ require(msg.sender == subgraphService, "Caller must be the subgraph service");
// Must be curated to accept tokens
require(isCurated(_subgraphDeploymentID), "Subgraph deployment must be curated to collect fees");
@@ -174,7 +189,8 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation {
* @param _subgraphDeploymentID Subgraph deployment pool from where to mint signal
* @param _tokensIn Amount of Graph Tokens to deposit
* @param _signalOutMin Expected minimum amount of signal to receive
- * @return Signal minted and deposit tax
+ * @return Signal minted
+ * @return Curation tax paid
*/
function mint(
bytes32 _subgraphDeploymentID,
@@ -228,12 +244,7 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation {
}
/**
- * @notice Deposit Graph Tokens in exchange for signal of a SubgraphDeployment curation pool.
- * @dev This function charges no tax and can only be called by GNS in specific scenarios (for now
- * only during an L1-L2 transfer).
- * @param _subgraphDeploymentID Subgraph deployment pool from where to mint signal
- * @param _tokensIn Amount of Graph Tokens to deposit
- * @return Signal minted
+ * @inheritdoc IL2Curation
*/
function mintTaxFree(
bytes32 _subgraphDeploymentID,
@@ -387,11 +398,7 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation {
}
/**
- * @notice Calculate amount of signal that can be bought with tokens in a curation pool,
- * without accounting for curation tax.
- * @param _subgraphDeploymentID Subgraph deployment to mint signal
- * @param _tokensIn Amount of tokens used to mint signal
- * @return Amount of signal that can be bought
+ * @inheritdoc IL2Curation
*/
function tokensToSignalNoTax(
bytes32 _subgraphDeploymentID,
@@ -401,12 +408,7 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation {
}
/**
- * @notice Calculate the amount of tokens that would be recovered if minting signal with
- * the input tokens and then burning it. This can be used to compute rounding error.
- * This function does not account for curation tax.
- * @param _subgraphDeploymentID Subgraph deployment for which to mint signal
- * @param _tokensIn Amount of tokens used to mint signal
- * @return Amount of tokens that would be recovered after minting and burning signal
+ * @inheritdoc IL2Curation
*/
function tokensToSignalToTokensNoTax(
bytes32 _subgraphDeploymentID,
@@ -436,7 +438,7 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation {
}
/**
- * @dev Internal: Set the minimum deposit amount for curators.
+ * @notice Internal: Set the minimum deposit amount for curators.
* Update the minimum deposit amount to `_minimumCurationDeposit`
* @param _minimumCurationDeposit Minimum amount of tokens required deposit
*/
@@ -448,7 +450,7 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation {
}
/**
- * @dev Internal: Set the curation tax percentage to charge when a curator deposits GRT tokens.
+ * @notice Internal: Set the curation tax percentage to charge when a curator deposits GRT tokens.
* @param _percentage Curation tax percentage charged when depositing GRT tokens
*/
function _setCurationTaxPercentage(uint32 _percentage) private {
@@ -459,7 +461,7 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation {
}
/**
- * @dev Internal: Set the master copy to use as clones for the curation token.
+ * @notice Internal: Set the master copy to use as clones for the curation token.
* @param _curationTokenMaster Address of implementation contract to use for curation tokens
*/
function _setCurationTokenMaster(address _curationTokenMaster) private {
@@ -471,7 +473,7 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation {
}
/**
- * @dev Triggers an update of rewards due to a change in signal.
+ * @notice Triggers an update of rewards due to a change in signal.
* @param _subgraphDeploymentID Subgraph deployment updated
*/
function _updateRewards(bytes32 _subgraphDeploymentID) private {
@@ -482,7 +484,7 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation {
}
/**
- * @dev Calculate amount of signal that can be bought with tokens in a curation pool.
+ * @notice Calculate amount of signal that can be bought with tokens in a curation pool.
* @param _subgraphDeploymentID Subgraph deployment to mint signal
* @param _tokensIn Amount of tokens used to mint signal
* @return Amount of signal that can be bought with tokens
diff --git a/packages/contracts/contracts/l2/discovery/IL2GNS.sol b/packages/contracts/contracts/l2/discovery/IL2GNS.sol
deleted file mode 100644
index a24216fbb..000000000
--- a/packages/contracts/contracts/l2/discovery/IL2GNS.sol
+++ /dev/null
@@ -1,56 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-import { ICallhookReceiver } from "../../gateway/ICallhookReceiver.sol";
-
-/**
- * @title Interface for the L2GNS contract.
- */
-interface IL2GNS is ICallhookReceiver {
- enum L1MessageCodes {
- RECEIVE_SUBGRAPH_CODE,
- RECEIVE_CURATOR_BALANCE_CODE
- }
-
- /**
- * @dev The SubgraphL2TransferData struct holds information
- * about a subgraph related to its transfer from L1 to L2.
- */
- struct SubgraphL2TransferData {
- uint256 tokens; // GRT that will be sent to L2 to mint signal
- mapping(address => bool) curatorBalanceClaimed; // True for curators whose balance has been claimed in L2
- bool l2Done; // Transfer finished on L2 side
- uint256 subgraphReceivedOnL2BlockNumber; // Block number when the subgraph was received on L2
- }
-
- /**
- * @notice Finish a subgraph transfer from L1.
- * The subgraph must have been previously sent through the bridge
- * using the sendSubgraphToL2 function on L1GNS.
- * @param _l2SubgraphID Subgraph ID in L2 (aliased from the L1 subgraph ID)
- * @param _subgraphDeploymentID Latest subgraph deployment to assign to the subgraph
- * @param _subgraphMetadata IPFS hash of the subgraph metadata
- * @param _versionMetadata IPFS hash of the version metadata
- */
- function finishSubgraphTransferFromL1(
- uint256 _l2SubgraphID,
- bytes32 _subgraphDeploymentID,
- bytes32 _subgraphMetadata,
- bytes32 _versionMetadata
- ) external;
-
- /**
- * @notice Return the aliased L2 subgraph ID from a transferred L1 subgraph ID
- * @param _l1SubgraphID L1 subgraph ID
- * @return L2 subgraph ID
- */
- function getAliasedL2SubgraphID(uint256 _l1SubgraphID) external pure returns (uint256);
-
- /**
- * @notice Return the unaliased L1 subgraph ID from a transferred L2 subgraph ID
- * @param _l2SubgraphID L2 subgraph ID
- * @return L1subgraph ID
- */
- function getUnaliasedL1SubgraphID(uint256 _l2SubgraphID) external pure returns (uint256);
-}
diff --git a/packages/contracts/contracts/l2/discovery/L2GNS.sol b/packages/contracts/contracts/l2/discovery/L2GNS.sol
index 34d47d400..bd176dbcf 100644
--- a/packages/contracts/contracts/l2/discovery/L2GNS.sol
+++ b/packages/contracts/contracts/l2/discovery/L2GNS.sol
@@ -3,18 +3,22 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-indexed-events, gas-small-strings, gas-strict-inequalities
+
import { SafeMathUpgradeable } from "@openzeppelin/contracts-upgradeable/math/SafeMathUpgradeable.sol";
import { GNS } from "../../discovery/GNS.sol";
-import { ICuration } from "../../curation/ICuration.sol";
-import { IL2GNS } from "./IL2GNS.sol";
+import { ICuration } from "@graphprotocol/interfaces/contracts/contracts/curation/ICuration.sol";
+import { IL2GNS } from "@graphprotocol/interfaces/contracts/contracts/l2/discovery/IL2GNS.sol";
import { L2GNSV1Storage } from "./L2GNSStorage.sol";
-import { IL2Curation } from "../curation/IL2Curation.sol";
+import { IL2Curation } from "@graphprotocol/interfaces/contracts/contracts/l2/curation/IL2Curation.sol";
/**
* @title L2GNS
- * @dev The Graph Name System contract provides a decentralized naming system for subgraphs
+ * @author Edge & Node
+ * @notice The Graph Name System contract provides a decentralized naming system for subgraphs
* used in the scope of the Graph Network. It translates Subgraphs into Subgraph Versions.
* Each version is associated with a Subgraph Deployment. The contract has no knowledge of
* human-readable names. All human readable names emitted in events.
@@ -26,35 +30,47 @@ import { IL2Curation } from "../curation/IL2Curation.sol";
contract L2GNS is GNS, L2GNSV1Storage, IL2GNS {
using SafeMathUpgradeable for uint256;
- /// Offset added to an L1 subgraph ID to compute the L2 subgraph ID alias
+ /// @notice Offset added to an L1 subgraph ID to compute the L2 subgraph ID alias
uint256 public constant SUBGRAPH_ID_ALIAS_OFFSET =
uint256(0x1111000000000000000000000000000000000000000000000000000000001111);
- /// Maximum rounding error when receiving signal tokens from L1, in parts-per-million.
- /// If the error from minting signal is above this, tokens will be sent back to the curator.
+ /// @notice Maximum rounding error when receiving signal tokens from L1, in parts-per-million
+ /// @dev If the error from minting signal is above this, tokens will be sent back to the curator
uint256 public constant MAX_ROUNDING_ERROR = 1000;
/// @dev 100% expressed in parts-per-million
uint256 private constant MAX_PPM = 1000000;
- /// @dev Emitted when a subgraph is received from L1 through the bridge
+ /// @notice Emitted when a subgraph is received from L1 through the bridge
+ /// @param _l1SubgraphID Subgraph ID on L1
+ /// @param _l2SubgraphID Subgraph ID on L2 (aliased)
+ /// @param _owner Address of the subgraph owner
+ /// @param _tokens Amount of tokens transferred with the subgraph
event SubgraphReceivedFromL1(
uint256 indexed _l1SubgraphID,
uint256 indexed _l2SubgraphID,
address indexed _owner,
uint256 _tokens
);
- /// @dev Emitted when a subgraph transfer from L1 is finalized, so the subgraph is published on L2
+ /// @notice Emitted when a subgraph transfer from L1 is finalized, so the subgraph is published on L2
+ /// @param _l2SubgraphID Subgraph ID on L2
event SubgraphL2TransferFinalized(uint256 indexed _l2SubgraphID);
- /// @dev Emitted when the L1 balance for a curator has been claimed
+ /// @notice Emitted when the L1 balance for a curator has been claimed
+ /// @param _l1SubgraphId Subgraph ID on L1
+ /// @param _l2SubgraphID Subgraph ID on L2 (aliased)
+ /// @param _l2Curator Address of the curator on L2
+ /// @param _tokens Amount of tokens received
event CuratorBalanceReceived(
uint256 indexed _l1SubgraphId,
uint256 indexed _l2SubgraphID,
address indexed _l2Curator,
uint256 _tokens
);
- /// @dev Emitted when the L1 balance for a curator has been returned to the beneficiary.
+ /// @notice Emitted when the L1 balance for a curator has been returned to the beneficiary.
/// This can happen if the subgraph transfer was not finished when the curator's tokens arrived.
+ /// @param _l1SubgraphID Subgraph ID on L1
+ /// @param _l2Curator Address of the curator on L2
+ /// @param _tokens Amount of tokens returned
event CuratorBalanceReturnedToBeneficiary(
uint256 indexed _l1SubgraphID,
address indexed _l2Curator,
@@ -103,13 +119,7 @@ contract L2GNS is GNS, L2GNSV1Storage, IL2GNS {
}
/**
- * @notice Finish a subgraph transfer from L1.
- * The subgraph must have been previously sent through the bridge
- * using the sendSubgraphToL2 function on L1GNS.
- * @param _l2SubgraphID Subgraph ID (aliased from the L1 subgraph ID)
- * @param _subgraphDeploymentID Latest subgraph deployment to assign to the subgraph
- * @param _subgraphMetadata IPFS hash of the subgraph metadata
- * @param _versionMetadata IPFS hash of the version metadata
+ * @inheritdoc IL2GNS
*/
function finishSubgraphTransferFromL1(
uint256 _l2SubgraphID,
@@ -220,25 +230,21 @@ contract L2GNS is GNS, L2GNSV1Storage, IL2GNS {
}
/**
- * @notice Return the aliased L2 subgraph ID from a transferred L1 subgraph ID
- * @param _l1SubgraphID L1 subgraph ID
- * @return L2 subgraph ID
+ * @inheritdoc IL2GNS
*/
function getAliasedL2SubgraphID(uint256 _l1SubgraphID) public pure override returns (uint256) {
return _l1SubgraphID + SUBGRAPH_ID_ALIAS_OFFSET;
}
/**
- * @notice Return the unaliased L1 subgraph ID from a transferred L2 subgraph ID
- * @param _l2SubgraphID L2 subgraph ID
- * @return L1subgraph ID
+ * @inheritdoc IL2GNS
*/
function getUnaliasedL1SubgraphID(uint256 _l2SubgraphID) public pure override returns (uint256) {
return _l2SubgraphID - SUBGRAPH_ID_ALIAS_OFFSET;
}
/**
- * @dev Receive a subgraph from L1.
+ * @notice Receive a subgraph from L1.
* This function will initialize a subgraph received through the bridge,
* and store the transfer data so that it's finalized later using finishSubgraphTransferFromL1.
* @param _l1SubgraphID Subgraph ID in L1 (will be aliased)
@@ -308,9 +314,9 @@ contract L2GNS is GNS, L2GNSV1Storage, IL2GNS {
}
/**
- * @dev Get subgraph data.
- * Since there are no legacy subgraphs in L2, we override the base
- * GNS method to save us the step of checking for legacy subgraphs.
+ * @notice Get subgraph data
+ * @dev Since there are no legacy subgraphs in L2, we override the base
+ * GNS method to save us the step of checking for legacy subgraphs
* @param _subgraphID Subgraph ID
* @return Subgraph Data
*/
diff --git a/packages/contracts/contracts/l2/discovery/L2GNSStorage.sol b/packages/contracts/contracts/l2/discovery/L2GNSStorage.sol
index f658c49d9..d464ea891 100644
--- a/packages/contracts/contracts/l2/discovery/L2GNSStorage.sol
+++ b/packages/contracts/contracts/l2/discovery/L2GNSStorage.sol
@@ -1,17 +1,20 @@
// SPDX-License-Identifier: GPL-2.0-or-later
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable named-parameters-mapping
+
pragma solidity ^0.7.6;
pragma abicoder v2;
-import { IL2GNS } from "./IL2GNS.sol";
+import { IL2GNS } from "@graphprotocol/interfaces/contracts/contracts/l2/discovery/IL2GNS.sol";
/**
* @title L2GNSV1Storage
+ * @author Edge & Node
* @notice This contract holds all the L2-specific storage variables for the L2GNS contract, version 1
- * @dev
*/
abstract contract L2GNSV1Storage {
- /// Data for subgraph transfer from L1 to L2
+ /// @notice Data for subgraph transfer from L1 to L2
mapping(uint256 => IL2GNS.SubgraphL2TransferData) public subgraphL2TransferData;
/// @dev Storage gap to keep storage slots fixed in future versions
uint256[50] private __gap;
diff --git a/packages/contracts/contracts/l2/gateway/L2GraphTokenGateway.sol b/packages/contracts/contracts/l2/gateway/L2GraphTokenGateway.sol
index be8f212b8..aa8868c49 100644
--- a/packages/contracts/contracts/l2/gateway/L2GraphTokenGateway.sol
+++ b/packages/contracts/contracts/l2/gateway/L2GraphTokenGateway.sol
@@ -3,20 +3,24 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-indexed-events
+
import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol";
import { SafeMathUpgradeable } from "@openzeppelin/contracts-upgradeable/math/SafeMathUpgradeable.sol";
import { L2ArbitrumMessenger } from "../../arbitrum/L2ArbitrumMessenger.sol";
import { AddressAliasHelper } from "../../arbitrum/AddressAliasHelper.sol";
-import { ITokenGateway } from "../../arbitrum/ITokenGateway.sol";
+import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol";
import { Managed } from "../../governance/Managed.sol";
import { GraphTokenGateway } from "../../gateway/GraphTokenGateway.sol";
-import { ICallhookReceiver } from "../../gateway/ICallhookReceiver.sol";
+import { ICallhookReceiver } from "@graphprotocol/interfaces/contracts/contracts/gateway/ICallhookReceiver.sol";
import { L2GraphToken } from "../token/L2GraphToken.sol";
/**
* @title L2 Graph Token Gateway Contract
- * @dev Provides the L2 side of the Ethereum-Arbitrum GRT bridge. Receives GRT from the L1 chain
+ * @author Edge & Node
+ * @notice Provides the L2 side of the Ethereum-Arbitrum GRT bridge. Receives GRT from the L1 chain
* and mints them on the L2 side. Sends GRT back to L1 by burning them on the L2 side.
* Based on Offchain Labs' reference implementation and Livepeer's arbitrum-lpt-bridge
* (See: https://github.com/OffchainLabs/arbitrum/tree/master/packages/arb-bridge-peripherals/contracts/tokenbridge
@@ -25,23 +29,42 @@ import { L2GraphToken } from "../token/L2GraphToken.sol";
contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger, ReentrancyGuardUpgradeable {
using SafeMathUpgradeable for uint256;
- /// Address of the Graph Token contract on L1
+ /// @notice Address of the Graph Token contract on L1
address public l1GRT;
- /// Address of the L1GraphTokenGateway that is the counterpart of this gateway on L1
+ /// @notice Address of the L1GraphTokenGateway that is the counterpart of this gateway on L1
address public l1Counterpart;
- /// Address of the Arbitrum Gateway Router on L2
+ /// @notice Address of the Arbitrum Gateway Router on L2
address public l2Router;
/// @dev Calldata included in an outbound transfer, stored as a structure for convenience and stack depth
+ /**
+ * @dev Struct for outbound transfer calldata
+ * @param from Address sending the tokens
+ * @param extraData Additional data for the transfer
+ */
struct OutboundCalldata {
address from;
bytes extraData;
}
- /// Emitted when an incoming transfer is finalized, i.e. tokens were deposited from L1 to L2
+ /**
+ * @notice Emitted when an incoming transfer is finalized, i.e. tokens were deposited from L1 to L2
+ * @param l1Token Address of the L1 token
+ * @param from Address sending the tokens on L1
+ * @param to Address receiving the tokens on L2
+ * @param amount Amount of tokens transferred
+ */
event DepositFinalized(address indexed l1Token, address indexed from, address indexed to, uint256 amount);
- /// Emitted when an outbound transfer is initiated, i.e. tokens are being withdrawn from L2 back to L1
+ /**
+ * @notice Emitted when an outbound transfer is initiated, i.e. tokens are being withdrawn from L2 back to L1
+ * @param l1Token Address of the L1 token
+ * @param from Address sending the tokens on L2
+ * @param to Address receiving the tokens on L1
+ * @param l2ToL1Id ID of the L2 to L1 message
+ * @param exitNum Exit number (always 0 for this contract)
+ * @param amount Amount of tokens transferred
+ */
event WithdrawalInitiated(
address l1Token,
address indexed from,
@@ -51,11 +74,22 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger, Reentran
uint256 amount
);
- /// Emitted when the Arbitrum Gateway Router address on L2 has been updated
+ /**
+ * @notice Emitted when the Arbitrum Gateway Router address on L2 has been updated
+ * @param l2Router Address of the L2 Gateway Router
+ */
event L2RouterSet(address l2Router);
- /// Emitted when the L1 Graph Token address has been updated
+
+ /**
+ * @notice Emitted when the L1 Graph Token address has been updated
+ * @param l1GRT Address of the L1 GRT contract
+ */
event L1TokenAddressSet(address l1GRT);
- /// Emitted when the address of the counterpart gateway on L1 has been updated
+
+ /**
+ * @notice Emitted when the address of the counterpart gateway on L1 has been updated
+ * @param l1Counterpart Address of the L1 counterpart gateway
+ */
event L1CounterpartAddressSet(address l1Counterpart);
/**
@@ -135,7 +169,7 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger, Reentran
}
/**
- * @notice Receives token amount from L1 and mints the equivalent tokens to the receiving address
+ * @inheritdoc ITokenGateway
* @dev Only accepts transactions from the L1 GRT Gateway.
* The function is payable for ITokenGateway compatibility, but msg.value must be zero.
* Note that allowlisted senders (some protocol contracts) can include additional calldata
@@ -144,11 +178,6 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger, Reentran
* never succeeds. This requires extra care when adding contracts to the allowlist, but is necessary to ensure that
* the tickets can be retried in the case of a temporary failure, and to ensure the atomicity of callhooks
* with token transfers.
- * @param _l1Token L1 Address of GRT
- * @param _from Address of the sender on L1
- * @param _to Recipient address on L2
- * @param _amount Amount of tokens transferred
- * @param _data Extra callhook data, only used when the sender is allowlisted
*/
function finalizeInboundTransfer(
address _l1Token,
@@ -170,18 +199,14 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger, Reentran
}
/**
- * @notice Burns L2 tokens and initiates a transfer to L1.
+ * @inheritdoc ITokenGateway
+ * @dev Burns L2 tokens and initiates a transfer to L1.
* The tokens will be available on L1 only after the wait period (7 days) is over,
* and will require an Outbox.executeTransaction to finalize.
* Note that the caller must previously allow the gateway to spend the specified amount of GRT.
- * @dev no additional callhook data is allowed. The two unused params are needed
+ * No additional callhook data is allowed. The two unused params are needed
* for compatibility with Arbitrum's gateway router.
* The function is payable for ITokenGateway compatibility, but msg.value must be zero.
- * @param _l1Token L1 Address of GRT (needed for compatibility with Arbitrum Gateway Router)
- * @param _to Recipient address on L1
- * @param _amount Amount of tokens to burn
- * @param _data Contains sender and additional data (always empty) to send to L1
- * @return ID of the withdraw transaction
*/
function outboundTransfer(
address _l1Token,
@@ -218,10 +243,8 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger, Reentran
}
/**
- * @notice Calculate the L2 address of a bridged token
+ * @inheritdoc ITokenGateway
* @dev In our case, this would only work for GRT.
- * @param l1ERC20 address of L1 GRT contract
- * @return L2 address of the bridged GRT token
*/
function calculateL2TokenAddress(address l1ERC20) public view override returns (address) {
if (l1ERC20 != l1GRT) {
@@ -259,10 +282,8 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger, Reentran
);
}
- /**
- * @dev Runs state validation before unpausing, reverts if
- * something is not set properly
- */
+ /// @inheritdoc GraphTokenGateway
+ // solhint-disable-next-line use-natspec
function _checksBeforeUnpause() internal view override {
require(l2Router != address(0), "L2_ROUTER_NOT_SET");
require(l1Counterpart != address(0), "L1_COUNTERPART_NOT_SET");
diff --git a/packages/contracts/contracts/l2/staking/IL2Staking.sol b/packages/contracts/contracts/l2/staking/IL2Staking.sol
deleted file mode 100644
index 4b7748e31..000000000
--- a/packages/contracts/contracts/l2/staking/IL2Staking.sol
+++ /dev/null
@@ -1,19 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity >=0.6.12 <0.8.0;
-pragma abicoder v2;
-
-import { IStaking } from "../../staking/IStaking.sol";
-import { IL2StakingBase } from "./IL2StakingBase.sol";
-import { IL2StakingTypes } from "./IL2StakingTypes.sol";
-
-/**
- * @title Interface for the L2 Staking contract
- * @notice This is the interface that should be used when interacting with the L2 Staking contract.
- * It extends the IStaking interface with the functions that are specific to L2, adding the callhook receiver
- * to receive transferred stake and delegation from L1.
- * @dev Note that L2Staking doesn't actually inherit this interface. This is because of
- * the custom setup of the Staking contract where part of the functionality is implemented
- * in a separate contract (StakingExtension) to which calls are delegated through the fallback function.
- */
-interface IL2Staking is IStaking, IL2StakingBase, IL2StakingTypes {}
diff --git a/packages/contracts/contracts/l2/staking/IL2StakingBase.sol b/packages/contracts/contracts/l2/staking/IL2StakingBase.sol
deleted file mode 100644
index f5c33c2d0..000000000
--- a/packages/contracts/contracts/l2/staking/IL2StakingBase.sol
+++ /dev/null
@@ -1,14 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-import { ICallhookReceiver } from "../../gateway/ICallhookReceiver.sol";
-
-/**
- * @title Base interface for the L2Staking contract.
- * @notice This interface is used to define the callhook receiver interface that is implemented by L2Staking.
- * @dev Note it includes only the L2-specific functionality, not the full IStaking interface.
- */
-interface IL2StakingBase is ICallhookReceiver {
- event TransferredDelegationReturnedToDelegator(address indexed indexer, address indexed delegator, uint256 amount);
-}
diff --git a/packages/contracts/contracts/l2/staking/IL2StakingTypes.sol b/packages/contracts/contracts/l2/staking/IL2StakingTypes.sol
deleted file mode 100644
index 500694e89..000000000
--- a/packages/contracts/contracts/l2/staking/IL2StakingTypes.sol
+++ /dev/null
@@ -1,22 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-interface IL2StakingTypes {
- /// @dev Message codes for the L1 -> L2 bridge callhook
- enum L1MessageCodes {
- RECEIVE_INDEXER_STAKE_CODE,
- RECEIVE_DELEGATION_CODE
- }
-
- /// @dev Encoded message struct when receiving indexer stake through the bridge
- struct ReceiveIndexerStakeData {
- address indexer;
- }
-
- /// @dev Encoded message struct when receiving delegation through the bridge
- struct ReceiveDelegationData {
- address indexer;
- address delegator;
- }
-}
diff --git a/packages/contracts/contracts/l2/staking/L2Staking.sol b/packages/contracts/contracts/l2/staking/L2Staking.sol
index 278e26a50..305747801 100644
--- a/packages/contracts/contracts/l2/staking/L2Staking.sol
+++ b/packages/contracts/contracts/l2/staking/L2Staking.sol
@@ -3,16 +3,23 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-indexed-events
+
import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol";
import { Staking } from "../../staking/Staking.sol";
-import { IL2StakingBase } from "./IL2StakingBase.sol";
+import { IL2StakingBase } from "@graphprotocol/interfaces/contracts/contracts/l2/staking/IL2StakingBase.sol";
import { Stakes } from "../../staking/libs/Stakes.sol";
-import { IStakes } from "../../staking/libs/IStakes.sol";
-import { IL2StakingTypes } from "./IL2StakingTypes.sol";
+import { IStakes } from "@graphprotocol/interfaces/contracts/contracts/staking/libs/IStakes.sol";
+import { IL2StakingTypes } from "@graphprotocol/interfaces/contracts/contracts/l2/staking/IL2StakingTypes.sol";
+
+// solhint-disable-next-line no-unused-import
+import { ICallhookReceiver } from "@graphprotocol/interfaces/contracts/contracts/gateway/ICallhookReceiver.sol"; // Used by @inheritdoc
/**
* @title L2Staking contract
- * @dev This contract is the L2 variant of the Staking contract. It adds a function
+ * @author Edge & Node
+ * @notice This contract is the L2 variant of the Staking contract. It adds a function
* to receive an indexer's stake or delegation from L1. Note that this contract inherits Staking,
* which uses a StakingExtension contract to implement the full IStaking interface through delegatecalls.
*/
@@ -24,10 +31,14 @@ contract L2Staking is Staking, IL2StakingBase {
uint256 private constant MINIMUM_DELEGATION = 1e18;
/**
- * @dev Emitted when `delegator` delegated `tokens` to the `indexer`, the delegator
+ * @notice Emitted when `delegator` delegated `tokens` to the `indexer`, the delegator
* gets `shares` for the delegation pool proportionally to the tokens staked.
* This is copied from IStakingExtension, but we can't inherit from it because we
* don't implement the full interface here.
+ * @param indexer Address of the indexer receiving the delegation
+ * @param delegator Address of the delegator
+ * @param tokens Amount of tokens delegated
+ * @param shares Amount of shares issued to the delegator
*/
event StakeDelegated(address indexed indexer, address indexed delegator, uint256 tokens, uint256 shares);
@@ -48,7 +59,7 @@ contract L2Staking is Staking, IL2StakingBase {
}
/**
- * @notice Receive tokens with a callhook from the bridge.
+ * @inheritdoc ICallhookReceiver
* @dev The encoded _data can contain information about an indexer's stake
* or a delegator's delegation.
* See L1MessageCodes in IL2Staking for the supported messages.
@@ -82,7 +93,7 @@ contract L2Staking is Staking, IL2StakingBase {
}
/**
- * @dev Receive an Indexer's stake from L1.
+ * @notice Receive an Indexer's stake from L1.
* The specified amount is added to the indexer's stake; the indexer's
* address is specified in the _indexerData struct.
* @param _amount Amount of tokens that were transferred
@@ -105,7 +116,7 @@ contract L2Staking is Staking, IL2StakingBase {
}
/**
- * @dev Receive a Delegator's delegation from L1.
+ * @notice Receive a Delegator's delegation from L1.
* The specified amount is added to the delegator's delegation; the delegator's
* address and the indexer's address are specified in the _delegationData struct.
* Note that no delegation tax is applied here.
diff --git a/packages/contracts/contracts/l2/token/GraphTokenUpgradeable.sol b/packages/contracts/contracts/l2/token/GraphTokenUpgradeable.sol
index 0f5cf0ecb..d26371533 100644
--- a/packages/contracts/contracts/l2/token/GraphTokenUpgradeable.sol
+++ b/packages/contracts/contracts/l2/token/GraphTokenUpgradeable.sol
@@ -2,6 +2,10 @@
pragma solidity ^0.7.6;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-increment-by-one, gas-small-strings, gas-strict-inequalities
+// solhint-disable named-parameters-mapping
+
import { ERC20BurnableUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20BurnableUpgradeable.sol";
import { ECDSAUpgradeable } from "@openzeppelin/contracts-upgradeable/cryptography/ECDSAUpgradeable.sol";
@@ -10,7 +14,8 @@ import { Governed } from "../../governance/Governed.sol";
/**
* @title GraphTokenUpgradeable contract
- * @dev This is the implementation of the ERC20 Graph Token.
+ * @author Edge & Node
+ * @notice This is the implementation of the ERC20 Graph Token.
* The implementation exposes a permit() function to allow for a spender to send a signed message
* and approve funds to a spender following EIP2612 to make integration with other contracts easier.
*
@@ -47,16 +52,23 @@ abstract contract GraphTokenUpgradeable is GraphUpgradeable, Governed, ERC20Burn
bytes32 private DOMAIN_SEPARATOR; // solhint-disable-line var-name-mixedcase
/// @dev Addresses for which this mapping is true are allowed to mint tokens
mapping(address => bool) private _minters;
- /// Nonces for permit signatures for each token holder
+ /// @notice Nonces for permit signatures for each token holder
mapping(address => uint256) public nonces;
/// @dev Storage gap added in case we need to add state variables to this contract
uint256[47] private __gap;
// -- Events --
- /// Emitted when a new minter is added
+ /**
+ * @notice Emitted when a new minter is added
+ * @param account Address of the minter that was added
+ */
event MinterAdded(address indexed account);
- /// Emitted when a minter is removed
+
+ /**
+ * @notice Emitted when a minter is removed
+ * @param account Address of the minter that was removed
+ */
event MinterRemoved(address indexed account);
/// @dev Reverts if the caller is not an authorized minter
@@ -145,7 +157,7 @@ abstract contract GraphTokenUpgradeable is GraphUpgradeable, Governed, ERC20Burn
}
/**
- * @dev Graph Token Contract initializer.
+ * @notice Graph Token Contract initializer.
* @param _owner Owner of this contract, who will hold the initial supply and will be a minter
* @param _initialSupply Initial supply of GRT
*/
@@ -173,7 +185,7 @@ abstract contract GraphTokenUpgradeable is GraphUpgradeable, Governed, ERC20Burn
}
/**
- * @dev Add a new minter.
+ * @notice Add a new minter.
* @param _account Address of the minter
*/
function _addMinter(address _account) private {
@@ -182,7 +194,7 @@ abstract contract GraphTokenUpgradeable is GraphUpgradeable, Governed, ERC20Burn
}
/**
- * @dev Remove a minter.
+ * @notice Remove a minter.
* @param _account Address of the minter
*/
function _removeMinter(address _account) private {
@@ -191,7 +203,7 @@ abstract contract GraphTokenUpgradeable is GraphUpgradeable, Governed, ERC20Burn
}
/**
- * @dev Get the running network chain ID.
+ * @notice Get the running network chain ID.
* @return The chain ID
*/
function _getChainID() private pure returns (uint256) {
diff --git a/packages/contracts/contracts/l2/token/L2GraphToken.sol b/packages/contracts/contracts/l2/token/L2GraphToken.sol
index 639444870..d37731f53 100644
--- a/packages/contracts/contracts/l2/token/L2GraphToken.sol
+++ b/packages/contracts/contracts/l2/token/L2GraphToken.sol
@@ -2,27 +2,48 @@
pragma solidity ^0.7.6;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-indexed-events
+
import { GraphTokenUpgradeable } from "./GraphTokenUpgradeable.sol";
-import { IArbToken } from "../../arbitrum/IArbToken.sol";
+import { IArbToken } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/IArbToken.sol";
/**
* @title L2 Graph Token Contract
- * @dev Provides the L2 version of the GRT token, meant to be minted/burned
+ * @author Edge & Node
+ * @notice Provides the L2 version of the GRT token, meant to be minted/burned
* through the L2GraphTokenGateway.
*/
contract L2GraphToken is GraphTokenUpgradeable, IArbToken {
- /// Address of the gateway (on L2) that is allowed to mint tokens
+ /// @notice Address of the gateway (on L2) that is allowed to mint tokens
address public gateway;
- /// Address of the corresponding Graph Token contract on L1
+ /// @notice Address of the corresponding Graph Token contract on L1
address public override l1Address;
- /// Emitted when the bridge / gateway has minted new tokens, i.e. tokens were transferred to L2
+ /**
+ * @notice Emitted when the bridge / gateway has minted new tokens, i.e. tokens were transferred to L2
+ * @param account Address that received the minted tokens
+ * @param amount Amount of tokens minted
+ */
event BridgeMinted(address indexed account, uint256 amount);
- /// Emitted when the bridge / gateway has burned tokens, i.e. tokens were transferred back to L1
+
+ /**
+ * @notice Emitted when the bridge / gateway has burned tokens, i.e. tokens were transferred back to L1
+ * @param account Address from which tokens were burned
+ * @param amount Amount of tokens burned
+ */
event BridgeBurned(address indexed account, uint256 amount);
- /// Emitted when the address of the gateway has been updated
+
+ /**
+ * @notice Emitted when the address of the gateway has been updated
+ * @param gateway Address of the new gateway
+ */
event GatewaySet(address gateway);
- /// Emitted when the address of the Graph Token contract on L1 has been updated
+
+ /**
+ * @notice Emitted when the address of the Graph Token contract on L1 has been updated
+ * @param l1Address Address of the L1 Graph Token contract
+ */
event L1AddressSet(address l1Address);
/**
@@ -69,9 +90,8 @@ contract L2GraphToken is GraphTokenUpgradeable, IArbToken {
}
/**
- * @notice Increases token supply, only callable by the L1/L2 bridge (when tokens are transferred to L2)
- * @param _account Address to credit with the new tokens
- * @param _amount Number of tokens to mint
+ * @inheritdoc IArbToken
+ * @dev Only callable by the L2GraphTokenGateway when tokens are transferred to L2
*/
function bridgeMint(address _account, uint256 _amount) external override onlyGateway {
_mint(_account, _amount);
@@ -79,9 +99,8 @@ contract L2GraphToken is GraphTokenUpgradeable, IArbToken {
}
/**
- * @notice Decreases token supply, only callable by the L1/L2 bridge (when tokens are transferred to L1).
- * @param _account Address from which to extract the tokens
- * @param _amount Number of tokens to burn
+ * @inheritdoc IArbToken
+ * @dev Only callable by the L2GraphTokenGateway when tokens are transferred back to L1
*/
function bridgeBurn(address _account, uint256 _amount) external override onlyGateway {
burnFrom(_account, _amount);
diff --git a/packages/contracts/contracts/libraries/Base58Encoder.sol b/packages/contracts/contracts/libraries/Base58Encoder.sol
index 9af197855..91caa8855 100644
--- a/packages/contracts/contracts/libraries/Base58Encoder.sol
+++ b/packages/contracts/contracts/libraries/Base58Encoder.sol
@@ -2,14 +2,25 @@
pragma solidity ^0.7.6;
-/// @title Base58Encoder
-/// @author Original author - Martin Lundfall (martin.lundfall@gmail.com)
-/// Based on https://github.com/MrChico/verifyIPFS
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-increment-by-one
+
+/**
+ * @title Base58Encoder
+ * @author Original author - Martin Lundfall (martin.lundfall@gmail.com)
+ * @notice Library for encoding bytes to Base58 format, used for IPFS hashes
+ * @dev Based on https://github.com/MrChico/verifyIPFS
+ */
library Base58Encoder {
- bytes constant sha256MultiHash = hex"1220";
- bytes constant ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
+ /// @dev SHA-256 multihash prefix for IPFS hashes
+ // solhint-disable-next-line const-name-snakecase
+ bytes internal constant sha256MultiHash = hex"1220";
+ /// @dev Base58 alphabet used for encoding
+ bytes internal constant ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
- /// @dev Converts hex string to base 58
+ /// @notice Converts hex string to base 58
+ /// @param source The bytes to encode
+ /// @return The base58 encoded bytes
function encode(bytes memory source) internal pure returns (bytes memory) {
if (source.length == 0) return new bytes(0);
uint8[] memory digits = new uint8[](64);
@@ -32,6 +43,12 @@ library Base58Encoder {
return toAlphabet(reverse(truncate(digits, digitlength)));
}
+ /**
+ * @notice Truncate an array to a specific length
+ * @param array The array to truncate
+ * @param length The desired length
+ * @return The truncated array
+ */
function truncate(uint8[] memory array, uint8 length) internal pure returns (uint8[] memory) {
uint8[] memory output = new uint8[](length);
for (uint256 i = 0; i < length; i++) {
@@ -40,6 +57,11 @@ library Base58Encoder {
return output;
}
+ /**
+ * @notice Reverse an array
+ * @param input The array to reverse
+ * @return The reversed array
+ */
function reverse(uint8[] memory input) internal pure returns (uint8[] memory) {
uint8[] memory output = new uint8[](input.length);
for (uint256 i = 0; i < input.length; i++) {
@@ -48,6 +70,11 @@ library Base58Encoder {
return output;
}
+ /**
+ * @notice Convert indices to alphabet characters
+ * @param indices The indices to convert
+ * @return The alphabet characters as bytes
+ */
function toAlphabet(uint8[] memory indices) internal pure returns (bytes memory) {
bytes memory output = new bytes(indices.length);
for (uint256 i = 0; i < indices.length; i++) {
diff --git a/packages/contracts/contracts/libraries/HexStrings.sol b/packages/contracts/contracts/libraries/HexStrings.sol
index 4842883a9..2b5e314e6 100644
--- a/packages/contracts/contracts/libraries/HexStrings.sol
+++ b/packages/contracts/contracts/libraries/HexStrings.sol
@@ -2,12 +2,22 @@
pragma solidity ^0.7.6;
-/// @title HexStrings
-/// Based on https://github.com/OpenZeppelin/openzeppelin-contracts/blob/8dd744fc1843d285c38e54e9d439dea7f6b93495/contracts/utils/Strings.sol
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-increment-by-one
+
+/**
+ * @title HexStrings
+ * @author Edge & Node
+ * @notice Library for converting values to hexadecimal string representations
+ * @dev Based on https://github.com/OpenZeppelin/openzeppelin-contracts/blob/8dd744fc1843d285c38e54e9d439dea7f6b93495/contracts/utils/Strings.sol
+ */
library HexStrings {
+ /// @dev Hexadecimal symbols used for string conversion
bytes16 private constant _HEX_SYMBOLS = "0123456789abcdef";
- /// @dev Converts a `uint256` to its ASCII `string` hexadecimal representation.
+ /// @notice Converts a `uint256` to its ASCII `string` hexadecimal representation.
+ /// @param value The uint256 value to convert
+ /// @return The hexadecimal string representation
function toString(uint256 value) internal pure returns (string memory) {
if (value == 0) {
return "0x00";
@@ -21,7 +31,10 @@ library HexStrings {
return toHexString(value, length);
}
- /// @dev Converts a `uint256` to its ASCII `string` hexadecimal representation with fixed length.
+ /// @notice Converts a `uint256` to its ASCII `string` hexadecimal representation with fixed length.
+ /// @param value The uint256 value to convert
+ /// @param length The fixed length of the output string
+ /// @return The hexadecimal string representation with fixed length
function toHexString(uint256 value, uint256 length) internal pure returns (string memory) {
bytes memory buffer = new bytes(2 * length + 2);
buffer[0] = "0";
diff --git a/packages/contracts/contracts/payments/AllocationExchange.sol b/packages/contracts/contracts/payments/AllocationExchange.sol
index 5f0b30b44..288bdda32 100644
--- a/packages/contracts/contracts/payments/AllocationExchange.sol
+++ b/packages/contracts/contracts/payments/AllocationExchange.sol
@@ -3,16 +3,20 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
-import "@openzeppelin/contracts/cryptography/ECDSA.sol";
-import "@openzeppelin/contracts/utils/Address.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-calldata-parameters, gas-increment-by-one, gas-indexed-events, gas-small-strings
+// solhint-disable named-parameters-mapping
-import "../governance/Governed.sol";
-import "../staking/IStaking.sol";
-import { IGraphToken } from "../token/IGraphToken.sol";
+import { ECDSA } from "@openzeppelin/contracts/cryptography/ECDSA.sol";
+import { Address } from "@openzeppelin/contracts/utils/Address.sol";
+import { Governed } from "../governance/Governed.sol";
+import { IStaking } from "@graphprotocol/interfaces/contracts/contracts/staking/IStaking.sol";
+import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol";
/**
* @title Allocation Exchange
- * @dev This contract holds tokens that anyone with a voucher signed by the
+ * @author Edge & Node
+ * @notice This contract holds tokens that anyone with a voucher signed by the
* authority can redeem. The contract validates if the voucher presented is valid
* and then sends tokens to the Staking contract by calling the collect() function
* passing the voucher allocationID. The contract enforces that only one voucher for
@@ -20,9 +24,14 @@ import { IGraphToken } from "../token/IGraphToken.sol";
* Only governance can change the authority.
*/
contract AllocationExchange is Governed {
- // An allocation voucher represents a signed message that allows
- // redeeming an amount of funds from this contract and collect
- // them as part of an allocation
+ /**
+ * @dev An allocation voucher represents a signed message that allows
+ * redeeming an amount of funds from this contract and collect
+ * them as part of an allocation
+ * @param allocationID Address of the allocation
+ * @param amount Amount of tokens to redeem
+ * @param signature Signature from the authority (65 bytes)
+ */
struct AllocationVoucher {
address allocationID;
uint256 amount;
@@ -31,20 +40,43 @@ contract AllocationExchange is Governed {
// -- Constants --
+ /// @dev Maximum uint256 value used for unlimited token approvals
uint256 private constant MAX_UINT256 = 2 ** 256 - 1;
+ /// @dev Expected length of ECDSA signatures
uint256 private constant SIGNATURE_LENGTH = 65;
// -- State --
- IStaking private immutable staking;
- IGraphToken private immutable graphToken;
+ /// @dev Reference to the Staking contract
+ IStaking private immutable STAKING;
+ /// @dev Reference to the Graph Token contract
+ IGraphToken private immutable GRAPH_TOKEN;
+ /// @notice Mapping of authorized accounts that can redeem allocations
mapping(address => bool) public authority;
+ /// @notice Mapping of allocations that have been redeemed
mapping(address => bool) public allocationsRedeemed;
// -- Events
+ /**
+ * @notice Emitted when an authority is set or unset
+ * @param account Address of the authority
+ * @param authorized Whether the authority is authorized
+ */
event AuthoritySet(address indexed account, bool authorized);
+
+ /**
+ * @notice Emitted when an allocation voucher is redeemed
+ * @param allocationID Address of the allocation
+ * @param amount Amount of tokens redeemed
+ */
event AllocationRedeemed(address indexed allocationID, uint256 amount);
+
+ /**
+ * @notice Emitted when tokens are withdrawn from the contract
+ * @param to Address that received the tokens
+ * @param amount Amount of tokens withdrawn
+ */
event TokensWithdrawn(address indexed to, uint256 amount);
// -- Functions
@@ -60,8 +92,8 @@ contract AllocationExchange is Governed {
require(_governor != address(0), "Exchange: governor must be set");
Governed._initialize(_governor);
- graphToken = _graphToken;
- staking = _staking;
+ GRAPH_TOKEN = _graphToken;
+ STAKING = _staking;
_setAuthority(_authority, true);
}
@@ -70,7 +102,7 @@ contract AllocationExchange is Governed {
* @dev Increased gas efficiency instead of approving on each voucher redeem
*/
function approveAll() external {
- graphToken.approve(address(staking), MAX_UINT256);
+ GRAPH_TOKEN.approve(address(STAKING), MAX_UINT256);
}
/**
@@ -82,7 +114,7 @@ contract AllocationExchange is Governed {
function withdraw(address _to, uint256 _amount) external onlyGovernor {
require(_to != address(0), "Exchange: empty destination");
require(_amount != 0, "Exchange: empty amount");
- require(graphToken.transfer(_to, _amount), "Exchange: cannot transfer");
+ require(GRAPH_TOKEN.transfer(_to, _amount), "Exchange: cannot transfer");
emit TokensWithdrawn(_to, _amount);
}
@@ -155,7 +187,7 @@ contract AllocationExchange is Governed {
// Make the staking contract collect funds from this contract
// The Staking contract will validate if the allocation is valid
- staking.collect(_voucher.amount, _voucher.allocationID);
+ STAKING.collect(_voucher.amount, _voucher.allocationID);
emit AllocationRedeemed(_voucher.allocationID, _voucher.amount);
}
diff --git a/packages/contracts/contracts/rewards/IRewardsIssuer.sol b/packages/contracts/contracts/rewards/IRewardsIssuer.sol
deleted file mode 100644
index d50410b33..000000000
--- a/packages/contracts/contracts/rewards/IRewardsIssuer.sol
+++ /dev/null
@@ -1,37 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-interface IRewardsIssuer {
- /**
- * @dev Get allocation data to calculate rewards issuance
- *
- * @param allocationId The allocation Id
- * @return isActive Whether the allocation is active or not
- * @return indexer The indexer address
- * @return subgraphDeploymentId Subgraph deployment id for the allocation
- * @return tokens Amount of allocated tokens
- * @return accRewardsPerAllocatedToken Rewards snapshot
- * @return accRewardsPending Snapshot of accumulated rewards from previous allocation resizing, pending to be claimed
- */
- function getAllocationData(
- address allocationId
- )
- external
- view
- returns (
- bool isActive,
- address indexer,
- bytes32 subgraphDeploymentId,
- uint256 tokens,
- uint256 accRewardsPerAllocatedToken,
- uint256 accRewardsPending
- );
-
- /**
- * @notice Return the total amount of tokens allocated to subgraph.
- * @param _subgraphDeploymentId Deployment Id for the subgraph
- * @return Total tokens allocated to subgraph
- */
- function getSubgraphAllocatedTokens(bytes32 _subgraphDeploymentId) external view returns (uint256);
-}
diff --git a/packages/contracts/contracts/rewards/IRewardsManager.sol b/packages/contracts/contracts/rewards/IRewardsManager.sol
deleted file mode 100644
index b31064d1b..000000000
--- a/packages/contracts/contracts/rewards/IRewardsManager.sol
+++ /dev/null
@@ -1,57 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-interface IRewardsManager {
- /**
- * @dev Stores accumulated rewards and snapshots related to a particular SubgraphDeployment.
- */
- struct Subgraph {
- uint256 accRewardsForSubgraph;
- uint256 accRewardsForSubgraphSnapshot;
- uint256 accRewardsPerSignalSnapshot;
- uint256 accRewardsPerAllocatedToken;
- }
-
- // -- Config --
-
- function setIssuancePerBlock(uint256 _issuancePerBlock) external;
-
- function setMinimumSubgraphSignal(uint256 _minimumSubgraphSignal) external;
-
- function setSubgraphService(address _subgraphService) external;
-
- // -- Denylist --
-
- function setSubgraphAvailabilityOracle(address _subgraphAvailabilityOracle) external;
-
- function setDenied(bytes32 _subgraphDeploymentID, bool _deny) external;
-
- function isDenied(bytes32 _subgraphDeploymentID) external view returns (bool);
-
- // -- Getters --
-
- function getNewRewardsPerSignal() external view returns (uint256);
-
- function getAccRewardsPerSignal() external view returns (uint256);
-
- function getAccRewardsForSubgraph(bytes32 _subgraphDeploymentID) external view returns (uint256);
-
- function getAccRewardsPerAllocatedToken(bytes32 _subgraphDeploymentID) external view returns (uint256, uint256);
-
- function getRewards(address _rewardsIssuer, address _allocationID) external view returns (uint256);
-
- function calcRewards(uint256 _tokens, uint256 _accRewardsPerAllocatedToken) external pure returns (uint256);
-
- // -- Updates --
-
- function updateAccRewardsPerSignal() external returns (uint256);
-
- function takeRewards(address _allocationID) external returns (uint256);
-
- // -- Hooks --
-
- function onSubgraphSignalUpdate(bytes32 _subgraphDeploymentID) external returns (uint256);
-
- function onSubgraphAllocationUpdate(bytes32 _subgraphDeploymentID) external returns (uint256);
-}
diff --git a/packages/contracts/contracts/rewards/RewardsManager.sol b/packages/contracts/contracts/rewards/RewardsManager.sol
index 58f654b91..f251dc5f8 100644
--- a/packages/contracts/contracts/rewards/RewardsManager.sol
+++ b/packages/contracts/contracts/rewards/RewardsManager.sol
@@ -1,21 +1,29 @@
// SPDX-License-Identifier: GPL-2.0-or-later
-pragma solidity 0.7.6;
+pragma solidity ^0.7.6;
pragma abicoder v2;
import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol";
+import { IERC165 } from "@openzeppelin/contracts/introspection/IERC165.sol";
import { GraphUpgradeable } from "../upgrades/GraphUpgradeable.sol";
import { Managed } from "../governance/Managed.sol";
import { MathUtils } from "../staking/libs/MathUtils.sol";
-import { IGraphToken } from "../token/IGraphToken.sol";
-import { RewardsManagerV5Storage } from "./RewardsManagerStorage.sol";
-import { IRewardsManager } from "./IRewardsManager.sol";
-import { IRewardsIssuer } from "./IRewardsIssuer.sol";
+import { RewardsManagerV6Storage } from "./RewardsManagerStorage.sol";
+import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsIssuer.sol";
+import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol";
+import { IRewardsManagerDeprecated } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManagerDeprecated.sol";
+import { IIssuanceAllocationDistribution } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocationDistribution.sol";
+import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol";
+import { IProviderEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibility.sol";
+import { IProviderEligibilityManagement } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibilityManagement.sol";
+import { RewardsCondition } from "@graphprotocol/interfaces/contracts/contracts/rewards/RewardsCondition.sol";
/**
* @title Rewards Manager Contract
+ * @author Edge & Node
+ * @notice Manages rewards distribution for indexers and delegators in the Graph Protocol
* @dev Tracks how inflationary GRT rewards should be handed out. Relies on the Curation contract
* and the Staking contract. Signaled GRT in Curation determine what percentage of the tokens go
* towards each subgraph. Then each Subgraph can have multiple Indexers Staked on it. Thus, the
@@ -30,46 +38,29 @@ import { IRewardsIssuer } from "./IRewardsIssuer.sol";
* - getRewards
* These functions may overestimate the actual rewards due to changes in the total supply
* until the actual takeRewards function is called.
+ * custom:security-contact Please email security+contracts@ thegraph.com (remove space) if you find any bugs. We might have an active bug bounty program.
*/
-contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsManager {
+contract RewardsManager is
+ GraphUpgradeable,
+ IERC165,
+ IRewardsManager,
+ IIssuanceTarget,
+ IProviderEligibilityManagement,
+ IRewardsManagerDeprecated,
+ RewardsManagerV6Storage
+{
using SafeMath for uint256;
/// @dev Fixed point scaling factor used for decimals in reward calculations
uint256 private constant FIXED_POINT_SCALING_FACTOR = 1e18;
- // -- Events --
-
- /**
- * @dev Emitted when rewards are assigned to an indexer.
- * @dev We use the Horizon prefix to change the event signature which makes network subgraph development much easier
- */
- event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount);
-
- /**
- * @dev Emitted when rewards are denied to an indexer
- * @param indexer Address of the indexer being denied rewards
- * @param allocationID Address of the allocation being denied rewards
- */
- event RewardsDenied(address indexed indexer, address indexed allocationID);
-
- /**
- * @dev Emitted when a subgraph is denied for claiming rewards
- * @param subgraphDeploymentID Subgraph deployment ID being denied
- * @param sinceBlock Block number since when the subgraph is denied
- */
- event RewardsDenylistUpdated(bytes32 indexed subgraphDeploymentID, uint256 sinceBlock);
-
- /**
- * @dev Emitted when the subgraph service is set
- */
- event SubgraphServiceSet(address indexed oldSubgraphService, address indexed newSubgraphService);
-
// -- Modifiers --
/**
* @dev Modifier to restrict access to the subgraph availability oracle only
*/
modifier onlySubgraphAvailabilityOracle() {
+ // solhint-disable-next-line gas-small-strings
require(msg.sender == address(subgraphAvailabilityOracle), "Caller must be the subgraph availability oracle");
_;
}
@@ -82,23 +73,39 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa
Managed._initialize(_controller);
}
+ /**
+ * @inheritdoc IERC165
+ * @dev Implements ERC165 interface detection
+ * Returns true if this contract implements the interface defined by interfaceId.
+ * See: https://eips.ethereum.org/EIPS/eip-165
+ */
+ function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
+ return
+ interfaceId == type(IERC165).interfaceId ||
+ interfaceId == type(IIssuanceTarget).interfaceId ||
+ interfaceId == type(IRewardsManager).interfaceId ||
+ interfaceId == type(IProviderEligibilityManagement).interfaceId;
+ }
+
// -- Config --
/**
- * @dev Sets the GRT issuance per block.
+ * @inheritdoc IRewardsManagerDeprecated
+ * @dev When an IssuanceAllocator is set, the effective issuance will be determined by the allocator,
+ * but this local value can still be updated for cases when the allocator is later removed.
+ *
* The issuance is defined as a fixed amount of rewards per block in GRT.
* Whenever this function is called in layer 2, the updateL2MintAllowance function
* _must_ be called on the L1GraphTokenGateway in L1, to ensure the bridge can mint the
* right amount of tokens.
- * @param _issuancePerBlock Issuance expressed in GRT per block (scaled by 1e18)
*/
function setIssuancePerBlock(uint256 _issuancePerBlock) external override onlyGovernor {
_setIssuancePerBlock(_issuancePerBlock);
}
/**
- * @dev Sets the GRT issuance per block.
- * The issuance is defined as a fixed amount of rewards per block in GRT.
+ * @notice Sets the GRT issuance per block.
+ * @dev The issuance is defined as a fixed amount of rewards per block in GRT.
* @param _issuancePerBlock Issuance expressed in GRT per block (scaled by 1e18)
*/
function _setIssuancePerBlock(uint256 _issuancePerBlock) private {
@@ -110,8 +117,7 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa
}
/**
- * @notice Sets the subgraph oracle allowed to deny distribution of rewards to subgraphs
- * @param _subgraphAvailabilityOracle Address of the subgraph availability oracle
+ * @inheritdoc IRewardsManager
*/
function setSubgraphAvailabilityOracle(address _subgraphAvailabilityOracle) external override onlyGovernor {
subgraphAvailabilityOracle = _subgraphAvailabilityOracle;
@@ -119,9 +125,27 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa
}
/**
- * @notice Sets the minimum signaled tokens on a subgraph to start accruing rewards
+ * @inheritdoc IRewardsManager
* @dev Can be set to zero which means that this feature is not being used
* @param _minimumSubgraphSignal Minimum signaled tokens
+ *
+ * IMPORTANT: This function does not update existing subgraphs. When subgraphs are later
+ * updated, the current threshold is applied to ALL pending rewards since their last update,
+ * regardless of historical threshold values.
+ *
+ * ## Rewards Accounting Issue
+ *
+ * - Threshold increase: Pending rewards on previously eligible subgraphs are reclaimed
+ * - Threshold decrease: Previously ineligible subgraphs retroactively accumulate pending rewards
+ *
+ * ## Mitigation
+ *
+ * 1. Communicate the planned threshold change with a specific future date
+ * 2. Wait - notice period allows participants to adjust signal if desired
+ * 3. Identify affected subgraphs off-chain (those crossing the threshold)
+ * 4. Call onSubgraphSignalUpdate() for all affected subgraphs to accumulate pending rewards
+ * under current eligibility rules
+ * 5. Execute threshold change via this function (promptly after step 4, ideally same block)
*/
function setMinimumSubgraphSignal(uint256 _minimumSubgraphSignal) external override {
// Caller can be the SAO or the governor
@@ -133,40 +157,151 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa
emit ParameterUpdated("minimumSubgraphSignal");
}
+ /**
+ * @inheritdoc IRewardsManager
+ */
function setSubgraphService(address _subgraphService) external override onlyGovernor {
address oldSubgraphService = address(subgraphService);
subgraphService = IRewardsIssuer(_subgraphService);
emit SubgraphServiceSet(oldSubgraphService, _subgraphService);
}
- // -- Denylist --
+ /**
+ * @inheritdoc IIssuanceTarget
+ * @dev This function facilitates upgrades by providing a standard way for targets
+ * to change their allocator. Only the governor can call this function.
+ * Note that the IssuanceAllocator can be set to the zero address to disable use of an allocator, and
+ * use the local `issuancePerBlock` variable instead to control issuance.
+ */
+ function setIssuanceAllocator(IIssuanceAllocationDistribution newIssuanceAllocator) external override onlyGovernor {
+ if (issuanceAllocator != newIssuanceAllocator) {
+ // Update rewards calculation before changing the issuance allocator
+ updateAccRewardsPerSignal();
+
+ // Check that the contract supports the IIssuanceAllocationDistribution interface
+ // Allow zero address to disable the allocator
+ if (address(newIssuanceAllocator) != address(0)) {
+ // solhint-disable-next-line gas-small-strings
+ require(
+ IERC165(address(newIssuanceAllocator)).supportsInterface(
+ type(IIssuanceAllocationDistribution).interfaceId
+ ),
+ "Contract does not support IIssuanceAllocationDistribution interface"
+ );
+ }
+
+ emit IssuanceAllocatorSet(issuanceAllocator, newIssuanceAllocator);
+ issuanceAllocator = newIssuanceAllocator;
+ }
+ }
/**
- * @notice Denies to claim rewards for a subgraph
- * @dev Can only be called by the subgraph availability oracle
- * @param _subgraphDeploymentID Subgraph deployment ID
- * @param _deny Whether to set the subgraph as denied for claiming rewards or not
+ * @inheritdoc IIssuanceTarget
+ * @dev Ensures that all reward calculations are up-to-date with the current block
+ * before any allocation changes take effect.
+ *
+ * This function can be called by anyone to update the rewards calculation state.
+ * The IssuanceAllocator calls this function before changing a target's allocation to ensure
+ * all issuance is properly accounted for with the current issuance rate before applying an
+ * issuance allocation change.
+ */
+ function beforeIssuanceAllocationChange() external override {
+ // Update rewards calculation with the current issuance rate
+ updateAccRewardsPerSignal();
+ }
+
+ /**
+ * @inheritdoc IProviderEligibilityManagement
+ * @dev Note that the eligibility oracle can be set to the zero address to disable use of an oracle, in
+ * which case no indexers will be denied rewards due to eligibility.
+ */
+ function setProviderEligibilityOracle(IProviderEligibility oracle) external override onlyGovernor {
+ IProviderEligibility oldOracle = rewardsEligibilityOracle;
+ if (address(oldOracle) == address(oracle)) return;
+
+ // Check that the contract supports the IProviderEligibility interface
+ // Allow zero address to disable the oracle
+ if (address(oracle) != address(0)) {
+ // solhint-disable-next-line gas-small-strings
+ require(
+ IERC165(address(oracle)).supportsInterface(type(IProviderEligibility).interfaceId),
+ "Contract does not support IProviderEligibility interface"
+ );
+ }
+
+ rewardsEligibilityOracle = oracle;
+ emit ProviderEligibilityOracleSet(oldOracle, oracle);
+ }
+
+ /**
+ * @inheritdoc IRewardsManager
+ * @dev bytes32(0) is reserved as an invalid reason to prevent accidental misconfiguration
+ * and catch uninitialized reason identifiers.
+ *
+ * IMPORTANT: Changes take effect immediately and retroactively. All unclaimed rewards from
+ * previous periods will be sent to the new reclaim address when they are eventually reclaimed,
+ * regardless of which address was configured when the rewards were originally accrued.
+ */
+ function setReclaimAddress(bytes32 reason, address newAddress) external override onlyGovernor {
+ // solhint-disable-next-line gas-small-strings
+ require(reason != RewardsCondition.NONE, "Cannot set reclaim address for NONE");
+
+ address oldAddress = reclaimAddresses[reason];
+
+ if (oldAddress != newAddress) {
+ reclaimAddresses[reason] = newAddress;
+ emit ReclaimAddressSet(reason, oldAddress, newAddress);
+ }
+ }
+
+ /**
+ * @inheritdoc IRewardsManager
*/
- function setDenied(bytes32 _subgraphDeploymentID, bool _deny) external override onlySubgraphAvailabilityOracle {
- _setDenied(_subgraphDeploymentID, _deny);
+ function setDefaultReclaimAddress(address newAddress) external override onlyGovernor {
+ address oldAddress = defaultReclaimAddress;
+
+ if (oldAddress != newAddress) {
+ defaultReclaimAddress = newAddress;
+ emit DefaultReclaimAddressSet(oldAddress, newAddress);
+ }
+ }
+
+ /// @inheritdoc IRewardsManager
+ function setRevertOnIneligible(bool _revertOnIneligible) external override onlyGovernor {
+ if (revertOnIneligible != _revertOnIneligible) {
+ revertOnIneligible = _revertOnIneligible;
+ emit ParameterUpdated("revertOnIneligible");
+ }
}
+ // -- Denylist --
+
/**
- * @dev Internal: Denies to claim rewards for a subgraph.
- * @param _subgraphDeploymentID Subgraph deployment ID
- * @param _deny Whether to set the subgraph as denied for claiming rewards or not
+ * @inheritdoc IRewardsManager
+ * @dev Can only be called by the subgraph availability oracle
*/
- function _setDenied(bytes32 _subgraphDeploymentID, bool _deny) private {
- uint256 sinceBlock = _deny ? block.number : 0;
- denylist[_subgraphDeploymentID] = sinceBlock;
- emit RewardsDenylistUpdated(_subgraphDeploymentID, sinceBlock);
+ function setDenied(bytes32 subgraphDeploymentId, bool deny) external override onlySubgraphAvailabilityOracle {
+ _setDenied(subgraphDeploymentId, deny);
}
/**
- * @notice Tells if subgraph is in deny list
- * @param _subgraphDeploymentID Subgraph deployment ID to check
- * @return Whether the subgraph is denied for claiming rewards or not
+ * @notice Sets the denied status for a subgraph.
+ * @dev Idempotent: redundant calls skip the update but still call `onSubgraphAllocationUpdate`.
+ * @param subgraphDeploymentId Subgraph deployment ID
+ * @param deny True to deny rewards, false to allow
*/
+ function _setDenied(bytes32 subgraphDeploymentId, bool deny) private {
+ onSubgraphAllocationUpdate(subgraphDeploymentId);
+
+ bool stateChange = deny == (denylist[subgraphDeploymentId] == 0);
+ if (stateChange) {
+ uint256 sinceBlock = deny ? block.number : 0;
+ denylist[subgraphDeploymentId] = sinceBlock;
+ emit RewardsDenylistUpdated(subgraphDeploymentId, sinceBlock);
+ }
+ }
+
+ /// @inheritdoc IRewardsManager
function isDenied(bytes32 _subgraphDeploymentID) public view override returns (bool) {
return denylist[_subgraphDeploymentID] > 0;
}
@@ -174,101 +309,128 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa
// -- Getters --
/**
- * @notice Gets the issuance of rewards per signal since last updated
- * @dev Linear formula: `x = r * t`
- *
- * Notation:
- * t: time steps are in blocks since last updated
- * x: newly accrued rewards tokens for the period `t`
- *
- * @return newly accrued rewards per signal since last update, scaled by FIXED_POINT_SCALING_FACTOR
+ * @inheritdoc IRewardsManager
+ */
+ function getAllocatedIssuancePerBlock() public view override returns (uint256) {
+ return
+ address(issuanceAllocator) != address(0)
+ ? issuanceAllocator.getTargetIssuancePerBlock(address(this)).selfIssuanceRate
+ : issuancePerBlock;
+ }
+
+ /**
+ * @inheritdoc IRewardsManager
*/
- function getNewRewardsPerSignal() public view override returns (uint256) {
+ function getRawIssuancePerBlock() external view override returns (uint256) {
+ return issuancePerBlock;
+ }
+
+ /**
+ * @inheritdoc IIssuanceTarget
+ */
+ function getIssuanceAllocator() external view override returns (IIssuanceAllocationDistribution) {
+ return issuanceAllocator;
+ }
+
+ /**
+ * @inheritdoc IRewardsManager
+ */
+ function getReclaimAddress(bytes32 reason) external view override returns (address) {
+ return reclaimAddresses[reason];
+ }
+
+ /**
+ * @inheritdoc IRewardsManager
+ */
+ function getDefaultReclaimAddress() external view override returns (address) {
+ return defaultReclaimAddress;
+ }
+
+ /**
+ * @inheritdoc IProviderEligibilityManagement
+ */
+ function getProviderEligibilityOracle() external view override returns (IProviderEligibility) {
+ return rewardsEligibilityOracle;
+ }
+
+ /// @inheritdoc IRewardsManager
+ function getRevertOnIneligible() external view override returns (bool) {
+ return revertOnIneligible;
+ }
+
+ /// @inheritdoc IRewardsManager
+ function getNewRewardsPerSignal() public view override returns (uint256 claimablePerSignal) {
+ (claimablePerSignal, ) = _getNewRewardsPerSignal();
+ }
+
+ /**
+ * @notice Calculate new rewards per signal since last update
+ * @dev Formula: `x = r * t` where t = blocks since last update.
+ * @return claimablePerSignal Rewards per signal (scaled by FIXED_POINT_SCALING_FACTOR)
+ * @return unclaimableTokens Tokens not distributed due to zero signal
+ */
+ function _getNewRewardsPerSignal() private view returns (uint256 claimablePerSignal, uint256 unclaimableTokens) {
// Calculate time steps
uint256 t = block.number.sub(accRewardsPerSignalLastBlockUpdated);
// Optimization to skip calculations if zero time steps elapsed
- if (t == 0) {
- return 0;
- }
- // ...or if issuance is zero
- if (issuancePerBlock == 0) {
- return 0;
- }
+ if (t == 0) return (0, 0);
- // Zero issuance if no signalled tokens
- IGraphToken graphToken = graphToken();
- uint256 signalledTokens = graphToken.balanceOf(address(curation()));
- if (signalledTokens == 0) {
- return 0;
- }
+ uint256 rewardsIssuancePerBlock = getAllocatedIssuancePerBlock();
+
+ if (rewardsIssuancePerBlock == 0) return (0, 0);
- uint256 x = issuancePerBlock.mul(t);
+ uint256 x = rewardsIssuancePerBlock.mul(t);
+
+ // Check signalled tokens
+ uint256 signalledTokens = graphToken().balanceOf(address(curation()));
+ if (signalledTokens == 0) return (0, x); // All unclaimable when no signal
// Get the new issuance per signalled token
// We multiply the decimals to keep the precision as fixed-point number
- return x.mul(FIXED_POINT_SCALING_FACTOR).div(signalledTokens);
+ return (x.mul(FIXED_POINT_SCALING_FACTOR).div(signalledTokens), 0);
}
- /**
- * @notice Gets the currently accumulated rewards per signal
- * @return Currently accumulated rewards per signal
- */
+ /// @inheritdoc IRewardsManager
function getAccRewardsPerSignal() public view override returns (uint256) {
return accRewardsPerSignal.add(getNewRewardsPerSignal());
}
/**
- * @notice Gets the accumulated rewards for the subgraph
- * @param _subgraphDeploymentID Subgraph deployment
- * @return Accumulated rewards for subgraph
+ * @inheritdoc IRewardsManager
+ * @dev Returns accumulated rewards for external callers.
+ * New rewards are only included if the subgraph is claimable (neither denied nor below minimum signal).
+ * Reclaim for non-claimable subgraphs is handled in `onSubgraphSignalUpdate()` and `onSubgraphAllocationUpdate()`.
*/
function getAccRewardsForSubgraph(bytes32 _subgraphDeploymentID) public view override returns (uint256) {
Subgraph storage subgraph = subgraphs[_subgraphDeploymentID];
-
- // Get tokens signalled on the subgraph
- uint256 subgraphSignalledTokens = curation().getCurationPoolTokens(_subgraphDeploymentID);
-
- // Only accrue rewards if over a threshold
- uint256 newRewards = (subgraphSignalledTokens >= minimumSubgraphSignal) // Accrue new rewards since last snapshot
- ? getAccRewardsPerSignal().sub(subgraph.accRewardsPerSignalSnapshot).mul(subgraphSignalledTokens).div(
- FIXED_POINT_SCALING_FACTOR
- )
- : 0;
- return subgraph.accRewardsForSubgraph.add(newRewards);
+ (uint256 newRewards, , bytes32 condition) = _getSubgraphRewardsState(_subgraphDeploymentID);
+ return subgraph.accRewardsForSubgraph.add(condition == RewardsCondition.NONE ? newRewards : 0);
}
/**
- * @notice Gets the accumulated rewards per allocated token for the subgraph
- * @param _subgraphDeploymentID Subgraph deployment
- * @return Accumulated rewards per allocated token for the subgraph
- * @return Accumulated rewards for subgraph
+ * @inheritdoc IRewardsManager
+ * @dev New rewards are only included via `getAccRewardsForSubgraph` when subgraph is claimable.
+ * Pre-existing stored rewards are always shown as distributable (preserved for when conditions clear).
+ * Does not check indexer eligibility - that can change and doesn't affect reward accrual.
*/
function getAccRewardsPerAllocatedToken(
bytes32 _subgraphDeploymentID
) public view override returns (uint256, uint256) {
Subgraph storage subgraph = subgraphs[_subgraphDeploymentID];
+ // getAccRewardsForSubgraph already handles claimability: excludes new rewards when not claimable
uint256 accRewardsForSubgraph = getAccRewardsForSubgraph(_subgraphDeploymentID);
uint256 newRewardsForSubgraph = MathUtils.diffOrZero(
accRewardsForSubgraph,
subgraph.accRewardsForSubgraphSnapshot
);
- // There are two contributors to subgraph allocated tokens:
- // - the legacy allocations on the legacy staking contract
- // - the new allocations on the subgraph service
- uint256 subgraphAllocatedTokens = 0;
- address[2] memory rewardsIssuers = [address(staking()), address(subgraphService)];
- for (uint256 i = 0; i < rewardsIssuers.length; i++) {
- if (rewardsIssuers[i] != address(0)) {
- subgraphAllocatedTokens += IRewardsIssuer(rewardsIssuers[i]).getSubgraphAllocatedTokens(
- _subgraphDeploymentID
- );
- }
- }
+ // Get total allocated tokens across all issuers
+ uint256 subgraphAllocatedTokens = _getSubgraphAllocatedTokens(_subgraphDeploymentID);
if (subgraphAllocatedTokens == 0) {
- return (0, accRewardsForSubgraph);
+ // No allocations to distribute to, return stored value (no pending updates possible)
+ return (subgraph.accRewardsPerAllocatedToken, accRewardsForSubgraph);
}
uint256 newRewardsPerAllocatedToken = newRewardsForSubgraph.mul(FIXED_POINT_SCALING_FACTOR).div(
@@ -277,68 +439,216 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa
return (subgraph.accRewardsPerAllocatedToken.add(newRewardsPerAllocatedToken), accRewardsForSubgraph);
}
+ // -- Internal Helpers --
+
+ /**
+ * @notice Get subgraph rewards state including effective reclaim condition
+ * @dev Determines claimability with priority: SUBGRAPH_DENIED > BELOW_MINIMUM_SIGNAL > NO_ALLOCATED_TOKENS > NONE
+ * When multiple conditions apply, prefers conditions with configured reclaim addresses.
+ * @param _subgraphDeploymentID Subgraph deployment
+ * @return newRewards Rewards accumulated since last snapshot
+ * @return subgraphAllocatedTokens Total tokens allocated to this subgraph
+ * @return condition The effective condition for reclaim routing (NONE if claimable)
+ */
+ function _getSubgraphRewardsState(
+ bytes32 _subgraphDeploymentID
+ ) private view returns (uint256 newRewards, uint256 subgraphAllocatedTokens, bytes32 condition) {
+ Subgraph storage subgraph = subgraphs[_subgraphDeploymentID];
+ uint256 signalledTokens = curation().getCurationPoolTokens(_subgraphDeploymentID);
+ uint256 accRewardsPerSignalDelta = getAccRewardsPerSignal().sub(subgraph.accRewardsPerSignalSnapshot);
+ newRewards = accRewardsPerSignalDelta.mul(signalledTokens).div(FIXED_POINT_SCALING_FACTOR);
+ subgraphAllocatedTokens = _getSubgraphAllocatedTokens(_subgraphDeploymentID);
+
+ condition = isDenied(_subgraphDeploymentID) ? RewardsCondition.SUBGRAPH_DENIED : RewardsCondition.NONE;
+ if (
+ signalledTokens < minimumSubgraphSignal &&
+ (condition == RewardsCondition.NONE || reclaimAddresses[condition] == address(0))
+ ) condition = RewardsCondition.BELOW_MINIMUM_SIGNAL;
+ if (
+ subgraphAllocatedTokens == 0 &&
+ (condition == RewardsCondition.NONE || reclaimAddresses[condition] == address(0))
+ ) condition = RewardsCondition.NO_ALLOCATED_TOKENS;
+ }
+
+ /**
+ * @notice Get total allocated tokens for a subgraph across all issuers
+ * @param _subgraphDeploymentID Subgraph deployment
+ * @return subgraphAllocatedTokens Total tokens allocated to this subgraph
+ */
+ function _getSubgraphAllocatedTokens(
+ bytes32 _subgraphDeploymentID
+ ) private view returns (uint256 subgraphAllocatedTokens) {
+ if (address(subgraphService) != address(0))
+ subgraphAllocatedTokens += subgraphService.getSubgraphAllocatedTokens(_subgraphDeploymentID);
+ }
+
// -- Updates --
/**
- * @notice Updates the accumulated rewards per signal and save checkpoint block number
+ * @inheritdoc IRewardsManager
* @dev Must be called before `issuancePerBlock` or `total signalled GRT` changes.
* Called from the Curation contract on mint() and burn()
- * @return Accumulated rewards per signal
+ *
+ * ## Zero Signal Handling
+ *
+ * When total signalled tokens is zero, issuance for the period is reclaimed
+ * (if NO_SIGNAL reclaim address is configured) rather than being lost.
*/
function updateAccRewardsPerSignal() public override returns (uint256) {
- accRewardsPerSignal = getAccRewardsPerSignal();
+ if (accRewardsPerSignalLastBlockUpdated == block.number) return accRewardsPerSignal;
+
+ (uint256 claimablePerSignal, uint256 unclaimableTokens) = _getNewRewardsPerSignal();
+
+ if (0 < unclaimableTokens)
+ _reclaimRewards(RewardsCondition.NO_SIGNAL, unclaimableTokens, address(0), address(0), bytes32(0));
+
+ uint256 newAccRewardsPerSignal = accRewardsPerSignal.add(claimablePerSignal);
+ accRewardsPerSignal = newAccRewardsPerSignal;
accRewardsPerSignalLastBlockUpdated = block.number;
- return accRewardsPerSignal;
+ return newAccRewardsPerSignal;
}
/**
- * @notice Triggers an update of rewards for a subgraph
+ * @notice Internal function that updates subgraph reward accumulators.
+ * Shared logic for both signal and allocation update hooks.
+ *
+ * @param subgraph Storage pointer to the subgraph
+ * @param _subgraphDeploymentID The subgraph deployment ID
+ * @param accRewardsPerSignal Current global rewards per signal
+ * @param accRewardsForSubgraph Current subgraph accumulated rewards
+ * @param accRewardsPerAllocatedToken Current rewards per allocated token
+ * @return newAccRewardsForSubgraph Updated subgraph accumulated rewards
+ * @return newAccRewardsPerAllocatedToken Updated rewards per allocated token
+ */
+ function _updateSubgraphRewards(
+ Subgraph storage subgraph,
+ bytes32 _subgraphDeploymentID,
+ uint256 accRewardsPerSignal,
+ uint256 accRewardsForSubgraph,
+ uint256 accRewardsPerAllocatedToken
+ ) internal returns (uint256 newAccRewardsForSubgraph, uint256 newAccRewardsPerAllocatedToken) {
+ (
+ uint256 rewardsSinceSignalSnapshot,
+ uint256 subgraphAllocatedTokens,
+ bytes32 condition
+ ) = _getSubgraphRewardsState(_subgraphDeploymentID);
+ subgraph.accRewardsPerSignalSnapshot = accRewardsPerSignal;
+
+ // undistributed = (accRewardsForSubgraph + rewardsSinceSignalSnapshot) - accRewardsForSubgraphSnapshot
+ // We add rewardsSinceSignalSnapshot before subtracting accRewardsForSubgraphSnapshot to avoid
+ // an intermediate underflow: pre-upgrade state can have accRewardsForSubgraph <
+ // accRewardsForSubgraphSnapshot (the old alloc hook set the snapshot from a view that included
+ // pending rewards, while the old signal hook only wrote the stored value). The full expression
+ // is always non-negative because rewardsSinceSignalSnapshot covers a superset of the gap.
+ uint256 undistributedRewards = accRewardsForSubgraph.add(rewardsSinceSignalSnapshot).sub(
+ subgraph.accRewardsForSubgraphSnapshot
+ );
+
+ if (condition != RewardsCondition.NONE) {
+ _reclaimRewards(condition, undistributedRewards, address(0), address(0), _subgraphDeploymentID);
+ undistributedRewards = 0;
+ newAccRewardsForSubgraph = accRewardsForSubgraph;
+ } else {
+ newAccRewardsForSubgraph = accRewardsForSubgraph.add(rewardsSinceSignalSnapshot);
+ subgraph.accRewardsForSubgraph = newAccRewardsForSubgraph;
+ }
+
+ subgraph.accRewardsForSubgraphSnapshot = newAccRewardsForSubgraph;
+
+ newAccRewardsPerAllocatedToken = accRewardsPerAllocatedToken;
+ if (undistributedRewards != 0) {
+ newAccRewardsPerAllocatedToken = accRewardsPerAllocatedToken.add(
+ undistributedRewards.mul(FIXED_POINT_SCALING_FACTOR).div(subgraphAllocatedTokens)
+ );
+ subgraph.accRewardsPerAllocatedToken = newAccRewardsPerAllocatedToken;
+ }
+ }
+
+ /**
+ * @inheritdoc IRewardsManager
* @dev Must be called before `signalled GRT` on a subgraph changes.
* Hook called from the Curation contract on mint() and burn()
- * @param _subgraphDeploymentID Subgraph deployment
- * @return Accumulated rewards for subgraph
+ *
+ * ## Claimability Behavior
+ *
+ * When a subgraph is not claimable (denied, below minimum signal, or no allocations):
+ * - Rewards are reclaimed immediately with the appropriate reason
+ * - `accRewardsForSubgraph` is NOT updated (rewards go to reclaim, not accumulator)
+ *
+ * When claimable (not denied, above minimum signal, has allocations):
+ * - Rewards are added to `accRewardsForSubgraph` for later distribution via `onSubgraphAllocationUpdate`
*/
- function onSubgraphSignalUpdate(bytes32 _subgraphDeploymentID) external override returns (uint256) {
+ function onSubgraphSignalUpdate(
+ bytes32 _subgraphDeploymentID
+ ) external override returns (uint256 accRewardsForSubgraph) {
// Called since `total signalled GRT` will change
- updateAccRewardsPerSignal();
+ uint256 accRewardsPerSignal = updateAccRewardsPerSignal();
- // Updates the accumulated rewards for a subgraph
Subgraph storage subgraph = subgraphs[_subgraphDeploymentID];
- subgraph.accRewardsForSubgraph = getAccRewardsForSubgraph(_subgraphDeploymentID);
- subgraph.accRewardsPerSignalSnapshot = accRewardsPerSignal;
- return subgraph.accRewardsForSubgraph;
+ accRewardsForSubgraph = subgraph.accRewardsForSubgraph;
+
+ if (subgraph.accRewardsPerSignalSnapshot == accRewardsPerSignal) return accRewardsForSubgraph;
+
+ (accRewardsForSubgraph, ) = _updateSubgraphRewards(
+ subgraph,
+ _subgraphDeploymentID,
+ accRewardsPerSignal,
+ accRewardsForSubgraph,
+ subgraph.accRewardsPerAllocatedToken
+ );
}
/**
- * @notice Triggers an update of rewards for a subgraph
- * @dev Must be called before allocation on a subgraph changes.
- * Hook called from the Staking contract on allocate() and close()
+ * @inheritdoc IRewardsManager
+ * @dev Hook called from the IRewardsIssuer contract on allocate() and close()
*
- * @param _subgraphDeploymentID Subgraph deployment
- * @return Accumulated rewards per allocated token for a subgraph
+ * ## Claimability Behavior
+ *
+ * When a subgraph is not claimable (denied, below minimum signal, or no allocations):
+ * - Rewards are reclaimed immediately with the appropriate reason
+ * - `accRewardsForSubgraph` is NOT updated (rewards go to reclaim, not accumulator)
+ * - `accRewardsPerAllocatedToken` does NOT increase
+ *
+ * When claimable (not denied, above minimum signal, has allocations):
+ * - Rewards are added to `accRewardsForSubgraph`
+ * - `accRewardsPerAllocatedToken` increases (rewards distributable to allocations)
+ *
+ * @return accRewardsPerAllocatedToken Current `accRewardsPerAllocatedToken`
*/
- function onSubgraphAllocationUpdate(bytes32 _subgraphDeploymentID) public override returns (uint256) {
+ function onSubgraphAllocationUpdate(
+ bytes32 _subgraphDeploymentID
+ ) public override returns (uint256 accRewardsPerAllocatedToken) {
Subgraph storage subgraph = subgraphs[_subgraphDeploymentID];
- (uint256 accRewardsPerAllocatedToken, uint256 accRewardsForSubgraph) = getAccRewardsPerAllocatedToken(
- _subgraphDeploymentID
+
+ uint256 accRewardsPerSignal = updateAccRewardsPerSignal();
+ uint256 accRewardsForSubgraph = subgraph.accRewardsForSubgraph;
+ accRewardsPerAllocatedToken = subgraph.accRewardsPerAllocatedToken;
+
+ // Return early to save gas if both snapshots are up-to-date
+ if (
+ subgraph.accRewardsPerSignalSnapshot == accRewardsPerSignal &&
+ subgraph.accRewardsForSubgraphSnapshot == accRewardsForSubgraph
+ ) return accRewardsPerAllocatedToken;
+
+ (, accRewardsPerAllocatedToken) = _updateSubgraphRewards(
+ subgraph,
+ _subgraphDeploymentID,
+ accRewardsPerSignal,
+ accRewardsForSubgraph,
+ accRewardsPerAllocatedToken
);
- subgraph.accRewardsPerAllocatedToken = accRewardsPerAllocatedToken;
- subgraph.accRewardsForSubgraphSnapshot = accRewardsForSubgraph;
- return subgraph.accRewardsPerAllocatedToken;
}
/**
- * @dev Calculate current rewards for a given allocation on demand.
- * The allocation could be a legacy allocation or a new subgraph service allocation.
- * Returns 0 if the allocation is not active.
- * @param _allocationID Allocation
- * @return Rewards amount for an allocation
+ * @inheritdoc IRewardsManager
+ * @dev Reflects the gap between the subgraph accumulator and the allocation's snapshot, plus
+ * stored pending rewards. During exclusion (denied, below minimum signal, no allocations), the
+ * accumulator is frozen: new rewards are excluded but the existing gap remains claimable when
+ * conditions clear. Does not check indexer eligibility - that is verified at claim time via
+ * takeRewards().
*/
function getRewards(address _rewardsIssuer, address _allocationID) external view override returns (uint256) {
- require(
- _rewardsIssuer == address(staking()) || _rewardsIssuer == address(subgraphService),
- "Not a rewards issuer"
- );
+ require(_rewardsIssuer == address(subgraphService), "Not a rewards issuer");
(
bool isActive,
@@ -359,7 +669,7 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa
}
/**
- * @dev Calculate rewards for a given accumulated rewards per allocated token.
+ * @notice Calculate rewards for a given accumulated rewards per allocated token
* @param _tokens Tokens allocated
* @param _accRewardsPerAllocatedToken Allocation accumulated rewards per token
* @return Rewards amount
@@ -372,7 +682,7 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa
}
/**
- * @dev Calculate current rewards for a given allocation.
+ * @notice Calculate current rewards for a given allocation.
* @param _tokens Tokens allocated
* @param _startAccRewardsPerAllocatedToken Allocation start accumulated rewards
* @param _endAccRewardsPerAllocatedToken Allocation end accumulated rewards
@@ -388,54 +698,155 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa
}
/**
- * @dev Pull rewards from the contract for a particular allocation.
- * This function can only be called by an authorized rewards issuer which are
- * the staking contract (for legacy allocations), and the subgraph service (for new allocations).
- * This function will mint the necessary tokens to reward based on the inflation calculation.
- * Mints 0 tokens if the allocation is not active.
- * @param _allocationID Allocation
- * @return Assigned rewards amount
+ * @notice Calculate rewards for an allocation
+ * @param rewardsIssuer Address of the rewards issuer calling the function
+ * @param allocationID Address of the allocation
+ * @return rewards Amount of rewards calculated
+ * @return indexer Address of the indexer
+ * @return subgraphDeploymentID Subgraph deployment ID
*/
- function takeRewards(address _allocationID) external override returns (uint256) {
- address rewardsIssuer = msg.sender;
- require(
- rewardsIssuer == address(staking()) || rewardsIssuer == address(subgraphService),
- "Caller must be a rewards issuer"
- );
-
+ function _calcAllocationRewards(
+ address rewardsIssuer,
+ address allocationID
+ ) private returns (uint256 rewards, address indexer, bytes32 subgraphDeploymentID) {
(
bool isActive,
- address indexer,
- bytes32 subgraphDeploymentID,
+ address _indexer,
+ bytes32 _subgraphDeploymentID,
uint256 tokens,
uint256 accRewardsPerAllocatedToken,
uint256 accRewardsPending
- ) = IRewardsIssuer(rewardsIssuer).getAllocationData(_allocationID);
+ ) = IRewardsIssuer(rewardsIssuer).getAllocationData(allocationID);
- uint256 updatedAccRewardsPerAllocatedToken = onSubgraphAllocationUpdate(subgraphDeploymentID);
+ uint256 updatedAccRewardsPerAllocatedToken = onSubgraphAllocationUpdate(_subgraphDeploymentID);
- // Do not do rewards on denied subgraph deployments ID
- if (isDenied(subgraphDeploymentID)) {
- emit RewardsDenied(indexer, _allocationID);
- return 0;
- }
-
- uint256 rewards = 0;
- if (isActive) {
- // Calculate rewards accrued by this allocation
- rewards = accRewardsPending.add(
+ rewards = isActive
+ ? accRewardsPending.add(
_calcRewards(tokens, accRewardsPerAllocatedToken, updatedAccRewardsPerAllocatedToken)
- );
- if (rewards > 0) {
- // Mint directly to rewards issuer for the reward amount
- // The rewards issuer contract will do bookkeeping of the reward and
- // assign in proportion to each stakeholder incentive
- graphToken().mint(rewardsIssuer, rewards);
- }
- }
+ )
+ : 0;
+ indexer = _indexer;
+ subgraphDeploymentID = _subgraphDeploymentID;
+ }
+
+ /**
+ * @notice Reclaim rewards to reason-specific address or default fallback
+ * @param reason Reclaim reason identifier
+ * @param rewards Amount of rewards to reclaim
+ * @param indexer Address of the indexer
+ * @param allocationId Address of the allocation
+ * @param subgraphDeploymentId Subgraph deployment ID for the allocation
+ * @return Amount reclaimed (0 if no target address configured)
+ *
+ * @dev ## Reclaim Priority
+ *
+ * 1. Try the reason-specific address
+ * 2. If not configured, try defaultReclaimAddress
+ * 3. If neither configured, rewards are dropped (not minted), returns 0
+ */
+ function _reclaimRewards(
+ bytes32 reason,
+ uint256 rewards,
+ address indexer,
+ address allocationId,
+ bytes32 subgraphDeploymentId
+ ) private returns (uint256) {
+ if (rewards == 0) return 0;
+ if (reason == RewardsCondition.NONE) return 0; // NONE cannot be used as reclaim reason
+
+ address target = reclaimAddresses[reason];
+ if (target == address(0)) target = defaultReclaimAddress;
+ if (target == address(0)) return 0; // Dropped, not reclaimed
+
+ graphToken().mint(target, rewards);
+ emit RewardsReclaimed(reason, rewards, indexer, allocationId, subgraphDeploymentId);
+ return rewards;
+ }
+
+ /**
+ * @notice Check if rewards should be denied and attempt to reclaim them
+ * @param rewards Amount of rewards to check
+ * @param indexer Address of the indexer
+ * @param allocationID Address of the allocation
+ * @param subgraphDeploymentID Subgraph deployment ID for the allocation
+ * @return denied True if rewards are denied (either reclaimed or dropped), false if they should be minted
+ * @dev Emits denial events, then attempts reclaim.
+ * Prefers subgraph denial over indexer ineligibility as reason when both apply.
+ * First configured applicable reclaim address is used.
+ * If rewards denied but no specific address is configured, the default reclaim address is used.
+ * If no applicable reclaim address is configured, rewards are not minted.
+ */
+ function _deniedRewards(
+ uint256 rewards,
+ address indexer,
+ address allocationID,
+ bytes32 subgraphDeploymentID
+ ) private returns (bool denied) {
+ bool isDeniedSubgraph = isDenied(subgraphDeploymentID);
+ bool isIneligible = address(rewardsEligibilityOracle) != address(0) &&
+ !rewardsEligibilityOracle.isEligible(indexer);
+
+ // When configured to revert, block collection so rewards remain claimable if
+ // the indexer becomes eligible and collects before the allocation goes stale.
+ require(!isIneligible || !revertOnIneligible, "Indexer not eligible for rewards");
+
+ if (!isDeniedSubgraph && !isIneligible) return false;
+
+ if (isDeniedSubgraph) emit RewardsDenied(indexer, allocationID);
+ if (isIneligible) emit RewardsDeniedDueToEligibility(indexer, allocationID, rewards);
+
+ bytes32 reason = isDeniedSubgraph ? RewardsCondition.SUBGRAPH_DENIED : RewardsCondition.NONE;
+ if (isIneligible && (!isDeniedSubgraph || reclaimAddresses[reason] == address(0)))
+ reason = RewardsCondition.INDEXER_INELIGIBLE;
+
+ _reclaimRewards(reason, rewards, indexer, allocationID, subgraphDeploymentID);
+ return true;
+ }
+
+ /**
+ * @inheritdoc IRewardsManager
+ * @dev This function can only be called by an authorized rewards issuer which are
+ * - the subgraph service (for allocations).
+ * Mints 0 tokens if the allocation is not active.
+ * @dev First successful reclaim wins - short-circuits on reclaim:
+ * - If subgraph denied with reclaim address → reclaim to SUBGRAPH_DENIED address (eligibility NOT checked)
+ * - If subgraph not denied OR denied without address, then check eligibility → reclaim to INDEXER_INELIGIBLE if configured
+ * - Subsequent denial emitted only when earlier denial has no reclaim address
+ * - Any denial without reclaim address drops rewards (no minting)
+ */
+ function takeRewards(address _allocationID) external override returns (uint256) {
+ address rewardsIssuer = msg.sender;
+ require(rewardsIssuer == address(subgraphService), "Caller must be a rewards issuer");
+
+ (uint256 rewards, address indexer, bytes32 subgraphDeploymentID) = _calcAllocationRewards(
+ rewardsIssuer,
+ _allocationID
+ );
+
+ if (rewards == 0) return 0;
+ if (_deniedRewards(rewards, indexer, _allocationID, subgraphDeploymentID)) return 0;
+
+ graphToken().mint(rewardsIssuer, rewards);
emit HorizonRewardsAssigned(indexer, _allocationID, rewards);
return rewards;
}
+
+ /**
+ * @inheritdoc IRewardsManager
+ * @dev bytes32(0) (NONE) cannot be used as a reclaim reason and will return 0.
+ * Use specific RewardsCondition constants for reclaim reasons.
+ */
+ function reclaimRewards(bytes32 reason, address allocationID) external override returns (uint256) {
+ address rewardsIssuer = msg.sender;
+ require(rewardsIssuer == address(subgraphService), "Not a rewards issuer");
+
+ (uint256 rewards, address indexer, bytes32 subgraphDeploymentID) = _calcAllocationRewards(
+ rewardsIssuer,
+ allocationID
+ );
+
+ return _reclaimRewards(reason, rewards, indexer, allocationID, subgraphDeploymentID);
+ }
}
diff --git a/packages/contracts/contracts/rewards/RewardsManagerStorage.sol b/packages/contracts/contracts/rewards/RewardsManagerStorage.sol
index d2ffa2b42..72a2d3176 100644
--- a/packages/contracts/contracts/rewards/RewardsManagerStorage.sol
+++ b/packages/contracts/contracts/rewards/RewardsManagerStorage.sol
@@ -1,44 +1,125 @@
// SPDX-License-Identifier: GPL-2.0-or-later
-pragma solidity ^0.7.6 || 0.8.27;
+/* solhint-disable one-contract-per-file */
-import { IRewardsIssuer } from "./IRewardsIssuer.sol";
-import { IRewardsManager } from "./IRewardsManager.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable named-parameters-mapping
+
+pragma solidity ^0.7.6 || ^0.8.27;
+
+import { IIssuanceAllocationDistribution } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocationDistribution.sol";
+import { IProviderEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibility.sol";
+import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsIssuer.sol";
+import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol";
+import { IRewardsManagerDeprecated } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManagerDeprecated.sol";
import { Managed } from "../governance/Managed.sol";
+/**
+ * @title RewardsManagerV1Storage
+ * @author Edge & Node
+ * @notice Storage layout for RewardsManager V1
+ */
contract RewardsManagerV1Storage is Managed {
// -- State --
+ /// @dev Deprecated issuance rate variable (no longer used)
uint256 private __DEPRECATED_issuanceRate; // solhint-disable-line var-name-mixedcase
+
+ /// @notice Accumulated rewards per signal (fixed-point, scaled by 1e18)
+ /// @dev Never decreases. Only increases via updateAccRewardsPerSignal().
+ /// Represents the cumulative GRT rewards per signaled token since contract deployment.
uint256 public accRewardsPerSignal;
+
+ /// @notice Block number when accumulated rewards per signal was last updated
+ /// @dev Used to calculate time delta for new reward accrual. Must be updated atomically
+ /// with accRewardsPerSignal to maintain accounting consistency.
uint256 public accRewardsPerSignalLastBlockUpdated;
- // Address of role allowed to deny rewards on subgraphs
+ /// @notice Address of role allowed to deny rewards on subgraphs
address public subgraphAvailabilityOracle;
- // Subgraph related rewards: subgraph deployment ID => subgraph rewards
+ /// @notice Subgraph related rewards: subgraph deployment ID => subgraph rewards
+ /// @dev Accumulation state tracked per subgraph.
mapping(bytes32 => IRewardsManager.Subgraph) public subgraphs;
- // Subgraph denylist : subgraph deployment ID => block when added or zero (if not denied)
+ /// @notice Subgraph denylist: subgraph deployment ID => block when added or zero (if not denied)
+ /// @dev **Denial Semantics**:
+ /// - Non-zero value: subgraph is denied since that block number
+ /// - Zero value: subgraph is not denied
+ /// - When denied: accRewardsPerAllocatedToken freezes (stops updating)
+ /// - New rewards during denial are reclaimed (if reclaim address configured) or dropped
mapping(bytes32 => uint256) public denylist;
}
+/**
+ * @title RewardsManagerV2Storage
+ * @author Edge & Node
+ * @notice Storage layout for RewardsManager V2
+ */
contract RewardsManagerV2Storage is RewardsManagerV1Storage {
- // Minimum amount of signaled tokens on a subgraph required to accrue rewards
+ /// @notice Minimum amount of signaled tokens on a subgraph required to accrue rewards
uint256 public minimumSubgraphSignal;
}
+/**
+ * @title RewardsManagerV3Storage
+ * @author Edge & Node
+ * @notice Storage layout for RewardsManager V3
+ */
contract RewardsManagerV3Storage is RewardsManagerV2Storage {
- // Snapshot of the total supply of GRT when accRewardsPerSignal was last updated
+ /// @dev Deprecated token supply snapshot variable (no longer used)
uint256 private __DEPRECATED_tokenSupplySnapshot; // solhint-disable-line var-name-mixedcase
}
-contract RewardsManagerV4Storage is RewardsManagerV3Storage {
- // GRT issued for indexer rewards per block
- uint256 public issuancePerBlock;
+/**
+ * @title RewardsManagerV4Storage
+ * @author Edge & Node
+ * @notice Storage layout for RewardsManager V4
+ */
+abstract contract RewardsManagerV4Storage is IRewardsManagerDeprecated, RewardsManagerV3Storage {
+ /// @notice GRT issued for indexer rewards per block
+ /// @dev Only used when issuanceAllocator is zero address.
+ uint256 public override issuancePerBlock;
}
-contract RewardsManagerV5Storage is RewardsManagerV4Storage {
- // Address of the subgraph service
- IRewardsIssuer public subgraphService;
+/**
+ * @title RewardsManagerV5Storage
+ * @author Edge & Node
+ * @notice Storage layout for RewardsManager V5
+ */
+abstract contract RewardsManagerV5Storage is IRewardsManager, RewardsManagerV4Storage {
+ /// @notice Address of the subgraph service
+ IRewardsIssuer public override subgraphService;
+}
+
+/**
+ * @title RewardsManagerV6Storage
+ * @author Edge & Node
+ * @notice Storage layout for RewardsManager V6
+ * Includes support for Rewards Eligibility Oracle, Issuance Allocator, and reclaim addresses.
+ */
+abstract contract RewardsManagerV6Storage is RewardsManagerV5Storage {
+ /// @dev Address of the rewards eligibility oracle contract
+ /// When set, indexers must pass eligibility check to claim rewards.
+ /// Zero address disables eligibility checks.
+ IProviderEligibility internal rewardsEligibilityOracle;
+
+ /// @dev Address of the issuance allocator
+ /// When set, determines GRT issued per block. Zero address uses issuancePerBlock storage value.
+ IIssuanceAllocationDistribution internal issuanceAllocator;
+
+ /// @dev Mapping of reclaim reason identifiers to reclaim addresses
+ /// @dev Uses bytes32 for extensibility. See RewardsCondition library for canonical reasons.
+ /// **IMPORTANT**: Changes to reclaim addresses are retroactive. When an address is changed,
+ /// ALL future reclaims for that reason go to the new address, regardless of when the
+ /// rewards were originally accrued. Zero address means rewards are dropped (not minted).
+ mapping(bytes32 => address) internal reclaimAddresses;
+ /// @dev Default fallback address for reclaiming rewards when no reason-specific address is configured.
+ /// Zero address means rewards are dropped (not minted) if no specific reclaim address matches.
+ address internal defaultReclaimAddress;
+
+ /// @dev When true, ineligible indexers cause takeRewards to revert (blocking POI presentation
+ /// and allowing allocations to go stale). When false (default), ineligible indexers have
+ /// rewards reclaimed but takeRewards succeeds (returning 0).
+ bool internal revertOnIneligible;
}
diff --git a/packages/contracts/contracts/rewards/SubgraphAvailabilityManager.sol b/packages/contracts/contracts/rewards/SubgraphAvailabilityManager.sol
index 3c0c98cd2..d24577b42 100644
--- a/packages/contracts/contracts/rewards/SubgraphAvailabilityManager.sol
+++ b/packages/contracts/contracts/rewards/SubgraphAvailabilityManager.sol
@@ -2,12 +2,17 @@
pragma solidity ^0.7.6;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-increment-by-one, gas-indexed-events, gas-small-strings, gas-strict-inequalities
+// solhint-disable named-parameters-mapping
+
import { Governed } from "../governance/Governed.sol";
-import { IRewardsManager } from "./IRewardsManager.sol";
+import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol";
/**
* @title Subgraph Availability Manager
- * @dev Manages the availability of subgraphs by allowing oracles to vote on whether
+ * @author Edge & Node
+ * @notice Manages the availability of subgraphs by allowing oracles to vote on whether
* a subgraph should be denied rewards or not. When enough oracles have voted to deny or
* allow rewards for a subgraph, it calls the RewardsManager Contract to set the correct
* state. The oracles and the execution threshold are set at deployment time.
@@ -21,15 +26,16 @@ contract SubgraphAvailabilityManager is Governed {
uint256 public constant NUM_ORACLES = 5;
/// @notice Number of votes required to execute a deny or allow call to the RewardsManager
- uint256 public immutable executionThreshold;
+ uint256 public immutable executionThreshold; // solhint-disable-line immutable-vars-naming
/// @dev Address of the RewardsManager contract
+ // solhint-disable-next-line immutable-vars-naming
IRewardsManager private immutable rewardsManager;
// -- State --
- /// @dev Nonce for generating votes on subgraph deployment IDs.
- /// Increased whenever oracles or voteTimeLimit change, to invalidate old votes.
+ /// @notice Nonce for generating votes on subgraph deployment IDs
+ /// @dev Increased whenever oracles or voteTimeLimit change, to invalidate old votes
uint256 public currentNonce;
/// @notice Time limit for a vote to be valid
@@ -39,30 +45,30 @@ contract SubgraphAvailabilityManager is Governed {
address[NUM_ORACLES] public oracles;
/// @notice Mapping of current nonce to subgraph deployment ID to an array of timestamps of last deny vote
- /// currentNonce => subgraphDeploymentId => timestamp[oracleIndex]
+ /// @dev currentNonce => subgraphDeploymentId => timestamp[oracleIndex]
mapping(uint256 => mapping(bytes32 => uint256[NUM_ORACLES])) public lastDenyVote;
- /// @notice Mapping of current nonce to subgraph deployment ID to an array of timestamp of last allow vote
- /// currentNonce => subgraphDeploymentId => timestamp[oracleIndex]
+ /// @notice Mapping of current nonce to subgraph deployment ID to an array of timestamp of last allow vote
+ /// @dev currentNonce => subgraphDeploymentId => timestamp[oracleIndex]
mapping(uint256 => mapping(bytes32 => uint256[NUM_ORACLES])) public lastAllowVote;
// -- Events --
/**
- * @dev Emitted when an oracle is set
+ * @notice Emitted when an oracle is set
* @param index Index of the oracle
* @param oracle Address of the oracle
*/
event OracleSet(uint256 indexed index, address indexed oracle);
/**
- * @dev Emitted when the vote time limit is set
+ * @notice Emitted when the vote time limit is set
* @param voteTimeLimit Vote time limit in seconds
*/
event VoteTimeLimitSet(uint256 voteTimeLimit);
/**
- * @dev Emitted when an oracle votes to deny or allow a subgraph
+ * @notice Emitted when an oracle votes to deny or allow a subgraph
* @param subgraphDeploymentID Subgraph deployment ID
* @param deny True to deny, false to allow
* @param oracleIndex Index of the oracle voting
@@ -72,6 +78,10 @@ contract SubgraphAvailabilityManager is Governed {
// -- Modifiers --
+ /**
+ * @dev Modifier to restrict access to authorized oracles only
+ * @param _oracleIndex Index of the oracle in the oracles array
+ */
modifier onlyOracle(uint256 _oracleIndex) {
require(_oracleIndex < NUM_ORACLES, "SAM: index out of bounds");
require(msg.sender == oracles[_oracleIndex], "SAM: caller must be oracle");
@@ -81,7 +91,7 @@ contract SubgraphAvailabilityManager is Governed {
// -- Constructor --
/**
- * @dev Contract constructor
+ * @notice Contract constructor
* @param _governor Account that can set or remove oracles and set the vote time limit
* @param _rewardsManager Address of the RewardsManager contract
* @param _executionThreshold Number of votes required to execute a deny or allow call to the RewardsManager
@@ -118,7 +128,7 @@ contract SubgraphAvailabilityManager is Governed {
// -- Functions --
/**
- * @dev Set the vote time limit. Refreshes all existing votes by incrementing the current nonce.
+ * @notice Set the vote time limit. Refreshes all existing votes by incrementing the current nonce.
* @param _voteTimeLimit Vote time limit in seconds
*/
function setVoteTimeLimit(uint256 _voteTimeLimit) external onlyGovernor {
@@ -128,7 +138,7 @@ contract SubgraphAvailabilityManager is Governed {
}
/**
- * @dev Set oracle address with index. Refreshes all existing votes by incrementing the current nonce.
+ * @notice Set oracle address with index. Refreshes all existing votes by incrementing the current nonce.
* @param _index Index of the oracle
* @param _oracle Address of the oracle
*/
@@ -144,7 +154,7 @@ contract SubgraphAvailabilityManager is Governed {
}
/**
- * @dev Vote deny or allow for a subgraph.
+ * @notice Vote deny or allow for a subgraph.
* NOTE: Can only be called by an oracle.
* @param _subgraphDeploymentID Subgraph deployment ID
* @param _deny True to deny, false to allow
@@ -155,7 +165,7 @@ contract SubgraphAvailabilityManager is Governed {
}
/**
- * @dev Vote deny or allow for many subgraphs.
+ * @notice Vote deny or allow for many subgraphs.
* NOTE: Can only be called by an oracle.
* @param _subgraphDeploymentID Array of subgraph deployment IDs
* @param _deny Array of booleans, true to deny, false to allow
@@ -173,7 +183,7 @@ contract SubgraphAvailabilityManager is Governed {
}
/**
- * @dev Vote deny or allow for a subgraph.
+ * @notice Vote deny or allow for a subgraph.
* When oracles cast their votes we store the timestamp of the vote.
* Check if the execution threshold has been reached for a subgraph.
* If execution threshold is reached we call the RewardsManager to set the correct state.
@@ -203,7 +213,7 @@ contract SubgraphAvailabilityManager is Governed {
}
/**
- * @dev Check if the execution threshold has been reached for a subgraph.
+ * @notice Check if the execution threshold has been reached for a subgraph.
* For a vote to be valid it needs to be within the vote time limit.
* @param _subgraphDeploymentID Subgraph deployment ID
* @param _deny True to deny, false to allow
diff --git a/packages/contracts/contracts/staking/IL1GraphTokenLockTransferTool.sol b/packages/contracts/contracts/staking/IL1GraphTokenLockTransferTool.sol
deleted file mode 100644
index b935682b9..000000000
--- a/packages/contracts/contracts/staking/IL1GraphTokenLockTransferTool.sol
+++ /dev/null
@@ -1,30 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity >=0.6.12 <0.8.0;
-pragma abicoder v2;
-
-/**
- * @title Interface for the L1GraphTokenLockTransferTool contract
- * @dev This interface defines the function to get the L2 wallet address for a given L1 token lock wallet.
- * The Transfer Tool contract is implemented in the token-distribution repo: https://github.com/graphprotocol/token-distribution/pull/64
- * and is only included here to provide support in L1Staking for the transfer of stake and delegation
- * owned by token lock contracts. See GIP-0046 for details: https://forum.thegraph.com/t/4023
- */
-interface IL1GraphTokenLockTransferTool {
- /**
- * @notice Pulls ETH from an L1 wallet's account to use for L2 ticket gas.
- * @dev This function is only callable by the staking contract.
- * @param _l1Wallet Address of the L1 token lock wallet
- * @param _amount Amount of ETH to pull from the transfer tool contract
- */
- function pullETH(address _l1Wallet, uint256 _amount) external;
-
- /**
- * @notice Get the L2 token lock wallet address for a given L1 token lock wallet
- * @dev In the actual L1GraphTokenLockTransferTool contract, this is simply the default getter for a public mapping variable.
- * @param _l1Wallet Address of the L1 token lock wallet
- * @return Address of the L2 token lock wallet if the wallet has an L2 counterpart, or address zero if
- * the wallet doesn't have an L2 counterpart (or is not known to be a token lock wallet).
- */
- function l2WalletAddress(address _l1Wallet) external view returns (address);
-}
diff --git a/packages/contracts/contracts/staking/IL1Staking.sol b/packages/contracts/contracts/staking/IL1Staking.sol
deleted file mode 100644
index 4a446f787..000000000
--- a/packages/contracts/contracts/staking/IL1Staking.sol
+++ /dev/null
@@ -1,18 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity >=0.6.12 <0.8.0;
-pragma abicoder v2;
-
-import { IStaking } from "./IStaking.sol";
-import { IL1StakingBase } from "./IL1StakingBase.sol";
-
-/**
- * @title Interface for the L1 Staking contract
- * @notice This is the interface that should be used when interacting with the L1 Staking contract.
- * It extends the IStaking interface with the functions that are specific to L1, adding the transfer tools
- * to send stake and delegation to L2.
- * @dev Note that L1Staking doesn't actually inherit this interface. This is because of
- * the custom setup of the Staking contract where part of the functionality is implemented
- * in a separate contract (StakingExtension) to which calls are delegated through the fallback function.
- */
-interface IL1Staking is IStaking, IL1StakingBase {}
diff --git a/packages/contracts/contracts/staking/IL1StakingBase.sol b/packages/contracts/contracts/staking/IL1StakingBase.sol
deleted file mode 100644
index fad2136c2..000000000
--- a/packages/contracts/contracts/staking/IL1StakingBase.sol
+++ /dev/null
@@ -1,148 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity >=0.6.12 <0.8.0;
-pragma abicoder v2;
-
-import { IL1GraphTokenLockTransferTool } from "./IL1GraphTokenLockTransferTool.sol";
-
-/**
- * @title Base interface for the L1Staking contract.
- * @notice This interface is used to define the transfer tools that are implemented in L1Staking.
- * @dev Note it includes only the L1-specific functionality, not the full IStaking interface.
- */
-interface IL1StakingBase {
- /// @dev Emitted when an indexer transfers their stake to L2.
- /// This can happen several times as indexers can transfer partial stake.
- event IndexerStakeTransferredToL2(
- address indexed indexer,
- address indexed l2Indexer,
- uint256 transferredStakeTokens
- );
-
- /// @dev Emitted when a delegator transfers their delegation to L2
- event DelegationTransferredToL2(
- address indexed delegator,
- address indexed l2Delegator,
- address indexed indexer,
- address l2Indexer,
- uint256 transferredDelegationTokens
- );
-
- /// @dev Emitted when the L1GraphTokenLockTransferTool is set
- event L1GraphTokenLockTransferToolSet(address l1GraphTokenLockTransferTool);
-
- /// @dev Emitted when a delegator unlocks their tokens ahead of time because the indexer has transferred to L2
- event StakeDelegatedUnlockedDueToL2Transfer(address indexed indexer, address indexed delegator);
-
- /**
- * @notice Set the L1GraphTokenLockTransferTool contract address
- * @dev This function can only be called by the governor.
- * @param _l1GraphTokenLockTransferTool Address of the L1GraphTokenLockTransferTool contract
- */
- function setL1GraphTokenLockTransferTool(IL1GraphTokenLockTransferTool _l1GraphTokenLockTransferTool) external;
-
- /**
- * @notice Send an indexer's stake to L2.
- * @dev This function can only be called by the indexer (not an operator).
- * It will validate that the remaining stake is sufficient to cover all the allocated
- * stake, so the indexer might have to close some allocations before transferring.
- * It will also check that the indexer's stake is not locked for withdrawal.
- * Since the indexer address might be an L1-only contract, the function takes a beneficiary
- * address that will be the indexer's address in L2.
- * The caller must provide an amount of ETH to use for the L2 retryable ticket, that
- * must be at least `_maxSubmissionCost + _gasPriceBid * _maxGas`.
- * @param _l2Beneficiary Address of the indexer in L2. If the indexer has previously transferred stake, this must match the previously-used value.
- * @param _amount Amount of stake GRT to transfer to L2
- * @param _maxGas Max gas to use for the L2 retryable ticket
- * @param _gasPriceBid Gas price bid for the L2 retryable ticket
- * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket
- */
- function transferStakeToL2(
- address _l2Beneficiary,
- uint256 _amount,
- uint256 _maxGas,
- uint256 _gasPriceBid,
- uint256 _maxSubmissionCost
- ) external payable;
-
- /**
- * @notice Send an indexer's stake to L2, from a GraphTokenLockWallet vesting contract.
- * @dev This function can only be called by the indexer (not an operator).
- * It will validate that the remaining stake is sufficient to cover all the allocated
- * stake, so the indexer might have to close some allocations before transferring.
- * It will also check that the indexer's stake is not locked for withdrawal.
- * The L2 beneficiary for the stake will be determined by calling the L1GraphTokenLockTransferTool contract,
- * so the caller must have previously transferred tokens through that first
- * (see GIP-0046 for details: https://forum.thegraph.com/t/4023).
- * The ETH for the L2 gas will be pulled from the L1GraphTokenLockTransferTool, so the owner of
- * the GraphTokenLockWallet must have previously deposited at least `_maxSubmissionCost + _gasPriceBid * _maxGas`
- * ETH into the L1GraphTokenLockTransferTool contract (using its depositETH function).
- * @param _amount Amount of stake GRT to transfer to L2
- * @param _maxGas Max gas to use for the L2 retryable ticket
- * @param _gasPriceBid Gas price bid for the L2 retryable ticket
- * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket
- */
- function transferLockedStakeToL2(
- uint256 _amount,
- uint256 _maxGas,
- uint256 _gasPriceBid,
- uint256 _maxSubmissionCost
- ) external;
-
- /**
- * @notice Send a delegator's delegated tokens to L2
- * @dev This function can only be called by the delegator.
- * This function will validate that the indexer has transferred their stake using transferStakeToL2,
- * and that the delegation is not locked for undelegation.
- * Since the delegator's address might be an L1-only contract, the function takes a beneficiary
- * address that will be the delegator's address in L2.
- * The caller must provide an amount of ETH to use for the L2 retryable ticket, that
- * must be at least `_maxSubmissionCost + _gasPriceBid * _maxGas`.
- * @param _indexer Address of the indexer (in L1, before transferring to L2)
- * @param _l2Beneficiary Address of the delegator in L2
- * @param _maxGas Max gas to use for the L2 retryable ticket
- * @param _gasPriceBid Gas price bid for the L2 retryable ticket
- * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket
- */
- function transferDelegationToL2(
- address _indexer,
- address _l2Beneficiary,
- uint256 _maxGas,
- uint256 _gasPriceBid,
- uint256 _maxSubmissionCost
- ) external payable;
-
- /**
- * @notice Send a delegator's delegated tokens to L2, for a GraphTokenLockWallet vesting contract
- * @dev This function can only be called by the delegator.
- * This function will validate that the indexer has transferred their stake using transferStakeToL2,
- * and that the delegation is not locked for undelegation.
- * The L2 beneficiary for the delegation will be determined by calling the L1GraphTokenLockTransferTool contract,
- * so the caller must have previously transferred tokens through that first
- * (see GIP-0046 for details: https://forum.thegraph.com/t/4023).
- * The ETH for the L2 gas will be pulled from the L1GraphTokenLockTransferTool, so the owner of
- * the GraphTokenLockWallet must have previously deposited at least `_maxSubmissionCost + _gasPriceBid * _maxGas`
- * ETH into the L1GraphTokenLockTransferTool contract (using its depositETH function).
- * @param _indexer Address of the indexer (in L1, before transferring to L2)
- * @param _maxGas Max gas to use for the L2 retryable ticket
- * @param _gasPriceBid Gas price bid for the L2 retryable ticket
- * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket
- */
- function transferLockedDelegationToL2(
- address _indexer,
- uint256 _maxGas,
- uint256 _gasPriceBid,
- uint256 _maxSubmissionCost
- ) external;
-
- /**
- * @notice Unlock a delegator's delegated tokens, if the indexer has transferred to L2
- * @dev This function can only be called by the delegator.
- * This function will validate that the indexer has transferred their stake using transferStakeToL2,
- * and that the indexer has no remaining stake in L1.
- * The tokens must previously be locked for undelegation by calling `undelegate()`,
- * and can be withdrawn with `withdrawDelegated()` immediately after calling this.
- * @param _indexer Address of the indexer (in L1, before transferring to L2)
- */
- function unlockDelegationToTransferredIndexer(address _indexer) external;
-}
diff --git a/packages/contracts/contracts/staking/IStaking.sol b/packages/contracts/contracts/staking/IStaking.sol
deleted file mode 100644
index a7d89feea..000000000
--- a/packages/contracts/contracts/staking/IStaking.sol
+++ /dev/null
@@ -1,18 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity >=0.6.12 <0.8.0 || 0.8.27;
-pragma abicoder v2;
-
-import { IStakingBase } from "./IStakingBase.sol";
-import { IStakingExtension } from "./IStakingExtension.sol";
-import { IMulticall } from "../base/IMulticall.sol";
-import { IManaged } from "../governance/IManaged.sol";
-
-/**
- * @title Interface for the Staking contract
- * @notice This is the interface that should be used when interacting with the Staking contract.
- * @dev Note that Staking doesn't actually inherit this interface. This is because of
- * the custom setup of the Staking contract where part of the functionality is implemented
- * in a separate contract (StakingExtension) to which calls are delegated through the fallback function.
- */
-interface IStaking is IStakingBase, IStakingExtension, IMulticall, IManaged {}
diff --git a/packages/contracts/contracts/staking/IStakingBase.sol b/packages/contracts/contracts/staking/IStakingBase.sol
deleted file mode 100644
index 588144b2a..000000000
--- a/packages/contracts/contracts/staking/IStakingBase.sol
+++ /dev/null
@@ -1,400 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity >=0.6.12 <0.8.0 || 0.8.27;
-pragma abicoder v2;
-
-import { IStakingData } from "./IStakingData.sol";
-
-/**
- * @title Base interface for the Staking contract.
- * @dev This interface includes only what's implemented in the base Staking contract.
- * It does not include the L1 and L2 specific functionality. It also does not include
- * several functions that are implemented in the StakingExtension contract, and are called
- * via delegatecall through the fallback function. See IStaking.sol for an interface
- * that includes the full functionality.
- */
-interface IStakingBase is IStakingData {
- /**
- * @dev Emitted when `indexer` stakes `tokens` amount.
- */
- event StakeDeposited(address indexed indexer, uint256 tokens);
-
- /**
- * @dev Emitted when `indexer` unstaked and locked `tokens` amount until `until` block.
- */
- event StakeLocked(address indexed indexer, uint256 tokens, uint256 until);
-
- /**
- * @dev Emitted when `indexer` withdrew `tokens` staked.
- */
- event StakeWithdrawn(address indexed indexer, uint256 tokens);
-
- /**
- * @dev Emitted when `indexer` allocated `tokens` amount to `subgraphDeploymentID`
- * during `epoch`.
- * `allocationID` indexer derived address used to identify the allocation.
- * `metadata` additional information related to the allocation.
- */
- event AllocationCreated(
- address indexed indexer,
- bytes32 indexed subgraphDeploymentID,
- uint256 epoch,
- uint256 tokens,
- address indexed allocationID,
- bytes32 metadata
- );
-
- /**
- * @dev Emitted when `indexer` close an allocation in `epoch` for `allocationID`.
- * An amount of `tokens` get unallocated from `subgraphDeploymentID`.
- * This event also emits the POI (proof of indexing) submitted by the indexer.
- * `isPublic` is true if the sender was someone other than the indexer.
- */
- event AllocationClosed(
- address indexed indexer,
- bytes32 indexed subgraphDeploymentID,
- uint256 epoch,
- uint256 tokens,
- address indexed allocationID,
- address sender,
- bytes32 poi,
- bool isPublic
- );
-
- /**
- * @dev Emitted when `indexer` collects a rebate on `subgraphDeploymentID` for `allocationID`.
- * `epoch` is the protocol epoch the rebate was collected on
- * The rebate is for `tokens` amount which are being provided by `assetHolder`; `queryFees`
- * is the amount up for rebate after `curationFees` are distributed and `protocolTax` is burnt.
- * `queryRebates` is the amount distributed to the `indexer` with `delegationFees` collected
- * and sent to the delegation pool.
- */
- event RebateCollected(
- address assetHolder,
- address indexed indexer,
- bytes32 indexed subgraphDeploymentID,
- address indexed allocationID,
- uint256 epoch,
- uint256 tokens,
- uint256 protocolTax,
- uint256 curationFees,
- uint256 queryFees,
- uint256 queryRebates,
- uint256 delegationRewards
- );
-
- /**
- * @dev Emitted when `indexer` update the delegation parameters for its delegation pool.
- */
- event DelegationParametersUpdated(
- address indexed indexer,
- uint32 indexingRewardCut,
- uint32 queryFeeCut,
- uint32 __DEPRECATED_cooldownBlocks // solhint-disable-line var-name-mixedcase
- );
-
- /**
- * @dev Emitted when `indexer` set `operator` access.
- */
- event SetOperator(address indexed indexer, address indexed operator, bool allowed);
-
- /**
- * @dev Emitted when `indexer` set an address to receive rewards.
- */
- event SetRewardsDestination(address indexed indexer, address indexed destination);
-
- /**
- * @dev Emitted when `extensionImpl` was set as the address of the StakingExtension contract
- * to which extended functionality is delegated.
- */
- event ExtensionImplementationSet(address indexed extensionImpl);
-
- /**
- * @dev Possible states an allocation can be.
- * States:
- * - Null = indexer == address(0)
- * - Active = not Null && tokens > 0
- * - Closed = Active && closedAtEpoch != 0
- */
- enum AllocationState {
- Null,
- Active,
- Closed
- }
-
- /**
- * @notice Initialize this contract.
- * @param _controller Address of the controller that manages this contract
- * @param _minimumIndexerStake Minimum amount of tokens that an indexer must stake
- * @param _thawingPeriod Number of blocks that tokens get locked after unstaking
- * @param _protocolPercentage Percentage of query fees that are burned as protocol fee (in PPM)
- * @param _curationPercentage Percentage of query fees that are given to curators (in PPM)
- * @param _maxAllocationEpochs The maximum number of epochs that an allocation can be active
- * @param _delegationUnbondingPeriod The period in epochs that tokens get locked after undelegating
- * @param _delegationRatio The ratio between an indexer's own stake and the delegation they can use
- * @param _rebatesParameters Alpha and lambda parameters for rebates function
- * @param _extensionImpl Address of the StakingExtension implementation
- */
- function initialize(
- address _controller,
- uint256 _minimumIndexerStake,
- uint32 _thawingPeriod,
- uint32 _protocolPercentage,
- uint32 _curationPercentage,
- uint32 _maxAllocationEpochs,
- uint32 _delegationUnbondingPeriod,
- uint32 _delegationRatio,
- RebatesParameters calldata _rebatesParameters,
- address _extensionImpl
- ) external;
-
- /**
- * @notice Set the address of the StakingExtension implementation.
- * @dev This function can only be called by the governor.
- * @param _extensionImpl Address of the StakingExtension implementation
- */
- function setExtensionImpl(address _extensionImpl) external;
-
- /**
- * @notice Set the address of the counterpart (L1 or L2) staking contract.
- * @dev This function can only be called by the governor.
- * @param _counterpart Address of the counterpart staking contract in the other chain, without any aliasing.
- */
- function setCounterpartStakingAddress(address _counterpart) external;
-
- /**
- * @notice Set the minimum stake needed to be an Indexer
- * @dev This function can only be called by the governor.
- * @param _minimumIndexerStake Minimum amount of tokens that an indexer must stake
- */
- function setMinimumIndexerStake(uint256 _minimumIndexerStake) external;
-
- /**
- * @notice Set the number of blocks that tokens get locked after unstaking
- * @dev This function can only be called by the governor.
- * @param _thawingPeriod Number of blocks that tokens get locked after unstaking
- */
- function setThawingPeriod(uint32 _thawingPeriod) external;
-
- /**
- * @notice Set the curation percentage of query fees sent to curators.
- * @dev This function can only be called by the governor.
- * @param _percentage Percentage of query fees sent to curators
- */
- function setCurationPercentage(uint32 _percentage) external;
-
- /**
- * @notice Set a protocol percentage to burn when collecting query fees.
- * @dev This function can only be called by the governor.
- * @param _percentage Percentage of query fees to burn as protocol fee
- */
- function setProtocolPercentage(uint32 _percentage) external;
-
- /**
- * @notice Set the max time allowed for indexers to allocate on a subgraph
- * before others are allowed to close the allocation.
- * @dev This function can only be called by the governor.
- * @param _maxAllocationEpochs Allocation duration limit in epochs
- */
- function setMaxAllocationEpochs(uint32 _maxAllocationEpochs) external;
-
- /**
- * @notice Set the rebate parameters
- * @dev This function can only be called by the governor.
- * @param _alphaNumerator Numerator of `alpha`
- * @param _alphaDenominator Denominator of `alpha`
- * @param _lambdaNumerator Numerator of `lambda`
- * @param _lambdaDenominator Denominator of `lambda`
- */
- function setRebateParameters(
- uint32 _alphaNumerator,
- uint32 _alphaDenominator,
- uint32 _lambdaNumerator,
- uint32 _lambdaDenominator
- ) external;
-
- /**
- * @notice Authorize or unauthorize an address to be an operator for the caller.
- * @param _operator Address to authorize or unauthorize
- * @param _allowed Whether the operator is authorized or not
- */
- function setOperator(address _operator, bool _allowed) external;
-
- /**
- * @notice Deposit tokens on the indexer's stake.
- * The amount staked must be over the minimumIndexerStake.
- * @param _tokens Amount of tokens to stake
- */
- function stake(uint256 _tokens) external;
-
- /**
- * @notice Deposit tokens on the Indexer stake, on behalf of the Indexer.
- * The amount staked must be over the minimumIndexerStake.
- * @param _indexer Address of the indexer
- * @param _tokens Amount of tokens to stake
- */
- function stakeTo(address _indexer, uint256 _tokens) external;
-
- /**
- * @notice Unstake tokens from the indexer stake, lock them until the thawing period expires.
- * @dev NOTE: The function accepts an amount greater than the currently staked tokens.
- * If that happens, it will try to unstake the max amount of tokens it can.
- * The reason for this behaviour is to avoid time conditions while the transaction
- * is in flight.
- * @param _tokens Amount of tokens to unstake
- */
- function unstake(uint256 _tokens) external;
-
- /**
- * @notice Withdraw indexer tokens once the thawing period has passed.
- */
- function withdraw() external;
-
- /**
- * @notice Set the destination where to send rewards for an indexer.
- * @param _destination Rewards destination address. If set to zero, rewards will be restaked
- */
- function setRewardsDestination(address _destination) external;
-
- /**
- * @notice Set the delegation parameters for the caller.
- * @param _indexingRewardCut Percentage of indexing rewards left for the indexer
- * @param _queryFeeCut Percentage of query fees left for the indexer
- */
- function setDelegationParameters(
- uint32 _indexingRewardCut,
- uint32 _queryFeeCut,
- uint32 // _cooldownBlocks, deprecated
- ) external;
-
- /**
- * @notice Allocate available tokens to a subgraph deployment.
- * @param _subgraphDeploymentID ID of the SubgraphDeployment where tokens will be allocated
- * @param _tokens Amount of tokens to allocate
- * @param _allocationID The allocation identifier
- * @param _metadata IPFS hash for additional information about the allocation
- * @param _proof A 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationID)`
- */
- function allocate(
- bytes32 _subgraphDeploymentID,
- uint256 _tokens,
- address _allocationID,
- bytes32 _metadata,
- bytes calldata _proof
- ) external;
-
- /**
- * @notice Allocate available tokens to a subgraph deployment from and indexer's stake.
- * The caller must be the indexer or the indexer's operator.
- * @param _indexer Indexer address to allocate funds from.
- * @param _subgraphDeploymentID ID of the SubgraphDeployment where tokens will be allocated
- * @param _tokens Amount of tokens to allocate
- * @param _allocationID The allocation identifier
- * @param _metadata IPFS hash for additional information about the allocation
- * @param _proof A 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationID)`
- */
- function allocateFrom(
- address _indexer,
- bytes32 _subgraphDeploymentID,
- uint256 _tokens,
- address _allocationID,
- bytes32 _metadata,
- bytes calldata _proof
- ) external;
-
- /**
- * @notice Close an allocation and free the staked tokens.
- * To be eligible for rewards a proof of indexing must be presented.
- * Presenting a bad proof is subject to slashable condition.
- * To opt out of rewards set _poi to 0x0
- * @param _allocationID The allocation identifier
- * @param _poi Proof of indexing submitted for the allocated period
- */
- function closeAllocation(address _allocationID, bytes32 _poi) external;
-
- /**
- * @notice Collect query fees from state channels and assign them to an allocation.
- * Funds received are only accepted from a valid sender.
- * @dev To avoid reverting on the withdrawal from channel flow this function will:
- * 1) Accept calls with zero tokens.
- * 2) Accept calls after an allocation passed the dispute period, in that case, all
- * the received tokens are burned.
- * @param _tokens Amount of tokens to collect
- * @param _allocationID Allocation where the tokens will be assigned
- */
- function collect(uint256 _tokens, address _allocationID) external;
-
- /**
- * @notice Return true if operator is allowed for indexer.
- * @param _operator Address of the operator
- * @param _indexer Address of the indexer
- * @return True if operator is allowed for indexer, false otherwise
- */
- function isOperator(address _operator, address _indexer) external view returns (bool);
-
- /**
- * @notice Getter that returns if an indexer has any stake.
- * @param _indexer Address of the indexer
- * @return True if indexer has staked tokens
- */
- function hasStake(address _indexer) external view returns (bool);
-
- /**
- * @notice Get the total amount of tokens staked by the indexer.
- * @param _indexer Address of the indexer
- * @return Amount of tokens staked by the indexer
- */
- function getIndexerStakedTokens(address _indexer) external view returns (uint256);
-
- /**
- * @notice Get the total amount of tokens available to use in allocations.
- * This considers the indexer stake and delegated tokens according to delegation ratio
- * @param _indexer Address of the indexer
- * @return Amount of tokens available to allocate including delegation
- */
- function getIndexerCapacity(address _indexer) external view returns (uint256);
-
- /**
- * @notice Return the allocation by ID.
- * @param _allocationID Address used as allocation identifier
- * @return Allocation data
- */
- function getAllocation(address _allocationID) external view returns (Allocation memory);
-
- /**
- * @dev New function to get the allocation data for the rewards manager
- * @dev Note that this is only to make tests pass, as the staking contract with
- * this changes will never get deployed. HorizonStaking is taking it's place.
- */
- function getAllocationData(
- address _allocationID
- ) external view returns (bool, address, bytes32, uint256, uint256, uint256);
-
- /**
- * @dev New function to get the allocation active status for the rewards manager
- * @dev Note that this is only to make tests pass, as the staking contract with
- * this changes will never get deployed. HorizonStaking is taking it's place.
- */
- function isActiveAllocation(address _allocationID) external view returns (bool);
-
- /**
- * @notice Return the current state of an allocation
- * @param _allocationID Allocation identifier
- * @return AllocationState enum with the state of the allocation
- */
- function getAllocationState(address _allocationID) external view returns (AllocationState);
-
- /**
- * @notice Return if allocationID is used.
- * @param _allocationID Address used as signer by the indexer for an allocation
- * @return True if allocationID already used
- */
- function isAllocation(address _allocationID) external view returns (bool);
-
- /**
- * @notice Return the total amount of tokens allocated to subgraph.
- * @param _subgraphDeploymentID Deployment ID for the subgraph
- * @return Total tokens allocated to subgraph
- */
- function getSubgraphAllocatedTokens(bytes32 _subgraphDeploymentID) external view returns (uint256);
-}
diff --git a/packages/contracts/contracts/staking/IStakingData.sol b/packages/contracts/contracts/staking/IStakingData.sol
deleted file mode 100644
index 149e3b8a6..000000000
--- a/packages/contracts/contracts/staking/IStakingData.sol
+++ /dev/null
@@ -1,59 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity >=0.6.12 <0.8.0 || 0.8.27;
-
-/**
- * @title Staking Data interface
- * @dev This interface defines some structures used by the Staking contract.
- */
-interface IStakingData {
- /**
- * @dev Allocate GRT tokens for the purpose of serving queries of a subgraph deployment
- * An allocation is created in the allocate() function and closed in closeAllocation()
- */
- struct Allocation {
- address indexer;
- bytes32 subgraphDeploymentID;
- uint256 tokens; // Tokens allocated to a SubgraphDeployment
- uint256 createdAtEpoch; // Epoch when it was created
- uint256 closedAtEpoch; // Epoch when it was closed
- uint256 collectedFees; // Collected fees for the allocation
- uint256 __DEPRECATED_effectiveAllocation; // solhint-disable-line var-name-mixedcase
- uint256 accRewardsPerAllocatedToken; // Snapshot used for reward calc
- uint256 distributedRebates; // Collected rebates that have been rebated
- }
-
- // -- Delegation Data --
-
- /**
- * @dev Delegation pool information. One per indexer.
- */
- struct DelegationPool {
- uint32 __DEPRECATED_cooldownBlocks; // solhint-disable-line var-name-mixedcase
- uint32 indexingRewardCut; // in PPM
- uint32 queryFeeCut; // in PPM
- uint256 updatedAtBlock; // Block when the pool was last updated
- uint256 tokens; // Total tokens as pool reserves
- uint256 shares; // Total shares minted in the pool
- mapping(address => Delegation) delegators; // Mapping of delegator => Delegation
- }
-
- /**
- * @dev Individual delegation data of a delegator in a pool.
- */
- struct Delegation {
- uint256 shares; // Shares owned by a delegator in the pool
- uint256 tokensLocked; // Tokens locked for undelegation
- uint256 tokensLockedUntil; // Epoch when locked tokens can be withdrawn
- }
-
- /**
- * @dev Rebates parameters. Used to avoid stack too deep errors in Staking initialize function.
- */
- struct RebatesParameters {
- uint32 alphaNumerator;
- uint32 alphaDenominator;
- uint32 lambdaNumerator;
- uint32 lambdaDenominator;
- }
-}
diff --git a/packages/contracts/contracts/staking/IStakingExtension.sol b/packages/contracts/contracts/staking/IStakingExtension.sol
deleted file mode 100644
index 9a998aac5..000000000
--- a/packages/contracts/contracts/staking/IStakingExtension.sol
+++ /dev/null
@@ -1,292 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity >=0.6.12 <0.8.0 || 0.8.27;
-pragma abicoder v2;
-
-import { IStakingData } from "./IStakingData.sol";
-import { IStakes } from "./libs/IStakes.sol";
-
-/**
- * @title Interface for the StakingExtension contract
- * @dev This interface defines the events and functions implemented
- * in the StakingExtension contract, which is used to extend the functionality
- * of the Staking contract while keeping it within the 24kB mainnet size limit.
- * In particular, this interface includes delegation functions and various storage
- * getters.
- */
-interface IStakingExtension is IStakingData {
- /**
- * @dev DelegationPool struct as returned by delegationPools(), since
- * the original DelegationPool in IStakingData.sol contains a nested mapping.
- */
- struct DelegationPoolReturn {
- uint32 __DEPRECATED_cooldownBlocks; // solhint-disable-line var-name-mixedcase
- uint32 indexingRewardCut; // in PPM
- uint32 queryFeeCut; // in PPM
- uint256 updatedAtBlock; // Block when the pool was last updated
- uint256 tokens; // Total tokens as pool reserves
- uint256 shares; // Total shares minted in the pool
- }
-
- /**
- * @dev Emitted when `delegator` delegated `tokens` to the `indexer`, the delegator
- * gets `shares` for the delegation pool proportionally to the tokens staked.
- */
- event StakeDelegated(address indexed indexer, address indexed delegator, uint256 tokens, uint256 shares);
-
- /**
- * @dev Emitted when `delegator` undelegated `tokens` from `indexer`.
- * Tokens get locked for withdrawal after a period of time.
- */
- event StakeDelegatedLocked(
- address indexed indexer,
- address indexed delegator,
- uint256 tokens,
- uint256 shares,
- uint256 until
- );
-
- /**
- * @dev Emitted when `delegator` withdrew delegated `tokens` from `indexer`.
- */
- event StakeDelegatedWithdrawn(address indexed indexer, address indexed delegator, uint256 tokens);
-
- /**
- * @dev Emitted when `indexer` was slashed for a total of `tokens` amount.
- * Tracks `reward` amount of tokens given to `beneficiary`.
- */
- event StakeSlashed(address indexed indexer, uint256 tokens, uint256 reward, address beneficiary);
-
- /**
- * @dev Emitted when `caller` set `slasher` address as `allowed` to slash stakes.
- */
- event SlasherUpdate(address indexed caller, address indexed slasher, bool allowed);
-
- /**
- * @notice Set the delegation ratio.
- * If set to 10 it means the indexer can use up to 10x the indexer staked amount
- * from their delegated tokens
- * @dev This function is only callable by the governor
- * @param _delegationRatio Delegation capacity multiplier
- */
- function setDelegationRatio(uint32 _delegationRatio) external;
-
- /**
- * @notice Set the time, in epochs, a Delegator needs to wait to withdraw tokens after undelegating.
- * @dev This function is only callable by the governor
- * @param _delegationUnbondingPeriod Period in epochs to wait for token withdrawals after undelegating
- */
- function setDelegationUnbondingPeriod(uint32 _delegationUnbondingPeriod) external;
-
- /**
- * @notice Set a delegation tax percentage to burn when delegated funds are deposited.
- * @dev This function is only callable by the governor
- * @param _percentage Percentage of delegated tokens to burn as delegation tax, expressed in parts per million
- */
- function setDelegationTaxPercentage(uint32 _percentage) external;
-
- /**
- * @notice Set or unset an address as allowed slasher.
- * @dev This function can only be called by the governor.
- * @param _slasher Address of the party allowed to slash indexers
- * @param _allowed True if slasher is allowed
- */
- function setSlasher(address _slasher, bool _allowed) external;
-
- /**
- * @notice Delegate tokens to an indexer.
- * @param _indexer Address of the indexer to which tokens are delegated
- * @param _tokens Amount of tokens to delegate
- * @return Amount of shares issued from the delegation pool
- */
- function delegate(address _indexer, uint256 _tokens) external returns (uint256);
-
- /**
- * @notice Undelegate tokens from an indexer. Tokens will be locked for the unbonding period.
- * @param _indexer Address of the indexer to which tokens had been delegated
- * @param _shares Amount of shares to return and undelegate tokens
- * @return Amount of tokens returned for the shares of the delegation pool
- */
- function undelegate(address _indexer, uint256 _shares) external returns (uint256);
-
- /**
- * @notice Withdraw undelegated tokens once the unbonding period has passed, and optionally
- * re-delegate to a new indexer.
- * @param _indexer Withdraw available tokens delegated to indexer
- * @param _newIndexer Re-delegate to indexer address if non-zero, withdraw if zero address
- */
- function withdrawDelegated(address _indexer, address _newIndexer) external returns (uint256);
-
- /**
- * @notice Slash the indexer stake. Delegated tokens are not subject to slashing.
- * @dev Can only be called by the slasher role.
- * @param _indexer Address of indexer to slash
- * @param _tokens Amount of tokens to slash from the indexer stake
- * @param _reward Amount of reward tokens to send to a beneficiary
- * @param _beneficiary Address of a beneficiary to receive a reward for the slashing
- */
- function slash(address _indexer, uint256 _tokens, uint256 _reward, address _beneficiary) external;
-
- /**
- * @notice Return the delegation from a delegator to an indexer.
- * @param _indexer Address of the indexer where funds have been delegated
- * @param _delegator Address of the delegator
- * @return Delegation data
- */
- function getDelegation(address _indexer, address _delegator) external view returns (Delegation memory);
-
- /**
- * @notice Return whether the delegator has delegated to the indexer.
- * @param _indexer Address of the indexer where funds have been delegated
- * @param _delegator Address of the delegator
- * @return True if delegator has tokens delegated to the indexer
- */
- function isDelegator(address _indexer, address _delegator) external view returns (bool);
-
- /**
- * @notice Returns amount of delegated tokens ready to be withdrawn after unbonding period.
- * @param _delegation Delegation of tokens from delegator to indexer
- * @return Amount of tokens to withdraw
- */
- function getWithdraweableDelegatedTokens(Delegation memory _delegation) external view returns (uint256);
-
- /**
- * @notice Getter for the delegationRatio, i.e. the delegation capacity multiplier:
- * If delegation ratio is 100, and an Indexer has staked 5 GRT,
- * then they can use up to 500 GRT from the delegated stake
- * @return Delegation ratio
- */
- function delegationRatio() external view returns (uint32);
-
- /**
- * @notice Getter for delegationUnbondingPeriod:
- * Time in epochs a delegator needs to wait to withdraw delegated stake
- * @return Delegation unbonding period in epochs
- */
- function delegationUnbondingPeriod() external view returns (uint32);
-
- /**
- * @notice Getter for delegationTaxPercentage:
- * Percentage of tokens to tax a delegation deposit, expressed in parts per million
- * @return Delegation tax percentage in parts per million
- */
- function delegationTaxPercentage() external view returns (uint32);
-
- /**
- * @notice Getter for delegationPools[_indexer]:
- * gets the delegation pool structure for a particular indexer.
- * @param _indexer Address of the indexer for which to query the delegation pool
- * @return Delegation pool as a DelegationPoolReturn struct
- */
- function delegationPools(address _indexer) external view returns (DelegationPoolReturn memory);
-
- /**
- * @notice Getter for operatorAuth[_indexer][_maybeOperator]:
- * returns true if the operator is authorized to operate on behalf of the indexer.
- * @param _indexer The indexer address for which to query authorization
- * @param _maybeOperator The address that may or may not be an operator
- * @return True if the operator is authorized to operate on behalf of the indexer
- */
- function operatorAuth(address _indexer, address _maybeOperator) external view returns (bool);
-
- /**
- * @notice Getter for rewardsDestination[_indexer]:
- * returns the address where the indexer's rewards are sent.
- * @param _indexer The indexer address for which to query the rewards destination
- * @return The address where the indexer's rewards are sent, zero if none is set in which case rewards are re-staked
- */
- function rewardsDestination(address _indexer) external view returns (address);
-
- /**
- * @notice Getter for subgraphAllocations[_subgraphDeploymentId]:
- * returns the amount of tokens allocated to a subgraph deployment.
- * @param _subgraphDeploymentId The subgraph deployment for which to query the allocations
- * @return The amount of tokens allocated to the subgraph deployment
- */
- function subgraphAllocations(bytes32 _subgraphDeploymentId) external view returns (uint256);
-
- /**
- * @notice Getter for slashers[_maybeSlasher]:
- * returns true if the address is a slasher, i.e. an entity that can slash indexers
- * @param _maybeSlasher Address for which to check the slasher role
- * @return True if the address is a slasher
- */
- function slashers(address _maybeSlasher) external view returns (bool);
-
- /**
- * @notice Getter for minimumIndexerStake: the minimum
- * amount of GRT that an indexer needs to stake.
- * @return Minimum indexer stake in GRT
- */
- function minimumIndexerStake() external view returns (uint256);
-
- /**
- * @notice Getter for thawingPeriod: the time in blocks an
- * indexer needs to wait to unstake tokens.
- * @return Thawing period in blocks
- */
- function thawingPeriod() external view returns (uint32);
-
- /**
- * @notice Getter for curationPercentage: the percentage of
- * query fees that are distributed to curators.
- * @return Curation percentage in parts per million
- */
- function curationPercentage() external view returns (uint32);
-
- /**
- * @notice Getter for protocolPercentage: the percentage of
- * query fees that are burned as protocol fees.
- * @return Protocol percentage in parts per million
- */
- function protocolPercentage() external view returns (uint32);
-
- /**
- * @notice Getter for maxAllocationEpochs: the maximum time in epochs
- * that an allocation can be open before anyone is allowed to close it. This
- * also caps the effective allocation when sending the allocation's query fees
- * to the rebate pool.
- * @return Maximum allocation period in epochs
- */
- function maxAllocationEpochs() external view returns (uint32);
-
- /**
- * @notice Getter for the numerator of the rebates alpha parameter
- * @return Alpha numerator
- */
- function alphaNumerator() external view returns (uint32);
-
- /**
- * @notice Getter for the denominator of the rebates alpha parameter
- * @return Alpha denominator
- */
- function alphaDenominator() external view returns (uint32);
-
- /**
- * @notice Getter for the numerator of the rebates lambda parameter
- * @return Lambda numerator
- */
- function lambdaNumerator() external view returns (uint32);
-
- /**
- * @notice Getter for the denominator of the rebates lambda parameter
- * @return Lambda denominator
- */
- function lambdaDenominator() external view returns (uint32);
-
- /**
- * @notice Getter for stakes[_indexer]:
- * gets the stake information for an indexer as a IStakes.Indexer struct.
- * @param _indexer Indexer address for which to query the stake information
- * @return Stake information for the specified indexer, as a IStakes.Indexer struct
- */
- function stakes(address _indexer) external view returns (IStakes.Indexer memory);
-
- /**
- * @notice Getter for allocations[_allocationID]:
- * gets an allocation's information as an IStakingData.Allocation struct.
- * @param _allocationID Allocation ID for which to query the allocation information
- * @return The specified allocation, as an IStakingData.Allocation struct
- */
- function allocations(address _allocationID) external view returns (IStakingData.Allocation memory);
-}
diff --git a/packages/contracts/contracts/staking/L1Staking.sol b/packages/contracts/contracts/staking/L1Staking.sol
index 78ce8bc63..b2a00c57e 100644
--- a/packages/contracts/contracts/staking/L1Staking.sol
+++ b/packages/contracts/contracts/staking/L1Staking.sol
@@ -3,23 +3,27 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable function-max-lines, gas-strict-inequalities
+
import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol";
-import { ITokenGateway } from "../arbitrum/ITokenGateway.sol";
+import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol";
import { Staking } from "./Staking.sol";
import { Stakes } from "./libs/Stakes.sol";
-import { IStakes } from "./libs/IStakes.sol";
-import { IStakingData } from "./IStakingData.sol";
+import { IStakes } from "@graphprotocol/interfaces/contracts/contracts/staking/libs/IStakes.sol";
+import { IStakingData } from "@graphprotocol/interfaces/contracts/contracts/staking/IStakingData.sol";
import { L1StakingV1Storage } from "./L1StakingStorage.sol";
-import { IGraphToken } from "../token/IGraphToken.sol";
-import { IL1StakingBase } from "./IL1StakingBase.sol";
+import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol";
+import { IL1StakingBase } from "@graphprotocol/interfaces/contracts/contracts/staking/IL1StakingBase.sol";
import { MathUtils } from "./libs/MathUtils.sol";
-import { IL1GraphTokenLockTransferTool } from "./IL1GraphTokenLockTransferTool.sol";
-import { IL2StakingTypes } from "../l2/staking/IL2StakingTypes.sol";
+import { IL1GraphTokenLockTransferTool } from "@graphprotocol/interfaces/contracts/contracts/staking/IL1GraphTokenLockTransferTool.sol";
+import { IL2StakingTypes } from "@graphprotocol/interfaces/contracts/contracts/l2/staking/IL2StakingTypes.sol";
/**
* @title L1Staking contract
- * @dev This contract is the L1 variant of the Staking contract. It adds functions
+ * @author Edge & Node
+ * @notice This contract is the L1 variant of the Staking contract. It adds functions
* to send an indexer's stake to L2, and to send delegation to L2 as well.
*/
contract L1Staking is Staking, L1StakingV1Storage, IL1StakingBase {
@@ -36,9 +40,7 @@ contract L1Staking is Staking, L1StakingV1Storage, IL1StakingBase {
}
/**
- * @notice Set the L1GraphTokenLockTransferTool contract address
- * @dev This function can only be called by the governor.
- * @param _l1GraphTokenLockTransferTool Address of the L1GraphTokenLockTransferTool contract
+ * @inheritdoc IL1StakingBase
*/
function setL1GraphTokenLockTransferTool(
IL1GraphTokenLockTransferTool _l1GraphTokenLockTransferTool
@@ -48,21 +50,7 @@ contract L1Staking is Staking, L1StakingV1Storage, IL1StakingBase {
}
/**
- * @notice Send an indexer's stake to L2.
- * @dev This function can only be called by the indexer (not an operator).
- * It will validate that the remaining stake is sufficient to cover all the allocated
- * stake, so the indexer might have to close some allocations before transferring.
- * It will also check that the indexer's stake is not locked for withdrawal.
- * Since the indexer address might be an L1-only contract, the function takes a beneficiary
- * address that will be the indexer's address in L2.
- * The caller must provide an amount of ETH to use for the L2 retryable ticket, that
- * must be at _exactly_ `_maxSubmissionCost + _gasPriceBid * _maxGas`.
- * Any refunds for the submission fee or L2 gas will be lost.
- * @param _l2Beneficiary Address of the indexer in L2. If the indexer has previously transferred stake, this must match the previously-used value.
- * @param _amount Amount of stake GRT to transfer to L2
- * @param _maxGas Max gas to use for the L2 retryable ticket
- * @param _gasPriceBid Gas price bid for the L2 retryable ticket
- * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket
+ * @inheritdoc IL1StakingBase
*/
function transferStakeToL2(
address _l2Beneficiary,
@@ -76,22 +64,7 @@ contract L1Staking is Staking, L1StakingV1Storage, IL1StakingBase {
}
/**
- * @notice Send an indexer's stake to L2, from a GraphTokenLockWallet vesting contract.
- * @dev This function can only be called by the indexer (not an operator).
- * It will validate that the remaining stake is sufficient to cover all the allocated
- * stake, so the indexer might have to close some allocations before transferring.
- * It will also check that the indexer's stake is not locked for withdrawal.
- * The L2 beneficiary for the stake will be determined by calling the L1GraphTokenLockTransferTool contract,
- * so the caller must have previously transferred tokens through that first
- * (see GIP-0046 for details: https://forum.thegraph.com/t/4023).
- * The ETH for the L2 gas will be pulled from the L1GraphTokenLockTransferTool, so the owner of
- * the GraphTokenLockWallet must have previously deposited at least `_maxSubmissionCost + _gasPriceBid * _maxGas`
- * ETH into the L1GraphTokenLockTransferTool contract (using its depositETH function).
- * Any refunds for the submission fee or L2 gas will be lost.
- * @param _amount Amount of stake GRT to transfer to L2
- * @param _maxGas Max gas to use for the L2 retryable ticket
- * @param _gasPriceBid Gas price bid for the L2 retryable ticket
- * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket
+ * @inheritdoc IL1StakingBase
*/
function transferLockedStakeToL2(
uint256 _amount,
@@ -109,20 +82,7 @@ contract L1Staking is Staking, L1StakingV1Storage, IL1StakingBase {
}
/**
- * @notice Send a delegator's delegated tokens to L2
- * @dev This function can only be called by the delegator.
- * This function will validate that the indexer has transferred their stake using transferStakeToL2,
- * and that the delegation is not locked for undelegation.
- * Since the delegator's address might be an L1-only contract, the function takes a beneficiary
- * address that will be the delegator's address in L2.
- * The caller must provide an amount of ETH to use for the L2 retryable ticket, that
- * must be _exactly_ `_maxSubmissionCost + _gasPriceBid * _maxGas`.
- * Any refunds for the submission fee or L2 gas will be lost.
- * @param _indexer Address of the indexer (in L1, before transferring to L2)
- * @param _l2Beneficiary Address of the delegator in L2
- * @param _maxGas Max gas to use for the L2 retryable ticket
- * @param _gasPriceBid Gas price bid for the L2 retryable ticket
- * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket
+ * @inheritdoc IL1StakingBase
*/
function transferDelegationToL2(
address _indexer,
@@ -144,21 +104,7 @@ contract L1Staking is Staking, L1StakingV1Storage, IL1StakingBase {
}
/**
- * @notice Send a delegator's delegated tokens to L2, for a GraphTokenLockWallet vesting contract
- * @dev This function can only be called by the delegator.
- * This function will validate that the indexer has transferred their stake using transferStakeToL2,
- * and that the delegation is not locked for undelegation.
- * The L2 beneficiary for the delegation will be determined by calling the L1GraphTokenLockTransferTool contract,
- * so the caller must have previously transferred tokens through that first
- * (see GIP-0046 for details: https://forum.thegraph.com/t/4023).
- * The ETH for the L2 gas will be pulled from the L1GraphTokenLockTransferTool, so the owner of
- * the GraphTokenLockWallet must have previously deposited at least `_maxSubmissionCost + _gasPriceBid * _maxGas`
- * ETH into the L1GraphTokenLockTransferTool contract (using its depositETH function).
- * Any refunds for the submission fee or L2 gas will be lost.
- * @param _indexer Address of the indexer (in L1, before transferring to L2)
- * @param _maxGas Max gas to use for the L2 retryable ticket
- * @param _gasPriceBid Gas price bid for the L2 retryable ticket
- * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket
+ * @inheritdoc IL1StakingBase
*/
function transferLockedDelegationToL2(
address _indexer,
@@ -184,13 +130,7 @@ contract L1Staking is Staking, L1StakingV1Storage, IL1StakingBase {
}
/**
- * @notice Unlock a delegator's delegated tokens, if the indexer has transferred to L2
- * @dev This function can only be called by the delegator.
- * This function will validate that the indexer has transferred their stake using transferStakeToL2,
- * and that the indexer has no remaining stake in L1.
- * The tokens must previously be locked for undelegation by calling `undelegate()`,
- * and can be withdrawn with `withdrawDelegated()` immediately after calling this.
- * @param _indexer Address of the indexer (in L1, before transferring to L2)
+ * @inheritdoc IL1StakingBase
*/
function unlockDelegationToTransferredIndexer(address _indexer) external override notPartialPaused {
require(
@@ -209,13 +149,14 @@ contract L1Staking is Staking, L1StakingV1Storage, IL1StakingBase {
}
/**
- * @dev Implements sending an indexer's stake to L2.
+ * @notice Implements sending an indexer's stake to L2.
* This function can only be called by the indexer (not an operator).
* It will validate that the remaining stake is sufficient to cover all the allocated
* stake, so the indexer might have to close some allocations before transferring.
* It will also check that the indexer's stake is not locked for withdrawal.
* Since the indexer address might be an L1-only contract, the function takes a beneficiary
* address that will be the indexer's address in L2.
+ * @param _indexer Address of the indexer transferring stake
* @param _l2Beneficiary Address of the indexer in L2. If the indexer has previously transferred stake, this must match the previously-used value.
* @param _amount Amount of stake GRT to transfer to L2
* @param _maxGas Max gas to use for the L2 retryable ticket
@@ -282,12 +223,13 @@ contract L1Staking is Staking, L1StakingV1Storage, IL1StakingBase {
}
/**
- * @dev Implements sending a delegator's delegated tokens to L2.
+ * @notice Implements sending a delegator's delegated tokens to L2.
* This function can only be called by the delegator.
* This function will validate that the indexer has transferred their stake using transferStakeToL2,
* and that the delegation is not locked for undelegation.
* Since the delegator's address might be an L1-only contract, the function takes a beneficiary
* address that will be the delegator's address in L2.
+ * @param _delegator Address of the delegator transferring delegation
* @param _indexer Address of the indexer (in L1, before transferring to L2)
* @param _l2Beneficiary Address of the delegator in L2
* @param _maxGas Max gas to use for the L2 retryable ticket
@@ -352,7 +294,7 @@ contract L1Staking is Staking, L1StakingV1Storage, IL1StakingBase {
}
/**
- * @dev Sends a message to the L2Staking with some extra data,
+ * @notice Sends a message to the L2Staking with some extra data,
* also sending some tokens, using the L1GraphTokenGateway.
* @param _tokens Amount of tokens to send to L2
* @param _maxGas Max gas to use for the L2 retryable ticket
diff --git a/packages/contracts/contracts/staking/L1StakingStorage.sol b/packages/contracts/contracts/staking/L1StakingStorage.sol
index bd7a7f0ee..5ee76f633 100644
--- a/packages/contracts/contracts/staking/L1StakingStorage.sol
+++ b/packages/contracts/contracts/staking/L1StakingStorage.sol
@@ -1,18 +1,22 @@
// SPDX-License-Identifier: GPL-2.0-or-later
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable named-parameters-mapping
+
pragma solidity ^0.7.6;
pragma abicoder v2;
-import { IL1GraphTokenLockTransferTool } from "./IL1GraphTokenLockTransferTool.sol";
+import { IL1GraphTokenLockTransferTool } from "@graphprotocol/interfaces/contracts/contracts/staking/IL1GraphTokenLockTransferTool.sol";
/**
* @title L1StakingV1Storage
+ * @author Edge & Node
* @notice This contract holds all the L1-specific storage variables for the L1Staking contract, version 1
* @dev When adding new versions, make sure to move the gap to the new version and
* reduce the size of the gap accordingly.
*/
abstract contract L1StakingV1Storage {
- /// If an indexer has transferred to L2, this mapping will hold the indexer's address in L2
+ /// @notice If an indexer has transferred to L2, this mapping will hold the indexer's address in L2
mapping(address => address) public indexerTransferredToL2;
/// @dev For locked indexers/delegations, this contract holds the mapping of L1 to L2 addresses
IL1GraphTokenLockTransferTool internal l1GraphTokenLockTransferTool;
diff --git a/packages/contracts/contracts/staking/Staking.sol b/packages/contracts/contracts/staking/Staking.sol
index c61fe0ded..4b7551d02 100644
--- a/packages/contracts/contracts/staking/Staking.sol
+++ b/packages/contracts/contracts/staking/Staking.sol
@@ -3,27 +3,31 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable function-max-lines, gas-strict-inequalities
+
import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol";
import { ECDSA } from "@openzeppelin/contracts/cryptography/ECDSA.sol";
import { Multicall } from "../base/Multicall.sol";
import { GraphUpgradeable } from "../upgrades/GraphUpgradeable.sol";
import { TokenUtils } from "../utils/TokenUtils.sol";
-import { IGraphToken } from "../token/IGraphToken.sol";
-import { IStakingBase } from "./IStakingBase.sol";
+import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol";
+import { IStakingBase } from "@graphprotocol/interfaces/contracts/contracts/staking/IStakingBase.sol";
import { StakingV4Storage } from "./StakingStorage.sol";
import { MathUtils } from "./libs/MathUtils.sol";
import { Stakes } from "./libs/Stakes.sol";
-import { IStakes } from "./libs/IStakes.sol";
+import { IStakes } from "@graphprotocol/interfaces/contracts/contracts/staking/libs/IStakes.sol";
import { Managed } from "../governance/Managed.sol";
-import { ICuration } from "../curation/ICuration.sol";
-import { IRewardsManager } from "../rewards/IRewardsManager.sol";
+import { ICuration } from "@graphprotocol/interfaces/contracts/contracts/curation/ICuration.sol";
+import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol";
import { StakingExtension } from "./StakingExtension.sol";
import { LibExponential } from "./libs/Exponential.sol";
/**
* @title Base Staking contract
- * @dev The Staking contract allows Indexers to Stake on Subgraphs. Indexers Stake by creating
+ * @author Edge & Node
+ * @notice The Staking contract allows Indexers to Stake on Subgraphs. Indexers Stake by creating
* Allocations on a Subgraph. It also allows Delegators to Delegate towards an Indexer. The
* contract also has the slashing functionality.
* The contract is abstract as the implementation that is deployed depends on each layer: L1Staking on mainnet
@@ -45,9 +49,11 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
* @dev This function does not return to its internal call site, it will return directly to the
* external caller.
*/
- // solhint-disable-next-line payable-fallback, no-complex-fallback
fallback() external {
+ // solhint-disable-previous-line payable-fallback, no-complex-fallback
+
require(_implementation() != address(0), "only through proxy");
+
// solhint-disable-next-line no-inline-assembly
assembly {
// (a) get free memory pointer
@@ -80,17 +86,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Initialize this contract.
- * @param _controller Address of the controller that manages this contract
- * @param _minimumIndexerStake Minimum amount of tokens that an indexer must stake
- * @param _thawingPeriod Number of epochs that tokens get locked after unstaking
- * @param _protocolPercentage Percentage of query fees that are burned as protocol fee (in PPM)
- * @param _curationPercentage Percentage of query fees that are given to curators (in PPM)
- * @param _maxAllocationEpochs The maximum number of epochs that an allocation can be active
- * @param _delegationUnbondingPeriod The period in epochs that tokens get locked after undelegating
- * @param _delegationRatio The ratio between an indexer's own stake and the delegation they can use
- * @param _rebatesParameters Alpha and lambda parameters for rebates function
- * @param _extensionImpl Address of the StakingExtension implementation
+ * @inheritdoc IStakingBase
*/
function initialize(
address _controller,
@@ -140,9 +136,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Set the address of the StakingExtension implementation.
- * @dev This function can only be called by the governor.
- * @param _extensionImpl Address of the StakingExtension implementation
+ * @inheritdoc IStakingBase
*/
function setExtensionImpl(address _extensionImpl) external override onlyGovernor {
extensionImpl = _extensionImpl;
@@ -150,9 +144,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Set the address of the counterpart (L1 or L2) staking contract.
- * @dev This function can only be called by the governor.
- * @param _counterpart Address of the counterpart staking contract in the other chain, without any aliasing.
+ * @inheritdoc IStakingBase
*/
function setCounterpartStakingAddress(address _counterpart) external override onlyGovernor {
counterpartStakingAddress = _counterpart;
@@ -160,52 +152,42 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Set the minimum stake required to be an indexer.
- * @param _minimumIndexerStake Minimum indexer stake
+ * @inheritdoc IStakingBase
*/
function setMinimumIndexerStake(uint256 _minimumIndexerStake) external override onlyGovernor {
_setMinimumIndexerStake(_minimumIndexerStake);
}
/**
- * @notice Set the thawing period for unstaking.
- * @param _thawingPeriod Period in blocks to wait for token withdrawals after unstaking
+ * @inheritdoc IStakingBase
*/
function setThawingPeriod(uint32 _thawingPeriod) external override onlyGovernor {
_setThawingPeriod(_thawingPeriod);
}
/**
- * @notice Set the curation percentage of query fees sent to curators.
- * @param _percentage Percentage of query fees sent to curators
+ * @inheritdoc IStakingBase
*/
function setCurationPercentage(uint32 _percentage) external override onlyGovernor {
_setCurationPercentage(_percentage);
}
/**
- * @notice Set a protocol percentage to burn when collecting query fees.
- * @param _percentage Percentage of query fees to burn as protocol fee
+ * @inheritdoc IStakingBase
*/
function setProtocolPercentage(uint32 _percentage) external override onlyGovernor {
_setProtocolPercentage(_percentage);
}
/**
- * @notice Set the max time allowed for indexers to allocate on a subgraph
- * before others are allowed to close the allocation.
- * @param _maxAllocationEpochs Allocation duration limit in epochs
+ * @inheritdoc IStakingBase
*/
function setMaxAllocationEpochs(uint32 _maxAllocationEpochs) external override onlyGovernor {
_setMaxAllocationEpochs(_maxAllocationEpochs);
}
/**
- * @dev Set the rebate parameters.
- * @param _alphaNumerator Numerator of `alpha` in the rebates function
- * @param _alphaDenominator Denominator of `alpha` in the rebates function
- * @param _lambdaNumerator Numerator of `lambda` in the rebates function
- * @param _lambdaDenominator Denominator of `lambda` in the rebates function
+ * @inheritdoc IStakingBase
*/
function setRebateParameters(
uint32 _alphaNumerator,
@@ -217,9 +199,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Authorize or unauthorize an address to be an operator for the caller.
- * @param _operator Address to authorize or unauthorize
- * @param _allowed Whether the operator is authorized or not
+ * @inheritdoc IStakingBase
*/
function setOperator(address _operator, bool _allowed) external override {
require(_operator != msg.sender, "operator == sender");
@@ -228,21 +208,18 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Deposit tokens on the indexer's stake.
- * The amount staked must be over the minimumIndexerStake.
- * @param _tokens Amount of tokens to stake
+ * @inheritdoc IStakingBase
*/
function stake(uint256 _tokens) external override {
stakeTo(msg.sender, _tokens);
}
/**
- * @notice Unstake tokens from the indexer stake, lock them until the thawing period expires.
+ * @inheritdoc IStakingBase
* @dev NOTE: The function accepts an amount greater than the currently staked tokens.
* If that happens, it will try to unstake the max amount of tokens it can.
* The reason for this behaviour is to avoid time conditions while the transaction
* is in flight.
- * @param _tokens Amount of tokens to unstake
*/
function unstake(uint256 _tokens) external override notPartialPaused {
address indexer = msg.sender;
@@ -271,15 +248,14 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Withdraw indexer tokens once the thawing period has passed.
+ * @inheritdoc IStakingBase
*/
function withdraw() external override notPaused {
_withdraw(msg.sender);
}
/**
- * @notice Set the destination where to send rewards for an indexer.
- * @param _destination Rewards destination address. If set to zero, rewards will be restaked
+ * @inheritdoc IStakingBase
*/
function setRewardsDestination(address _destination) external override {
__rewardsDestination[msg.sender] = _destination;
@@ -287,12 +263,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Allocate available tokens to a subgraph deployment.
- * @param _subgraphDeploymentID ID of the SubgraphDeployment where tokens will be allocated
- * @param _tokens Amount of tokens to allocate
- * @param _allocationID The allocation identifier
- * @param _metadata IPFS hash for additional information about the allocation
- * @param _proof A 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationID)`
+ * @inheritdoc IStakingBase
*/
function allocate(
bytes32 _subgraphDeploymentID,
@@ -305,14 +276,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Allocate available tokens to a subgraph deployment from and indexer's stake.
- * The caller must be the indexer or the indexer's operator.
- * @param _indexer Indexer address to allocate funds from.
- * @param _subgraphDeploymentID ID of the SubgraphDeployment where tokens will be allocated
- * @param _tokens Amount of tokens to allocate
- * @param _allocationID The allocation identifier
- * @param _metadata IPFS hash for additional information about the allocation
- * @param _proof A 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationID)`
+ * @inheritdoc IStakingBase
*/
function allocateFrom(
address _indexer,
@@ -326,25 +290,20 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Close an allocation and free the staked tokens.
- * To be eligible for rewards a proof of indexing must be presented.
+ * @inheritdoc IStakingBase
+ * @dev To be eligible for rewards a proof of indexing must be presented.
* Presenting a bad proof is subject to slashable condition.
* To opt out of rewards set _poi to 0x0
- * @param _allocationID The allocation identifier
- * @param _poi Proof of indexing submitted for the allocated period
*/
function closeAllocation(address _allocationID, bytes32 _poi) external override notPaused {
_closeAllocation(_allocationID, _poi);
}
/**
- * @dev Collect and rebate query fees from state channels to the indexer
- * To avoid reverting on the withdrawal from channel flow this function will accept calls with zero tokens.
- * We use an exponential rebate formula to calculate the amount of tokens to rebate to the indexer.
+ * @inheritdoc IStakingBase
+ * @dev We use an exponential rebate formula to calculate the amount of tokens to rebate to the indexer.
* This implementation allows collecting multiple times on the same allocation, keeping track of the
* total amount rebated, the total amount collected and compensating the indexer for the difference.
- * @param _tokens Amount of tokens to collect
- * @param _allocationID Allocation where the tokens will be assigned
*/
function collect(uint256 _tokens, address _allocationID) external override {
// Allocation identifier validation
@@ -447,36 +406,28 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Return if allocationID is used.
- * @param _allocationID Address used as signer by the indexer for an allocation
- * @return True if allocationID already used
+ * @inheritdoc IStakingBase
*/
function isAllocation(address _allocationID) external view override returns (bool) {
return _getAllocationState(_allocationID) != AllocationState.Null;
}
/**
- * @notice Getter that returns if an indexer has any stake.
- * @param _indexer Address of the indexer
- * @return True if indexer has staked tokens
+ * @inheritdoc IStakingBase
*/
function hasStake(address _indexer) external view override returns (bool) {
return __stakes[_indexer].tokensStaked > 0;
}
/**
- * @notice Return the allocation by ID.
- * @param _allocationID Address used as allocation identifier
- * @return Allocation data
+ * @inheritdoc IStakingBase
*/
function getAllocation(address _allocationID) external view override returns (Allocation memory) {
return __allocations[_allocationID];
}
/**
- * @dev New function to get the allocation data for the rewards manager
- * @dev Note that this is only to make tests pass, as the staking contract with
- * this changes will never get deployed. HorizonStaking is taking it's place.
+ * @inheritdoc IStakingBase
*/
function getAllocationData(
address _allocationID
@@ -495,46 +446,35 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev New function to get the allocation active status for the rewards manager
- * @dev Note that this is only to make tests pass, as the staking contract with
- * this changes will never get deployed. HorizonStaking is taking it's place.
+ * @inheritdoc IStakingBase
*/
function isActiveAllocation(address _allocationID) external view override returns (bool) {
return _getAllocationState(_allocationID) == AllocationState.Active;
}
/**
- * @notice Return the current state of an allocation
- * @param _allocationID Allocation identifier
- * @return AllocationState enum with the state of the allocation
+ * @inheritdoc IStakingBase
*/
function getAllocationState(address _allocationID) external view override returns (AllocationState) {
return _getAllocationState(_allocationID);
}
/**
- * @notice Return the total amount of tokens allocated to subgraph.
- * @param _subgraphDeploymentID Deployment ID for the subgraph
- * @return Total tokens allocated to subgraph
+ * @inheritdoc IStakingBase
*/
function getSubgraphAllocatedTokens(bytes32 _subgraphDeploymentID) external view override returns (uint256) {
return __subgraphAllocations[_subgraphDeploymentID];
}
/**
- * @notice Get the total amount of tokens staked by the indexer.
- * @param _indexer Address of the indexer
- * @return Amount of tokens staked by the indexer
+ * @inheritdoc IStakingBase
*/
function getIndexerStakedTokens(address _indexer) external view override returns (uint256) {
return __stakes[_indexer].tokensStaked;
}
/**
- * @notice Deposit tokens on the Indexer stake, on behalf of the Indexer.
- * The amount staked must be over the minimumIndexerStake.
- * @param _indexer Address of the indexer
- * @param _tokens Amount of tokens to stake
+ * @inheritdoc IStakingBase
*/
function stakeTo(address _indexer, uint256 _tokens) public override notPartialPaused {
require(_tokens > 0, "!tokens");
@@ -547,9 +487,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Set the delegation parameters for the caller.
- * @param _indexingRewardCut Percentage of indexing rewards left for the indexer
- * @param _queryFeeCut Percentage of query fees left for the indexer
+ * @inheritdoc IStakingBase
*/
function setDelegationParameters(
uint32 _indexingRewardCut,
@@ -560,10 +498,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Get the total amount of tokens available to use in allocations.
- * This considers the indexer stake and delegated tokens according to delegation ratio
- * @param _indexer Address of the indexer
- * @return Amount of tokens available to allocate including delegation
+ * @inheritdoc IStakingBase
*/
function getIndexerCapacity(address _indexer) public view override returns (uint256) {
IStakes.Indexer memory indexerStake = __stakes[_indexer];
@@ -576,17 +511,14 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @notice Return true if operator is allowed for indexer.
- * @param _operator Address of the operator
- * @param _indexer Address of the indexer
- * @return True if operator is allowed for indexer, false otherwise
+ * @inheritdoc IStakingBase
*/
function isOperator(address _operator, address _indexer) public view override returns (bool) {
return __operatorAuth[_indexer][_operator];
}
/**
- * @dev Internal: Set the minimum indexer stake required.
+ * @notice Internal: Set the minimum indexer stake required.
* @param _minimumIndexerStake Minimum indexer stake
*/
function _setMinimumIndexerStake(uint256 _minimumIndexerStake) private {
@@ -596,7 +528,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Internal: Set the thawing period for unstaking.
+ * @notice Internal: Set the thawing period for unstaking.
* @param _thawingPeriod Period in blocks to wait for token withdrawals after unstaking
*/
function _setThawingPeriod(uint32 _thawingPeriod) private {
@@ -606,7 +538,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Internal: Set the curation percentage of query fees sent to curators.
+ * @notice Internal: Set the curation percentage of query fees sent to curators.
* @param _percentage Percentage of query fees sent to curators
*/
function _setCurationPercentage(uint32 _percentage) private {
@@ -617,7 +549,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Internal: Set a protocol percentage to burn when collecting query fees.
+ * @notice Internal: Set a protocol percentage to burn when collecting query fees.
* @param _percentage Percentage of query fees to burn as protocol fee
*/
function _setProtocolPercentage(uint32 _percentage) private {
@@ -628,7 +560,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Internal: Set the max time allowed for indexers stake on allocations.
+ * @notice Internal: Set the max time allowed for indexers stake on allocations.
* @param _maxAllocationEpochs Allocation duration limit in epochs
*/
function _setMaxAllocationEpochs(uint32 _maxAllocationEpochs) private {
@@ -637,7 +569,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Set the rebate parameters.
+ * @notice Set the rebate parameters.
* @param _alphaNumerator Numerator of `alpha` in the rebates function
* @param _alphaDenominator Denominator of `alpha` in the rebates function
* @param _lambdaNumerator Numerator of `lambda` in the rebates function
@@ -660,7 +592,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Set the delegation parameters for a particular indexer.
+ * @notice Set the delegation parameters for a particular indexer.
* @param _indexer Indexer to set delegation parameters
* @param _indexingRewardCut Percentage of indexing rewards left for delegators
* @param _queryFeeCut Percentage of query fees left for delegators
@@ -681,7 +613,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Stake tokens on the indexer.
+ * @notice Stake tokens on the indexer.
* This function does not check minimum indexer stake requirement to allow
* to be called by functions that increase the stake when collecting rewards
* without reverting
@@ -704,7 +636,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Withdraw indexer tokens once the thawing period has passed.
+ * @notice Withdraw indexer tokens once the thawing period has passed.
* @param _indexer Address of indexer to withdraw funds from
*/
function _withdraw(address _indexer) private {
@@ -719,7 +651,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Allocate available tokens to a subgraph deployment.
+ * @notice Allocate available tokens to a subgraph deployment.
* @param _indexer Indexer address to allocate funds from.
* @param _subgraphDeploymentID ID of the SubgraphDeployment where tokens will be allocated
* @param _tokens Amount of tokens to allocate
@@ -794,7 +726,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Close an allocation and free the staked tokens.
+ * @notice Close an allocation and free the staked tokens.
* @param _allocationID The allocation identifier
* @param _poi Proof of indexing submitted for the allocated period
*/
@@ -861,7 +793,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Collect the delegation rewards for query fees.
+ * @notice Collect the delegation rewards for query fees.
* This function will assign the collected fees to the delegation pool.
* @param _indexer Indexer to which the tokens to distribute are related
* @param _tokens Total tokens received used to calculate the amount of fees to collect
@@ -879,7 +811,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Collect the delegation rewards for indexing.
+ * @notice Collect the delegation rewards for indexing.
* This function will assign the collected fees to the delegation pool.
* @param _indexer Indexer to which the tokens to distribute are related
* @param _tokens Total tokens received used to calculate the amount of fees to collect
@@ -897,7 +829,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Collect the curation fees for a subgraph deployment from an amount of tokens.
+ * @notice Collect the curation fees for a subgraph deployment from an amount of tokens.
* This function transfer curation fees to the Curation contract by calling Curation.collect
* @param _graphToken Token to collect
* @param _subgraphDeploymentID Subgraph deployment to which the curation fees are related
@@ -926,7 +858,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
if (curationFees > 0) {
// Transfer and call collect()
// This function transfer tokens to a trusted protocol contracts
- // Then we call collect() to do the transfer Bookkeeping
+ // Then we call collect() to do the transfer bookkeeping
rewardsManager().onSubgraphSignalUpdate(_subgraphDeploymentID);
TokenUtils.pushTokens(_graphToken, address(curation), curationFees);
curation.collect(_subgraphDeploymentID, curationFees);
@@ -937,7 +869,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Collect tax to burn for an amount of tokens.
+ * @notice Collect tax to burn for an amount of tokens.
* @param _graphToken Token to burn
* @param _tokens Total tokens received used to calculate the amount of tax to collect
* @param _percentage Percentage of tokens to burn as tax
@@ -953,7 +885,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Triggers an update of rewards due to a change in allocations.
+ * @notice Triggers an update of rewards due to a change in allocations.
* @param _subgraphDeploymentID Subgraph deployment updated
* @return Accumulated rewards per allocated token for the subgraph deployment
*/
@@ -966,7 +898,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Assign rewards for the closed allocation to indexer and delegators.
+ * @notice Assign rewards for the closed allocation to indexer and delegators.
* @param _allocationID Allocation
* @param _indexer Address of the indexer that did the allocation
*/
@@ -993,7 +925,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Send rewards to the appropriate destination.
+ * @notice Send rewards to the appropriate destination.
* @param _graphToken Graph token
* @param _amount Number of rewards tokens
* @param _beneficiary Address of the beneficiary of rewards
@@ -1013,7 +945,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Check if the caller is authorized to operate on behalf of
+ * @notice Check if the caller is authorized to operate on behalf of
* an indexer (i.e. the caller is the indexer or an operator)
* @param _indexer Indexer address
* @return True if the caller is authorized to operate on behalf of the indexer
@@ -1023,7 +955,7 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
}
/**
- * @dev Return the current state of an allocation
+ * @notice Return the current state of an allocation
* @param _allocationID Allocation identifier
* @return AllocationState enum with the state of the allocation
*/
diff --git a/packages/contracts/contracts/staking/StakingExtension.sol b/packages/contracts/contracts/staking/StakingExtension.sol
index b06fbe894..8bde14add 100644
--- a/packages/contracts/contracts/staking/StakingExtension.sol
+++ b/packages/contracts/contracts/staking/StakingExtension.sol
@@ -3,20 +3,24 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-strict-inequalities
+
import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol";
import { StakingV4Storage } from "./StakingStorage.sol";
-import { IStakingExtension } from "./IStakingExtension.sol";
+import { IStakingExtension } from "@graphprotocol/interfaces/contracts/contracts/staking/IStakingExtension.sol";
import { TokenUtils } from "../utils/TokenUtils.sol";
-import { IGraphToken } from "../token/IGraphToken.sol";
+import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol";
import { GraphUpgradeable } from "../upgrades/GraphUpgradeable.sol";
-import { IStakes } from "./libs/IStakes.sol";
+import { IStakes } from "@graphprotocol/interfaces/contracts/contracts/staking/libs/IStakes.sol";
import { Stakes } from "./libs/Stakes.sol";
-import { IStakingData } from "./IStakingData.sol";
+import { IStakingData } from "@graphprotocol/interfaces/contracts/contracts/staking/IStakingData.sol";
import { MathUtils } from "./libs/MathUtils.sol";
/**
* @title StakingExtension contract
- * @dev This contract provides the logic to manage delegations and other Staking
+ * @author Edge & Node
+ * @notice This contract provides the logic to manage delegations and other Staking
* extension features (e.g. storage getters). It is meant to be called through delegatecall from the
* Staking contract, and is only kept separate to keep the Staking contract size
* within limits.
@@ -44,12 +48,14 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
* initialize() function, so it uses the same access control check to ensure it is
* being called by the Staking implementation as part of the proxy upgrade process.
* @param _delegationUnbondingPeriod Delegation unbonding period in blocks
+ * @param _cooldownBlocks Deprecated parameter (no longer used)
* @param _delegationRatio Delegation capacity multiplier (e.g. 10 means 10x the indexer stake)
* @param _delegationTaxPercentage Percentage of delegated tokens to burn as delegation tax, expressed in parts per million
*/
function initialize(
uint32 _delegationUnbondingPeriod,
- uint32, //_cooldownBlocks, deprecated
+ // solhint-disable-next-line no-unused-vars
+ uint32 _cooldownBlocks, // deprecated
uint32 _delegationRatio,
uint32 _delegationTaxPercentage
) external onlyImpl {
@@ -59,38 +65,28 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
}
/**
- * @notice Set a delegation tax percentage to burn when delegated funds are deposited.
- * @dev This function is only callable by the governor
- * @param _percentage Percentage of delegated tokens to burn as delegation tax, expressed in parts per million
+ * @inheritdoc IStakingExtension
*/
function setDelegationTaxPercentage(uint32 _percentage) external override onlyGovernor {
_setDelegationTaxPercentage(_percentage);
}
/**
- * @notice Set the delegation ratio.
- * If set to 10 it means the indexer can use up to 10x the indexer staked amount
- * from their delegated tokens
- * @dev This function is only callable by the governor
- * @param _delegationRatio Delegation capacity multiplier
+ * @inheritdoc IStakingExtension
*/
function setDelegationRatio(uint32 _delegationRatio) external override onlyGovernor {
_setDelegationRatio(_delegationRatio);
}
/**
- * @notice Set the time, in epochs, a Delegator needs to wait to withdraw tokens after undelegating.
- * @dev This function is only callable by the governor
- * @param _delegationUnbondingPeriod Period in epochs to wait for token withdrawals after undelegating
+ * @inheritdoc IStakingExtension
*/
function setDelegationUnbondingPeriod(uint32 _delegationUnbondingPeriod) external override onlyGovernor {
_setDelegationUnbondingPeriod(_delegationUnbondingPeriod);
}
/**
- * @notice Set or unset an address as allowed slasher.
- * @param _slasher Address of the party allowed to slash indexers
- * @param _allowed True if slasher is allowed
+ * @inheritdoc IStakingExtension
*/
function setSlasher(address _slasher, bool _allowed) external override onlyGovernor {
require(_slasher != address(0), "!slasher");
@@ -99,10 +95,7 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
}
/**
- * @notice Delegate tokens to an indexer.
- * @param _indexer Address of the indexer to which tokens are delegated
- * @param _tokens Amount of tokens to delegate
- * @return Amount of shares issued from the delegation pool
+ * @inheritdoc IStakingExtension
*/
function delegate(address _indexer, uint256 _tokens) external override notPartialPaused returns (uint256) {
address delegator = msg.sender;
@@ -115,32 +108,21 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
}
/**
- * @notice Undelegate tokens from an indexer. Tokens will be locked for the unbonding period.
- * @param _indexer Address of the indexer to which tokens had been delegated
- * @param _shares Amount of shares to return and undelegate tokens
- * @return Amount of tokens returned for the shares of the delegation pool
+ * @inheritdoc IStakingExtension
*/
function undelegate(address _indexer, uint256 _shares) external override notPartialPaused returns (uint256) {
return _undelegate(msg.sender, _indexer, _shares);
}
/**
- * @notice Withdraw undelegated tokens once the unbonding period has passed, and optionally
- * re-delegate to a new indexer.
- * @param _indexer Withdraw available tokens delegated to indexer
- * @param _newIndexer Re-delegate to indexer address if non-zero, withdraw if zero address
+ * @inheritdoc IStakingExtension
*/
function withdrawDelegated(address _indexer, address _newIndexer) external override notPaused returns (uint256) {
return _withdrawDelegated(msg.sender, _indexer, _newIndexer);
}
/**
- * @notice Slash the indexer stake. Delegated tokens are not subject to slashing.
- * @dev Can only be called by the slasher role.
- * @param _indexer Address of indexer to slash
- * @param _tokens Amount of tokens to slash from the indexer stake
- * @param _reward Amount of reward tokens to send to a beneficiary
- * @param _beneficiary Address of a beneficiary to receive a reward for the slashing
+ * @inheritdoc IStakingExtension
*/
function slash(
address _indexer,
@@ -188,48 +170,35 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
}
/**
- * @notice Return the delegation from a delegator to an indexer.
- * @param _indexer Address of the indexer where funds have been delegated
- * @param _delegator Address of the delegator
- * @return Delegation data
+ * @inheritdoc IStakingExtension
*/
function getDelegation(address _indexer, address _delegator) external view override returns (Delegation memory) {
return __delegationPools[_indexer].delegators[_delegator];
}
/**
- * @notice Getter for the delegationRatio, i.e. the delegation capacity multiplier:
- * If delegation ratio is 100, and an Indexer has staked 5 GRT,
- * then they can use up to 500 GRT from the delegated stake
- * @return Delegation ratio
+ * @inheritdoc IStakingExtension
*/
function delegationRatio() external view override returns (uint32) {
return __delegationRatio;
}
/**
- * @notice Getter for delegationUnbondingPeriod:
- * Time in epochs a delegator needs to wait to withdraw delegated stake
- * @return Delegation unbonding period in epochs
+ * @inheritdoc IStakingExtension
*/
function delegationUnbondingPeriod() external view override returns (uint32) {
return __delegationUnbondingPeriod;
}
/**
- * @notice Getter for delegationTaxPercentage:
- * Percentage of tokens to tax a delegation deposit, expressed in parts per million
- * @return Delegation tax percentage in parts per million
+ * @inheritdoc IStakingExtension
*/
function delegationTaxPercentage() external view override returns (uint32) {
return __delegationTaxPercentage;
}
/**
- * @notice Getter for delegationPools[_indexer]:
- * gets the delegation pool structure for a particular indexer.
- * @param _indexer Address of the indexer for which to query the delegation pool
- * @return Delegation pool as a DelegationPoolReturn struct
+ * @inheritdoc IStakingExtension
*/
function delegationPools(address _indexer) external view override returns (DelegationPoolReturn memory) {
DelegationPool storage pool = __delegationPools[_indexer];
@@ -245,120 +214,91 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
}
/**
- * @notice Getter for rewardsDestination[_indexer]:
- * returns the address where the indexer's rewards are sent.
- * @param _indexer The indexer address for which to query the rewards destination
- * @return The address where the indexer's rewards are sent, zero if none is set in which case rewards are re-staked
+ * @inheritdoc IStakingExtension
*/
function rewardsDestination(address _indexer) external view override returns (address) {
return __rewardsDestination[_indexer];
}
/**
- * @notice Getter for operatorAuth[_indexer][_maybeOperator]:
- * returns true if the operator is authorized to operate on behalf of the indexer.
- * @param _indexer The indexer address for which to query authorization
- * @param _maybeOperator The address that may or may not be an operator
- * @return True if the operator is authorized to operate on behalf of the indexer
+ * @inheritdoc IStakingExtension
*/
function operatorAuth(address _indexer, address _maybeOperator) external view override returns (bool) {
return __operatorAuth[_indexer][_maybeOperator];
}
/**
- * @notice Getter for subgraphAllocations[_subgraphDeploymentId]:
- * returns the amount of tokens allocated to a subgraph deployment.
- * @param _subgraphDeploymentId The subgraph deployment for which to query the allocations
- * @return The amount of tokens allocated to the subgraph deployment
+ * @inheritdoc IStakingExtension
*/
function subgraphAllocations(bytes32 _subgraphDeploymentId) external view override returns (uint256) {
return __subgraphAllocations[_subgraphDeploymentId];
}
/**
- * @notice Getter for slashers[_maybeSlasher]:
- * returns true if the address is a slasher, i.e. an entity that can slash indexers
- * @param _maybeSlasher Address for which to check the slasher role
- * @return True if the address is a slasher
+ * @inheritdoc IStakingExtension
*/
function slashers(address _maybeSlasher) external view override returns (bool) {
return __slashers[_maybeSlasher];
}
/**
- * @notice Getter for minimumIndexerStake: the minimum
- * amount of GRT that an indexer needs to stake.
- * @return Minimum indexer stake in GRT
+ * @inheritdoc IStakingExtension
*/
function minimumIndexerStake() external view override returns (uint256) {
return __minimumIndexerStake;
}
/**
- * @notice Getter for thawingPeriod: the time in blocks an
- * indexer needs to wait to unstake tokens.
- * @return Thawing period in blocks
+ * @inheritdoc IStakingExtension
*/
function thawingPeriod() external view override returns (uint32) {
return __thawingPeriod;
}
/**
- * @notice Getter for curationPercentage: the percentage of
- * query fees that are distributed to curators.
- * @return Curation percentage in parts per million
+ * @inheritdoc IStakingExtension
*/
function curationPercentage() external view override returns (uint32) {
return __curationPercentage;
}
/**
- * @notice Getter for protocolPercentage: the percentage of
- * query fees that are burned as protocol fees.
- * @return Protocol percentage in parts per million
+ * @inheritdoc IStakingExtension
*/
function protocolPercentage() external view override returns (uint32) {
return __protocolPercentage;
}
/**
- * @notice Getter for maxAllocationEpochs: the maximum time in epochs
- * that an allocation can be open before anyone is allowed to close it. This
- * also caps the effective allocation when sending the allocation's query fees
- * to the rebate pool.
- * @return Maximum allocation period in epochs
+ * @inheritdoc IStakingExtension
*/
function maxAllocationEpochs() external view override returns (uint32) {
return __maxAllocationEpochs;
}
/**
- * @notice Getter for the numerator of the rebates alpha parameter
- * @return Alpha numerator
+ * @inheritdoc IStakingExtension
*/
function alphaNumerator() external view override returns (uint32) {
return __alphaNumerator;
}
/**
- * @notice Getter for the denominator of the rebates alpha parameter
- * @return Alpha denominator
+ * @inheritdoc IStakingExtension
*/
function alphaDenominator() external view override returns (uint32) {
return __alphaDenominator;
}
/**
- * @notice Getter for the numerator of the rebates lambda parameter
- * @return Lambda numerator
+ * @inheritdoc IStakingExtension
*/
function lambdaNumerator() external view override returns (uint32) {
return __lambdaNumerator;
}
/**
- * @notice Getter for the denominator of the rebates lambda parameter
- * @return Lambda denominator
+ * @inheritdoc IStakingExtension
*/
function lambdaDenominator() external view override returns (uint32) {
return __lambdaDenominator;
@@ -369,35 +309,28 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
* gets the stake information for an indexer as an IStakes.Indexer struct.
* @param _indexer Indexer address for which to query the stake information
* @return Stake information for the specified indexer, as an IStakes.Indexer struct
+ * @inheritdoc IStakingExtension
*/
function stakes(address _indexer) external view override returns (IStakes.Indexer memory) {
return __stakes[_indexer];
}
/**
- * @notice Getter for allocations[_allocationID]:
- * gets an allocation's information as an IStakingData.Allocation struct.
- * @param _allocationID Allocation ID for which to query the allocation information
- * @return The specified allocation, as an IStakingData.Allocation struct
+ * @inheritdoc IStakingExtension
*/
function allocations(address _allocationID) external view override returns (IStakingData.Allocation memory) {
return __allocations[_allocationID];
}
/**
- * @notice Return whether the delegator has delegated to the indexer.
- * @param _indexer Address of the indexer where funds have been delegated
- * @param _delegator Address of the delegator
- * @return True if delegator has tokens delegated to the indexer
+ * @inheritdoc IStakingExtension
*/
function isDelegator(address _indexer, address _delegator) public view override returns (bool) {
return __delegationPools[_indexer].delegators[_delegator].shares > 0;
}
/**
- * @notice Returns amount of delegated tokens ready to be withdrawn after unbonding period.
- * @param _delegation Delegation of tokens from delegator to indexer
- * @return Amount of tokens to withdraw
+ * @inheritdoc IStakingExtension
*/
function getWithdraweableDelegatedTokens(Delegation memory _delegation) public view override returns (uint256) {
// There must be locked tokens and period passed
@@ -409,7 +342,7 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
}
/**
- * @dev Internal: Set a delegation tax percentage to burn when delegated funds are deposited.
+ * @notice Internal: Set a delegation tax percentage to burn when delegated funds are deposited.
* @param _percentage Percentage of delegated tokens to burn as delegation tax
*/
function _setDelegationTaxPercentage(uint32 _percentage) private {
@@ -420,7 +353,7 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
}
/**
- * @dev Internal: Set the delegation ratio.
+ * @notice Internal: Set the delegation ratio.
* If set to 10 it means the indexer can use up to 10x the indexer staked amount
* from their delegated tokens
* @param _delegationRatio Delegation capacity multiplier
@@ -431,7 +364,7 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
}
/**
- * @dev Internal: Set the period for undelegation of stake from indexer.
+ * @notice Internal: Set the period for undelegation of stake from indexer.
* @param _delegationUnbondingPeriod Period in epochs to wait for token withdrawals after undelegating
*/
function _setDelegationUnbondingPeriod(uint32 _delegationUnbondingPeriod) private {
@@ -441,7 +374,7 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
}
/**
- * @dev Delegate tokens to an indexer.
+ * @notice Delegate tokens to an indexer.
* @param _delegator Address of the delegator
* @param _indexer Address of the indexer to delegate tokens to
* @param _tokens Amount of tokens to delegate
@@ -480,7 +413,7 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
}
/**
- * @dev Undelegate tokens from an indexer.
+ * @notice Undelegate tokens from an indexer.
* @param _delegator Address of the delegator
* @param _indexer Address of the indexer where tokens had been delegated
* @param _shares Amount of shares to return and undelegate tokens
@@ -531,7 +464,7 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
}
/**
- * @dev Withdraw delegated tokens once the unbonding period has passed.
+ * @notice Withdraw delegated tokens once the unbonding period has passed.
* @param _delegator Delegator that is withdrawing tokens
* @param _indexer Withdraw available tokens delegated to indexer
* @param _delegateToIndexer Re-delegate to indexer address if non-zero, withdraw if zero address
@@ -570,7 +503,7 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
}
/**
- * @dev Collect tax to burn for an amount of tokens.
+ * @notice Collect tax to burn for an amount of tokens.
* @param _graphToken Token to burn
* @param _tokens Total tokens received used to calculate the amount of tax to collect
* @param _percentage Percentage of tokens to burn as tax
diff --git a/packages/contracts/contracts/staking/StakingStorage.sol b/packages/contracts/contracts/staking/StakingStorage.sol
index 949a63614..24f46009f 100644
--- a/packages/contracts/contracts/staking/StakingStorage.sol
+++ b/packages/contracts/contracts/staking/StakingStorage.sol
@@ -1,19 +1,24 @@
// SPDX-License-Identifier: GPL-2.0-or-later
+// solhint-disable one-contract-per-file
pragma solidity ^0.7.6;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable one-contract-per-file, max-states-count
+// solhint-disable named-parameters-mapping
+
import { Managed } from "../governance/Managed.sol";
-import { IStakingData } from "./IStakingData.sol";
-import { IStakes } from "./libs/IStakes.sol";
+import { IStakingData } from "@graphprotocol/interfaces/contracts/contracts/staking/IStakingData.sol";
+import { IStakes } from "@graphprotocol/interfaces/contracts/contracts/staking/libs/IStakes.sol";
/**
* @title StakingV1Storage
+ * @author Edge & Node
* @notice This contract holds all the storage variables for the Staking contract, version 1
* @dev Note that we use a double underscore prefix for variable names; this prefix identifies
* variables that used to be public but are now internal, getters can be found on StakingExtension.sol.
*/
-// solhint-disable-next-line max-states-count
contract StakingV1Storage is Managed {
// -- Staking --
@@ -54,7 +59,7 @@ contract StakingV1Storage is Managed {
/// @dev Subgraph Allocations: subgraphDeploymentID => tokens
mapping(bytes32 => uint256) internal __subgraphAllocations;
- // Rebate pools : epoch => Pool
+ /// @dev Deprecated rebate pools mapping (no longer used)
mapping(uint256 => uint256) private __DEPRECATED_rebates; // solhint-disable-line var-name-mixedcase
// -- Slashing --
@@ -95,6 +100,7 @@ contract StakingV1Storage is Managed {
/**
* @title StakingV2Storage
+ * @author Edge & Node
* @notice This contract holds all the storage variables for the Staking contract, version 2
* @dev Note that we use a double underscore prefix for variable names; this prefix identifies
* variables that used to be public but are now internal, getters can be found on StakingExtension.sol.
@@ -106,6 +112,7 @@ contract StakingV2Storage is StakingV1Storage {
/**
* @title StakingV3Storage
+ * @author Edge & Node
* @notice This contract holds all the storage variables for the base Staking contract, version 3.
*/
contract StakingV3Storage is StakingV2Storage {
@@ -117,13 +124,15 @@ contract StakingV3Storage is StakingV2Storage {
/**
* @title StakingV4Storage
+ * @author Edge & Node
* @notice This contract holds all the storage variables for the base Staking contract, version 4.
* @dev Note that it includes a storage gap - if adding future versions, make sure to move the gap
* to the new version and reduce the size of the gap accordingly.
*/
contract StakingV4Storage is StakingV3Storage {
- // Additional rebate parameters for exponential rebates
+ /// @dev Numerator for the lambda parameter in exponential rebate calculations
uint32 internal __lambdaNumerator;
+ /// @dev Denominator for the lambda parameter in exponential rebate calculations
uint32 internal __lambdaDenominator;
/// @dev Gap to allow adding variables in future upgrades (since L1Staking and L2Staking can have their own storage as well)
diff --git a/packages/contracts/contracts/staking/libs/Exponential.sol b/packages/contracts/contracts/staking/libs/Exponential.sol
index c9370342e..2b0222daa 100644
--- a/packages/contracts/contracts/staking/libs/Exponential.sol
+++ b/packages/contracts/contracts/staking/libs/Exponential.sol
@@ -6,13 +6,14 @@ import { LibFixedMath } from "./LibFixedMath.sol";
/**
* @title LibExponential library
+ * @author Edge & Node
* @notice A library to compute query fee rebates using an exponential formula
*/
library LibExponential {
/// @dev Maximum value of the exponent for which to compute the exponential before clamping to zero.
uint32 private constant MAX_EXPONENT = 15;
- /// @dev The exponential formula used to compute fee-based rewards for
+ /// @notice The exponential formula used to compute fee-based rewards for
/// staking pools in a given epoch. This function does not perform
/// bounds checking on the inputs, but the following conditions
/// need to be true:
diff --git a/packages/contracts/contracts/staking/libs/IStakes.sol b/packages/contracts/contracts/staking/libs/IStakes.sol
deleted file mode 100644
index 701336409..000000000
--- a/packages/contracts/contracts/staking/libs/IStakes.sol
+++ /dev/null
@@ -1,13 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-pragma abicoder v2;
-
-interface IStakes {
- struct Indexer {
- uint256 tokensStaked; // Tokens on the indexer stake (staked by the indexer)
- uint256 tokensAllocated; // Tokens used in allocations
- uint256 tokensLocked; // Tokens locked for withdrawal subject to thawing period
- uint256 tokensLockedUntil; // Block when locked tokens can be withdrawn
- }
-}
diff --git a/packages/contracts/contracts/staking/libs/LibFixedMath.sol b/packages/contracts/contracts/staking/libs/LibFixedMath.sol
index ae8c9b69e..55628ea6e 100644
--- a/packages/contracts/contracts/staking/libs/LibFixedMath.sol
+++ b/packages/contracts/contracts/staking/libs/LibFixedMath.sol
@@ -20,36 +20,49 @@
pragma solidity ^0.7.6;
-// solhint-disable indent
-/// @dev Signed, fixed-point, 127-bit precision math library.
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable function-max-lines, gas-strict-inequalities
+
+/**
+ * @title LibFixedMath
+ * @author Edge & Node
+ * @notice Signed, fixed-point, 127-bit precision math library
+ */
library LibFixedMath {
- // 1
+ /// @dev Fixed-point representation of 1
int256 private constant FIXED_1 = int256(0x0000000000000000000000000000000080000000000000000000000000000000);
- // 2**255
+ /// @dev Minimum fixed-point value (2**255)
int256 private constant MIN_FIXED_VAL = int256(0x8000000000000000000000000000000000000000000000000000000000000000);
- // 1^2 (in fixed-point)
+ /// @dev Fixed-point representation of 1^2
int256 private constant FIXED_1_SQUARED =
int256(0x4000000000000000000000000000000000000000000000000000000000000000);
- // 1
+ /// @dev Maximum value for natural logarithm calculation
int256 private constant LN_MAX_VAL = FIXED_1;
- // e ^ -63.875
+ /// @dev Minimum value for natural logarithm calculation (e ^ -63.875)
int256 private constant LN_MIN_VAL = int256(0x0000000000000000000000000000000000000000000000000000000733048c5a);
- // 0
+ /// @dev Maximum value for exponentiation calculation
int256 private constant EXP_MAX_VAL = 0;
- // -63.875
+ /// @dev Minimum value for exponentiation calculation (-63.875)
int256 private constant EXP_MIN_VAL = -int256(0x0000000000000000000000000000001ff0000000000000000000000000000000);
- /// @dev Get one as a fixed-point number.
+ /// @notice Get one as a fixed-point number.
+ /// @return f The fixed-point representation of 1
function one() internal pure returns (int256 f) {
f = FIXED_1;
}
- /// @dev Returns the addition of two fixed point numbers, reverting on overflow.
+ /// @notice Returns the addition of two fixed point numbers, reverting on overflow.
+ /// @param a First fixed-point number
+ /// @param b Second fixed-point number
+ /// @return c The sum of a and b
function add(int256 a, int256 b) internal pure returns (int256 c) {
c = _add(a, b);
}
- /// @dev Returns the addition of two fixed point numbers, reverting on overflow.
+ /// @notice Returns the subtraction of two fixed point numbers, reverting on overflow.
+ /// @param a First fixed-point number
+ /// @param b Second fixed-point number
+ /// @return c The difference a - b
function sub(int256 a, int256 b) internal pure returns (int256 c) {
if (b == MIN_FIXED_VAL) {
revert("out-of-bounds");
@@ -57,24 +70,37 @@ library LibFixedMath {
c = _add(a, -b);
}
- /// @dev Returns the multiplication of two fixed point numbers, reverting on overflow.
+ /// @notice Returns the multiplication of two fixed point numbers, reverting on overflow.
+ /// @param a First fixed-point number
+ /// @param b Second fixed-point number
+ /// @return c The product of a and b
function mul(int256 a, int256 b) internal pure returns (int256 c) {
c = _mul(a, b) / FIXED_1;
}
- /// @dev Returns the division of two fixed point numbers.
+ /// @notice Returns the division of two fixed point numbers.
+ /// @param a Dividend fixed-point number
+ /// @param b Divisor fixed-point number
+ /// @return c The quotient a / b
function div(int256 a, int256 b) internal pure returns (int256 c) {
c = _div(_mul(a, FIXED_1), b);
}
- /// @dev Performs (a * n) / d, without scaling for precision.
+ /// @notice Performs (a * n) / d, without scaling for precision.
+ /// @param a First operand
+ /// @param n Numerator
+ /// @param d Denominator
+ /// @return c The result of (a * n) / d
function mulDiv(int256 a, int256 n, int256 d) internal pure returns (int256 c) {
c = _div(_mul(a, n), d);
}
- /// @dev Returns the unsigned integer result of multiplying a fixed-point
+ /// @notice Returns the unsigned integer result of multiplying a fixed-point
/// number with an integer, reverting if the multiplication overflows.
/// Negative results are clamped to zero.
+ /// @param f Fixed-point number
+ /// @param u Unsigned integer
+ /// @return The result of f * u as an unsigned integer
function uintMul(int256 f, uint256 u) internal pure returns (uint256) {
if (int256(u) < int256(0)) {
revert("out-of-bounds");
@@ -86,7 +112,9 @@ library LibFixedMath {
return uint256(uint256(c) >> 127);
}
- /// @dev Returns the absolute value of a fixed point number.
+ /// @notice Returns the absolute value of a fixed point number.
+ /// @param f Fixed-point number
+ /// @return c The absolute value of f
function abs(int256 f) internal pure returns (int256 c) {
if (f == MIN_FIXED_VAL) {
revert("out-of-bounds");
@@ -98,23 +126,32 @@ library LibFixedMath {
}
}
- /// @dev Returns 1 / `x`, where `x` is a fixed-point number.
+ /// @notice Returns 1 / `x`, where `x` is a fixed-point number.
+ /// @param f Fixed-point number to invert
+ /// @return c The reciprocal of f
function invert(int256 f) internal pure returns (int256 c) {
c = _div(FIXED_1_SQUARED, f);
}
- /// @dev Convert signed `n` / 1 to a fixed-point number.
+ /// @notice Convert signed `n` / 1 to a fixed-point number.
+ /// @param n Signed integer to convert
+ /// @return f The fixed-point representation of n
function toFixed(int256 n) internal pure returns (int256 f) {
f = _mul(n, FIXED_1);
}
- /// @dev Convert signed `n` / `d` to a fixed-point number.
+ /// @notice Convert signed `n` / `d` to a fixed-point number.
+ /// @param n Numerator
+ /// @param d Denominator
+ /// @return f The fixed-point representation of n/d
function toFixed(int256 n, int256 d) internal pure returns (int256 f) {
f = _div(_mul(n, FIXED_1), d);
}
- /// @dev Convert unsigned `n` / 1 to a fixed-point number.
+ /// @notice Convert unsigned `n` / 1 to a fixed-point number.
/// Reverts if `n` is too large to fit in a fixed-point number.
+ /// @param n Unsigned integer to convert
+ /// @return f The fixed-point representation of n
function toFixed(uint256 n) internal pure returns (int256 f) {
if (int256(n) < int256(0)) {
revert("out-of-bounds");
@@ -122,8 +159,11 @@ library LibFixedMath {
f = _mul(int256(n), FIXED_1);
}
- /// @dev Convert unsigned `n` / `d` to a fixed-point number.
+ /// @notice Convert unsigned `n` / `d` to a fixed-point number.
/// Reverts if `n` / `d` is too large to fit in a fixed-point number.
+ /// @param n Numerator
+ /// @param d Denominator
+ /// @return f The fixed-point representation of n/d
function toFixed(uint256 n, uint256 d) internal pure returns (int256 f) {
if (int256(n) < int256(0)) {
revert("out-of-bounds");
@@ -134,12 +174,16 @@ library LibFixedMath {
f = _div(_mul(int256(n), FIXED_1), int256(d));
}
- /// @dev Convert a fixed-point number to an integer.
+ /// @notice Convert a fixed-point number to an integer.
+ /// @param f Fixed-point number to convert
+ /// @return n The integer representation of f
function toInteger(int256 f) internal pure returns (int256 n) {
return f / FIXED_1;
}
- /// @dev Get the natural logarithm of a fixed-point number 0 < `x` <= LN_MAX_VAL
+ /// @notice Get the natural logarithm of a fixed-point number 0 < `x` <= LN_MAX_VAL
+ /// @param x Fixed-point number to compute logarithm of
+ /// @return r The natural logarithm of x
function ln(int256 x) internal pure returns (int256 r) {
if (x > LN_MAX_VAL) {
revert("out-of-bounds");
@@ -228,7 +272,9 @@ library LibFixedMath {
r += (z * (0x088888888888888888888888888888888 - y)) / 0x800000000000000000000000000000000; // add y^15 / 15 - y^16 / 16
}
- /// @dev Compute the natural exponent for a fixed-point number EXP_MIN_VAL <= `x` <= 1
+ /// @notice Compute the natural exponent for a fixed-point number EXP_MIN_VAL <= `x` <= 1
+ /// @param x Fixed-point number to compute exponent of
+ /// @return r The natural exponent of x
function exp(int256 x) internal pure returns (int256 r) {
if (x < EXP_MIN_VAL) {
// Saturate to zero below EXP_MIN_VAL.
@@ -350,7 +396,10 @@ library LibFixedMath {
}
}
- /// @dev Returns the multiplication two numbers, reverting on overflow.
+ /// @notice Returns the multiplication two numbers, reverting on overflow.
+ /// @param a First operand
+ /// @param b Second operand
+ /// @return c The product of a and b
function _mul(int256 a, int256 b) private pure returns (int256 c) {
if (a == 0 || b == 0) {
return 0;
@@ -361,7 +410,10 @@ library LibFixedMath {
}
}
- /// @dev Returns the division of two numbers, reverting on division by zero.
+ /// @notice Returns the division of two numbers, reverting on division by zero.
+ /// @param a Dividend
+ /// @param b Divisor
+ /// @return c The quotient of a and b
function _div(int256 a, int256 b) private pure returns (int256 c) {
if (b == 0) {
revert("overflow");
@@ -372,7 +424,10 @@ library LibFixedMath {
c = a / b;
}
- /// @dev Adds two numbers, reverting on overflow.
+ /// @notice Adds two numbers, reverting on overflow.
+ /// @param a First operand
+ /// @param b Second operand
+ /// @return c The sum of a and b
function _add(int256 a, int256 b) private pure returns (int256 c) {
c = a + b;
if ((a < 0 && b < 0 && c > a) || (a > 0 && b > 0 && c < a)) {
diff --git a/packages/contracts/contracts/staking/libs/MathUtils.sol b/packages/contracts/contracts/staking/libs/MathUtils.sol
index 0fb20389a..467e1ae2a 100644
--- a/packages/contracts/contracts/staking/libs/MathUtils.sol
+++ b/packages/contracts/contracts/staking/libs/MathUtils.sol
@@ -2,17 +2,21 @@
pragma solidity ^0.7.6;
-import "@openzeppelin/contracts/math/SafeMath.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-strict-inequalities
+
+import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol";
/**
* @title MathUtils Library
+ * @author Edge & Node
* @notice A collection of functions to perform math operations
*/
library MathUtils {
using SafeMath for uint256;
/**
- * @dev Calculates the weighted average of two values pondering each of these
+ * @notice Calculates the weighted average of two values pondering each of these
* values based on configured weights. The contribution of each value N is
* weightN/(weightA + weightB). The calculation rounds up to ensure the result
* is always greater than the smallest of the two values.
@@ -20,6 +24,7 @@ library MathUtils {
* @param weightA The weight to use for value A
* @param valueB The amount for value B
* @param weightB The weight to use for value B
+ * @return The weighted average of the two values, rounded up
*/
function weightedAverageRoundingUp(
uint256 valueA,
@@ -31,14 +36,20 @@ library MathUtils {
}
/**
- * @dev Returns the minimum of two numbers.
+ * @notice Returns the minimum of two numbers.
+ * @param x First number
+ * @param y Second number
+ * @return The smaller of the two numbers
*/
function min(uint256 x, uint256 y) internal pure returns (uint256) {
return x <= y ? x : y;
}
/**
- * @dev Returns the difference between two numbers or zero if negative.
+ * @notice Returns the difference between two numbers or zero if negative.
+ * @param x First number
+ * @param y Second number
+ * @return The difference x - y, or 0 if y > x
*/
function diffOrZero(uint256 x, uint256 y) internal pure returns (uint256) {
return (x > y) ? x.sub(y) : 0;
diff --git a/packages/contracts/contracts/staking/libs/Stakes.sol b/packages/contracts/contracts/staking/libs/Stakes.sol
index b09101032..459a82996 100644
--- a/packages/contracts/contracts/staking/libs/Stakes.sol
+++ b/packages/contracts/contracts/staking/libs/Stakes.sol
@@ -3,13 +3,15 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
-import "@openzeppelin/contracts/math/SafeMath.sol";
+import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol";
-import "./MathUtils.sol";
-import "./IStakes.sol";
+import { MathUtils } from "./MathUtils.sol";
+import { IStakes } from "@graphprotocol/interfaces/contracts/contracts/staking/libs/IStakes.sol";
/**
* @title A collection of data structures and functions to manage the Indexer Stake state.
+ * @author Edge & Node
+ * @notice A collection of data structures and functions to manage the Indexer Stake state.
* Used for low-level state changes, require() conditions should be evaluated
* at the caller function scope.
*/
@@ -18,7 +20,7 @@ library Stakes {
using Stakes for IStakes.Indexer;
/**
- * @dev Deposit tokens to the indexer stake.
+ * @notice Deposit tokens to the indexer stake.
* @param stake Stake data
* @param _tokens Amount of tokens to deposit
*/
@@ -27,7 +29,7 @@ library Stakes {
}
/**
- * @dev Release tokens from the indexer stake.
+ * @notice Release tokens from the indexer stake.
* @param stake Stake data
* @param _tokens Amount of tokens to release
*/
@@ -36,7 +38,7 @@ library Stakes {
}
/**
- * @dev Allocate tokens from the main stack to a SubgraphDeployment.
+ * @notice Allocate tokens from the main stack to a SubgraphDeployment.
* @param stake Stake data
* @param _tokens Amount of tokens to allocate
*/
@@ -45,7 +47,7 @@ library Stakes {
}
/**
- * @dev Unallocate tokens from a SubgraphDeployment back to the main stack.
+ * @notice Unallocate tokens from a SubgraphDeployment back to the main stack.
* @param stake Stake data
* @param _tokens Amount of tokens to unallocate
*/
@@ -54,7 +56,7 @@ library Stakes {
}
/**
- * @dev Lock tokens until a thawing period pass.
+ * @notice Lock tokens until a thawing period pass.
* @param stake Stake data
* @param _tokens Amount of tokens to unstake
* @param _period Period in blocks that need to pass before withdrawal
@@ -77,7 +79,7 @@ library Stakes {
}
/**
- * @dev Unlock tokens.
+ * @notice Unlock tokens.
* @param stake Stake data
* @param _tokens Amount of tokens to unlock
*/
@@ -89,7 +91,7 @@ library Stakes {
}
/**
- * @dev Take all tokens out from the locked stake for withdrawal.
+ * @notice Take all tokens out from the locked stake for withdrawal.
* @param stake Stake data
* @return Amount of tokens being withdrawn
*/
@@ -109,7 +111,7 @@ library Stakes {
}
/**
- * @dev Return the amount of tokens used in allocations and locked for withdrawal.
+ * @notice Return the amount of tokens used in allocations and locked for withdrawal.
* @param stake Stake data
* @return Token amount
*/
@@ -118,7 +120,7 @@ library Stakes {
}
/**
- * @dev Return the amount of tokens staked not considering the ones that are already going
+ * @notice Return the amount of tokens staked not considering the ones that are already going
* through the thawing period or are ready for withdrawal. We call it secure stake because
* it is not subject to change by a withdraw call from the indexer.
* @param stake Stake data
@@ -129,7 +131,7 @@ library Stakes {
}
/**
- * @dev Tokens free balance on the indexer stake that can be used for any purpose.
+ * @notice Tokens free balance on the indexer stake that can be used for any purpose.
* Any token that is allocated cannot be used as well as tokens that are going through the
* thawing period or are withdrawable
* Calc: tokensStaked - tokensAllocated - tokensLocked
@@ -141,7 +143,7 @@ library Stakes {
}
/**
- * @dev Tokens free balance on the indexer stake that can be used for allocations.
+ * @notice Tokens free balance on the indexer stake that can be used for allocations.
* This function accepts a parameter for extra delegated capacity that takes into
* account delegated tokens
* @param stake Stake data
@@ -171,7 +173,7 @@ library Stakes {
}
/**
- * @dev Tokens available for withdrawal after thawing period.
+ * @notice Tokens available for withdrawal after thawing period.
* @param stake Stake data
* @return Token amount
*/
diff --git a/packages/contracts/contracts/tests/CallhookReceiverMock.sol b/packages/contracts/contracts/tests/CallhookReceiverMock.sol
index e2418f3c8..f6b9d130e 100644
--- a/packages/contracts/contracts/tests/CallhookReceiverMock.sol
+++ b/packages/contracts/contracts/tests/CallhookReceiverMock.sol
@@ -2,21 +2,29 @@
pragma solidity ^0.7.6;
-import "../gateway/ICallhookReceiver.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-indexed-events, use-natspec
+
+import { ICallhookReceiver } from "@graphprotocol/interfaces/contracts/contracts/gateway/ICallhookReceiver.sol";
/**
- * @title GovernedMock contract
+ * @title CallhookReceiverMock contract
+ * @dev Mock contract for testing callhook receiver functionality
*/
contract CallhookReceiverMock is ICallhookReceiver {
+ /**
+ * @dev Emitted when a transfer is received
+ * @param from Address that sent the transfer
+ * @param amount Amount of tokens transferred
+ * @param foo First test parameter
+ * @param bar Second test parameter
+ */
event TransferReceived(address from, uint256 amount, uint256 foo, uint256 bar);
/**
- * @dev Receive tokens with a callhook from the bridge
- * Expects two uint256 values encoded in _data.
+ * @inheritdoc ICallhookReceiver
+ * @dev Expects two uint256 values encoded in _data.
* Reverts if the first of these values is zero.
- * @param _from Token sender in L1
- * @param _amount Amount of tokens that were transferred
- * @param _data ABI-encoded callhook data
*/
function onTokenTransfer(address _from, uint256 _amount, bytes calldata _data) external override {
uint256 foo;
diff --git a/packages/contracts/contracts/tests/GovernedMock.sol b/packages/contracts/contracts/tests/GovernedMock.sol
index cc908287b..9e6c2cc18 100644
--- a/packages/contracts/contracts/tests/GovernedMock.sol
+++ b/packages/contracts/contracts/tests/GovernedMock.sol
@@ -2,12 +2,19 @@
pragma solidity ^0.7.6;
-import "../governance/Governed.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable use-natspec
+
+import { Governed } from "../governance/Governed.sol";
/**
* @title GovernedMock contract
+ * @dev Mock contract for testing Governed functionality
*/
contract GovernedMock is Governed {
+ /**
+ * @dev Constructor that initializes the contract with the deployer as governor
+ */
constructor() {
Governed._initialize(msg.sender);
}
diff --git a/packages/contracts/contracts/tests/L1GraphTokenLockTransferToolBadMock.sol b/packages/contracts/contracts/tests/L1GraphTokenLockTransferToolBadMock.sol
index f540b1b96..12b06b332 100644
--- a/packages/contracts/contracts/tests/L1GraphTokenLockTransferToolBadMock.sol
+++ b/packages/contracts/contracts/tests/L1GraphTokenLockTransferToolBadMock.sol
@@ -3,17 +3,37 @@
pragma solidity ^0.7.6;
pragma experimental ABIEncoderV2;
+// solhint-disable named-parameters-mapping
+// solhint-disable gas-small-strings
+
+/**
+ * @title L1GraphTokenLockTransferToolBadMock
+ * @author Edge & Node
+ * @notice Mock contract for testing L1 Graph Token Lock Transfer Tool with bad behavior
+ */
contract L1GraphTokenLockTransferToolBadMock {
+ /**
+ * @notice Mapping from L1 wallet address to L2 wallet address
+ */
mapping(address => address) public l2WalletAddress;
- function setL2WalletAddress(address _l1Address, address _l2Address) external {
- l2WalletAddress[_l1Address] = _l2Address;
+ /**
+ * @notice Set the L2 wallet address for an L1 wallet
+ * @param l1Address L1 wallet address
+ * @param l2Address L2 wallet address
+ */
+ function setL2WalletAddress(address l1Address, address l2Address) external {
+ l2WalletAddress[l1Address] = l2Address;
}
- // Sends 1 wei less than requested
- function pullETH(address _l1Wallet, uint256 _amount) external {
- require(l2WalletAddress[_l1Wallet] != address(0), "L1GraphTokenLockTransferToolMock: unknown L1 wallet");
- (bool success, ) = payable(msg.sender).call{ value: _amount - 1 }("");
+ /**
+ * @notice Pull ETH from the contract to the caller (sends 1 wei less than requested for testing)
+ * @param l1Wallet L1 wallet address to check
+ * @param amount Amount of ETH to pull
+ */
+ function pullETH(address l1Wallet, uint256 amount) external {
+ require(l2WalletAddress[l1Wallet] != address(0), "L1GraphTokenLockTransferToolMock: unknown L1 wallet");
+ (bool success, ) = payable(msg.sender).call{ value: amount - 1 }("");
require(success, "L1GraphTokenLockTransferToolMock: ETH pull failed");
}
}
diff --git a/packages/contracts/contracts/tests/L1GraphTokenLockTransferToolMock.sol b/packages/contracts/contracts/tests/L1GraphTokenLockTransferToolMock.sol
index a1321d62f..92e8f73f7 100644
--- a/packages/contracts/contracts/tests/L1GraphTokenLockTransferToolMock.sol
+++ b/packages/contracts/contracts/tests/L1GraphTokenLockTransferToolMock.sol
@@ -3,16 +3,37 @@
pragma solidity ^0.7.6;
pragma experimental ABIEncoderV2;
+// solhint-disable named-parameters-mapping
+// solhint-disable gas-small-strings
+
+/**
+ * @title L1GraphTokenLockTransferToolMock
+ * @author Edge & Node
+ * @notice Mock contract for testing L1 Graph Token Lock Transfer Tool functionality
+ */
contract L1GraphTokenLockTransferToolMock {
+ /**
+ * @notice Mapping from L1 wallet address to L2 wallet address
+ */
mapping(address => address) public l2WalletAddress;
- function setL2WalletAddress(address _l1Address, address _l2Address) external {
- l2WalletAddress[_l1Address] = _l2Address;
+ /**
+ * @notice Set the L2 wallet address for an L1 wallet
+ * @param l1Address L1 wallet address
+ * @param l2Address L2 wallet address
+ */
+ function setL2WalletAddress(address l1Address, address l2Address) external {
+ l2WalletAddress[l1Address] = l2Address;
}
- function pullETH(address _l1Wallet, uint256 _amount) external {
- require(l2WalletAddress[_l1Wallet] != address(0), "L1GraphTokenLockTransferToolMock: unknown L1 wallet");
- (bool success, ) = payable(msg.sender).call{ value: _amount }("");
+ /**
+ * @notice Pull ETH from the contract to the caller
+ * @param l1Wallet L1 wallet address to check
+ * @param amount Amount of ETH to pull
+ */
+ function pullETH(address l1Wallet, uint256 amount) external {
+ require(l2WalletAddress[l1Wallet] != address(0), "L1GraphTokenLockTransferToolMock: unknown L1 wallet");
+ (bool success, ) = payable(msg.sender).call{ value: amount }("");
require(success, "L1GraphTokenLockTransferToolMock: ETH pull failed");
}
}
diff --git a/packages/contracts/contracts/tests/LegacyGNSMock.sol b/packages/contracts/contracts/tests/LegacyGNSMock.sol
index b2b4088b9..30f98ea31 100644
--- a/packages/contracts/contracts/tests/LegacyGNSMock.sol
+++ b/packages/contracts/contracts/tests/LegacyGNSMock.sol
@@ -3,8 +3,11 @@
pragma solidity ^0.7.6;
pragma abicoder v2;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable use-natspec
+
import { L1GNS } from "../discovery/L1GNS.sol";
-import { IGNS } from "../discovery/IGNS.sol";
+import { IGNS } from "@graphprotocol/interfaces/contracts/contracts/discovery/IGNS.sol";
/**
* @title LegacyGNSMock contract
diff --git a/packages/contracts/contracts/tests/MockERC165.sol b/packages/contracts/contracts/tests/MockERC165.sol
new file mode 100644
index 000000000..446c752a7
--- /dev/null
+++ b/packages/contracts/contracts/tests/MockERC165.sol
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+pragma solidity ^0.7.6;
+
+import { IERC165 } from "@openzeppelin/contracts/introspection/IERC165.sol";
+
+/**
+ * @title MockERC165
+ * @author Edge & Node
+ * @dev Minimal implementation of IERC165 for testing
+ * @notice Used to test interface validation - supports only ERC165, not specific interfaces
+ */
+contract MockERC165 is IERC165 {
+ /**
+ * @inheritdoc IERC165
+ */
+ function supportsInterface(bytes4 interfaceId) public pure override returns (bool) {
+ return interfaceId == type(IERC165).interfaceId;
+ }
+}
diff --git a/packages/contracts/contracts/tests/MockIssuanceAllocator.sol b/packages/contracts/contracts/tests/MockIssuanceAllocator.sol
new file mode 100644
index 000000000..24e482a55
--- /dev/null
+++ b/packages/contracts/contracts/tests/MockIssuanceAllocator.sol
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// solhint-disable gas-increment-by-one, gas-indexed-events, named-parameters-mapping, use-natspec
+
+pragma solidity ^0.7.6;
+pragma abicoder v2;
+
+import { IERC165 } from "@openzeppelin/contracts/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";
+
+/**
+ * @title MockIssuanceAllocator
+ * @dev A simple mock contract for the IssuanceAllocator interfaces used by RewardsManager.
+ */
+contract MockIssuanceAllocator is IERC165, IIssuanceAllocationDistribution {
+ /// @dev Mapping to store TargetIssuancePerBlock for each target
+ mapping(address => TargetIssuancePerBlock) private _targetIssuance;
+
+ /**
+ * @dev Call beforeIssuanceAllocationChange on a target
+ * @param target The target contract address
+ */
+ function callBeforeIssuanceAllocationChange(address target) external {
+ IIssuanceTarget(target).beforeIssuanceAllocationChange();
+ }
+
+ /**
+ * @inheritdoc IIssuanceAllocationDistribution
+ */
+ function getTargetIssuancePerBlock(address target) external view override returns (TargetIssuancePerBlock memory) {
+ return _targetIssuance[target];
+ }
+
+ /**
+ * @inheritdoc IIssuanceAllocationDistribution
+ * @dev Mock always returns current block number
+ */
+ function distributeIssuance() external view override returns (uint256) {
+ return block.number;
+ }
+
+ /**
+ * @dev Set target issuance directly for testing
+ * @param target The target contract address
+ * @param allocatorIssuance The allocator issuance per block
+ * @param selfIssuance The self issuance per block
+ * @param callBefore Whether to call beforeIssuanceAllocationChange on the target
+ */
+ 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
+ });
+ }
+
+ /**
+ * @inheritdoc IERC165
+ */
+ function supportsInterface(bytes4 interfaceId) public pure override returns (bool) {
+ return
+ interfaceId == type(IIssuanceAllocationDistribution).interfaceId ||
+ interfaceId == type(IERC165).interfaceId;
+ }
+}
diff --git a/packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol b/packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol
new file mode 100644
index 000000000..b0ac05a19
--- /dev/null
+++ b/packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol
@@ -0,0 +1,71 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// solhint-disable named-parameters-mapping
+
+pragma solidity ^0.7.6;
+
+import { IProviderEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibility.sol";
+import { IERC165 } from "@openzeppelin/contracts/introspection/IERC165.sol";
+
+/**
+ * @title MockRewardsEligibilityOracle
+ * @author Edge & Node
+ * @notice A simple mock contract for the RewardsEligibilityOracle interface
+ * @dev A simple mock contract for the RewardsEligibilityOracle interface
+ */
+contract MockRewardsEligibilityOracle is IProviderEligibility, IERC165 {
+ /// @dev Mapping to store eligibility status for each indexer
+ mapping(address => bool) private eligible;
+
+ /// @dev Mapping to track which indexers have been explicitly set
+ mapping(address => bool) private isSet;
+
+ /// @dev Default response for indexers not explicitly set
+ bool private defaultResponse;
+
+ /**
+ * @notice Constructor
+ * @param newDefaultResponse Default response for isEligible
+ */
+ constructor(bool newDefaultResponse) {
+ defaultResponse = newDefaultResponse;
+ }
+
+ /**
+ * @notice Set whether a specific indexer is eligible
+ * @param indexer The indexer address
+ * @param eligibility Whether the indexer is eligible
+ */
+ function setIndexerEligible(address indexer, bool eligibility) external {
+ eligible[indexer] = eligibility;
+ isSet[indexer] = true;
+ }
+
+ /**
+ * @notice Set the default response for indexers not explicitly set
+ * @param newDefaultResponse The default response
+ */
+ function setDefaultResponse(bool newDefaultResponse) external {
+ defaultResponse = newDefaultResponse;
+ }
+
+ /**
+ * @inheritdoc IProviderEligibility
+ */
+ function isEligible(address indexer) external view override returns (bool) {
+ // If the indexer has been explicitly set, return that value
+ if (isSet[indexer]) {
+ return eligible[indexer];
+ }
+
+ // Otherwise return the default response
+ return defaultResponse;
+ }
+
+ /**
+ * @inheritdoc IERC165
+ */
+ function supportsInterface(bytes4 interfaceId) public pure override returns (bool) {
+ return interfaceId == type(IProviderEligibility).interfaceId || interfaceId == type(IERC165).interfaceId;
+ }
+}
diff --git a/packages/contracts/contracts/tests/MockSubgraphService.sol b/packages/contracts/contracts/tests/MockSubgraphService.sol
new file mode 100644
index 000000000..1e355923b
--- /dev/null
+++ b/packages/contracts/contracts/tests/MockSubgraphService.sol
@@ -0,0 +1,126 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// solhint-disable named-parameters-mapping
+
+pragma solidity ^0.7.6;
+
+import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsIssuer.sol";
+
+/**
+ * @title MockSubgraphService
+ * @author Edge & Node
+ * @notice A mock contract for testing SubgraphService as a rewards issuer
+ * @dev Implements IRewardsIssuer interface to simulate SubgraphService behavior in tests
+ */
+contract MockSubgraphService is IRewardsIssuer {
+ /// @dev Struct to store allocation data
+ struct Allocation {
+ bool isActive;
+ address indexer;
+ bytes32 subgraphDeploymentId;
+ uint256 tokens;
+ uint256 accRewardsPerAllocatedToken;
+ uint256 accRewardsPending;
+ }
+
+ /// @dev Mapping of allocation ID to allocation data
+ mapping(address => Allocation) private allocations;
+
+ /// @dev Mapping of subgraph deployment ID to total allocated tokens
+ mapping(bytes32 => uint256) private subgraphAllocatedTokens;
+
+ /**
+ * @notice Set allocation data for testing
+ * @param allocationId The allocation ID
+ * @param isActive Whether the allocation is active
+ * @param indexer The indexer address
+ * @param subgraphDeploymentId The subgraph deployment ID
+ * @param tokens Amount of allocated tokens
+ * @param accRewardsPerAllocatedToken Rewards snapshot
+ * @param accRewardsPending Accumulated rewards pending
+ */
+ function setAllocation(
+ address allocationId,
+ bool isActive,
+ address indexer,
+ bytes32 subgraphDeploymentId,
+ uint256 tokens,
+ uint256 accRewardsPerAllocatedToken,
+ uint256 accRewardsPending
+ ) external {
+ allocations[allocationId] = Allocation({
+ isActive: isActive,
+ indexer: indexer,
+ subgraphDeploymentId: subgraphDeploymentId,
+ tokens: tokens,
+ accRewardsPerAllocatedToken: accRewardsPerAllocatedToken,
+ accRewardsPending: accRewardsPending
+ });
+ }
+
+ /**
+ * @notice Set total allocated tokens for a subgraph
+ * @param subgraphDeploymentId The subgraph deployment ID
+ * @param tokens Total tokens allocated
+ */
+ function setSubgraphAllocatedTokens(bytes32 subgraphDeploymentId, uint256 tokens) external {
+ subgraphAllocatedTokens[subgraphDeploymentId] = tokens;
+ }
+
+ /**
+ * @inheritdoc IRewardsIssuer
+ */
+ function getAllocationData(
+ address allocationId
+ )
+ external
+ view
+ override
+ returns (
+ bool isActive,
+ address indexer,
+ bytes32 subgraphDeploymentId,
+ uint256 tokens,
+ uint256 accRewardsPerAllocatedToken,
+ uint256 accRewardsPending
+ )
+ {
+ Allocation memory allocation = allocations[allocationId];
+ return (
+ allocation.isActive,
+ allocation.indexer,
+ allocation.subgraphDeploymentId,
+ allocation.tokens,
+ allocation.accRewardsPerAllocatedToken,
+ allocation.accRewardsPending
+ );
+ }
+
+ /**
+ * @inheritdoc IRewardsIssuer
+ */
+ function getSubgraphAllocatedTokens(bytes32 subgraphDeploymentId) external view override returns (uint256) {
+ return subgraphAllocatedTokens[subgraphDeploymentId];
+ }
+
+ /**
+ * @notice Helper function to call reclaimRewards on RewardsManager for testing
+ * @param rewardsManager Address of the RewardsManager contract
+ * @param reason Reason identifier for reclaiming rewards
+ * @param allocationId The allocation ID
+ * @return Amount of rewards reclaimed
+ */
+ function callReclaimRewards(
+ address rewardsManager,
+ bytes32 reason,
+ address allocationId
+ ) external returns (uint256) {
+ // Call reclaimRewards on the RewardsManager
+ // solhint-disable-next-line avoid-low-level-calls
+ (bool success, bytes memory data) = rewardsManager.call(
+ abi.encodeWithSignature("reclaimRewards(bytes32,address)", reason, allocationId)
+ );
+ require(success, "reclaimRewards call failed");
+ return abi.decode(data, (uint256));
+ }
+}
diff --git a/packages/contracts/contracts/tests/arbitrum/ArbSysMock.sol b/packages/contracts/contracts/tests/arbitrum/ArbSysMock.sol
index b5af6114e..3c256fa74 100644
--- a/packages/contracts/contracts/tests/arbitrum/ArbSysMock.sol
+++ b/packages/contracts/contracts/tests/arbitrum/ArbSysMock.sol
@@ -2,12 +2,22 @@
pragma solidity ^0.7.6;
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable use-natspec
+
/**
* @title ArbSys Mock Contract
* @dev This is a mock implementation of the ArbSys precompiled contract used in Arbitrum
* It's used for testing the L2GraphTokenGateway contract
*/
contract ArbSysMock {
+ /**
+ * @dev Emitted when a transaction is sent from L2 to L1
+ * @param from Address sending the transaction on L2
+ * @param to Address receiving the transaction on L1
+ * @param id Unique identifier for the L2-to-L1 transaction
+ * @param data Transaction data
+ */
event L2ToL1Tx(address indexed from, address indexed to, uint256 indexed id, bytes data);
/**
diff --git a/packages/contracts/contracts/tests/arbitrum/BridgeMock.sol b/packages/contracts/contracts/tests/arbitrum/BridgeMock.sol
index 77be89b4e..9bcc71982 100644
--- a/packages/contracts/contracts/tests/arbitrum/BridgeMock.sol
+++ b/packages/contracts/contracts/tests/arbitrum/BridgeMock.sol
@@ -2,29 +2,35 @@
pragma solidity ^0.7.6;
-import "../../arbitrum/IBridge.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-increment-by-one, use-natspec
+
+import { IBridge } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/IBridge.sol";
/**
* @title Arbitrum Bridge mock contract
* @dev This contract implements Arbitrum's IBridge interface for testing purposes
*/
contract BridgeMock is IBridge {
- // Address of the (mock) Arbitrum Inbox
+ /**
+ * @notice Address of the (mock) Arbitrum Inbox
+ */
address public inbox;
- // Address of the (mock) Arbitrum Outbox
+ /**
+ * @notice Address of the (mock) Arbitrum Outbox
+ */
address public outbox;
- // Index of the next message on the inbox messages array
+ /**
+ * @notice Index of the next message on the inbox messages array
+ */
uint256 public messageIndex;
- // Inbox messages array
+ /**
+ * @inheritdoc IBridge
+ */
bytes32[] public override inboxAccs;
/**
- * @dev Deliver a message to the inbox. The encoded message will be
- * added to the inbox array, and messageIndex will be incremented.
- * @param _kind Type of the message
- * @param _sender Address that is sending the message
- * @param _messageDataHash keccak256 hash of the message data
- * @return The next index for the inbox array
+ * @inheritdoc IBridge
*/
function deliverMessageToInbox(
uint8 _kind,
@@ -38,13 +44,7 @@ contract BridgeMock is IBridge {
}
/**
- * @dev Executes an L1 function call incoing from L2. This can only be called
- * by the Outbox.
- * @param _destAddr Contract to call
- * @param _amount ETH value to send
- * @param _data Calldata for the function call
- * @return True if the call was successful, false otherwise
- * @return Return data from the call
+ * @inheritdoc IBridge
*/
function executeCall(
address _destAddr,
@@ -62,9 +62,7 @@ contract BridgeMock is IBridge {
}
/**
- * @dev Set the address of the inbox. Anyone can call this, because it's a mock.
- * @param _inbox Address of the inbox
- * @param _enabled Enable the inbox (ignored)
+ * @inheritdoc IBridge
*/
function setInbox(address _inbox, bool _enabled) external override {
inbox = _inbox;
@@ -72,9 +70,7 @@ contract BridgeMock is IBridge {
}
/**
- * @dev Set the address of the outbox. Anyone can call this, because it's a mock.
- * @param _outbox Address of the outbox
- * @param _enabled Enable the outbox (ignored)
+ * @inheritdoc IBridge
*/
function setOutbox(address _outbox, bool _enabled) external override {
outbox = _outbox;
@@ -84,33 +80,28 @@ contract BridgeMock is IBridge {
// View functions
/**
- * @dev Getter for the active outbox (in this case there's only one)
+ * @inheritdoc IBridge
*/
function activeOutbox() external view override returns (address) {
return outbox;
}
/**
- * @dev Getter for whether an address is an allowed inbox (in this case there's only one)
- * @param _inbox Address to check
- * @return True if the address is the allowed inbox, false otherwise
+ * @inheritdoc IBridge
*/
function allowedInboxes(address _inbox) external view override returns (bool) {
return _inbox == inbox;
}
/**
- * @dev Getter for whether an address is an allowed outbox (in this case there's only one)
- * @param _outbox Address to check
- * @return True if the address is the allowed outbox, false otherwise
+ * @inheritdoc IBridge
*/
function allowedOutboxes(address _outbox) external view override returns (bool) {
return _outbox == outbox;
}
/**
- * @dev Getter for the count of messages in the inboxAccs
- * @return Number of messages in inboxAccs
+ * @inheritdoc IBridge
*/
function messageCount() external view override returns (uint256) {
return inboxAccs.length;
diff --git a/packages/contracts/contracts/tests/arbitrum/InboxMock.sol b/packages/contracts/contracts/tests/arbitrum/InboxMock.sol
index 57af6941c..38cee3b44 100644
--- a/packages/contracts/contracts/tests/arbitrum/InboxMock.sol
+++ b/packages/contracts/contracts/tests/arbitrum/InboxMock.sol
@@ -2,26 +2,28 @@
pragma solidity ^0.7.6;
-import "../../arbitrum/IInbox.sol";
-import "../../arbitrum/AddressAliasHelper.sol";
+import { IInbox } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/IInbox.sol";
+import { AddressAliasHelper } from "../../arbitrum/AddressAliasHelper.sol";
+import { IBridge } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/IBridge.sol";
/**
* @title Arbitrum Inbox mock contract
- * @dev This contract implements (a subset of) Arbitrum's IInbox interface for testing purposes
+ * @author Edge & Node
+ * @notice This contract implements (a subset of) Arbitrum's IInbox interface for testing purposes
*/
contract InboxMock is IInbox {
- // Type indicator for a standard L2 message
+ /// @dev Type indicator for a standard L2 message
uint8 internal constant L2_MSG = 3;
- // Type indicator for a retryable ticket message
+ /// @dev Type indicator for a retryable ticket message
// solhint-disable-next-line const-name-snakecase
uint8 internal constant L1MessageType_submitRetryableTx = 9;
- // Address of the Bridge (mock) contract
+ /**
+ * @inheritdoc IInbox
+ */
IBridge public override bridge;
/**
- * @dev Send a message to L2 (by delivering it to the Bridge)
- * @param _messageData Encoded data to send in the message
- * @return message number returned by the inbox
+ * @inheritdoc IInbox
*/
function sendL2Message(bytes calldata _messageData) external override returns (uint256) {
uint256 msgNum = deliverToBridge(L2_MSG, msg.sender, keccak256(_messageData));
@@ -30,7 +32,7 @@ contract InboxMock is IInbox {
}
/**
- * @dev Set the address of the (mock) bridge
+ * @notice Set the address of the (mock) bridge
* @param _bridge Address of the bridge
*/
function setBridge(address _bridge) external {
@@ -38,6 +40,7 @@ contract InboxMock is IInbox {
}
/**
+ * @inheritdoc IInbox
* @dev Unimplemented in this mock
*/
function sendUnsignedTransaction(
@@ -52,6 +55,7 @@ contract InboxMock is IInbox {
}
/**
+ * @inheritdoc IInbox
* @dev Unimplemented in this mock
*/
function sendContractTransaction(
@@ -65,6 +69,7 @@ contract InboxMock is IInbox {
}
/**
+ * @inheritdoc IInbox
* @dev Unimplemented in this mock
*/
function sendL1FundedUnsignedTransaction(
@@ -78,6 +83,7 @@ contract InboxMock is IInbox {
}
/**
+ * @inheritdoc IInbox
* @dev Unimplemented in this mock
*/
function sendL1FundedContractTransaction(
@@ -90,16 +96,7 @@ contract InboxMock is IInbox {
}
/**
- * @dev Creates a retryable ticket for an L2 transaction
- * @param _destAddr Address of the contract to call in L2
- * @param _arbTxCallValue Callvalue to use in the L2 transaction
- * @param _maxSubmissionCost Max cost of submitting the ticket, in Wei
- * @param _submissionRefundAddress L2 address to refund for any remaining value from the submission cost
- * @param _valueRefundAddress L2 address to refund if the ticket times out or gets cancelled
- * @param _maxGas Max gas for the L2 transcation
- * @param _gasPriceBid Gas price bid on L2
- * @param _data Encoded calldata for the L2 transaction (including function selector)
- * @return message number returned by the bridge
+ * @inheritdoc IInbox
*/
function createRetryableTicket(
address _destAddr,
@@ -132,11 +129,16 @@ contract InboxMock is IInbox {
);
}
+ /**
+ * @inheritdoc IInbox
+ * @dev Unimplemented in this mock
+ */
function depositEth(uint256) external payable override returns (uint256) {
revert("Unimplemented");
}
/**
+ * @inheritdoc IInbox
* @dev Unimplemented in this mock
*/
function pauseCreateRetryables() external pure override {
@@ -144,6 +146,7 @@ contract InboxMock is IInbox {
}
/**
+ * @inheritdoc IInbox
* @dev Unimplemented in this mock
*/
function unpauseCreateRetryables() external pure override {
@@ -151,6 +154,7 @@ contract InboxMock is IInbox {
}
/**
+ * @inheritdoc IInbox
* @dev Unimplemented in this mock
*/
function startRewriteAddress() external pure override {
@@ -158,6 +162,7 @@ contract InboxMock is IInbox {
}
/**
+ * @inheritdoc IInbox
* @dev Unimplemented in this mock
*/
function stopRewriteAddress() external pure override {
@@ -165,7 +170,7 @@ contract InboxMock is IInbox {
}
/**
- * @dev Deliver a message to the bridge
+ * @notice Deliver a message to the bridge
* @param _kind Type of the message
* @param _sender Address that is sending the message
* @param _messageData Encoded message data
@@ -178,7 +183,7 @@ contract InboxMock is IInbox {
}
/**
- * @dev Deliver a message to the bridge
+ * @notice Deliver a message to the bridge
* @param _kind Type of the message
* @param _sender Address that is sending the message
* @param _messageDataHash keccak256 hash of the encoded message data
diff --git a/packages/contracts/contracts/tests/arbitrum/OutboxMock.sol b/packages/contracts/contracts/tests/arbitrum/OutboxMock.sol
index 92b9bb246..7a8e9932b 100644
--- a/packages/contracts/contracts/tests/arbitrum/OutboxMock.sol
+++ b/packages/contracts/contracts/tests/arbitrum/OutboxMock.sol
@@ -2,15 +2,26 @@
pragma solidity ^0.7.6;
-import "../../arbitrum/IOutbox.sol";
-import "../../arbitrum/IBridge.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable use-natspec
+
+import { IOutbox } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/IOutbox.sol";
+import { IBridge } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/IBridge.sol";
/**
* @title Arbitrum Outbox mock contract
* @dev This contract implements (a subset of) Arbitrum's IOutbox interface for testing purposes
*/
contract OutboxMock is IOutbox {
- // Context of an L2-to-L1 function call
+ /**
+ * @dev Context of an L2-to-L1 function call
+ * @param l2Block L2 block number
+ * @param l1Block L1 block number
+ * @param timestamp Timestamp of the call
+ * @param batchNum Batch number
+ * @param outputId Output ID
+ * @param sender Address of the sender
+ */
struct L2ToL1Context {
uint128 l2Block;
uint128 l1Block;
@@ -19,7 +30,7 @@ contract OutboxMock is IOutbox {
bytes32 outputId;
address sender;
}
- // Context of the current L2-to-L1 function call (set and cleared in each transaction)
+ /// @dev Context of the current L2-to-L1 function call (set and cleared in each transaction)
L2ToL1Context internal context;
// Address of the (mock) Arbitrum Bridge
@@ -33,59 +44,42 @@ contract OutboxMock is IOutbox {
bridge = IBridge(_bridge);
}
- /**
- * @dev Getter for the L2 sender of the current incoming message
- */
+ /// @inheritdoc IOutbox
function l2ToL1Sender() external view override returns (address) {
return context.sender;
}
- /**
- * @dev Getter for the L2 block of the current incoming message
- */
+ /// @inheritdoc IOutbox
function l2ToL1Block() external view override returns (uint256) {
return context.l2Block;
}
- /**
- * @dev Getter for the L1 block of the current incoming message
- */
+ /// @inheritdoc IOutbox
function l2ToL1EthBlock() external view override returns (uint256) {
return context.l1Block;
}
- /**
- * @dev Getter for the L1 timestamp of the current incoming message
- */
+ /// @inheritdoc IOutbox
function l2ToL1Timestamp() external view override returns (uint256) {
return context.timestamp;
}
- /**
- * @dev Getter for the L2 batch number of the current incoming message
- */
+ /// @inheritdoc IOutbox
function l2ToL1BatchNum() external view override returns (uint256) {
return context.batchNum;
}
- /**
- * @dev Getter for the output ID of the current incoming message
- */
+ /// @inheritdoc IOutbox
function l2ToL1OutputId() external view override returns (bytes32) {
return context.outputId;
}
- /**
- * @dev Unimplemented in this mock
- */
+ /// @inheritdoc IOutbox
function processOutgoingMessages(bytes calldata, uint256[] calldata) external pure override {
revert("Unimplemented");
}
- /**
- * @dev Check whether an outbox entry for a message exists.
- * This mock returns always true.
- */
+ /// @inheritdoc IOutbox
function outboxEntryExists(uint256) external pure override returns (bool) {
return true;
}
diff --git a/packages/contracts/contracts/tests/ens/IENS.sol b/packages/contracts/contracts/tests/ens/IENS.sol
index f03cb651c..042a9170f 100644
--- a/packages/contracts/contracts/tests/ens/IENS.sol
+++ b/packages/contracts/contracts/tests/ens/IENS.sol
@@ -1,9 +1,29 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
pragma solidity ^0.7.6;
// Needed for abi and typechain in the npm package
+/**
+ * @title ENS Registry Interface
+ * @author Edge & Node
+ * @notice Interface for the Ethereum Name Service registry
+ */
interface IENS {
+ /**
+ * @notice Get the owner of a node
+ * @param node The node to query
+ * @return The address of the owner
+ */
function owner(bytes32 node) external view returns (address);
- // Must call setRecord, not setOwner, We must namehash it ourselves as well
- function setSubnodeRecord(bytes32 node, bytes32 label, address owner, address resolver, uint64 ttl) external;
+ /**
+ * @notice Set the record for a subnode
+ * @dev Must call setRecord, not setOwner. We must namehash it ourselves as well.
+ * @param node The parent node
+ * @param label The label hash of the subnode
+ * @param _owner The address of the new owner
+ * @param resolver The address of the resolver
+ * @param ttl The TTL in seconds
+ */
+ function setSubnodeRecord(bytes32 node, bytes32 label, address _owner, address resolver, uint64 ttl) external;
}
diff --git a/packages/contracts/contracts/tests/ens/IPublicResolver.sol b/packages/contracts/contracts/tests/ens/IPublicResolver.sol
index 06ce2243b..7a449fb8d 100644
--- a/packages/contracts/contracts/tests/ens/IPublicResolver.sol
+++ b/packages/contracts/contracts/tests/ens/IPublicResolver.sol
@@ -1,8 +1,27 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
pragma solidity ^0.7.6;
// Needed for abi and typechain in the npm package
+/**
+ * @title ENS Public Resolver Interface
+ * @author Edge & Node
+ * @notice Interface for the ENS public resolver contract
+ */
interface IPublicResolver {
+ /**
+ * @notice Get the text record for a node
+ * @param node The node to query
+ * @param key The key of the text record
+ * @return The text record value
+ */
function text(bytes32 node, string calldata key) external view returns (string memory);
+ /**
+ * @notice Set the text record for a node
+ * @param node The node to set the record for
+ * @param key The key of the text record
+ * @param value The value to set
+ */
function setText(bytes32 node, string calldata key, string calldata value) external;
}
diff --git a/packages/contracts/contracts/tests/ens/ITestRegistrar.sol b/packages/contracts/contracts/tests/ens/ITestRegistrar.sol
index 8a795cc85..406a27fb7 100644
--- a/packages/contracts/contracts/tests/ens/ITestRegistrar.sol
+++ b/packages/contracts/contracts/tests/ens/ITestRegistrar.sol
@@ -1,5 +1,17 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
pragma solidity ^0.7.6;
+/**
+ * @title Test Registrar Interface
+ * @author Edge & Node
+ * @notice Interface for a test ENS registrar contract
+ */
interface ITestRegistrar {
+ /**
+ * @notice Register a name with the registrar
+ * @param label The label hash to register
+ * @param owner The address to assign ownership to
+ */
function register(bytes32 label, address owner) external;
}
diff --git a/packages/contracts/contracts/token/GraphToken.sol b/packages/contracts/contracts/token/GraphToken.sol
index 53496b9a5..652fa5477 100644
--- a/packages/contracts/contracts/token/GraphToken.sol
+++ b/packages/contracts/contracts/token/GraphToken.sol
@@ -2,16 +2,21 @@
pragma solidity ^0.7.6;
-import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
-import "@openzeppelin/contracts/token/ERC20/ERC20Burnable.sol";
-import "@openzeppelin/contracts/cryptography/ECDSA.sol";
-import "@openzeppelin/contracts/math/SafeMath.sol";
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-small-strings, gas-strict-inequalities
+// solhint-disable named-parameters-mapping
-import "../governance/Governed.sol";
+import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+import { ERC20Burnable } from "@openzeppelin/contracts/token/ERC20/ERC20Burnable.sol";
+import { ECDSA } from "@openzeppelin/contracts/cryptography/ECDSA.sol";
+import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol";
+
+import { Governed } from "../governance/Governed.sol";
/**
* @title GraphToken contract
- * @dev This is the implementation of the ERC20 Graph Token.
+ * @author Edge & Node
+ * @notice This is the implementation of the ERC20 Graph Token.
* The implementation exposes a Permit() function to allow for a spender to send a signed message
* and approve funds to a spender following EIP2612 to make integration with other contracts easier.
*
@@ -28,32 +33,53 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
// -- EIP712 --
// https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md#definition-of-domainseparator
+ /// @dev EIP-712 domain type hash for signature verification
bytes32 private constant DOMAIN_TYPE_HASH =
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)");
+ /// @dev EIP-712 domain name hash for signature verification
bytes32 private constant DOMAIN_NAME_HASH = keccak256("Graph Token");
+ /// @dev EIP-712 domain version hash for signature verification
bytes32 private constant DOMAIN_VERSION_HASH = keccak256("0");
+ /// @dev EIP-712 domain salt for signature verification (randomly generated)
bytes32 private constant DOMAIN_SALT = 0x51f3d585afe6dfeb2af01bba0889a36c1db03beec88c6a4d0c53817069026afa; // Randomly generated salt
+ /// @dev EIP-712 permit typehash for signature verification
bytes32 private constant PERMIT_TYPEHASH =
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
// -- State --
- bytes32 private DOMAIN_SEPARATOR;
+ /// @dev EIP-712 domain separator for signature verification
+ bytes32 private domainSeparator;
+ /// @dev Mapping of addresses authorized to mint tokens
mapping(address => bool) private _minters;
+ /**
+ * @notice Nonces for permit functionality (EIP-2612)
+ * @dev Mapping from owner address to current nonce for permit signatures
+ */
mapping(address => uint256) public nonces;
// -- Events --
+ /**
+ * @notice Emitted when a new minter is added
+ * @param account Address of the minter that was added
+ */
event MinterAdded(address indexed account);
+
+ /**
+ * @notice Emitted when a minter is removed
+ * @param account Address of the minter that was removed
+ */
event MinterRemoved(address indexed account);
+ /// @dev Modifier to restrict access to minters only
modifier onlyMinter() {
require(isMinter(msg.sender), "Only minter can call");
_;
}
/**
- * @dev Graph Token Contract Constructor.
+ * @notice Graph Token Contract Constructor.
* @param _initialSupply Initial supply of GRT
*/
constructor(uint256 _initialSupply) ERC20("Graph Token", "GRT") {
@@ -66,7 +92,7 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
_addMinter(msg.sender);
// EIP-712 domain separator
- DOMAIN_SEPARATOR = keccak256(
+ domainSeparator = keccak256(
abi.encode(
DOMAIN_TYPE_HASH,
DOMAIN_NAME_HASH,
@@ -79,7 +105,7 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
}
/**
- * @dev Approve token allowance by validating a message signed by the holder.
+ * @notice Approve token allowance by validating a message signed by the holder.
* @param _owner Address of the token holder
* @param _spender Address of the approved spender
* @param _value Amount of tokens to approve the spender
@@ -100,7 +126,7 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
bytes32 digest = keccak256(
abi.encodePacked(
"\x19\x01",
- DOMAIN_SEPARATOR,
+ domainSeparator,
keccak256(abi.encode(PERMIT_TYPEHASH, _owner, _spender, _value, nonces[_owner], _deadline))
)
);
@@ -114,7 +140,7 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
}
/**
- * @dev Add a new minter.
+ * @notice Add a new minter.
* @param _account Address of the minter
*/
function addMinter(address _account) external onlyGovernor {
@@ -122,7 +148,7 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
}
/**
- * @dev Remove a minter.
+ * @notice Remove a minter.
* @param _account Address of the minter
*/
function removeMinter(address _account) external onlyGovernor {
@@ -130,14 +156,14 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
}
/**
- * @dev Renounce to be a minter.
+ * @notice Renounce to be a minter.
*/
function renounceMinter() external {
_removeMinter(msg.sender);
}
/**
- * @dev Mint new tokens.
+ * @notice Mint new tokens.
* @param _to Address to send the newly minted tokens
* @param _amount Amount of tokens to mint
*/
@@ -146,7 +172,7 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
}
/**
- * @dev Return if the `_account` is a minter or not.
+ * @notice Return if the `_account` is a minter or not.
* @param _account Address to check
* @return True if the `_account` is minter
*/
@@ -155,7 +181,7 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
}
/**
- * @dev Add a new minter.
+ * @notice Add a new minter.
* @param _account Address of the minter
*/
function _addMinter(address _account) private {
@@ -164,7 +190,7 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
}
/**
- * @dev Remove a minter.
+ * @notice Remove a minter.
* @param _account Address of the minter
*/
function _removeMinter(address _account) private {
@@ -173,11 +199,12 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
}
/**
- * @dev Get the running network chain ID.
+ * @notice Get the running network chain ID.
* @return The chain ID
*/
function _getChainID() private pure returns (uint256) {
uint256 id;
+ // solhint-disable-next-line no-inline-assembly
assembly {
id := chainid()
}
diff --git a/packages/contracts/contracts/token/IGraphToken.sol b/packages/contracts/contracts/token/IGraphToken.sol
deleted file mode 100644
index df3b7643f..000000000
--- a/packages/contracts/contracts/token/IGraphToken.sol
+++ /dev/null
@@ -1,43 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
-
-interface IGraphToken is IERC20 {
- // -- Mint and Burn --
-
- function burn(uint256 amount) external;
-
- function burnFrom(address _from, uint256 amount) external;
-
- function mint(address _to, uint256 _amount) external;
-
- // -- Mint Admin --
-
- function addMinter(address _account) external;
-
- function removeMinter(address _account) external;
-
- function renounceMinter() external;
-
- function isMinter(address _account) external view returns (bool);
-
- // -- Permit --
-
- function permit(
- address _owner,
- address _spender,
- uint256 _value,
- uint256 _deadline,
- uint8 _v,
- bytes32 _r,
- bytes32 _s
- ) external;
-
- // -- Allowance --
-
- function increaseAllowance(address spender, uint256 addedValue) external returns (bool);
-
- function decreaseAllowance(address spender, uint256 subtractedValue) external returns (bool);
-}
diff --git a/packages/contracts/contracts/upgrades/GraphProxy.sol b/packages/contracts/contracts/upgrades/GraphProxy.sol
index d6fbfac7f..624c3a650 100644
--- a/packages/contracts/contracts/upgrades/GraphProxy.sol
+++ b/packages/contracts/contracts/upgrades/GraphProxy.sol
@@ -1,14 +1,20 @@
// SPDX-License-Identifier: GPL-2.0-or-later
-pragma solidity ^0.7.6 || 0.8.27;
+pragma solidity ^0.7.6 || ^0.8.27;
+
+// TODO: Re-enable and fix issues when publishing a new version
+// solhint-disable gas-small-strings
+
+/* solhint-disable gas-custom-errors */ // Cannot use custom errors with 0.7.6
import { GraphProxyStorage } from "./GraphProxyStorage.sol";
-import { IGraphProxy } from "./IGraphProxy.sol";
+import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol";
/**
* @title Graph Proxy
- * @dev Graph Proxy contract used to delegate call implementation contracts and support upgrades.
+ * @author Edge & Node
+ * @notice Graph Proxy contract used to delegate call implementation contracts and support upgrades.
* This contract should NOT define storage as it is managed by GraphProxyStorage.
* This contract implements a proxy that is upgradeable by an admin.
* https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies#transparent-proxies-and-function-clashes
@@ -69,56 +75,49 @@ contract GraphProxy is GraphProxyStorage, IGraphProxy {
}
/**
- * @notice Get the current admin
- *
+ * @inheritdoc IGraphProxy
* @dev NOTE: Only the admin and implementation can call this function.
*
* TIP: To get this value clients can read directly from the storage slot shown below (specified by EIP1967) using the
* https://eth.wiki/json-rpc/API#eth_getstorageat[`eth_getStorageAt`] RPC call.
* `0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103`
- *
- * @return The address of the current admin
*/
- function admin() external override ifAdminOrPendingImpl returns (address) {
+ function admin() external override ifAdminOrPendingImpl returns (address adminAddress) {
return _getAdmin();
}
/**
- * @notice Get the current implementation.
- *
+ * @inheritdoc IGraphProxy
* @dev NOTE: Only the admin can call this function.
*
* TIP: To get this value clients can read directly from the storage slot shown below (specified by EIP1967) using the
* https://eth.wiki/json-rpc/API#eth_getstorageat[`eth_getStorageAt`] RPC call.
* `0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc`
- *
- * @return The address of the current implementation for this proxy
*/
- function implementation() external override ifAdminOrPendingImpl returns (address) {
+ function implementation() external override ifAdminOrPendingImpl returns (address implementationAddress) {
return _getImplementation();
}
/**
- * @notice Get the current pending implementation.
- *
+ * @inheritdoc IGraphProxy
* @dev NOTE: Only the admin can call this function.
*
* TIP: To get this value clients can read directly from the storage slot shown below (specified by EIP1967) using the
* https://eth.wiki/json-rpc/API#eth_getstorageat[`eth_getStorageAt`] RPC call.
* `0x9e5eddc59e0b171f57125ab86bee043d9128098c3a6b9adb4f2e86333c2f6f8c`
- *
- * @return The address of the current pending implementation for this proxy
*/
- function pendingImplementation() external override ifAdminOrPendingImpl returns (address) {
+ function pendingImplementation()
+ external
+ override
+ ifAdminOrPendingImpl
+ returns (address pendingImplementationAddress)
+ {
return _getPendingImplementation();
}
/**
- * @notice Changes the admin of the proxy.
- *
+ * @inheritdoc IGraphProxy
* @dev NOTE: Only the admin can call this function.
- *
- * @param _newAdmin Address of the new admin
*/
function setAdmin(address _newAdmin) external override ifAdmin {
require(_newAdmin != address(0), "Admin cant be the zero address");
@@ -126,25 +125,22 @@ contract GraphProxy is GraphProxyStorage, IGraphProxy {
}
/**
- * @notice Upgrades to a new implementation contract.
+ * @inheritdoc IGraphProxy
* @dev NOTE: Only the admin can call this function.
- * @param _newImplementation Address of implementation contract
*/
function upgradeTo(address _newImplementation) external override ifAdmin {
_setPendingImplementation(_newImplementation);
}
/**
- * @notice Admin function for new implementation to accept its role as implementation.
+ * @inheritdoc IGraphProxy
*/
function acceptUpgrade() external override ifAdminOrPendingImpl {
_acceptUpgrade();
}
/**
- * @notice Admin function for new implementation to accept its role as implementation,
- * calling a function on the new implementation.
- * @param data Calldata (including selector) for the function to delegatecall into the implementation
+ * @inheritdoc IGraphProxy
*/
function acceptUpgradeAndCall(bytes calldata data) external override ifAdminOrPendingImpl {
_acceptUpgrade();
@@ -154,7 +150,7 @@ contract GraphProxy is GraphProxyStorage, IGraphProxy {
}
/**
- * @dev Admin function for new implementation to accept its role as implementation.
+ * @notice Admin function for new implementation to accept its role as implementation.
*/
function _acceptUpgrade() internal {
address _pendingImplementation = _getPendingImplementation();
@@ -166,7 +162,7 @@ contract GraphProxy is GraphProxyStorage, IGraphProxy {
}
/**
- * @dev Delegates the current call to implementation.
+ * @notice Delegates the current call to implementation.
* This function does not return to its internal call site, it will return directly to the
* external caller.
*/
diff --git a/packages/contracts/contracts/upgrades/GraphProxyAdmin.sol b/packages/contracts/contracts/upgrades/GraphProxyAdmin.sol
index db8e9dcb3..e603a6a50 100644
--- a/packages/contracts/contracts/upgrades/GraphProxyAdmin.sol
+++ b/packages/contracts/contracts/upgrades/GraphProxyAdmin.sol
@@ -1,15 +1,18 @@
// SPDX-License-Identifier: GPL-2.0-or-later
-pragma solidity ^0.7.6 || 0.8.27;
+pragma solidity ^0.7.6 || ^0.8.27;
+
+/* solhint-disable gas-custom-errors */ // Cannot use custom errors with 0.7.6
import { Governed } from "../governance/Governed.sol";
-import { IGraphProxy } from "./IGraphProxy.sol";
+import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol";
import { GraphUpgradeable } from "./GraphUpgradeable.sol";
/**
* @title GraphProxyAdmin
- * @dev This is the owner of upgradeable proxy contracts.
+ * @author Edge & Node
+ * @notice This is the owner of upgradeable proxy contracts.
* Proxy contracts use a TransparentProxy pattern, any admin related call
* like upgrading a contract or changing the admin needs to be send through
* this contract.
diff --git a/packages/contracts/contracts/upgrades/GraphProxyStorage.sol b/packages/contracts/contracts/upgrades/GraphProxyStorage.sol
index 7871e4996..d550d18f0 100644
--- a/packages/contracts/contracts/upgrades/GraphProxyStorage.sol
+++ b/packages/contracts/contracts/upgrades/GraphProxyStorage.sol
@@ -1,10 +1,13 @@
// SPDX-License-Identifier: GPL-2.0-or-later
-pragma solidity ^0.7.6 || 0.8.27;
+pragma solidity ^0.7.6 || ^0.8.27;
+
+/* solhint-disable gas-custom-errors */ // Cannot use custom errors with 0.7.6
/**
* @title Graph Proxy Storage
- * @dev Contract functions related to getting and setting proxy storage.
+ * @author Edge & Node
+ * @notice Contract functions related to getting and setting proxy storage.
* This contract does not actually define state variables managed by the compiler
* but uses fixed slot locations.
*/
@@ -32,7 +35,9 @@ abstract contract GraphProxyStorage {
bytes32 internal constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
/**
- * @dev Emitted when pendingImplementation is changed.
+ * @notice Emitted when pendingImplementation is changed.
+ * @param oldPendingImplementation Address of the previous pending implementation
+ * @param newPendingImplementation Address of the new pending implementation
*/
event PendingImplementationUpdated(
address indexed oldPendingImplementation,
@@ -40,13 +45,17 @@ abstract contract GraphProxyStorage {
);
/**
- * @dev Emitted when pendingImplementation is accepted,
+ * @notice Emitted when pendingImplementation is accepted,
* which means contract implementation is updated.
+ * @param oldImplementation Address of the previous implementation
+ * @param newImplementation Address of the new implementation
*/
event ImplementationUpdated(address indexed oldImplementation, address indexed newImplementation);
/**
- * @dev Emitted when the admin account has changed.
+ * @notice Emitted when the admin account has changed.
+ * @param oldAdmin Address of the previous admin
+ * @param newAdmin Address of the new admin
*/
event AdminUpdated(address indexed oldAdmin, address indexed newAdmin);
@@ -59,6 +68,7 @@ abstract contract GraphProxyStorage {
}
/**
+ * @notice Returns the current admin address
* @return adm The admin slot.
*/
function _getAdmin() internal view returns (address adm) {
@@ -70,7 +80,7 @@ abstract contract GraphProxyStorage {
}
/**
- * @dev Sets the address of the proxy admin.
+ * @notice Sets the address of the proxy admin.
* @param _newAdmin Address of the new proxy admin
*/
function _setAdmin(address _newAdmin) internal {
@@ -85,7 +95,7 @@ abstract contract GraphProxyStorage {
}
/**
- * @dev Returns the current implementation.
+ * @notice Returns the current implementation.
* @return impl Address of the current implementation
*/
function _getImplementation() internal view returns (address impl) {
@@ -97,7 +107,7 @@ abstract contract GraphProxyStorage {
}
/**
- * @dev Returns the current pending implementation.
+ * @notice Returns the current pending implementation.
* @return impl Address of the current pending implementation
*/
function _getPendingImplementation() internal view returns (address impl) {
@@ -109,7 +119,7 @@ abstract contract GraphProxyStorage {
}
/**
- * @dev Sets the implementation address of the proxy.
+ * @notice Sets the implementation address of the proxy.
* @param _newImplementation Address of the new implementation
*/
function _setImplementation(address _newImplementation) internal {
@@ -125,7 +135,7 @@ abstract contract GraphProxyStorage {
}
/**
- * @dev Sets the pending implementation address of the proxy.
+ * @notice Sets the pending implementation address of the proxy.
* @param _newImplementation Address of the new pending implementation
*/
function _setPendingImplementation(address _newImplementation) internal {
diff --git a/packages/contracts/contracts/upgrades/GraphUpgradeable.sol b/packages/contracts/contracts/upgrades/GraphUpgradeable.sol
index 60dfbe888..a6cc7b8c6 100644
--- a/packages/contracts/contracts/upgrades/GraphUpgradeable.sol
+++ b/packages/contracts/contracts/upgrades/GraphUpgradeable.sol
@@ -1,12 +1,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later
-pragma solidity ^0.7.6 || 0.8.27;
+pragma solidity ^0.7.6 || ^0.8.27;
-import { IGraphProxy } from "./IGraphProxy.sol";
+/* solhint-disable gas-custom-errors */ // Cannot use custom errors with 0.7.6
+
+import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol";
/**
* @title Graph Upgradeable
- * @dev This contract is intended to be inherited from upgradeable contracts.
+ * @author Edge & Node
+ * @notice This contract is intended to be inherited from upgradeable contracts.
*/
abstract contract GraphUpgradeable {
/**
@@ -18,6 +21,7 @@ abstract contract GraphUpgradeable {
/**
* @dev Check if the caller is the proxy admin.
+ * @param _proxy The proxy contract to check admin for
*/
modifier onlyProxyAdmin(IGraphProxy _proxy) {
require(msg.sender == _proxy.admin(), "Caller must be the proxy admin");
@@ -33,7 +37,7 @@ abstract contract GraphUpgradeable {
}
/**
- * @dev Returns the current implementation.
+ * @notice Returns the current implementation.
* @return impl Address of the current implementation
*/
function _implementation() internal view returns (address impl) {
diff --git a/packages/contracts/contracts/upgrades/IGraphProxy.sol b/packages/contracts/contracts/upgrades/IGraphProxy.sol
deleted file mode 100644
index 4f501ed7c..000000000
--- a/packages/contracts/contracts/upgrades/IGraphProxy.sol
+++ /dev/null
@@ -1,19 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-pragma solidity ^0.7.6 || 0.8.27;
-
-interface IGraphProxy {
- function admin() external returns (address);
-
- function setAdmin(address _newAdmin) external;
-
- function implementation() external returns (address);
-
- function pendingImplementation() external returns (address);
-
- function upgradeTo(address _newImplementation) external;
-
- function acceptUpgrade() external;
-
- function acceptUpgradeAndCall(bytes calldata data) external;
-}
diff --git a/packages/contracts/contracts/utils/TokenUtils.sol b/packages/contracts/contracts/utils/TokenUtils.sol
index ef2f03211..f4c0f58f5 100644
--- a/packages/contracts/contracts/utils/TokenUtils.sol
+++ b/packages/contracts/contracts/utils/TokenUtils.sol
@@ -1,18 +1,21 @@
// SPDX-License-Identifier: GPL-2.0-or-later
-pragma solidity ^0.7.6 || 0.8.27;
+pragma solidity ^0.7.6 || ^0.8.27;
-import { IGraphToken } from "../token/IGraphToken.sol";
+/* solhint-disable gas-custom-errors */ // Cannot use custom errors with 0.7.6
+
+import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol";
/**
* @title TokenUtils library
+ * @author Edge & Node
* @notice This library contains utility functions for handling tokens (transfers and burns).
* It is specifically adapted for the GraphToken, so does not need to handle edge cases
* for other tokens.
*/
library TokenUtils {
/**
- * @dev Pull tokens from an address to this contract.
+ * @notice Pull tokens from an address to this contract.
* @param _graphToken Token to transfer
* @param _from Address sending the tokens
* @param _amount Amount of tokens to transfer
@@ -24,7 +27,7 @@ library TokenUtils {
}
/**
- * @dev Push tokens from this contract to a receiving address.
+ * @notice Push tokens from this contract to a receiving address.
* @param _graphToken Token to transfer
* @param _to Address receiving the tokens
* @param _amount Amount of tokens to transfer
@@ -36,7 +39,7 @@ library TokenUtils {
}
/**
- * @dev Burn tokens held by this contract.
+ * @notice Burn tokens held by this contract.
* @param _graphToken Token to burn
* @param _amount Amount of tokens to burn
*/
diff --git a/packages/contracts/hardhat.config.ts b/packages/contracts/hardhat.config.ts
index dc327f815..ba90039ca 100644
--- a/packages/contracts/hardhat.config.ts
+++ b/packages/contracts/hardhat.config.ts
@@ -7,6 +7,7 @@ import 'solidity-coverage' // for coverage script
import 'dotenv/config'
import '@nomicfoundation/hardhat-verify'
+import { vars } from 'hardhat/config'
import { HardhatUserConfig } from 'hardhat/config'
// Default mnemonic for basic hardhat network
@@ -57,7 +58,16 @@ const config: HardhatUserConfig = {
},
},
etherscan: {
- apiKey: process.env.ARBISCAN_API_KEY,
+ // Use ARBISCAN_API_KEY for Arbitrum networks
+ // For mainnet Ethereum, use ETHERSCAN_API_KEY
+ // Check both keystore (vars) and environment variable
+ apiKey: vars.has('ARBISCAN_API_KEY') ? vars.get('ARBISCAN_API_KEY') : (process.env.ARBISCAN_API_KEY ?? ''),
+ },
+ sourcify: {
+ enabled: false,
+ },
+ blockscout: {
+ enabled: false,
},
typechain: {
outDir: 'types',
diff --git a/packages/contracts/package.json b/packages/contracts/package.json
index 81f9b1d16..a7a4933c8 100644
--- a/packages/contracts/package.json
+++ b/packages/contracts/package.json
@@ -8,7 +8,8 @@
"main": "index.js",
"repository": {
"type": "git",
- "url": "git+https://github.com/graphprotocol/contracts.git"
+ "url": "git+https://github.com/graphprotocol/contracts",
+ "directory": "packages/contracts"
},
"author": "Edge & Node",
"license": "GPL-2.0-or-later",
@@ -19,7 +20,7 @@
"types": "index.d.ts",
"scripts": {
"prepack": "pnpm build",
- "clean": "rm -rf artifacts/ cache/ types/ abis/ build/ dist/ coverage/",
+ "clean": "rm -rf artifacts/ cache/ types/ abis/ build/ dist/ coverage/ test/node_modules/",
"build": "pnpm build:self",
"build:self": "pnpm compile",
"compile": "hardhat compile --quiet",
@@ -99,5 +100,14 @@
"winston": "^3.3.3",
"yaml": "^1.10.2",
"yargs": "^17.0.0"
+ },
+ "exports": {
+ ".": {
+ "types": "./index.d.ts",
+ "default": "./index.js"
+ },
+ "./artifacts/*": "./artifacts/*",
+ "./types": "./types/index.ts",
+ "./types/*": "./types/*"
}
}
diff --git a/packages/contracts/task/hardhat.config.ts b/packages/contracts/task/hardhat.config.ts
index 8d135decc..aa4223a88 100644
--- a/packages/contracts/task/hardhat.config.ts
+++ b/packages/contracts/task/hardhat.config.ts
@@ -155,16 +155,6 @@ const config: HardhatUserConfig = {
arbitrumGoerli: process.env.ARBISCAN_API_KEY || '',
arbitrumSepolia: process.env.ARBISCAN_API_KEY || '',
},
- customChains: [
- {
- network: 'arbitrumSepolia',
- chainId: 421614,
- urls: {
- apiURL: 'https://api-sepolia.arbiscan.io/api',
- browserURL: 'https://sepolia.arbiscan.io',
- },
- },
- ],
},
typechain: {
outDir: '../types',
diff --git a/packages/contracts/task/package.json b/packages/contracts/task/package.json
index 5d84ec8ba..fd9bb85d1 100644
--- a/packages/contracts/task/package.json
+++ b/packages/contracts/task/package.json
@@ -26,7 +26,7 @@
"dependencies": {
"@graphprotocol/contracts": "workspace:^",
"@graphprotocol/sdk": "0.6.0",
- "axios": "^1.9.0",
+ "axios": "^1.16.1",
"console-table-printer": "^2.14.1"
},
"devDependencies": {
diff --git a/packages/contracts/task/tasks/bridge/deposits.ts b/packages/contracts/task/tasks/bridge/deposits.ts
index e6e64d70b..366d55a0d 100644
--- a/packages/contracts/task/tasks/bridge/deposits.ts
+++ b/packages/contracts/task/tasks/bridge/deposits.ts
@@ -1,6 +1,3 @@
-// Import type extensions to make hre.graph available
-import '@graphprotocol/sdk/gre/type-extensions'
-
import { L1ToL2MessageStatus } from '@arbitrum/sdk'
import { getL1ToL2MessageStatus } from '@graphprotocol/sdk'
import { GraphRuntimeEnvironmentOptions, greTask } from '@graphprotocol/sdk/gre'
diff --git a/packages/contracts/task/tasks/bridge/withdrawals.ts b/packages/contracts/task/tasks/bridge/withdrawals.ts
index 2a51a3ed5..e0f729385 100644
--- a/packages/contracts/task/tasks/bridge/withdrawals.ts
+++ b/packages/contracts/task/tasks/bridge/withdrawals.ts
@@ -1,6 +1,3 @@
-// Import type extensions to make hre.graph available
-import '@graphprotocol/sdk/gre/type-extensions'
-
import { L2ToL1MessageStatus } from '@arbitrum/sdk'
import { getL2ToL1MessageStatus } from '@graphprotocol/sdk'
import { GraphRuntimeEnvironmentOptions, greTask } from '@graphprotocol/sdk/gre'
diff --git a/packages/contracts/task/tasks/verify/verify.ts b/packages/contracts/task/tasks/verify/verify.ts
index f4462ad77..def5b8c06 100644
--- a/packages/contracts/task/tasks/verify/verify.ts
+++ b/packages/contracts/task/tasks/verify/verify.ts
@@ -1,6 +1,3 @@
-// Import type extensions to make hre.graph available
-import '@graphprotocol/sdk/gre/type-extensions'
-
import { ContractConfigParam, getContractConfig, readConfig } from '@graphprotocol/contracts-task'
import { GraphRuntimeEnvironmentOptions, greTask } from '@graphprotocol/sdk/gre'
import fs from 'fs'
diff --git a/packages/contracts/test/contracts b/packages/contracts/test/contracts
deleted file mode 120000
index 0989a2ba8..000000000
--- a/packages/contracts/test/contracts
+++ /dev/null
@@ -1 +0,0 @@
-../contracts
\ No newline at end of file
diff --git a/packages/contracts/test/prettier.config.cjs b/packages/contracts/test/prettier.config.cjs
deleted file mode 100644
index 8eb0a0bee..000000000
--- a/packages/contracts/test/prettier.config.cjs
+++ /dev/null
@@ -1,5 +0,0 @@
-const baseConfig = require('../prettier.config.cjs')
-
-module.exports = {
- ...baseConfig,
-}
diff --git a/packages/data-edge/.solhint.json b/packages/data-edge/.solhint.json
index d30847305..780d82f39 100644
--- a/packages/data-edge/.solhint.json
+++ b/packages/data-edge/.solhint.json
@@ -1,3 +1,3 @@
{
- "extends": ["solhint:recommended", "./../../.solhint.json"]
+ "extends": "./../../.solhint.json"
}
diff --git a/packages/data-edge/contracts/DataEdge.sol b/packages/data-edge/contracts/DataEdge.sol
index fc39b7386..8b02c3ce0 100644
--- a/packages/data-edge/contracts/DataEdge.sol
+++ b/packages/data-edge/contracts/DataEdge.sol
@@ -5,8 +5,10 @@ pragma solidity ^0.8.12;
/// @title Data Edge contract is only used to store on-chain data, it does not
/// perform execution. On-chain client services can read the data
/// and decode the payload for different purposes.
+/// @author Edge & Node
+/// @notice Contract for storing on-chain data without execution
contract DataEdge {
- /// @dev Fallback function, accepts any payload
+ /// @notice Fallback function, accepts any payload
fallback() external payable {
// no-op
}
diff --git a/packages/data-edge/contracts/EventfulDataEdge.sol b/packages/data-edge/contracts/EventfulDataEdge.sol
index d995be665..d3725f151 100644
--- a/packages/data-edge/contracts/EventfulDataEdge.sol
+++ b/packages/data-edge/contracts/EventfulDataEdge.sol
@@ -6,9 +6,14 @@ pragma solidity ^0.8.12;
/// perform execution. On-chain client services can read the data
/// and decode the payload for different purposes.
/// NOTE: This version emits an event with the calldata.
+/// @author Edge & Node
+/// @notice Contract for storing on-chain data with event logging
contract EventfulDataEdge {
+ /// @notice Emitted when data is received
+ /// @param data The calldata received by the contract
event Log(bytes data);
+ /// @notice Accepts any payload and emits it as an event
/// @dev Fallback function, accepts any payload
fallback() external payable {
emit Log(msg.data);
diff --git a/packages/data-edge/hardhat.config.ts b/packages/data-edge/hardhat.config.ts
index d427a93eb..6dee140d0 100644
--- a/packages/data-edge/hardhat.config.ts
+++ b/packages/data-edge/hardhat.config.ts
@@ -1,15 +1,12 @@
import '@typechain/hardhat'
-// Plugins
-import '@nomiclabs/hardhat-ethers'
-import '@nomiclabs/hardhat-etherscan'
-import '@nomiclabs/hardhat-waffle'
+import '@nomicfoundation/hardhat-ethers'
+import '@nomicfoundation/hardhat-chai-matchers'
+import '@nomicfoundation/hardhat-verify'
import 'hardhat-abi-exporter'
import 'hardhat-gas-reporter'
import 'hardhat-contract-sizer'
-import '@openzeppelin/hardhat-upgrades'
import 'solidity-coverage'
-import '@tenderly/hardhat-tenderly'
-import 'hardhat-secure-accounts' // for graph config
+import 'hardhat-secure-accounts'
// Tasks
import './tasks/craft-calldata'
import './tasks/post-calldata'
@@ -29,20 +26,12 @@ interface NetworkConfig {
const networkConfigs: NetworkConfig[] = [
{ network: 'mainnet', chainId: 1 },
- { network: 'ropsten', chainId: 3 },
- { network: 'rinkeby', chainId: 4 },
- { network: 'kovan', chainId: 42 },
{ network: 'sepolia', chainId: 11155111 },
{
network: 'arbitrum-one',
chainId: 42161,
url: 'https://arb1.arbitrum.io/rpc',
},
- {
- network: 'arbitrum-goerli',
- chainId: 421613,
- url: 'https://goerli-rollup.arbitrum.io/rpc',
- },
{
network: 'arbitrum-sepolia',
chainId: 421614,
@@ -89,10 +78,6 @@ task('accounts', 'Prints the list of accounts', async (_, bre) => {
// Config
const config: HardhatUserConfig = {
- graph: {
- addressBook: process.env.ADDRESS_BOOK || 'addresses.json',
- disableSecureAccounts: true,
- },
paths: {
sources: './contracts',
tests: './test',
@@ -101,7 +86,7 @@ const config: HardhatUserConfig = {
solidity: {
compilers: [
{
- version: '0.8.12',
+ version: '0.8.35',
settings: {
optimizer: {
enabled: true,
@@ -140,22 +125,10 @@ const config: HardhatUserConfig = {
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,
},
- customChains: [
- {
- network: 'arbitrumSepolia',
- chainId: 421614,
- urls: {
- apiURL: 'https://api-sepolia.arbiscan.io/api',
- browserURL: 'https://sepolia.arbiscan.io',
- },
- },
- ],
},
gasReporter: {
enabled: process.env.REPORT_GAS ? true : false,
@@ -165,17 +138,13 @@ const config: HardhatUserConfig = {
},
typechain: {
outDir: 'build/types',
- target: 'ethers-v5',
+ target: 'ethers-v6',
},
abiExporter: {
path: './build/abis',
clear: false,
flat: true,
},
- tenderly: {
- project: process.env.TENDERLY_PROJECT,
- username: process.env.TENDERLY_USERNAME,
- },
contractSizer: {
alphaSort: true,
runOnCompile: false,
diff --git a/packages/data-edge/package.json b/packages/data-edge/package.json
index c97514031..15b97d050 100644
--- a/packages/data-edge/package.json
+++ b/packages/data-edge/package.json
@@ -7,8 +7,6 @@
"license": "GPL-2.0-or-later",
"main": "index.js",
"scripts": {
- "prepare": "cd ../.. && husky install packages/contracts/.husky",
- "prepublishOnly": "scripts/prepublish",
"build": "pnpm build:self",
"build:self": "scripts/build",
"clean": "rm -rf build/ cache/ dist/ reports/ artifacts/",
@@ -35,43 +33,30 @@
"LICENSE"
],
"devDependencies": {
- "@ethersproject/abi": "^5.7.0",
- "@ethersproject/bytes": "^5.7.0",
- "@ethersproject/providers": "^5.7.0",
- "@nomiclabs/hardhat-ethers": "^2.0.2",
- "@nomiclabs/hardhat-etherscan": "^3.1.2",
- "@nomiclabs/hardhat-waffle": "^2.0.1",
- "@openzeppelin/contracts": "^4.5.0",
- "@openzeppelin/hardhat-upgrades": "^1.8.2",
- "@tenderly/api-client": "^1.0.13",
- "@tenderly/hardhat-tenderly": "^1.0.13",
- "@typechain/ethers-v5": "^10.2.1",
- "@typechain/hardhat": "^6.1.6",
+ "@nomicfoundation/hardhat-chai-matchers": "catalog:",
+ "@nomicfoundation/hardhat-ethers": "catalog:",
+ "@nomicfoundation/hardhat-verify": "catalog:",
+ "@typechain/ethers-v6": "^0.5.0",
+ "@typechain/hardhat": "catalog:",
"@types/mocha": "^9.0.0",
- "@types/node": "^20.17.50",
+ "@types/node": "catalog:",
"@types/sinon-chai": "^3.2.12",
- "chai": "^4.2.0",
- "dotenv": "^16.0.0",
+ "chai": "catalog:",
+ "dotenv": "catalog:",
"eslint": "catalog:",
- "ethereum-waffle": "^3.0.2",
- "ethers": "^5.7.2",
- "ethlint": "^1.2.5",
+ "ethers": "catalog:",
"hardhat": "catalog:",
"hardhat-abi-exporter": "^2.2.0",
- "hardhat-contract-sizer": "^2.0.3",
- "hardhat-gas-reporter": "^1.0.4",
- "hardhat-secure-accounts": "0.0.6",
- "husky": "^7.0.4",
- "lint-staged": "^12.3.5",
- "lodash": "^4.17.21",
- "markdownlint-cli": "0.45.0",
+ "hardhat-contract-sizer": "catalog:",
+ "hardhat-gas-reporter": "catalog:",
+ "hardhat-secure-accounts": "catalog:",
+ "markdownlint-cli": "catalog:",
"prettier": "catalog:",
"prettier-plugin-solidity": "catalog:",
"solhint": "catalog:",
"solidity-coverage": "^0.8.16",
- "truffle-flattener": "^1.4.4",
- "ts-node": ">=8.0.0",
- "typechain": "^8.3.0",
+ "ts-node": "catalog:",
+ "typechain": "catalog:",
"typescript": "catalog:"
}
}
diff --git a/packages/data-edge/tasks/craft-calldata.ts b/packages/data-edge/tasks/craft-calldata.ts
index 8e285886c..855478f68 100644
--- a/packages/data-edge/tasks/craft-calldata.ts
+++ b/packages/data-edge/tasks/craft-calldata.ts
@@ -1,5 +1,3 @@
-import '@nomiclabs/hardhat-ethers'
-
import { Contract } from 'ethers'
import { task } from 'hardhat/config'
@@ -35,15 +33,13 @@ task('data:craft', 'Build calldata')
.addParam('selector', 'Selector name')
.addParam('data', 'Call data to post')
.setAction(async (taskArgs, hre) => {
- // parse input
const edgeAddress = taskArgs.edge
const calldata = taskArgs.data
const selector = taskArgs.selector
- // build data
const abi = getAbiForSelector(selector)
const contract = getContract(edgeAddress, abi, hre.ethers.provider)
- const tx = await contract.populateTransaction[selector](calldata)
+ const tx = await contract[selector].populateTransaction(calldata)
const txData = tx.data
console.log(txData)
})
diff --git a/packages/data-edge/tasks/deploy.ts b/packages/data-edge/tasks/deploy.ts
index 57a216a9c..ca142b1e2 100644
--- a/packages/data-edge/tasks/deploy.ts
+++ b/packages/data-edge/tasks/deploy.ts
@@ -1,5 +1,3 @@
-import '@nomiclabs/hardhat-ethers'
-
import { promises as fs } from 'fs'
import { task } from 'hardhat/config'
@@ -31,25 +29,25 @@ task('data-edge:deploy', 'Deploy a DataEdge contract')
console.log(`Deploying contract...`)
const contract = await factory.deploy()
- const tx = contract.deployTransaction
+ const tx = contract.deploymentTransaction()!
- // The address the Contract WILL have once mined
- console.log(`> deployer: ${await contract.signer.getAddress()}`)
- console.log(`> contract: ${contract.address}`)
+ const contractAddress = await contract.getAddress()
+ const [signer] = await hre.ethers.getSigners()
+ console.log(`> deployer: ${await signer.getAddress()}`)
+ console.log(`> contract: ${contractAddress}`)
console.log(
- `> tx: ${tx.hash} nonce:${tx.nonce} limit: ${tx.gasLimit.toString()} gas: ${tx.gasPrice.toNumber() / 1e9} (gwei)`,
+ `> tx: ${tx.hash} nonce:${tx.nonce} limit: ${tx.gasLimit.toString()} gas: ${Number(tx.gasPrice) / 1e9} (gwei)`,
)
- // The contract is NOT deployed yet; we must wait until it is mined
- await contract.deployed()
+ await contract.waitForDeployment()
console.log(`Done!`)
// Update addresses.json
- const chainId = hre.network.config.chainId.toString()
+ const chainId = hre.network.config.chainId!.toString()
if (!addresses[chainId]) {
addresses[chainId] = {}
}
const deployName = `${taskArgs.deployName}${taskArgs.contract}`
- addresses[chainId][deployName] = contract.address
- return fs.writeFile('addresses.json', JSON.stringify(addresses, null, 2))
+ addresses[chainId][deployName] = contractAddress
+ return fs.writeFile('addresses.json', JSON.stringify(addresses, null, 2) + '\n')
})
diff --git a/packages/data-edge/tasks/post-calldata.ts b/packages/data-edge/tasks/post-calldata.ts
index fbededfbc..edd455511 100644
--- a/packages/data-edge/tasks/post-calldata.ts
+++ b/packages/data-edge/tasks/post-calldata.ts
@@ -1,30 +1,28 @@
-import '@nomiclabs/hardhat-ethers'
-
import { task } from 'hardhat/config'
task('data:post', 'Post calldata')
.addParam('edge', 'Address of the data edge contract')
.addParam('data', 'Call data to post')
.setAction(async (taskArgs, hre) => {
- // prepare data
const edgeAddress = taskArgs.edge
const txData = taskArgs.data
+ const [signer] = await hre.ethers.getSigners()
const contract = await hre.ethers.getContractAt('DataEdge', edgeAddress)
+ const contractAddress = await contract.getAddress()
const txRequest = {
data: txData,
- to: contract.address,
+ to: contractAddress,
}
- // send transaction
console.log(`Sending data...`)
- console.log(`> edge: ${contract.address}`)
- console.log(`> sender: ${await contract.signer.getAddress()}`)
+ console.log(`> edge: ${contractAddress}`)
+ console.log(`> sender: ${await signer.getAddress()}`)
console.log(`> payload: ${txData}`)
- const tx = await contract.signer.sendTransaction(txRequest)
+ const tx = await signer.sendTransaction(txRequest)
console.log(
- `> tx: ${tx.hash} nonce:${tx.nonce} limit: ${tx.gasLimit.toString()} gas: ${tx.gasPrice.toNumber() / 1e9} (gwei)`,
+ `> tx: ${tx.hash} nonce:${tx.nonce} limit: ${tx.gasLimit.toString()} gas: ${Number(tx.gasPrice) / 1e9} (gwei)`,
)
const rx = await tx.wait()
- console.log('> rx: ', rx.status == 1 ? 'success' : 'failed')
+ console.log('> rx: ', rx!.status == 1 ? 'success' : 'failed')
console.log(`Done!`)
})
diff --git a/packages/data-edge/test/dataedge.test.ts b/packages/data-edge/test/dataedge.test.ts
index 479758881..b96257786 100644
--- a/packages/data-edge/test/dataedge.test.ts
+++ b/packages/data-edge/test/dataedge.test.ts
@@ -1,57 +1,43 @@
-import '@nomiclabs/hardhat-ethers'
-
-import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'
import { expect } from 'chai'
import { ethers } from 'hardhat'
-import { DataEdge, DataEdge__factory } from '../build/types'
-
-const { getContractFactory, getSigners } = ethers
-const { id, hexConcat, randomBytes, hexlify, defaultAbiCoder } = ethers.utils
+import { DataEdge } from '../build/types'
describe('DataEdge', () => {
let edge: DataEdge
- let me: SignerWithAddress
+ let me: Awaited>[0]
beforeEach(async () => {
- ;[me] = await getSigners()
+ ;[me] = await ethers.getSigners()
- const factory = (await getContractFactory('DataEdge', me)) as DataEdge__factory
+ const factory = await ethers.getContractFactory('DataEdge', me)
edge = await factory.deploy()
- await edge.deployed()
+ await edge.waitForDeployment()
})
describe('submit data', () => {
it('post any arbitrary data as selector', async () => {
- // virtual function call
const txRequest = {
data: '0x123123',
- to: edge.address,
+ to: await edge.getAddress(),
}
- // send transaction
const tx = await me.sendTransaction(txRequest)
const rx = await tx.wait()
- // transaction must work - it just stores data
- expect(rx.status).eq(1)
+ expect(rx!.status).eq(1)
})
it('post long calldata', async () => {
- // virtual function call
- const selector = id('setEpochBlocksPayload(bytes)').slice(0, 10)
- // calldata payload
- const messageBlocks = hexlify(randomBytes(1000))
- const txCalldata = defaultAbiCoder.encode(['bytes'], [messageBlocks]) // we abi encode to allow the subgraph to decode it properly
- const txData = hexConcat([selector, txCalldata])
- // craft full transaction
+ const selector = ethers.id('setEpochBlocksPayload(bytes)').slice(0, 10)
+ const messageBlocks = ethers.hexlify(ethers.randomBytes(1000))
+ const txCalldata = ethers.AbiCoder.defaultAbiCoder().encode(['bytes'], [messageBlocks])
+ const txData = ethers.concat([selector, txCalldata])
const txRequest = {
data: txData,
- to: edge.address,
+ to: await edge.getAddress(),
}
- // send transaction
const tx = await me.sendTransaction(txRequest)
const rx = await tx.wait()
- // transaction must work - it just stores data
- expect(rx.status).eq(1)
+ expect(rx!.status).eq(1)
})
})
})
diff --git a/packages/data-edge/test/eventful-dataedge.test.ts b/packages/data-edge/test/eventful-dataedge.test.ts
index 8bdf86a2e..974dde5dc 100644
--- a/packages/data-edge/test/eventful-dataedge.test.ts
+++ b/packages/data-edge/test/eventful-dataedge.test.ts
@@ -1,63 +1,47 @@
-import '@nomiclabs/hardhat-ethers'
-
-import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'
import { expect } from 'chai'
import { ethers } from 'hardhat'
-import { EventfulDataEdge, EventfulDataEdge__factory } from '../build/types'
-
-const { getContractFactory, getSigners } = ethers
-const { id, hexConcat, randomBytes, hexlify, defaultAbiCoder } = ethers.utils
+import { EventfulDataEdge } from '../build/types'
describe('EventfulDataEdge', () => {
let edge: EventfulDataEdge
- let me: SignerWithAddress
+ let me: Awaited>[0]
beforeEach(async () => {
- ;[me] = await getSigners()
+ ;[me] = await ethers.getSigners()
- const factory = (await getContractFactory('EventfulDataEdge', me)) as EventfulDataEdge__factory
+ const factory = await ethers.getContractFactory('EventfulDataEdge', me)
edge = await factory.deploy()
- await edge.deployed()
+ await edge.waitForDeployment()
})
describe('submit data', () => {
it('post any arbitrary data as selector', async () => {
- // virtual function call
const txRequest = {
data: '0x123123',
- to: edge.address,
+ to: await edge.getAddress(),
}
- // send transaction
const tx = await me.sendTransaction(txRequest)
const rx = await tx.wait()
- // transaction must work - it just stores data
- expect(rx.status).eq(1)
- // emit log event
- const event = edge.interface.parseLog(rx.logs[0]).args
- expect(event.data).eq(txRequest.data)
+ expect(rx!.status).eq(1)
+ const event = edge.interface.parseLog({ topics: rx!.logs[0].topics as string[], data: rx!.logs[0].data })
+ expect(event!.args.data).eq(txRequest.data)
})
it('post long calldata', async () => {
- // virtual function call
- const selector = id('setEpochBlocksPayload(bytes)').slice(0, 10)
- // calldata payload
- const messageBlocks = hexlify(randomBytes(1000))
- const txCalldata = defaultAbiCoder.encode(['bytes'], [messageBlocks]) // we abi encode to allow the subgraph to decode it properly
- const txData = hexConcat([selector, txCalldata])
- // craft full transaction
+ const selector = ethers.id('setEpochBlocksPayload(bytes)').slice(0, 10)
+ const messageBlocks = ethers.hexlify(ethers.randomBytes(1000))
+ const txCalldata = ethers.AbiCoder.defaultAbiCoder().encode(['bytes'], [messageBlocks])
+ const txData = ethers.concat([selector, txCalldata])
const txRequest = {
data: txData,
- to: edge.address,
+ to: await edge.getAddress(),
}
- // send transaction
const tx = await me.sendTransaction(txRequest)
const rx = await tx.wait()
- // transaction must work - it just stores data
- expect(rx.status).eq(1)
- // emit log event
- const event = edge.interface.parseLog(rx.logs[0]).args
- expect(event.data).eq(txRequest.data)
+ expect(rx!.status).eq(1)
+ const event = edge.interface.parseLog({ topics: rx!.logs[0].topics as string[], data: rx!.logs[0].data })
+ expect(event!.args.data).eq(txRequest.data)
})
})
})
diff --git a/packages/deployment/.gitignore b/packages/deployment/.gitignore
new file mode 100644
index 000000000..d48c62c73
--- /dev/null
+++ b/packages/deployment/.gitignore
@@ -0,0 +1,4 @@
+deployments/
+fork/
+txs/
+lib/generated/
diff --git a/packages/deployment/.markdownlint.json b/packages/deployment/.markdownlint.json
new file mode 100644
index 000000000..18947b0be
--- /dev/null
+++ b/packages/deployment/.markdownlint.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../../.markdownlint.json"
+}
diff --git a/packages/deployment/CLAUDE.md b/packages/deployment/CLAUDE.md
new file mode 100644
index 000000000..598c3baf4
--- /dev/null
+++ b/packages/deployment/CLAUDE.md
@@ -0,0 +1,25 @@
+# packages/deployment - Claude Code Guidance
+
+Parent: [../CLAUDE.md](../../CLAUDE.md)
+
+## Required Reading
+
+Before modifying any deployment scripts in `deploy/`, read:
+
+- [ImplementationPrinciples.md](docs/deploy/ImplementationPrinciples.md) - Core patterns and rules for all deploy scripts
+
+## Key Rules (from principles)
+
+- **`saveGovernanceTx` returns** - governance TX generation returns (not exit), downstream scripts check their own preconditions
+- **Idempotent scripts** - check on-chain state, skip if already done
+- **Shared precondition checks** - use `lib/preconditions.ts` for configure/transfer checks, not inline copies
+- **Package imports** - use `@graphprotocol/deployment/...` not relative paths
+- **Contract registry** - use `Contracts.X` not string literals
+- **Standard numbering** - `01_deploy`, `02_upgrade`, ..., `09_end`
+
+## Additional Documentation
+
+- [GovernanceWorkflow.md](docs/GovernanceWorkflow.md) - Governance TX generation and execution
+- [LocalForkTesting.md](docs/LocalForkTesting.md) - Fork mode testing workflow
+- [Architecture.md](docs/Architecture.md) - Package architecture
+- [Design.md](docs/Design.md) - Design decisions
diff --git a/packages/deployment/README.md b/packages/deployment/README.md
new file mode 100644
index 000000000..cce3d1c89
--- /dev/null
+++ b/packages/deployment/README.md
@@ -0,0 +1,84 @@
+# Graph Protocol Contracts - Unified Deployment
+
+Unified deployment package for Graph Protocol contracts.
+
+## Quick Start
+
+```bash
+cd packages/deployment
+
+# Read-only status (no --tags = no mutations)
+npx hardhat deploy:status --network arbitrumSepolia
+npx hardhat deploy --tags GIP-0088 --network arbitrumSepolia
+
+# Component lifecycle (single contract)
+npx hardhat deploy --tags IssuanceAllocator,deploy --network arbitrumSepolia
+npx hardhat deploy --tags IssuanceAllocator,configure --network arbitrumSepolia
+npx hardhat deploy --tags IssuanceAllocator,transfer --network arbitrumSepolia
+
+# Goal-driven (full GIP-0088 deployment)
+npx hardhat deploy --tags GIP-0088:upgrade,deploy --network arbitrumSepolia
+npx hardhat deploy --tags GIP-0088:upgrade,configure --network arbitrumSepolia
+npx hardhat deploy --tags GIP-0088:upgrade,transfer --network arbitrumSepolia
+npx hardhat deploy --tags GIP-0088:upgrade,upgrade --network arbitrumSepolia
+```
+
+See [docs/Gip0088.md](./docs/Gip0088.md) for the full GIP-0088 workflow.
+
+## Deployment Flow
+
+Each script is idempotent and goal-seeking: it checks on-chain state and either does what's needed or returns. Scripts that need governance authority build a TX batch and either execute it directly (deployer has permission) or save it for the Safe (`saveGovernanceTx` returns — does not exit).
+
+```
+sync → deploy → configure → transfer → upgrade (governance batch)
+ │ │ │ │ │
+ │ │ │ │ └─► Bundle proxy upgrades + deferred config
+ │ │ │ └─► Revoke deployer role + transfer ProxyAdmin
+ │ │ └─► Deployer-only role grants and params
+ │ └─► Deploy impl + proxy if needed; store pendingImplementation
+ └─► Import on-chain state into address books
+```
+
+## Structure
+
+```
+packages/deployment/
+├── deploy/ # rocketh deploy scripts (numbered per component)
+│ ├── common/ # 00_sync.ts
+│ ├── horizon/ # RM, HS, PE, L2Curation, RC
+│ ├── service/ # SubgraphService, DisputeManager
+│ ├── allocate/ # IssuanceAllocator, DefaultAllocation, DirectAllocation
+│ ├── agreement/ # RecurringAgreementManager
+│ ├── rewards/ # RewardsEligibilityOracle, Reclaim
+│ └── gip/0088/ # GIP-0088 goal orchestration
+├── lib/ # Shared utilities (preconditions, registry, tags, ABIs)
+├── tasks/ # Hardhat tasks (deploy:*)
+├── docs/ # Documentation
+└── test/ # Unit tests
+```
+
+## Available Tasks
+
+```bash
+npx hardhat deploy:status --network arbitrumOne # Show deployment and integration status
+npx hardhat deploy:list-pending --network arbitrumOne # List pending implementations
+npx hardhat deploy:reset-fork --network localhost # Reset fork state (for testing)
+npx hardhat deploy --tags sync --network arbitrumOne # Sync address books with on-chain state
+```
+
+## Testing
+
+```bash
+pnpm test
+
+# Fork-based tests
+FORK_NETWORK=arbitrumSepolia ARBITRUM_SEPOLIA_RPC= pnpm test
+```
+
+## See Also
+
+- [docs/deploy/ImplementationPrinciples.md](./docs/deploy/ImplementationPrinciples.md) - Core design principles and patterns
+- [docs/Architecture.md](./docs/Architecture.md) - Package structure and tags
+- [docs/GovernanceWorkflow.md](./docs/GovernanceWorkflow.md) - Detailed governance workflow
+- [docs/Design.md](./docs/Design.md) - Technical design documentation
+- [docs/LocalForkTesting.md](./docs/LocalForkTesting.md) - Fork-based and local network testing
diff --git a/packages/deployment/config/arbitrumOne.json5 b/packages/deployment/config/arbitrumOne.json5
new file mode 100644
index 000000000..85baff79a
--- /dev/null
+++ b/packages/deployment/config/arbitrumOne.json5
@@ -0,0 +1,12 @@
+{
+ // Deployment configuration for Arbitrum One (mainnet)
+ // Values here are committed for reference and reproducibility.
+
+ IssuanceAllocator: {
+ // RAM allocation: how much issuance flows to RecurringAgreementManager
+ // ramAllocatorMintingGrtPerBlock: GRT per block minted by IA and sent to RAM
+ // ramSelfMintingGrtPerBlock: 0 (RAM does not self-mint)
+ ramAllocatorMintingGrtPerBlock: '6',
+ ramSelfMintingGrtPerBlock: '0',
+ },
+}
diff --git a/packages/deployment/config/arbitrumSepolia.json5 b/packages/deployment/config/arbitrumSepolia.json5
new file mode 100644
index 000000000..3944d469d
--- /dev/null
+++ b/packages/deployment/config/arbitrumSepolia.json5
@@ -0,0 +1,12 @@
+{
+ // Deployment configuration for Arbitrum Sepolia (testnet)
+ // Values here are committed for reference and reproducibility.
+
+ IssuanceAllocator: {
+ // RAM allocation: how much issuance flows to RecurringAgreementManager
+ // ramAllocatorMintingGrtPerBlock: GRT per block minted by IA and sent to RAM
+ // ramSelfMintingGrtPerBlock: GRT per block (0 = RAM does not self-mint)
+ ramAllocatorMintingGrtPerBlock: '0.5',
+ ramSelfMintingGrtPerBlock: '0',
+ },
+}
diff --git a/packages/deployment/config/localNetwork.json5 b/packages/deployment/config/localNetwork.json5
new file mode 100644
index 000000000..09d9340ee
--- /dev/null
+++ b/packages/deployment/config/localNetwork.json5
@@ -0,0 +1,11 @@
+{
+ // Deployment configuration for local-network (docker-compose dev stack)
+ // Local network uses generous rates for fast iteration and testing.
+
+ IssuanceAllocator: {
+ // RAM allocation: how much issuance flows to RecurringAgreementManager
+ // Local network uses a high rate so agreements accumulate meaningful rewards quickly
+ ramAllocatorMintingGrtPerBlock: '6',
+ ramSelfMintingGrtPerBlock: '0',
+ },
+}
diff --git a/packages/deployment/deploy/agreement/manager/01_deploy.ts b/packages/deployment/deploy/agreement/manager/01_deploy.ts
new file mode 100644
index 000000000..dabd71cfb
--- /dev/null
+++ b/packages/deployment/deploy/agreement/manager/01_deploy.ts
@@ -0,0 +1,16 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { requireDeployer, requireGraphToken } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createProxyDeployModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createProxyDeployModule(
+ Contracts.issuance.RecurringAgreementManager,
+ (env) => {
+ const paymentsEscrow = env.getOrNull('PaymentsEscrow')
+ if (!paymentsEscrow) throw new Error('Missing PaymentsEscrow deployment after sync.')
+ return {
+ constructorArgs: [requireGraphToken(env).address, paymentsEscrow.address],
+ initializeArgs: [requireDeployer(env)],
+ }
+ },
+ { prerequisites: [Contracts.horizon.L2GraphToken, Contracts.horizon.PaymentsEscrow] },
+)
diff --git a/packages/deployment/deploy/agreement/manager/02_upgrade.ts b/packages/deployment/deploy/agreement/manager/02_upgrade.ts
new file mode 100644
index 000000000..70b140182
--- /dev/null
+++ b/packages/deployment/deploy/agreement/manager/02_upgrade.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createUpgradeModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createUpgradeModule(Contracts.issuance.RecurringAgreementManager)
diff --git a/packages/deployment/deploy/agreement/manager/04_configure.ts b/packages/deployment/deploy/agreement/manager/04_configure.ts
new file mode 100644
index 000000000..0d0d7b1a2
--- /dev/null
+++ b/packages/deployment/deploy/agreement/manager/04_configure.ts
@@ -0,0 +1,225 @@
+import { ACCESS_CONTROL_ENUMERABLE_ABI, ISSUANCE_TARGET_ABI } from '@graphprotocol/deployment/lib/abis.js'
+import { supportsInterface } from '@graphprotocol/deployment/lib/contract-checks.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { getGovernor, getPauseGuardian } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { ComponentTags, DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { requireContract, requireDeployer } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { checkRAMConfigured } from '@graphprotocol/deployment/lib/preconditions.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { graph, tx } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+import { encodeFunctionData, keccak256, toHex } from 'viem'
+
+/**
+ * Configure RecurringAgreementManager
+ *
+ * Grants:
+ * - COLLECTOR_ROLE to RecurringCollector
+ * - DATA_SERVICE_ROLE to SubgraphService
+ * - GOVERNOR_ROLE to protocol governor
+ * - PAUSE_ROLE to pause guardian
+ *
+ * Sets:
+ * - IssuanceAllocator as RAM's issuance source
+ *
+ * Idempotent: checks on-chain state, skips if already configured.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags RecurringAgreementManager:configure --network
+ */
+export default createActionModule(
+ Contracts.issuance.RecurringAgreementManager,
+ DeploymentActions.CONFIGURE,
+ async (env) => {
+ const client = graph.getPublicClient(env) as PublicClient
+ const governor = await getGovernor(env)
+ const pauseGuardian = await getPauseGuardian(env)
+
+ const ram = requireContract(env, Contracts.issuance.RecurringAgreementManager)
+ const rc = requireContract(env, Contracts.horizon.RecurringCollector)
+ const ss = requireContract(env, Contracts['subgraph-service'].SubgraphService)
+ const ia = requireContract(env, Contracts.issuance.IssuanceAllocator)
+
+ env.showMessage(`\n========== Configure ${Contracts.issuance.RecurringAgreementManager.name} ==========`)
+ env.showMessage(`RAM: ${ram.address}`)
+ env.showMessage(`RC: ${rc.address}`)
+ env.showMessage(`SS: ${ss.address}`)
+ env.showMessage(`IA: ${ia.address}`)
+
+ // Check if already configured (shared precondition check)
+ const precondition = await checkRAMConfigured(
+ client,
+ ram.address,
+ rc.address,
+ ss.address,
+ ia.address,
+ governor,
+ pauseGuardian,
+ )
+ if (precondition.done) {
+ env.showMessage(`\n✅ ${Contracts.issuance.RecurringAgreementManager.name} already configured\n`)
+ return
+ }
+
+ // Role constants
+ const COLLECTOR_ROLE = keccak256(toHex('COLLECTOR_ROLE'))
+ const DATA_SERVICE_ROLE = keccak256(toHex('DATA_SERVICE_ROLE'))
+ const GOVERNOR_ROLE = keccak256(toHex('GOVERNOR_ROLE'))
+ const PAUSE_ROLE = keccak256(toHex('PAUSE_ROLE'))
+
+ // Check what still needs configuring
+ env.showMessage('\n📋 Checking current configuration...\n')
+
+ const rcHasCollectorRole = (await client.readContract({
+ address: ram.address as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [COLLECTOR_ROLE, rc.address as `0x${string}`],
+ })) as boolean
+ env.showMessage(` RC COLLECTOR_ROLE: ${rcHasCollectorRole ? '✓' : '✗'}`)
+
+ const ssHasDataServiceRole = (await client.readContract({
+ address: ram.address as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [DATA_SERVICE_ROLE, ss.address as `0x${string}`],
+ })) as boolean
+ env.showMessage(` SS DATA_SERVICE_ROLE: ${ssHasDataServiceRole ? '✓' : '✗'}`)
+
+ // Check role grants
+ const governorHasRole = (await client.readContract({
+ address: ram.address as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [GOVERNOR_ROLE, governor as `0x${string}`],
+ })) as boolean
+ env.showMessage(` Governor GOVERNOR_ROLE: ${governorHasRole ? '✓' : '✗'}`)
+
+ const pauseGuardianHasRole = (await client.readContract({
+ address: ram.address as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [PAUSE_ROLE, pauseGuardian as `0x${string}`],
+ })) as boolean
+ env.showMessage(` PauseGuardian PAUSE_ROLE: ${pauseGuardianHasRole ? '✓' : '✗'}`)
+
+ // Determine executor: deployer (fresh) or governor (prod)
+ const deployer = requireDeployer(env)
+ const deployerIsGovernor = (await client.readContract({
+ address: ram.address as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [GOVERNOR_ROLE, deployer as `0x${string}`],
+ })) as boolean
+
+ if (!deployerIsGovernor) {
+ env.showMessage(`\n ○ Deployer does not have GOVERNOR_ROLE — skipping (governance TX in upgrade step)\n`)
+ return
+ }
+
+ // Build TX list for missing configuration
+ const txs: Array<{ to: string; data: `0x${string}`; label: string }> = []
+
+ if (!rcHasCollectorRole) {
+ txs.push({
+ to: ram.address,
+ data: encodeFunctionData({
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'grantRole',
+ args: [COLLECTOR_ROLE, rc.address as `0x${string}`],
+ }),
+ label: `grantRole(COLLECTOR_ROLE, ${rc.address})`,
+ })
+ }
+
+ if (!ssHasDataServiceRole) {
+ txs.push({
+ to: ram.address,
+ data: encodeFunctionData({
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'grantRole',
+ args: [DATA_SERVICE_ROLE, ss.address as `0x${string}`],
+ }),
+ label: `grantRole(DATA_SERVICE_ROLE, ${ss.address})`,
+ })
+ }
+
+ if (!governorHasRole) {
+ txs.push({
+ to: ram.address,
+ data: encodeFunctionData({
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'grantRole',
+ args: [GOVERNOR_ROLE, governor as `0x${string}`],
+ }),
+ label: `grantRole(GOVERNOR_ROLE, ${governor})`,
+ })
+ }
+
+ if (!pauseGuardianHasRole) {
+ txs.push({
+ to: ram.address,
+ data: encodeFunctionData({
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'grantRole',
+ args: [PAUSE_ROLE, pauseGuardian as `0x${string}`],
+ }),
+ label: `grantRole(PAUSE_ROLE, ${pauseGuardian})`,
+ })
+ }
+
+ // Check issuance allocator — skip if IA doesn't support the interface yet (pending upgrade)
+ let iaConfigured = false
+ try {
+ const currentIA = (await client.readContract({
+ address: ram.address as `0x${string}`,
+ abi: ISSUANCE_TARGET_ABI,
+ functionName: 'getIssuanceAllocator',
+ })) as string
+ iaConfigured = currentIA.toLowerCase() === ia.address.toLowerCase()
+ env.showMessage(` IssuanceAllocator: ${iaConfigured ? '✓' : '✗'} (current: ${currentIA})`)
+ } catch {
+ env.showMessage(` IssuanceAllocator: ✗ (getter not available)`)
+ }
+
+ if (!iaConfigured) {
+ const IISSUANCE_ALLOCATION_DISTRIBUTION_ID = '0x79da37fc' // type(IIssuanceAllocationDistribution).interfaceId
+ const iaSupported = await supportsInterface(client, ia.address, IISSUANCE_ALLOCATION_DISTRIBUTION_ID)
+ if (iaSupported) {
+ txs.push({
+ to: ram.address,
+ data: encodeFunctionData({
+ abi: ISSUANCE_TARGET_ABI,
+ functionName: 'setIssuanceAllocator',
+ args: [ia.address as `0x${string}`],
+ }),
+ label: `setIssuanceAllocator(${ia.address})`,
+ })
+ } else {
+ env.showMessage(` ○ IA does not yet support IIssuanceAllocationDistribution — skipping setIssuanceAllocator`)
+ }
+ }
+
+ if (txs.length === 0) return
+
+ env.showMessage('\n🔨 Executing configuration as deployer...\n')
+ const txFn = tx(env)
+ for (const t of txs) {
+ await txFn({ account: deployer, to: t.to as `0x${string}`, data: t.data })
+ env.showMessage(` ✓ ${t.label}`)
+ }
+ env.showMessage(`\n✅ ${Contracts.issuance.RecurringAgreementManager.name} configuration complete!\n`)
+ },
+ {
+ extraDependencies: [
+ ComponentTags.RECURRING_COLLECTOR,
+ ComponentTags.SUBGRAPH_SERVICE,
+ ComponentTags.ISSUANCE_ALLOCATOR,
+ ],
+ prerequisites: [
+ Contracts.horizon.RecurringCollector,
+ Contracts['subgraph-service'].SubgraphService,
+ Contracts.issuance.IssuanceAllocator,
+ ],
+ },
+)
diff --git a/packages/deployment/deploy/agreement/manager/05_transfer_governance.ts b/packages/deployment/deploy/agreement/manager/05_transfer_governance.ts
new file mode 100644
index 000000000..50d3f7582
--- /dev/null
+++ b/packages/deployment/deploy/agreement/manager/05_transfer_governance.ts
@@ -0,0 +1,60 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import {
+ requireContract,
+ requireDeployer,
+ transferProxyAdminOwnership,
+} from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { checkDeployerRevoked } from '@graphprotocol/deployment/lib/preconditions.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { execute, graph, read } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+
+/**
+ * Transfer RecurringAgreementManager governance from deployer
+ *
+ * - Revoke GOVERNOR_ROLE from deployment account
+ * - Transfer ProxyAdmin ownership to governor
+ *
+ * Role grants (GOVERNOR_ROLE, PAUSE_ROLE, COLLECTOR_ROLE, DATA_SERVICE_ROLE)
+ * happen in 04_configure.ts. This script only revokes deployer access.
+ *
+ * Idempotent: checks on-chain state, skips if already transferred.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags RecurringAgreementManager,transfer --network
+ */
+export default createActionModule(
+ Contracts.issuance.RecurringAgreementManager,
+ DeploymentActions.TRANSFER,
+ async (env) => {
+ const readFn = read(env)
+ const executeFn = execute(env)
+ const client = graph.getPublicClient(env) as PublicClient
+ const deployer = requireDeployer(env)
+ const ram = requireContract(env, Contracts.issuance.RecurringAgreementManager)
+
+ env.showMessage(`\n========== Transfer ${Contracts.issuance.RecurringAgreementManager.name} ==========`)
+
+ // Check if deployer GOVERNOR_ROLE already revoked (shared precondition check)
+ const precondition = await checkDeployerRevoked(client, ram.address, deployer)
+ if (precondition.done) {
+ env.showMessage(`✓ Deployer GOVERNOR_ROLE already revoked`)
+ } else {
+ const GOVERNOR_ROLE = (await readFn(ram, { functionName: 'GOVERNOR_ROLE' })) as `0x${string}`
+
+ env.showMessage(`🔨 Revoking deployer GOVERNOR_ROLE...`)
+ await executeFn(ram, {
+ account: deployer,
+ functionName: 'revokeRole',
+ args: [GOVERNOR_ROLE, deployer],
+ })
+ env.showMessage(` ✓ revokeRole(GOVERNOR_ROLE) executed`)
+ }
+
+ // Transfer ProxyAdmin ownership to governor
+ await transferProxyAdminOwnership(env, Contracts.issuance.RecurringAgreementManager)
+
+ env.showMessage(`\n✅ ${Contracts.issuance.RecurringAgreementManager.name} governance transferred!\n`)
+ },
+)
diff --git a/packages/deployment/deploy/agreement/manager/09_end.ts b/packages/deployment/deploy/agreement/manager/09_end.ts
new file mode 100644
index 000000000..c68c1db6a
--- /dev/null
+++ b/packages/deployment/deploy/agreement/manager/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts.issuance.RecurringAgreementManager)
diff --git a/packages/deployment/deploy/agreement/manager/10_status.ts b/packages/deployment/deploy/agreement/manager/10_status.ts
new file mode 100644
index 000000000..d7e3f98bc
--- /dev/null
+++ b/packages/deployment/deploy/agreement/manager/10_status.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createStatusModule(Contracts.issuance.RecurringAgreementManager)
diff --git a/packages/deployment/deploy/allocate/allocator/01_deploy.ts b/packages/deployment/deploy/allocate/allocator/01_deploy.ts
new file mode 100644
index 000000000..58bd3ca30
--- /dev/null
+++ b/packages/deployment/deploy/allocate/allocator/01_deploy.ts
@@ -0,0 +1,12 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { requireContract, requireDeployer } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createProxyDeployModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createProxyDeployModule(
+ Contracts.issuance.IssuanceAllocator,
+ (env) => ({
+ constructorArgs: [requireContract(env, Contracts.horizon.L2GraphToken).address],
+ initializeArgs: [requireDeployer(env)],
+ }),
+ { prerequisites: [Contracts.horizon.L2GraphToken] },
+)
diff --git a/packages/deployment/deploy/allocate/allocator/02_upgrade.ts b/packages/deployment/deploy/allocate/allocator/02_upgrade.ts
new file mode 100644
index 000000000..8f012a025
--- /dev/null
+++ b/packages/deployment/deploy/allocate/allocator/02_upgrade.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createUpgradeModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createUpgradeModule(Contracts.issuance.IssuanceAllocator)
diff --git a/packages/deployment/deploy/allocate/allocator/04_configure.ts b/packages/deployment/deploy/allocate/allocator/04_configure.ts
new file mode 100644
index 000000000..d46243e74
--- /dev/null
+++ b/packages/deployment/deploy/allocate/allocator/04_configure.ts
@@ -0,0 +1,168 @@
+import { ACCESS_CONTROL_ENUMERABLE_ABI, REWARDS_MANAGER_DEPRECATED_ABI } from '@graphprotocol/deployment/lib/abis.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { getGovernor, getPauseGuardian } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { ComponentTags, DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { requireContracts, requireDeployer } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { checkIAConfigured } from '@graphprotocol/deployment/lib/preconditions.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { graph, read, tx } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+import { encodeFunctionData } from 'viem'
+
+/**
+ * Configure IssuanceAllocator
+ *
+ * - Sets issuance rate to match RewardsManager
+ * - Configures RM as 100% self-minting target
+ * - Grants GOVERNOR_ROLE to protocol governor
+ * - Grants PAUSE_ROLE to pause guardian
+ *
+ * If deployer has GOVERNOR_ROLE (fresh deploy), executes directly.
+ * If governance transferred, generates governance TX or executes via governor.
+ *
+ * Idempotent: checks on-chain state, skips if already configured.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags IssuanceAllocator,configure --network
+ */
+export default createActionModule(
+ Contracts.issuance.IssuanceAllocator,
+ DeploymentActions.CONFIGURE,
+ async (env) => {
+ const readFn = read(env)
+ const deployer = requireDeployer(env)
+ const governor = await getGovernor(env)
+ const pauseGuardian = await getPauseGuardian(env)
+
+ const [issuanceAllocator, rewardsManager] = requireContracts(env, [
+ Contracts.issuance.IssuanceAllocator,
+ Contracts.horizon.RewardsManager,
+ ])
+
+ const client = graph.getPublicClient(env) as PublicClient
+
+ env.showMessage(`\n========== Configure ${Contracts.issuance.IssuanceAllocator.name} ==========`)
+ env.showMessage(`${Contracts.issuance.IssuanceAllocator.name}: ${issuanceAllocator.address}`)
+ env.showMessage(`${Contracts.horizon.RewardsManager.name}: ${rewardsManager.address}`)
+
+ // Check if already configured (shared precondition check)
+ const precondition = await checkIAConfigured(
+ client,
+ issuanceAllocator.address,
+ rewardsManager.address,
+ governor,
+ pauseGuardian,
+ )
+ if (precondition.done) {
+ env.showMessage(`\n✅ ${Contracts.issuance.IssuanceAllocator.name} already configured\n`)
+ return
+ }
+
+ // Get RM issuance rate (target for IA)
+ const rmIssuanceRate = (await client.readContract({
+ address: rewardsManager.address as `0x${string}`,
+ abi: REWARDS_MANAGER_DEPRECATED_ABI,
+ functionName: 'issuancePerBlock',
+ })) as bigint
+
+ if (rmIssuanceRate === 0n) {
+ env.showMessage(`\n ○ RM.issuancePerBlock is 0 — skipping IA configure\n`)
+ return
+ }
+
+ // Determine what still needs configuring
+ env.showMessage('\n📋 Checking current configuration...\n')
+
+ const iaIssuanceRate = (await readFn(issuanceAllocator, { functionName: 'getIssuancePerBlock' })) as bigint
+ const rateOk = iaIssuanceRate === rmIssuanceRate && iaIssuanceRate > 0n
+ env.showMessage(` Issuance rate: ${rateOk ? '✓' : '✗'} (IA: ${iaIssuanceRate}, RM: ${rmIssuanceRate})`)
+
+ // Check role grants
+ const GOVERNOR_ROLE = (await readFn(issuanceAllocator, { functionName: 'GOVERNOR_ROLE' })) as `0x${string}`
+ const PAUSE_ROLE = (await readFn(issuanceAllocator, { functionName: 'PAUSE_ROLE' })) as `0x${string}`
+
+ const governorHasRole = (await readFn(issuanceAllocator, {
+ functionName: 'hasRole',
+ args: [GOVERNOR_ROLE, governor],
+ })) as boolean
+ env.showMessage(` Governor GOVERNOR_ROLE: ${governorHasRole ? '✓' : '✗'}`)
+
+ const pauseGuardianHasRole = (await readFn(issuanceAllocator, {
+ functionName: 'hasRole',
+ args: [PAUSE_ROLE, pauseGuardian],
+ })) as boolean
+ env.showMessage(` PauseGuardian PAUSE_ROLE: ${pauseGuardianHasRole ? '✓' : '✗'}`)
+
+ // Determine executor: deployer if has GOVERNOR_ROLE, else protocol governor
+ const deployerHasRole = (await readFn(issuanceAllocator, {
+ functionName: 'hasRole',
+ args: [GOVERNOR_ROLE, deployer],
+ })) as boolean
+
+ // Build TX data for missing configuration
+ const txs: Array<{ to: string; data: `0x${string}`; label: string }> = []
+
+ if (!rateOk) {
+ txs.push({
+ to: issuanceAllocator.address,
+ data: encodeFunctionData({
+ abi: [
+ {
+ inputs: [{ type: 'uint256' }],
+ name: 'setIssuancePerBlock',
+ outputs: [],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ ],
+ functionName: 'setIssuancePerBlock',
+ args: [rmIssuanceRate],
+ }),
+ label: `setIssuancePerBlock(${rmIssuanceRate})`,
+ })
+ }
+
+ if (!governorHasRole) {
+ txs.push({
+ to: issuanceAllocator.address,
+ data: encodeFunctionData({
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'grantRole',
+ args: [GOVERNOR_ROLE, governor as `0x${string}`],
+ }),
+ label: `grantRole(GOVERNOR_ROLE, ${governor})`,
+ })
+ }
+
+ if (!pauseGuardianHasRole) {
+ txs.push({
+ to: issuanceAllocator.address,
+ data: encodeFunctionData({
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'grantRole',
+ args: [PAUSE_ROLE, pauseGuardian as `0x${string}`],
+ }),
+ label: `grantRole(PAUSE_ROLE, ${pauseGuardian})`,
+ })
+ }
+
+ if (!deployerHasRole) {
+ env.showMessage(`\n ○ Deployer does not have GOVERNOR_ROLE — skipping (governance TX in upgrade step)\n`)
+ return
+ }
+
+ if (txs.length === 0) return
+
+ env.showMessage('\n🔨 Executing configuration as deployer...\n')
+ const txFn = tx(env)
+ for (const t of txs) {
+ await txFn({ account: deployer, to: t.to as `0x${string}`, data: t.data })
+ env.showMessage(` ✓ ${t.label}`)
+ }
+ env.showMessage(`\n✅ ${Contracts.issuance.IssuanceAllocator.name} configuration complete!\n`)
+ },
+ {
+ extraDependencies: [ComponentTags.REWARDS_MANAGER],
+ prerequisites: [Contracts.horizon.RewardsManager],
+ },
+)
diff --git a/packages/deployment/deploy/allocate/allocator/06_transfer_governance.ts b/packages/deployment/deploy/allocate/allocator/06_transfer_governance.ts
new file mode 100644
index 000000000..b960839b7
--- /dev/null
+++ b/packages/deployment/deploy/allocate/allocator/06_transfer_governance.ts
@@ -0,0 +1,61 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { getGovernor } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import {
+ requireContracts,
+ requireDeployer,
+ transferProxyAdminOwnership,
+} from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { checkDeployerRevoked } from '@graphprotocol/deployment/lib/preconditions.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { execute, graph, read } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+
+/**
+ * Transfer IssuanceAllocator governance from deployer to protocol governor
+ *
+ * - Revoke GOVERNOR_ROLE from deployment account
+ * - Transfer ProxyAdmin ownership to governor
+ *
+ * Role grants (GOVERNOR_ROLE to governor, PAUSE_ROLE to pauseGuardian) happen
+ * in 04_configure.ts. This script only revokes deployer access.
+ *
+ * Idempotent: checks on-chain state, skips if already transferred.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags IssuanceAllocator,transfer --network
+ */
+export default createActionModule(Contracts.issuance.IssuanceAllocator, DeploymentActions.TRANSFER, async (env) => {
+ const readFn = read(env)
+ const executeFn = execute(env)
+ const client = graph.getPublicClient(env) as PublicClient
+
+ const deployer = requireDeployer(env)
+ const governor = await getGovernor(env)
+ const [issuanceAllocator] = requireContracts(env, [Contracts.issuance.IssuanceAllocator])
+
+ env.showMessage(`\n========== Transfer ${Contracts.issuance.IssuanceAllocator.name} ==========`)
+ env.showMessage(`Deployer: ${deployer}`)
+ env.showMessage(`Governor: ${governor}\n`)
+
+ // Check if deployer GOVERNOR_ROLE already revoked (shared precondition check)
+ const precondition = await checkDeployerRevoked(client, issuanceAllocator.address, deployer)
+ if (precondition.done) {
+ env.showMessage(`✓ Deployer GOVERNOR_ROLE already revoked`)
+ } else {
+ const GOVERNOR_ROLE = (await readFn(issuanceAllocator, { functionName: 'GOVERNOR_ROLE' })) as `0x${string}`
+
+ env.showMessage(`🔨 Revoking deployer GOVERNOR_ROLE...`)
+ await executeFn(issuanceAllocator, {
+ account: deployer,
+ functionName: 'revokeRole',
+ args: [GOVERNOR_ROLE, deployer],
+ })
+ env.showMessage(` ✓ revokeRole(GOVERNOR_ROLE) executed`)
+ }
+
+ // Transfer ProxyAdmin ownership to governor
+ await transferProxyAdminOwnership(env, Contracts.issuance.IssuanceAllocator)
+
+ env.showMessage(`\n✅ ${Contracts.issuance.IssuanceAllocator.name} governance transferred!\n`)
+})
diff --git a/packages/deployment/deploy/allocate/allocator/09_end.ts b/packages/deployment/deploy/allocate/allocator/09_end.ts
new file mode 100644
index 000000000..272c2915e
--- /dev/null
+++ b/packages/deployment/deploy/allocate/allocator/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts.issuance.IssuanceAllocator)
diff --git a/packages/deployment/deploy/allocate/allocator/10_status.ts b/packages/deployment/deploy/allocate/allocator/10_status.ts
new file mode 100644
index 000000000..23df5d817
--- /dev/null
+++ b/packages/deployment/deploy/allocate/allocator/10_status.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createStatusModule(Contracts.issuance.IssuanceAllocator)
diff --git a/packages/deployment/deploy/allocate/default/01_deploy.ts b/packages/deployment/deploy/allocate/default/01_deploy.ts
new file mode 100644
index 000000000..311c11b1b
--- /dev/null
+++ b/packages/deployment/deploy/allocate/default/01_deploy.ts
@@ -0,0 +1,39 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { ComponentTags, DeploymentActions, shouldSkipAction } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { deployProxyContract, requireDeployer } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+
+/**
+ * Deploy DefaultAllocation proxy — IA's default target for unallocated issuance
+ *
+ * Uses the shared DirectAllocation_Implementation.
+ * Initialized with deployer as governor (transferred in transfer step).
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags DefaultAllocation,deploy --network
+ */
+const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.DEPLOY)) return
+ await syncComponentsFromRegistry(env, [
+ Contracts.issuance.DirectAllocation_Implementation,
+ Contracts.issuance.DefaultAllocation,
+ ])
+
+ env.showMessage(`\n📦 Deploying DefaultAllocation proxy...`)
+ env.showMessage(` Shared implementation: ${Contracts.issuance.DirectAllocation_Implementation.name}`)
+
+ await deployProxyContract(env, {
+ contract: Contracts.issuance.DefaultAllocation,
+ sharedImplementation: Contracts.issuance.DirectAllocation_Implementation,
+ initializeArgs: [requireDeployer(env)],
+ })
+
+ env.showMessage('\n✓ DefaultAllocation deployment complete')
+}
+
+func.tags = [ComponentTags.DEFAULT_ALLOCATION]
+func.dependencies = [ComponentTags.DIRECT_ALLOCATION_IMPL]
+func.skip = async () => shouldSkipAction(DeploymentActions.DEPLOY)
+
+export default func
diff --git a/packages/deployment/deploy/allocate/default/02_upgrade.ts b/packages/deployment/deploy/allocate/default/02_upgrade.ts
new file mode 100644
index 000000000..2bb15a1da
--- /dev/null
+++ b/packages/deployment/deploy/allocate/default/02_upgrade.ts
@@ -0,0 +1,27 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { ComponentTags, DeploymentActions, shouldSkipAction } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import { upgradeImplementation } from '@graphprotocol/deployment/lib/upgrade-implementation.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+
+// DefaultAllocation Upgrade
+//
+// Upgrades DefaultAllocation proxy to DirectAllocation implementation via per-proxy ProxyAdmin.
+
+const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.UPGRADE)) return
+ await syncComponentsFromRegistry(env, [
+ Contracts.issuance.DirectAllocation_Implementation,
+ Contracts.issuance.DefaultAllocation,
+ ])
+ await upgradeImplementation(env, Contracts.issuance.DefaultAllocation, {
+ implementationName: 'DirectAllocation',
+ })
+ await syncComponentsFromRegistry(env, [Contracts.issuance.DefaultAllocation])
+}
+
+func.tags = [ComponentTags.DEFAULT_ALLOCATION]
+func.dependencies = [ComponentTags.DIRECT_ALLOCATION_IMPL]
+func.skip = async () => shouldSkipAction(DeploymentActions.UPGRADE)
+
+export default func
diff --git a/packages/deployment/deploy/allocate/default/04_configure.ts b/packages/deployment/deploy/allocate/default/04_configure.ts
new file mode 100644
index 000000000..528531ff6
--- /dev/null
+++ b/packages/deployment/deploy/allocate/default/04_configure.ts
@@ -0,0 +1,119 @@
+import { ACCESS_CONTROL_ENUMERABLE_ABI } from '@graphprotocol/deployment/lib/abis.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { getGovernor, getPauseGuardian } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { requireContract, requireDeployer } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { checkDefaultAllocationConfigured } from '@graphprotocol/deployment/lib/preconditions.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { graph, read, tx } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+import { encodeFunctionData } from 'viem'
+
+/**
+ * Configure DefaultAllocation
+ *
+ * - Grants GOVERNOR_ROLE to protocol governor
+ * - Grants PAUSE_ROLE to pause guardian
+ *
+ * Note: IA.setDefaultTarget(DA) is an activation step in issuance-connect,
+ * not a configure step (requires IA to have minter role).
+ *
+ * Idempotent: checks on-chain state, skips if already configured.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags DefaultAllocation,configure --network
+ */
+export default createActionModule(Contracts.issuance.DefaultAllocation, DeploymentActions.CONFIGURE, async (env) => {
+ const client = graph.getPublicClient(env) as PublicClient
+ const readFn = read(env)
+ const deployer = requireDeployer(env)
+ const governor = await getGovernor(env)
+ const pauseGuardian = await getPauseGuardian(env)
+
+ const defaultAllocation = requireContract(env, Contracts.issuance.DefaultAllocation)
+
+ env.showMessage(`\n========== Configure ${Contracts.issuance.DefaultAllocation.name} ==========`)
+ env.showMessage(`DefaultAllocation: ${defaultAllocation.address}`)
+
+ // Check if already configured (shared precondition check)
+ const precondition = await checkDefaultAllocationConfigured(
+ client,
+ defaultAllocation.address,
+ governor,
+ pauseGuardian,
+ )
+ if (precondition.done) {
+ env.showMessage(`\n✅ ${Contracts.issuance.DefaultAllocation.name} already configured\n`)
+ return
+ }
+
+ env.showMessage('\n📋 Checking current configuration...\n')
+
+ const GOVERNOR_ROLE = (await readFn(defaultAllocation, { functionName: 'GOVERNOR_ROLE' })) as `0x${string}`
+ const PAUSE_ROLE = (await readFn(defaultAllocation, { functionName: 'PAUSE_ROLE' })) as `0x${string}`
+
+ const governorHasRole = (await client.readContract({
+ address: defaultAllocation.address as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [GOVERNOR_ROLE, governor as `0x${string}`],
+ })) as boolean
+ env.showMessage(` Governor GOVERNOR_ROLE: ${governorHasRole ? '✓' : '✗'}`)
+
+ const pauseGuardianHasRole = (await client.readContract({
+ address: defaultAllocation.address as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [PAUSE_ROLE, pauseGuardian as `0x${string}`],
+ })) as boolean
+ env.showMessage(` PauseGuardian PAUSE_ROLE: ${pauseGuardianHasRole ? '✓' : '✗'}`)
+
+ const deployerHasRole = (await client.readContract({
+ address: defaultAllocation.address as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [GOVERNOR_ROLE, deployer as `0x${string}`],
+ })) as boolean
+
+ const txs: Array<{ to: string; data: `0x${string}`; label: string }> = []
+
+ if (!governorHasRole) {
+ txs.push({
+ to: defaultAllocation.address,
+ data: encodeFunctionData({
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'grantRole',
+ args: [GOVERNOR_ROLE, governor as `0x${string}`],
+ }),
+ label: `grantRole(GOVERNOR_ROLE, ${governor})`,
+ })
+ }
+
+ if (!pauseGuardianHasRole) {
+ txs.push({
+ to: defaultAllocation.address,
+ data: encodeFunctionData({
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'grantRole',
+ args: [PAUSE_ROLE, pauseGuardian as `0x${string}`],
+ }),
+ label: `grantRole(PAUSE_ROLE, ${pauseGuardian})`,
+ })
+ }
+
+ if (!deployerHasRole) {
+ env.showMessage(`\n ○ Deployer does not have GOVERNOR_ROLE — skipping (governance TX in upgrade step)\n`)
+ return
+ }
+
+ if (txs.length === 0) return
+
+ env.showMessage('\n🔨 Executing role grants as deployer...\n')
+ const txFn = tx(env)
+ for (const t of txs) {
+ await txFn({ account: deployer, to: t.to as `0x${string}`, data: t.data })
+ env.showMessage(` ✓ ${t.label}`)
+ }
+
+ env.showMessage(`\n✅ ${Contracts.issuance.DefaultAllocation.name} configuration complete!\n`)
+})
diff --git a/packages/deployment/deploy/allocate/default/05_transfer_governance.ts b/packages/deployment/deploy/allocate/default/05_transfer_governance.ts
new file mode 100644
index 000000000..af5bcd8e6
--- /dev/null
+++ b/packages/deployment/deploy/allocate/default/05_transfer_governance.ts
@@ -0,0 +1,51 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import {
+ requireContract,
+ requireDeployer,
+ transferProxyAdminOwnership,
+} from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { checkDeployerRevoked } from '@graphprotocol/deployment/lib/preconditions.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { execute, graph, read } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+
+/**
+ * Transfer DefaultAllocation governance from deployer
+ *
+ * - Revoke GOVERNOR_ROLE from deployment account
+ * - Transfer ProxyAdmin ownership to governor
+ *
+ * Role grants happen in 04_configure.ts.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags DefaultAllocation,transfer --network
+ */
+export default createActionModule(Contracts.issuance.DefaultAllocation, DeploymentActions.TRANSFER, async (env) => {
+ const readFn = read(env)
+ const executeFn = execute(env)
+ const client = graph.getPublicClient(env) as PublicClient
+ const deployer = requireDeployer(env)
+ const da = requireContract(env, Contracts.issuance.DefaultAllocation)
+
+ env.showMessage(`\n========== Transfer ${Contracts.issuance.DefaultAllocation.name} ==========`)
+
+ const precondition = await checkDeployerRevoked(client, da.address, deployer)
+ if (precondition.done) {
+ env.showMessage(`✓ Deployer GOVERNOR_ROLE already revoked`)
+ } else {
+ const GOVERNOR_ROLE = (await readFn(da, { functionName: 'GOVERNOR_ROLE' })) as `0x${string}`
+
+ env.showMessage(`🔨 Revoking deployer GOVERNOR_ROLE...`)
+ await executeFn(da, {
+ account: deployer,
+ functionName: 'revokeRole',
+ args: [GOVERNOR_ROLE, deployer],
+ })
+ env.showMessage(` ✓ revokeRole(GOVERNOR_ROLE) executed`)
+ }
+
+ await transferProxyAdminOwnership(env, Contracts.issuance.DefaultAllocation)
+
+ env.showMessage(`\n✅ ${Contracts.issuance.DefaultAllocation.name} governance transferred!\n`)
+})
diff --git a/packages/deployment/deploy/allocate/default/09_end.ts b/packages/deployment/deploy/allocate/default/09_end.ts
new file mode 100644
index 000000000..cacd93b61
--- /dev/null
+++ b/packages/deployment/deploy/allocate/default/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts.issuance.DefaultAllocation)
diff --git a/packages/deployment/deploy/allocate/default/10_status.ts b/packages/deployment/deploy/allocate/default/10_status.ts
new file mode 100644
index 000000000..012cc8be3
--- /dev/null
+++ b/packages/deployment/deploy/allocate/default/10_status.ts
@@ -0,0 +1,10 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+/**
+ * DefaultAllocation status — show detailed state of the default allocation proxy
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags DefaultAllocation --network
+ */
+export default createStatusModule(Contracts.issuance.DefaultAllocation)
diff --git a/packages/deployment/deploy/allocate/direct/01_impl.ts b/packages/deployment/deploy/allocate/direct/01_impl.ts
new file mode 100644
index 000000000..6ff6d6a56
--- /dev/null
+++ b/packages/deployment/deploy/allocate/direct/01_impl.ts
@@ -0,0 +1,74 @@
+import { loadDirectAllocationArtifact } from '@graphprotocol/deployment/lib/artifact-loaders.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { computeArtifactBytecodeHash } from '@graphprotocol/deployment/lib/deploy-implementation.js'
+import { buildDeploymentMetadata } from '@graphprotocol/deployment/lib/deployment-metadata.js'
+import { ComponentTags, DeploymentActions, shouldSkipAction } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import {
+ requireDeployer,
+ requireGraphToken,
+ showDeploymentStatus,
+} from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import { deploy, graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+
+/**
+ * Deploy shared DirectAllocation implementation
+ *
+ * This implementation is shared by all DirectAllocation proxies
+ * (DefaultAllocation, ReclaimedRewards). Runs during both deploy AND upgrade
+ * actions — deploying the implementation is a prerequisite for proxy upgrades.
+ *
+ * Rocketh handles idempotency: if bytecode is unchanged, no redeployment occurs.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags DirectAllocation_Implementation,deploy --network
+ */
+const func: DeployScriptModule = async (env) => {
+ // Run for both deploy and upgrade actions
+ if (shouldSkipAction(DeploymentActions.DEPLOY) && shouldSkipAction(DeploymentActions.UPGRADE)) return
+
+ await syncComponentsFromRegistry(env, [
+ Contracts.issuance.DirectAllocation_Implementation,
+ Contracts.horizon.L2GraphToken,
+ ])
+
+ const deployFn = deploy(env)
+ const deployer = requireDeployer(env)
+ const graphTokenDep = requireGraphToken(env)
+
+ env.showMessage(`\n📦 Deploying shared ${Contracts.issuance.DirectAllocation_Implementation.name}...`)
+
+ const artifact = loadDirectAllocationArtifact()
+ const result = await deployFn(Contracts.issuance.DirectAllocation_Implementation.name, {
+ account: deployer,
+ artifact,
+ args: [graphTokenDep.address],
+ })
+
+ // Persist to address book — only write metadata on new deployments
+ // to avoid overwriting stored hash with current artifact when deploy was a no-op
+ if (result.newlyDeployed) {
+ const metadata = buildDeploymentMetadata(
+ result,
+ computeArtifactBytecodeHash(Contracts.issuance.DirectAllocation_Implementation.artifact!),
+ )
+ if (metadata) {
+ await graph.updateIssuanceAddressBook(env, {
+ name: Contracts.issuance.DirectAllocation_Implementation.name,
+ address: result.address,
+ deployment: metadata,
+ })
+ }
+ }
+
+ showDeploymentStatus(env, Contracts.issuance.DirectAllocation_Implementation, result)
+
+ await syncComponentsFromRegistry(env, [Contracts.issuance.DirectAllocation_Implementation])
+}
+
+func.tags = [ComponentTags.DIRECT_ALLOCATION_IMPL]
+func.dependencies = []
+func.skip = async () => shouldSkipAction(DeploymentActions.DEPLOY) && shouldSkipAction(DeploymentActions.UPGRADE)
+
+export default func
diff --git a/packages/deployment/deploy/common/00_sync.ts b/packages/deployment/deploy/common/00_sync.ts
new file mode 100644
index 000000000..de4ff446f
--- /dev/null
+++ b/packages/deployment/deploy/common/00_sync.ts
@@ -0,0 +1,26 @@
+import { SpecialTags } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { runFullSync } from '@graphprotocol/deployment/lib/sync-utils.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+
+// Sync — full reconciliation between on-chain state and address books.
+//
+// For every deployable contract in every address book (Horizon, SubgraphService,
+// Issuance):
+// - Reconcile proxy implementations with on-chain state
+// - Import contract addresses into rocketh deployment records
+// - Validate prerequisites exist on-chain
+//
+// This script is the only one tagged with `SpecialTags.SYNC`. It runs when:
+// - The user invokes `npx hardhat deploy --tags sync` directly
+// - The `deploy:sync` Hardhat task is run (which delegates to the above)
+//
+// Per-component actions sync the contracts they touch immediately before and
+// after their work, so this full sync is no longer required as an automatic
+// dependency on every deployment script.
+
+const func: DeployScriptModule = async (env) => {
+ await runFullSync(env)
+}
+
+func.tags = [SpecialTags.SYNC]
+export default func
diff --git a/packages/deployment/deploy/gip/0088/09_end.ts b/packages/deployment/deploy/gip/0088/09_end.ts
new file mode 100644
index 000000000..2cb8b7fda
--- /dev/null
+++ b/packages/deployment/deploy/gip/0088/09_end.ts
@@ -0,0 +1,114 @@
+import { PROVIDER_ELIGIBILITY_MANAGEMENT_ABI, REWARDS_MANAGER_ABI } from '@graphprotocol/deployment/lib/abis.js'
+import {
+ addressEquals,
+ checkIssuanceAllocatorActivation,
+ isRewardsManagerUpgraded,
+} from '@graphprotocol/deployment/lib/contract-checks.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { getResolvedSettingsForEnv } from '@graphprotocol/deployment/lib/deployment-config.js'
+import { DeploymentActions, GoalTags, shouldSkipAction } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { requireContracts } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+import type { PublicClient } from 'viem'
+
+/**
+ * GIP-0088,all — Full GIP-0088 deployment verification
+ *
+ * Verifies all non-optional phases are complete:
+ * - Upgrade: RM upgraded (supports IIssuanceTarget)
+ * - Eligibility: REO integrated with RM, revertOnIneligible matches config
+ * - Issuance: IA connected to RM, minter role granted
+ *
+ * Does NOT verify optional goals (issuance-close-guard).
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags GIP-0088,all --network
+ */
+const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.ALL)) return
+ await syncComponentsFromRegistry(env, [
+ Contracts.issuance.IssuanceAllocator,
+ Contracts.horizon.RewardsManager,
+ Contracts.horizon.L2GraphToken,
+ Contracts.issuance.RewardsEligibilityOracleA,
+ ])
+ const [issuanceAllocator, rewardsManager, graphToken] = requireContracts(env, [
+ Contracts.issuance.IssuanceAllocator,
+ Contracts.horizon.RewardsManager,
+ Contracts.horizon.L2GraphToken,
+ ])
+
+ const client = graph.getPublicClient(env) as PublicClient
+ const failures: string[] = []
+
+ // Verify RM has been upgraded (supports IERC165)
+ const upgraded = await isRewardsManagerUpgraded(client, rewardsManager.address)
+ if (!upgraded) {
+ env.showMessage(`\n❌ ${Contracts.horizon.RewardsManager.name} not upgraded - run GIP-0088:upgrade,upgrade first\n`)
+ process.exit(1)
+ }
+
+ // Verify IA activation state (issuance phase)
+ const activation = await checkIssuanceAllocatorActivation(
+ client,
+ issuanceAllocator.address,
+ rewardsManager.address,
+ graphToken.address,
+ )
+
+ if (!activation.iaIntegrated) failures.push('IA not integrated with RM')
+ if (!activation.iaMinter) failures.push('IA missing minter role')
+
+ // Verify REO integration (eligibility phase)
+ const reo = env.getOrNull(Contracts.issuance.RewardsEligibilityOracleA.name)
+ if (reo) {
+ const currentOracle = (await client.readContract({
+ address: rewardsManager.address as `0x${string}`,
+ abi: PROVIDER_ELIGIBILITY_MANAGEMENT_ABI,
+ functionName: 'getProviderEligibilityOracle',
+ })) as string
+ if (!addressEquals(currentOracle, reo.address)) {
+ failures.push('REO not integrated with RM')
+ }
+ } else {
+ failures.push('RewardsEligibilityOracleA not deployed')
+ }
+
+ // Verify revertOnIneligible matches config
+ const settings = await getResolvedSettingsForEnv(env)
+ const desiredRevert = settings.rewardsManager.revertOnIneligible
+ try {
+ const onChainRevert = (await client.readContract({
+ address: rewardsManager.address as `0x${string}`,
+ abi: REWARDS_MANAGER_ABI,
+ functionName: 'getRevertOnIneligible',
+ })) as boolean
+ if (onChainRevert !== desiredRevert) {
+ failures.push(`revertOnIneligible mismatch: on-chain=${onChainRevert}, config=${desiredRevert}`)
+ }
+ } catch {
+ failures.push('RM does not support getRevertOnIneligible (not upgraded?)')
+ }
+
+ if (failures.length > 0) {
+ env.showMessage(`\n❌ GIP-0088 incomplete:`)
+ for (const f of failures) env.showMessage(` - ${f}`)
+ env.showMessage('')
+ process.exit(1)
+ }
+
+ env.showMessage(`\n✅ GIP-0088 complete: all contracts deployed, upgraded, and configured\n`)
+}
+
+func.tags = [GoalTags.GIP_0088]
+func.dependencies = [
+ GoalTags.GIP_0088_UPGRADE,
+ GoalTags.GIP_0088_ELIGIBILITY_INTEGRATE,
+ GoalTags.GIP_0088_ISSUANCE_CONNECT,
+ GoalTags.GIP_0088_ISSUANCE_ALLOCATE,
+]
+func.skip = async () => shouldSkipAction(DeploymentActions.ALL)
+
+export default func
diff --git a/packages/deployment/deploy/gip/0088/10_status.ts b/packages/deployment/deploy/gip/0088/10_status.ts
new file mode 100644
index 000000000..8da7509a8
--- /dev/null
+++ b/packages/deployment/deploy/gip/0088/10_status.ts
@@ -0,0 +1,179 @@
+import {
+ IISSUANCE_TARGET_INTERFACE_ID,
+ ISSUANCE_TARGET_ABI,
+ PROVIDER_ELIGIBILITY_MANAGEMENT_ABI,
+ SUBGRAPH_SERVICE_CLOSE_GUARD_ABI,
+} from '@graphprotocol/deployment/lib/abis.js'
+import { getAddressBookForType, getTargetChainIdFromEnv } from '@graphprotocol/deployment/lib/address-book-utils.js'
+import { addressEquals, isRewardsManagerUpgraded } from '@graphprotocol/deployment/lib/contract-checks.js'
+import { Contracts, type RegistryEntry } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { GoalTags } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { showDetailedComponentStatus, showPendingGovernanceTxs } from '@graphprotocol/deployment/lib/status-detail.js'
+import { getContractStatusLine, syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+
+/**
+ * GIP-0088 Status — Phase-structured deployment state display
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags GIP-0088 --network
+ */
+export default createStatusModule(GoalTags.GIP_0088, async (env) => {
+ // Sync the contracts this status touches via env.getOrNull so the read paths
+ // work without depending on a separate global sync run.
+ await syncComponentsFromRegistry(env, [
+ Contracts.horizon.RewardsManager,
+ Contracts.horizon.L2GraphToken,
+ Contracts['subgraph-service'].SubgraphService,
+ Contracts.issuance.IssuanceAllocator,
+ Contracts.issuance.RewardsEligibilityOracleA,
+ Contracts.issuance.RecurringAgreementManager,
+ ])
+
+ const client = graph.getPublicClient(env) as PublicClient
+ const targetChainId = await getTargetChainIdFromEnv(env)
+
+ env.showMessage('\n========== GIP-0088: Full Deployment Status ==========')
+
+ // --- Upgrade phase ---
+ env.showMessage('\nUpgrade:')
+
+ const upgradeContracts: RegistryEntry[] = [
+ Contracts.horizon.RewardsManager,
+ Contracts.horizon.HorizonStaking,
+ Contracts['subgraph-service'].SubgraphService,
+ Contracts['subgraph-service'].DisputeManager,
+ Contracts.horizon.PaymentsEscrow,
+ Contracts.horizon.L2Curation,
+ Contracts.horizon.RecurringCollector,
+ ]
+
+ const rm = env.getOrNull('RewardsManager')
+
+ for (const contract of upgradeContracts) {
+ const ab = getAddressBookForType(contract.addressBook, targetChainId)
+
+ const result = await getContractStatusLine(client, contract.addressBook, ab, contract.name)
+ env.showMessage(` ${result.line}`)
+
+ // RM: semantic check — does the on-chain code support IIssuanceTarget?
+ if (contract === Contracts.horizon.RewardsManager && result.exists && rm) {
+ const upgraded = await isRewardsManagerUpgraded(client, rm.address)
+ env.showMessage(` ${upgraded ? '✓' : '✗'} implements IIssuanceTarget (${IISSUANCE_TARGET_INTERFACE_ID})`)
+ }
+ }
+
+ // --- Eligibility phase ---
+ env.showMessage('\nEligibility:')
+ await showDetailedComponentStatus(env, Contracts.issuance.RewardsEligibilityOracleA, { showHints: false })
+
+ // --- Issuance phase ---
+ env.showMessage('\nIssuance:')
+ await showDetailedComponentStatus(env, Contracts.issuance.IssuanceAllocator, { showHints: false })
+
+ const ram = env.getOrNull('RecurringAgreementManager')
+ if (ram) {
+ await showDetailedComponentStatus(env, Contracts.issuance.RecurringAgreementManager, { showHints: false })
+ } else {
+ env.showMessage(` ○ RecurringAgreementManager not deployed`)
+ }
+
+ // --- Activation status ---
+ env.showMessage('\n--- Activation ---')
+
+ // eligibility-integrate: RM.providerEligibilityOracle == REO_A
+ if (rm) {
+ const upgraded = await isRewardsManagerUpgraded(client, rm.address)
+ if (upgraded) {
+ const reo = env.getOrNull(Contracts.issuance.RewardsEligibilityOracleA.name)
+ const currentOracle = (await client.readContract({
+ address: rm.address as `0x${string}`,
+ abi: PROVIDER_ELIGIBILITY_MANAGEMENT_ABI,
+ functionName: 'getProviderEligibilityOracle',
+ })) as string
+
+ if (reo) {
+ const integrated = addressEquals(currentOracle, reo.address)
+ env.showMessage(` ${integrated ? '✓' : '✗'} eligibility-integrate: RM.providerEligibilityOracle == REO_A`)
+ } else {
+ env.showMessage(` ○ eligibility-integrate: REO_A not deployed`)
+ }
+
+ // issuance-connect: RM.issuanceAllocator == IA + minter role
+ const ia = env.getOrNull('IssuanceAllocator')
+ if (ia) {
+ const currentIA = (await client.readContract({
+ address: rm.address as `0x${string}`,
+ abi: ISSUANCE_TARGET_ABI,
+ functionName: 'getIssuanceAllocator',
+ })) as string
+ const iaConnected = addressEquals(currentIA, ia.address)
+
+ const gt = env.getOrNull('L2GraphToken')
+ let isMinter = false
+ if (gt) {
+ const { GRAPH_TOKEN_ABI } = await import('@graphprotocol/deployment/lib/abis.js')
+ isMinter = (await client.readContract({
+ address: gt.address as `0x${string}`,
+ abi: GRAPH_TOKEN_ABI,
+ functionName: 'isMinter',
+ args: [ia.address as `0x${string}`],
+ })) as boolean
+ }
+
+ env.showMessage(
+ ` ${iaConnected && isMinter ? '✓' : '✗'} issuance-connect: RM ↔ IA${!iaConnected ? ' (not connected)' : ''}${!isMinter ? ' (no minter role)' : ''}`,
+ )
+ } else {
+ env.showMessage(` ○ issuance-connect: IA not deployed`)
+ }
+
+ // issuance-allocate: IA.getTargetAllocation(RAM) configured
+ if (ram) {
+ env.showMessage(` ○ issuance-allocate: check via --tags ${GoalTags.GIP_0088_ISSUANCE_ALLOCATE}`)
+ } else {
+ env.showMessage(` ○ issuance-allocate: RAM not deployed`)
+ }
+ } else {
+ env.showMessage(' ○ RM not upgraded (activation blocked)')
+ }
+ } else {
+ env.showMessage(' ○ RM not in address book')
+ }
+
+ // --- Optional status ---
+ env.showMessage('\n--- Optional (not planned) ---')
+
+ // issuance-close-guard
+ const ss = env.getOrNull('SubgraphService')
+ if (ss) {
+ try {
+ const closeGuard = (await client.readContract({
+ address: ss.address as `0x${string}`,
+ abi: SUBGRAPH_SERVICE_CLOSE_GUARD_ABI,
+ functionName: 'getBlockClosingAllocationWithActiveAgreement',
+ })) as boolean
+ env.showMessage(` ${closeGuard ? '✓' : '○'} issuance-close-guard: blockClosingAllocation = ${closeGuard}`)
+ } catch {
+ env.showMessage(` ○ issuance-close-guard: SS not upgraded`)
+ }
+ } else {
+ env.showMessage(` ○ issuance-close-guard: SS not deployed`)
+ }
+
+ // --- Actions ---
+ env.showMessage('\n--- Actions ---')
+ env.showMessage(' Deploy & upgrade:')
+ env.showMessage(' --tags GIP-0088:upgrade,')
+ env.showMessage(' Activation (after upgrades executed):')
+ env.showMessage(' --tags GIP-0088:eligibility-integrate')
+ env.showMessage(' --tags GIP-0088:issuance-connect')
+ env.showMessage(' --tags GIP-0088:issuance-allocate')
+ env.showMessage(' Optional:')
+ env.showMessage(' --tags GIP-0088:issuance-close-guard')
+
+ showPendingGovernanceTxs(env)
+ env.showMessage('')
+})
diff --git a/packages/deployment/deploy/gip/0088/eligibility_integrate.ts b/packages/deployment/deploy/gip/0088/eligibility_integrate.ts
new file mode 100644
index 000000000..47bd81f7b
--- /dev/null
+++ b/packages/deployment/deploy/gip/0088/eligibility_integrate.ts
@@ -0,0 +1,74 @@
+import { PROVIDER_ELIGIBILITY_MANAGEMENT_ABI } from '@graphprotocol/deployment/lib/abis.js'
+import { applyConfiguration } from '@graphprotocol/deployment/lib/apply-configuration.js'
+import { createRMIntegrationCondition } from '@graphprotocol/deployment/lib/contract-checks.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { canSignAsGovernor } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { ComponentTags, GoalTags } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { requireContracts } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+
+const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
+
+/**
+ * GIP-0088:eligibility-integrate — Set RewardsEligibilityOracle on RewardsManager
+ *
+ * Governance TX: RM.setProviderEligibilityOracle(REO_A)
+ *
+ * Skips if oracle already set (any value, not just REO_A) to avoid
+ * accidentally overriding a live oracle configuration.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags GIP-0088:eligibility-integrate --network
+ */
+export default createActionModule(
+ GoalTags.GIP_0088_ELIGIBILITY_INTEGRATE,
+ async (env) => {
+ await syncComponentsFromRegistry(env, [
+ Contracts.issuance.RewardsEligibilityOracleA,
+ Contracts.horizon.RewardsManager,
+ ])
+ const [reo, rm] = requireContracts(env, [
+ Contracts.issuance.RewardsEligibilityOracleA,
+ Contracts.horizon.RewardsManager,
+ ])
+ const client = graph.getPublicClient(env) as PublicClient
+
+ // Check if oracle already set — skip if any oracle configured (don't override)
+ try {
+ const currentOracle = (await client.readContract({
+ address: rm.address as `0x${string}`,
+ abi: PROVIDER_ELIGIBILITY_MANAGEMENT_ABI,
+ functionName: 'getProviderEligibilityOracle',
+ })) as string
+
+ if (currentOracle !== ZERO_ADDRESS) {
+ const isTarget = currentOracle.toLowerCase() === reo.address.toLowerCase()
+ env.showMessage(`\n ${isTarget ? '✓' : '○'} RM.providerEligibilityOracle already set: ${currentOracle}`)
+ if (!isTarget) {
+ env.showMessage(` (not REO_A — skipping to avoid override)`)
+ }
+ env.showMessage('')
+ return
+ }
+ } catch {
+ // Function not available — RM not upgraded, skip
+ env.showMessage(`\n ○ RM does not support getProviderEligibilityOracle — skipping\n`)
+ return
+ }
+
+ const { governor, canSign } = await canSignAsGovernor(env)
+
+ await applyConfiguration(env, client, [createRMIntegrationCondition(reo.address)], {
+ contractName: `${Contracts.horizon.RewardsManager.name}-REO`,
+ contractAddress: rm.address,
+ canExecuteDirectly: canSign,
+ executor: governor,
+ })
+ },
+ {
+ dependencies: [ComponentTags.REWARDS_MANAGER, ComponentTags.REWARDS_ELIGIBILITY_A],
+ },
+)
diff --git a/packages/deployment/deploy/gip/0088/issuance_allocate.ts b/packages/deployment/deploy/gip/0088/issuance_allocate.ts
new file mode 100644
index 000000000..525970477
--- /dev/null
+++ b/packages/deployment/deploy/gip/0088/issuance_allocate.ts
@@ -0,0 +1,193 @@
+import { ACCESS_CONTROL_ENUMERABLE_ABI, SET_TARGET_ALLOCATION_ABI } from '@graphprotocol/deployment/lib/abis.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { canSignAsGovernor } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { getResolvedSettingsForEnv } from '@graphprotocol/deployment/lib/deployment-config.js'
+import { ComponentTags, GoalTags } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import {
+ createGovernanceTxBuilder,
+ executeTxBatchDirect,
+ saveGovernanceTx,
+} from '@graphprotocol/deployment/lib/execute-governance.js'
+import { formatGRT } from '@graphprotocol/deployment/lib/format.js'
+import { requireContracts, requireDeployer } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import { graph, read, tx } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+import { encodeFunctionData, keccak256, parseUnits, toHex } from 'viem'
+
+/**
+ * GIP-0088:issuance-allocate — Allocate issuance to Recurring Agreement Manager
+ *
+ * Calls setTargetAllocation(RAM, allocatorMintingRate, selfMintingRate) so IA
+ * distributes minted GRT to RAM for agreement-based payments.
+ *
+ * Rates are read from config/.json5 (committed per-chain config).
+ * Skips if rate is 0 (not yet decided).
+ *
+ * Idempotent: checks on-chain state, skips if already configured.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags GIP-0088:issuance-allocate --network
+ */
+export default createActionModule(
+ GoalTags.GIP_0088_ISSUANCE_ALLOCATE,
+ async (env) => {
+ await syncComponentsFromRegistry(env, [
+ Contracts.issuance.IssuanceAllocator,
+ Contracts.issuance.RecurringAgreementManager,
+ Contracts.horizon.RewardsManager,
+ ])
+
+ const client = graph.getPublicClient(env) as PublicClient
+ const readFn = read(env)
+
+ const iaDep = env.getOrNull(Contracts.issuance.IssuanceAllocator.name)
+ const ramDep = env.getOrNull(Contracts.issuance.RecurringAgreementManager.name)
+ if (!iaDep || !ramDep) {
+ const missing = [!iaDep && 'IssuanceAllocator', !ramDep && 'RecurringAgreementManager'].filter(Boolean)
+ env.showMessage(`\n ○ Skipping RAM allocation — not deployed: ${missing.join(', ')}\n`)
+ return
+ }
+ const ia = iaDep
+ const ram = ramDep
+
+ env.showMessage(`\n========== GIP-0088: Issuance Allocate ==========`)
+ env.showMessage(`IA: ${ia.address}`)
+ env.showMessage(`RAM: ${ram.address}`)
+
+ // Load resolved settings
+ const settings = await getResolvedSettingsForEnv(env)
+ const allocatorMintingRate = parseUnits(settings.issuanceAllocator.ramAllocatorMintingGrtPerBlock, 18)
+ const selfMintingRate = parseUnits(settings.issuanceAllocator.ramSelfMintingGrtPerBlock, 18)
+
+ if (allocatorMintingRate === 0n && selfMintingRate === 0n) {
+ env.showMessage('\n⚠️ RAM allocation rates not configured (both 0).')
+ env.showMessage(' Set ramAllocatorMintingGrtPerBlock in config/.json5')
+ env.showMessage(' Skipping RAM allocation configuration.\n')
+ return
+ }
+
+ // Check current state
+ env.showMessage('\n📋 Checking current configuration...\n')
+ env.showMessage(
+ ` Config: allocatorMintingRate=${formatGRT(allocatorMintingRate)}, selfMintingRate=${formatGRT(selfMintingRate)}`,
+ )
+
+ let currentRamAlloc = 0n
+ let currentRamSelf = 0n
+ let ramAllocated = false
+ try {
+ const allocation = (await readFn(ia, {
+ functionName: 'getTargetAllocation',
+ args: [ram.address],
+ })) as { totalAllocationRate: bigint; allocatorMintingRate: bigint; selfMintingRate: bigint }
+ currentRamAlloc = allocation.allocatorMintingRate
+ currentRamSelf = allocation.selfMintingRate
+ ramAllocated = currentRamAlloc === allocatorMintingRate && currentRamSelf === selfMintingRate
+ env.showMessage(
+ ` On-chain: allocator=${formatGRT(currentRamAlloc)}, self=${formatGRT(currentRamSelf)} ${ramAllocated ? '✓' : '✗'}`,
+ )
+ } catch {
+ env.showMessage(` RAM allocation: ✗ (not configured)`)
+ }
+
+ if (ramAllocated) {
+ env.showMessage(`\n✅ RAM allocation already matches config\n`)
+ return
+ }
+
+ // The allocator enforces a 100% invariant (sum of all targets == issuancePerBlock).
+ // RewardsManager was given 100% as self-minting in issuance-connect, so we must
+ // atomically rebalance: take from RM's self-minting and give to RAM, in the same batch.
+ const [rewardsManager] = requireContracts(env, [Contracts.horizon.RewardsManager])
+ const rmAddress = rewardsManager.address as `0x${string}`
+ const rmAllocation = (await readFn(ia, {
+ functionName: 'getTargetAllocation',
+ args: [rmAddress],
+ })) as { totalAllocationRate: bigint; allocatorMintingRate: bigint; selfMintingRate: bigint }
+ env.showMessage(
+ ` RM on-chain: allocator=${formatGRT(rmAllocation.allocatorMintingRate)}, self=${formatGRT(rmAllocation.selfMintingRate)}`,
+ )
+
+ const newRamTotal = allocatorMintingRate + selfMintingRate
+ const currentRamTotal = currentRamAlloc + currentRamSelf
+ const delta = newRamTotal - currentRamTotal // signed: >0 RAM grows, <0 RAM shrinks
+ if (delta > 0n && rmAllocation.selfMintingRate < delta) {
+ env.showMessage(
+ `\n❌ Insufficient RM self-minting (${formatGRT(rmAllocation.selfMintingRate)}) to fund RAM increase (${formatGRT(delta)})\n`,
+ )
+ process.exit(1)
+ }
+ const newRmSelf = rmAllocation.selfMintingRate - delta
+
+ // Determine executor
+ const deployer = requireDeployer(env)
+ const GOVERNOR_ROLE = keccak256(toHex('GOVERNOR_ROLE'))
+ let deployerIsGovernor = false
+ try {
+ deployerIsGovernor = (await client.readContract({
+ address: ia.address as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [GOVERNOR_ROLE, deployer as `0x${string}`],
+ })) as boolean
+ } catch {
+ // Storage not available (stale fork) — fall through to governor path
+ }
+
+ const setRamData = encodeFunctionData({
+ abi: SET_TARGET_ALLOCATION_ABI,
+ functionName: 'setTargetAllocation',
+ args: [ram.address as `0x${string}`, allocatorMintingRate, selfMintingRate],
+ })
+ const setRmData = encodeFunctionData({
+ abi: SET_TARGET_ALLOCATION_ABI,
+ functionName: 'setTargetAllocation',
+ args: [rmAddress, rmAllocation.allocatorMintingRate, newRmSelf],
+ })
+ const ramLabel = `setTargetAllocation(RAM, ${formatGRT(allocatorMintingRate)}, ${formatGRT(selfMintingRate)})`
+ const rmLabel = `setTargetAllocation(RM, ${formatGRT(rmAllocation.allocatorMintingRate)}, ${formatGRT(newRmSelf)})`
+
+ // Order matters: free budget first, then consume.
+ // delta > 0 (RAM grows): reduce RM first so default target absorbs the slack.
+ // delta < 0 (RAM shrinks): reduce RAM first so default target absorbs the slack.
+ const txs =
+ delta > 0n
+ ? [
+ { data: setRmData, label: rmLabel },
+ { data: setRamData, label: ramLabel },
+ ]
+ : [
+ { data: setRamData, label: ramLabel },
+ { data: setRmData, label: rmLabel },
+ ]
+
+ if (deployerIsGovernor) {
+ env.showMessage('\n🔨 Executing as deployer...\n')
+ const txFn = tx(env)
+ for (const t of txs) {
+ await txFn({ account: deployer, to: ia.address, data: t.data })
+ env.showMessage(` ✓ ${t.label}`)
+ }
+ env.showMessage(`\n✅ GIP-0088: Issuance Allocate — RAM allocation configured!\n`)
+ } else {
+ const { governor, canSign } = await canSignAsGovernor(env)
+
+ const builder = await createGovernanceTxBuilder(env, `gip-0088-issuance-allocate`)
+ for (const t of txs) {
+ builder.addTx({ to: ia.address, value: '0', data: t.data })
+ env.showMessage(` + ${t.label}`)
+ }
+
+ if (canSign) {
+ env.showMessage('\n🔨 Executing configuration TX batch...\n')
+ await executeTxBatchDirect(env, builder, governor)
+ env.showMessage(`\n✅ GIP-0088: Issuance Allocate — RAM allocation configured!\n`)
+ } else {
+ saveGovernanceTx(env, builder, `GIP-0088: issuance-allocate`)
+ }
+ }
+ },
+ { dependencies: [GoalTags.GIP_0088_ISSUANCE_CONNECT, ComponentTags.RECURRING_AGREEMENT_MANAGER] },
+)
diff --git a/packages/deployment/deploy/gip/0088/issuance_close_guard.ts b/packages/deployment/deploy/gip/0088/issuance_close_guard.ts
new file mode 100644
index 000000000..55f33040a
--- /dev/null
+++ b/packages/deployment/deploy/gip/0088/issuance_close_guard.ts
@@ -0,0 +1,81 @@
+import { SUBGRAPH_SERVICE_CLOSE_GUARD_ABI } from '@graphprotocol/deployment/lib/abis.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { canSignAsGovernor } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { ComponentTags, GoalTags, shouldSkipOptionalGoal } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import {
+ createGovernanceTxBuilder,
+ executeTxBatchDirect,
+ saveGovernanceTx,
+} from '@graphprotocol/deployment/lib/execute-governance.js'
+import { requireContract } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+import type { PublicClient } from 'viem'
+import { encodeFunctionData } from 'viem'
+
+/**
+ * GIP-0088:issuance-close-guard — Prevent closing allocations with active agreements
+ *
+ * Optional governance TX: SS.setBlockClosingAllocationWithActiveAgreement(true)
+ *
+ * Not activated by `all` — requires explicit `--tags GIP-0088:issuance-close-guard`.
+ *
+ * Idempotent: reads on-chain state, skips if already enabled.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags GIP-0088:issuance-close-guard --network
+ */
+const func: DeployScriptModule = async (env) => {
+ if (shouldSkipOptionalGoal(GoalTags.GIP_0088_ISSUANCE_CLOSE_GUARD)) return
+ await syncComponentsFromRegistry(env, [Contracts['subgraph-service'].SubgraphService])
+
+ const client = graph.getPublicClient(env) as PublicClient
+ const ss = requireContract(env, Contracts['subgraph-service'].SubgraphService)
+
+ env.showMessage(`\n========== GIP-0088: Issuance Close Guard ==========`)
+ env.showMessage(`${Contracts['subgraph-service'].SubgraphService.name}: ${ss.address}`)
+
+ // Check current state
+ env.showMessage('\n📋 Checking current configuration...\n')
+
+ const enabled = (await client.readContract({
+ address: ss.address as `0x${string}`,
+ abi: SUBGRAPH_SERVICE_CLOSE_GUARD_ABI,
+ functionName: 'getBlockClosingAllocationWithActiveAgreement',
+ })) as boolean
+ env.showMessage(` blockClosingAllocationWithActiveAgreement: ${enabled ? '✓ true' : '✗ false'}`)
+
+ if (enabled) {
+ env.showMessage(`\n✅ ${Contracts['subgraph-service'].SubgraphService.name} close guard already enabled\n`)
+ return
+ }
+
+ const { governor, canSign } = await canSignAsGovernor(env)
+
+ env.showMessage('\n🔨 Building configuration TX batch...\n')
+
+ const builder = await createGovernanceTxBuilder(env, `gip-0088-issuance-close-guard`)
+
+ const data = encodeFunctionData({
+ abi: SUBGRAPH_SERVICE_CLOSE_GUARD_ABI,
+ functionName: 'setBlockClosingAllocationWithActiveAgreement',
+ args: [true],
+ })
+ builder.addTx({ to: ss.address, value: '0', data })
+ env.showMessage(` + setBlockClosingAllocationWithActiveAgreement(true)`)
+
+ if (canSign) {
+ env.showMessage('\n🔨 Executing configuration TX batch...\n')
+ await executeTxBatchDirect(env, builder, governor)
+ env.showMessage(`\n✅ GIP-0088: allocation close guard enabled\n`)
+ } else {
+ saveGovernanceTx(env, builder, `GIP-0088: allocation close guard`)
+ }
+}
+
+func.tags = [GoalTags.GIP_0088_ISSUANCE_CLOSE_GUARD]
+func.dependencies = [ComponentTags.SUBGRAPH_SERVICE]
+func.skip = async () => shouldSkipOptionalGoal(GoalTags.GIP_0088_ISSUANCE_CLOSE_GUARD)
+
+export default func
diff --git a/packages/deployment/deploy/gip/0088/issuance_connect.ts b/packages/deployment/deploy/gip/0088/issuance_connect.ts
new file mode 100644
index 000000000..30f8c170d
--- /dev/null
+++ b/packages/deployment/deploy/gip/0088/issuance_connect.ts
@@ -0,0 +1,247 @@
+import {
+ GRAPH_TOKEN_ABI,
+ ISSUANCE_ALLOCATOR_ABI,
+ ISSUANCE_TARGET_ABI,
+ REWARDS_MANAGER_DEPRECATED_ABI,
+ SET_TARGET_ALLOCATION_ABI,
+} from '@graphprotocol/deployment/lib/abis.js'
+import { getTargetChainIdFromEnv } from '@graphprotocol/deployment/lib/address-book-utils.js'
+import { requireRewardsManagerUpgraded } from '@graphprotocol/deployment/lib/contract-checks.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { canSignAsGovernor } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { ComponentTags, GoalTags } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import {
+ createGovernanceTxBuilder,
+ executeTxBatchDirect,
+ saveGovernanceTx,
+} from '@graphprotocol/deployment/lib/execute-governance.js'
+import { formatGRT } from '@graphprotocol/deployment/lib/format.js'
+import { requireContracts, requireDeployer } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+import { encodeFunctionData } from 'viem'
+
+/**
+ * GIP-0088:issuance-connect — Connect Rewards Manager to Issuance Allocator
+ *
+ * - Configure RewardsManager to use IssuanceAllocator
+ * - Grant minter role to IssuanceAllocator on GraphToken
+ *
+ * Idempotent: checks on-chain state, skips if already activated.
+ * If the provider has access to the governor key, executes directly.
+ * Otherwise generates governance TX file.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags GIP-0088:issuance-connect --network
+ */
+export default createActionModule(
+ GoalTags.GIP_0088_ISSUANCE_CONNECT,
+ async (env) => {
+ await syncComponentsFromRegistry(env, [
+ Contracts.issuance.IssuanceAllocator,
+ Contracts.horizon.RewardsManager,
+ Contracts.horizon.L2GraphToken,
+ Contracts.issuance.DefaultAllocation,
+ ])
+
+ const deployer = requireDeployer(env)
+
+ // Check if the provider can sign as the protocol governor
+ const { governor, canSign } = await canSignAsGovernor(env)
+
+ const [issuanceAllocator, rewardsManager, graphToken, defaultAllocation] = requireContracts(env, [
+ Contracts.issuance.IssuanceAllocator,
+ Contracts.horizon.RewardsManager,
+ Contracts.horizon.L2GraphToken,
+ Contracts.issuance.DefaultAllocation,
+ ])
+
+ const iaAddress = issuanceAllocator.address
+ const rmAddress = rewardsManager.address
+ const gtAddress = graphToken.address
+ const daAddress = defaultAllocation.address
+
+ // Create viem client for direct contract calls
+ const client = graph.getPublicClient(env) as PublicClient
+
+ // Check if RewardsManager supports IIssuanceTarget (has been upgraded)
+ // Throws error if not upgraded
+ await requireRewardsManagerUpgraded(client, rmAddress, env)
+
+ const targetChainId = await getTargetChainIdFromEnv(env)
+
+ env.showMessage(`\n========== GIP-0088: Issuance Connect ==========`)
+ env.showMessage(`Network: ${env.name} (chainId=${targetChainId})`)
+ env.showMessage(`Deployer: ${deployer}`)
+ env.showMessage(`Protocol Governor (from Controller): ${governor}`)
+ env.showMessage(`${Contracts.issuance.IssuanceAllocator.name}: ${iaAddress}`)
+ env.showMessage(`${Contracts.horizon.RewardsManager.name}: ${rmAddress}`)
+ env.showMessage(`${Contracts.horizon.L2GraphToken.name}: ${gtAddress}\n`)
+
+ // Check current state
+ env.showMessage('📋 Checking current activation state...\n')
+
+ const checks = {
+ iaIntegrated: false,
+ iaMinter: false,
+ }
+
+ // Check RM.getIssuanceAllocator() == IA
+ const currentIA = (await client.readContract({
+ address: rmAddress as `0x${string}`,
+ abi: ISSUANCE_TARGET_ABI,
+ functionName: 'getIssuanceAllocator',
+ })) as string
+ checks.iaIntegrated = currentIA.toLowerCase() === iaAddress.toLowerCase()
+ env.showMessage(` IA integrated: ${checks.iaIntegrated ? '✓' : '✗'} (current: ${currentIA})`)
+
+ // Check GraphToken.isMinter(IA)
+ checks.iaMinter = (await client.readContract({
+ address: gtAddress as `0x${string}`,
+ abi: GRAPH_TOKEN_ABI,
+ functionName: 'isMinter',
+ args: [iaAddress as `0x${string}`],
+ })) as boolean
+ env.showMessage(` IA minter: ${checks.iaMinter ? '✓' : '✗'}`)
+
+ // Check RM allocation on IA
+ let rmAllocationOk = false
+ try {
+ const rmAllocation = (await client.readContract({
+ address: iaAddress as `0x${string}`,
+ abi: ISSUANCE_ALLOCATOR_ABI,
+ functionName: 'getTargetAllocation',
+ args: [rmAddress as `0x${string}`],
+ })) as { totalAllocationRate: bigint; allocatorMintingRate: bigint; selfMintingRate: bigint }
+ const iaRate = (await client.readContract({
+ address: iaAddress as `0x${string}`,
+ abi: ISSUANCE_ALLOCATOR_ABI,
+ functionName: 'getIssuancePerBlock',
+ })) as bigint
+ rmAllocationOk =
+ rmAllocation.allocatorMintingRate === 0n && rmAllocation.selfMintingRate === iaRate && iaRate > 0n
+ env.showMessage(
+ ` RM allocation: ${rmAllocationOk ? '✓' : '✗'} (self: ${formatGRT(rmAllocation.selfMintingRate)}, allocator: ${formatGRT(rmAllocation.allocatorMintingRate)})`,
+ )
+ } catch {
+ env.showMessage(` RM allocation: ✗ (not set)`)
+ }
+
+ // All checks passed?
+ if (checks.iaIntegrated && checks.iaMinter && rmAllocationOk) {
+ env.showMessage(`\n✅ RM already connected to IssuanceAllocator\n`)
+ return
+ }
+
+ // Migration invariant: IA rate must match RM rate before connection
+ if (!checks.iaIntegrated) {
+ const rmRate = (await client.readContract({
+ address: rmAddress as `0x${string}`,
+ abi: REWARDS_MANAGER_DEPRECATED_ABI,
+ functionName: 'issuancePerBlock',
+ })) as bigint
+
+ const iaRate = (await client.readContract({
+ address: iaAddress as `0x${string}`,
+ abi: ISSUANCE_ALLOCATOR_ABI,
+ functionName: 'getIssuancePerBlock',
+ })) as bigint
+
+ if (iaRate !== rmRate) {
+ env.showMessage(
+ `\n❌ Migration invariant failed: IA.issuancePerBlock (${formatGRT(iaRate)}) != RM.issuancePerBlock (${formatGRT(rmRate)})`,
+ )
+ env.showMessage(` IA must have the same overall rate as RM before connection.\n`)
+ process.exit(1)
+ }
+
+ env.showMessage(` Migration invariant: ✓ IA rate == RM rate (${formatGRT(iaRate)})`)
+ }
+
+ // Build TX batch — order:
+ // 1. IA.setTargetAllocation(RM, 0, rate) — register RM in IA first
+ // 2. RM.setIssuanceAllocator(IA) — flip RM to read from a fully-configured IA
+ // 3. GraphToken.addMinter(IA) — grant IA the minter role
+ // 4. IA.setDefaultTarget(DA) — install safety-net default
+ // Conceptually: configure IA's view of RM before RM starts reading from IA. Atomic
+ // within the batch either way, but this avoids a transient where RM is wired to an
+ // IA that has no allocation entry for it.
+ env.showMessage('\n🔨 Building activation TX batch...\n')
+
+ const builder = await createGovernanceTxBuilder(env, `gip-0088-issuance-connect`)
+
+ // 1. IA.setTargetAllocation(RM, 0, rate) — RM as 100% self-minting target
+ if (!rmAllocationOk) {
+ const iaRate = (await client.readContract({
+ address: iaAddress as `0x${string}`,
+ abi: ISSUANCE_ALLOCATOR_ABI,
+ functionName: 'getIssuancePerBlock',
+ })) as bigint
+ const data = encodeFunctionData({
+ abi: SET_TARGET_ALLOCATION_ABI,
+ functionName: 'setTargetAllocation',
+ args: [rmAddress as `0x${string}`, 0n, iaRate],
+ })
+ builder.addTx({ to: iaAddress, value: '0', data })
+ env.showMessage(` + IA.setTargetAllocation(RM, 0, ${formatGRT(iaRate)})`)
+ }
+
+ // 2. RM.setIssuanceAllocator(IA) — RM accepts IA as its allocator
+ if (!checks.iaIntegrated) {
+ const data = encodeFunctionData({
+ abi: ISSUANCE_TARGET_ABI,
+ functionName: 'setIssuanceAllocator',
+ args: [iaAddress as `0x${string}`],
+ })
+ builder.addTx({ to: rmAddress, value: '0', data })
+ env.showMessage(` + RewardsManager.setIssuanceAllocator(${iaAddress})`)
+ }
+
+ // 3. GraphToken.addMinter(IA) — IA needs minter role for allocator-minting
+ if (!checks.iaMinter) {
+ const data = encodeFunctionData({
+ abi: GRAPH_TOKEN_ABI,
+ functionName: 'addMinter',
+ args: [iaAddress as `0x${string}`],
+ })
+ builder.addTx({ to: gtAddress, value: '0', data })
+ env.showMessage(` + GraphToken.addMinter(${iaAddress})`)
+ }
+
+ // 4. IA.setDefaultTarget(DA) — safety net for unallocated issuance
+ let defaultTargetOk = false
+ try {
+ const currentDefault = (await client.readContract({
+ address: iaAddress as `0x${string}`,
+ abi: ISSUANCE_ALLOCATOR_ABI,
+ functionName: 'getTargetAt',
+ args: [0n],
+ })) as string
+ defaultTargetOk = currentDefault.toLowerCase() === daAddress.toLowerCase()
+ } catch {
+ // No targets yet
+ }
+ env.showMessage(` DA default target: ${defaultTargetOk ? '✓' : '✗'}`)
+
+ if (!defaultTargetOk) {
+ const data = encodeFunctionData({
+ abi: ISSUANCE_ALLOCATOR_ABI,
+ functionName: 'setDefaultTarget',
+ args: [daAddress as `0x${string}`],
+ })
+ builder.addTx({ to: iaAddress, value: '0', data })
+ env.showMessage(` + IA.setDefaultTarget(${daAddress})`)
+ }
+
+ if (canSign) {
+ env.showMessage('\n🔨 Executing activation TX batch...\n')
+ await executeTxBatchDirect(env, builder, governor)
+ env.showMessage(`\n✅ GIP-0088: Issuance Connect — RM connected to IssuanceAllocator!\n`)
+ } else {
+ saveGovernanceTx(env, builder, `GIP-0088: issuance-connect`)
+ }
+ },
+ { dependencies: [ComponentTags.ISSUANCE_ALLOCATOR, ComponentTags.DEFAULT_ALLOCATION, ComponentTags.REWARDS_MANAGER] },
+)
diff --git a/packages/deployment/deploy/gip/0088/upgrade/01_deploy.ts b/packages/deployment/deploy/gip/0088/upgrade/01_deploy.ts
new file mode 100644
index 000000000..010564515
--- /dev/null
+++ b/packages/deployment/deploy/gip/0088/upgrade/01_deploy.ts
@@ -0,0 +1,47 @@
+import {
+ ComponentTags,
+ DeploymentActions,
+ GoalTags,
+ shouldSkipAction,
+} from '@graphprotocol/deployment/lib/deployment-tags.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+
+/**
+ * GIP-0088:upgrade — Deploy ALL contracts and implementations
+ *
+ * Deploys everything required for GIP-0088 in one step:
+ * - New implementations for existing proxies (RM, HS, SS, DM, PE, L2Curation)
+ * - New contracts (RC, IA, DA, Reclaim, RAM, REO A/B)
+ *
+ * The eligibility and issuance phases start from configure, not deploy.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags GIP-0088:upgrade,deploy --network
+ */
+const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.DEPLOY)) return
+ env.showMessage('\n✓ GIP-0088 upgrade: all contracts and implementations deployed\n')
+}
+
+func.tags = [GoalTags.GIP_0088_UPGRADE]
+func.dependencies = [
+ // New implementations for existing proxies
+ ComponentTags.REWARDS_MANAGER,
+ ComponentTags.HORIZON_STAKING,
+ ComponentTags.SUBGRAPH_SERVICE,
+ ComponentTags.DISPUTE_MANAGER,
+ ComponentTags.PAYMENTS_ESCROW,
+ ComponentTags.L2_CURATION,
+ // New contracts (proxy + implementation)
+ ComponentTags.RECURRING_COLLECTOR,
+ ComponentTags.ISSUANCE_ALLOCATOR,
+ ComponentTags.DIRECT_ALLOCATION_IMPL,
+ ComponentTags.DEFAULT_ALLOCATION,
+ ComponentTags.REWARDS_RECLAIM,
+ ComponentTags.RECURRING_AGREEMENT_MANAGER,
+ ComponentTags.REWARDS_ELIGIBILITY_A,
+ ComponentTags.REWARDS_ELIGIBILITY_B,
+]
+func.skip = async () => shouldSkipAction(DeploymentActions.DEPLOY)
+
+export default func
diff --git a/packages/deployment/deploy/gip/0088/upgrade/02_configure.ts b/packages/deployment/deploy/gip/0088/upgrade/02_configure.ts
new file mode 100644
index 000000000..94e431e52
--- /dev/null
+++ b/packages/deployment/deploy/gip/0088/upgrade/02_configure.ts
@@ -0,0 +1,40 @@
+import {
+ ComponentTags,
+ DeploymentActions,
+ GoalTags,
+ shouldSkipAction,
+} from '@graphprotocol/deployment/lib/deployment-tags.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+
+/**
+ * GIP-0088:upgrade — Configure all contracts (deployer-only)
+ *
+ * Checkpoint: component 04_configure scripts do the work.
+ *
+ * Only items the deployer can perform run here. Items that require GOVERNOR_ROLE
+ * on contracts the deployer doesn't yet control (e.g. RC.setPauseGuardian, RM
+ * integration with Reclaim, deferred role grants on new contracts) are bundled
+ * into the upgrade governance batch by `04_upgrade.ts`. RC's `04_configure`
+ * is read-only — it just reports state.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags GIP-0088:upgrade,configure --network
+ */
+const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.CONFIGURE)) return
+ env.showMessage('\n✓ GIP-0088 upgrade: contracts configured\n')
+}
+
+func.tags = [GoalTags.GIP_0088_UPGRADE]
+func.dependencies = [
+ ComponentTags.RECURRING_COLLECTOR,
+ ComponentTags.ISSUANCE_ALLOCATOR,
+ ComponentTags.DEFAULT_ALLOCATION,
+ ComponentTags.REWARDS_RECLAIM,
+ ComponentTags.RECURRING_AGREEMENT_MANAGER,
+ ComponentTags.REWARDS_ELIGIBILITY_A,
+ ComponentTags.REWARDS_ELIGIBILITY_B,
+]
+func.skip = async () => shouldSkipAction(DeploymentActions.CONFIGURE)
+
+export default func
diff --git a/packages/deployment/deploy/gip/0088/upgrade/03_transfer.ts b/packages/deployment/deploy/gip/0088/upgrade/03_transfer.ts
new file mode 100644
index 000000000..272aa8f8c
--- /dev/null
+++ b/packages/deployment/deploy/gip/0088/upgrade/03_transfer.ts
@@ -0,0 +1,39 @@
+import {
+ ComponentTags,
+ DeploymentActions,
+ GoalTags,
+ shouldSkipAction,
+} from '@graphprotocol/deployment/lib/deployment-tags.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+
+/**
+ * GIP-0088:upgrade — Transfer governance of all new contracts to protocol governor
+ *
+ * Checkpoint: component transfer scripts do the work.
+ * Covers all new contracts that were deployed with deployer as governor.
+ *
+ * Must run AFTER configure (deployer needs GOVERNOR_ROLE to configure)
+ * and BEFORE upgrade (governance must own proxies before upgrade TXs).
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags GIP-0088:upgrade,transfer --network
+ */
+const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.TRANSFER)) return
+ env.showMessage('\n✓ GIP-0088 upgrade: governance transferred\n')
+}
+
+func.tags = [GoalTags.GIP_0088_UPGRADE]
+func.dependencies = [
+ ComponentTags.RECURRING_COLLECTOR,
+ ComponentTags.ISSUANCE_ALLOCATOR,
+ ComponentTags.DEFAULT_ALLOCATION,
+ ComponentTags.RECURRING_AGREEMENT_MANAGER,
+ ComponentTags.REWARDS_RECLAIM,
+ ComponentTags.REWARDS_ELIGIBILITY_A,
+ ComponentTags.REWARDS_ELIGIBILITY_B,
+ ComponentTags.REWARDS_ELIGIBILITY_MOCK,
+]
+func.skip = async () => shouldSkipAction(DeploymentActions.TRANSFER)
+
+export default func
diff --git a/packages/deployment/deploy/gip/0088/upgrade/04_upgrade.ts b/packages/deployment/deploy/gip/0088/upgrade/04_upgrade.ts
new file mode 100644
index 000000000..cdec30636
--- /dev/null
+++ b/packages/deployment/deploy/gip/0088/upgrade/04_upgrade.ts
@@ -0,0 +1,447 @@
+import {
+ ACCESS_CONTROL_ENUMERABLE_ABI,
+ ISSUANCE_ALLOCATOR_ABI,
+ ISSUANCE_TARGET_ABI,
+ RECURRING_COLLECTOR_PAUSE_ABI,
+ REWARDS_MANAGER_ABI,
+ REWARDS_MANAGER_DEPRECATED_ABI,
+} from '@graphprotocol/deployment/lib/abis.js'
+import { getAddressBookForType, getTargetChainIdFromEnv } from '@graphprotocol/deployment/lib/address-book-utils.js'
+import { checkConfigurationStatus } from '@graphprotocol/deployment/lib/apply-configuration.js'
+import { getREOConditions } from '@graphprotocol/deployment/lib/contract-checks.js'
+import {
+ type AddressBookType,
+ CONTRACT_REGISTRY,
+ type ContractMetadata,
+ Contracts,
+} from '@graphprotocol/deployment/lib/contract-registry.js'
+import { canSignAsGovernor, getPauseGuardian } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { getResolvedSettingsForEnv, type ResolvedSettings } from '@graphprotocol/deployment/lib/deployment-config.js'
+import { DeploymentActions, GoalTags, shouldSkipAction } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import {
+ createGovernanceTxBuilder,
+ executeTxBatchDirect,
+ saveGovernanceTx,
+} from '@graphprotocol/deployment/lib/execute-governance.js'
+import { formatGRT } from '@graphprotocol/deployment/lib/format.js'
+import {
+ checkDefaultAllocationConfigured,
+ checkIAConfigured,
+ checkRAMConfigured,
+ checkReclaimRMIntegration,
+ checkReclaimRoles,
+ checkRMRevertOnIneligible,
+} from '@graphprotocol/deployment/lib/preconditions.js'
+import { runFullSync } from '@graphprotocol/deployment/lib/sync-utils.js'
+import type { TxBuilder } from '@graphprotocol/deployment/lib/tx-builder.js'
+import { buildUpgradeTxs } from '@graphprotocol/deployment/lib/upgrade-implementation.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { DeployScriptModule, Environment } from '@rocketh/core/types'
+import type { PublicClient } from 'viem'
+import { encodeFunctionData } from 'viem'
+
+/**
+ * GIP-0088:upgrade — Build the governance batch
+ *
+ * Single goal: assemble one TX batch that advances the deployment past the
+ * governance boundary. The batch contains three groups, each of which skips
+ * items already on-chain:
+ *
+ * 1. Proxy upgrades — every deployable proxy with a pendingImplementation
+ * 2. Existing-contract config — RC.setPauseGuardian, RM.setDefaultReclaimAddress
+ * 3. Deferred new-contract config — IA/DA/RAM/Reclaim/REO role grants and
+ * params that the deployer couldn't perform (no GOVERNOR_ROLE) or that
+ * depend on RM being upgraded
+ *
+ * Each helper takes the builder, adds zero or more TXs, and returns the count
+ * it added. The orchestrator just sums them, prints the result, and either
+ * executes or saves the batch.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags GIP-0088:upgrade,upgrade --network
+ * pnpm hardhat deploy:execute-governance --network
+ */
+const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.UPGRADE)) return
+
+ // The orchestration batch reads every deployable contract across all three
+ // address books, so we need a full sync first rather than a per-component one.
+ await runFullSync(env)
+
+ const targetChainId = await getTargetChainIdFromEnv(env)
+ const { governor, canSign } = await canSignAsGovernor(env)
+ const pauseGuardian = await getPauseGuardian(env)
+ const client = graph.getPublicClient(env) as PublicClient
+
+ env.showMessage('\n========== GIP-0088 Upgrade: Proxy Upgrades ==========\n')
+
+ const builder = await createGovernanceTxBuilder(env, 'gip-0088-upgrades', {
+ name: 'GIP-0088 Proxy Upgrades',
+ description: 'Upgrade all proxy contracts with pending implementations',
+ })
+
+ const proxyCount = await collectProxyUpgrades(env, builder, targetChainId)
+
+ const settings = await getResolvedSettingsForEnv(env)
+
+ env.showMessage('\nOutstanding configuration:')
+ const existingCount = await collectExistingContractConfig(env, builder, client, pauseGuardian, settings)
+ const newCount = await collectDeferredNewContractConfig(env, builder, client, targetChainId, governor, pauseGuardian)
+
+ const total = proxyCount + existingCount + newCount
+ if (total === 0) {
+ env.showMessage(' No pending upgrades found\n')
+ return
+ }
+
+ if (canSign) {
+ env.showMessage('\n🔨 Executing upgrade TX batch...\n')
+ await executeTxBatchDirect(env, builder, governor)
+ env.showMessage('\n✅ GIP-0088 Upgrade: All proxy upgrades executed\n')
+ } else {
+ saveGovernanceTx(env, builder, 'GIP-0088 Proxy Upgrades')
+ }
+}
+
+func.tags = [GoalTags.GIP_0088_UPGRADE]
+func.skip = async () => shouldSkipAction(DeploymentActions.UPGRADE)
+
+export default func
+
+// ============================================================================
+// Group 1 — Proxy upgrades
+// ============================================================================
+
+/**
+ * Iterate every deployable proxy in the registry. For each one with a
+ * pendingImplementation in its address book, add the proxy upgrade TX.
+ */
+async function collectProxyUpgrades(env: Environment, builder: TxBuilder, targetChainId: number): Promise {
+ let added = 0
+ const addressBooks: AddressBookType[] = ['horizon', 'subgraph-service', 'issuance']
+ for (const abType of addressBooks) {
+ const bookRegistry = CONTRACT_REGISTRY[abType]
+ const ab = getAddressBookForType(abType, targetChainId)
+
+ for (const [name, metadata] of Object.entries(bookRegistry)) {
+ const meta = metadata as ContractMetadata
+ if (!meta.deployable || !meta.proxyType) continue
+ if (!ab.entryExists(name)) continue
+ const entry = ab.getEntry(name)
+
+ // Skip contracts with no pending implementation unless they have a
+ // shared implementation that might have changed (auto-detected by buildUpgradeTxs)
+ if (!entry?.pendingImplementation?.address && !meta.sharedImplementation) continue
+
+ // Derive implementationName from sharedImplementation (e.g. 'DirectAllocation_Implementation' → 'DirectAllocation')
+ const implementationName = meta.sharedImplementation?.replace(/_Implementation$/, '')
+
+ const result = await buildUpgradeTxs(
+ env,
+ {
+ contractName: name,
+ proxyType: meta.proxyType,
+ proxyAdminName: meta.proxyAdminName,
+ addressBook: abType,
+ implementationName,
+ },
+ builder,
+ )
+ if (result.upgraded) added++
+ }
+ }
+ return added
+}
+
+// ============================================================================
+// Group 2 — Existing contract config (RC, RM)
+// ============================================================================
+
+/**
+ * Bundle the few governance-only configure items on contracts that already
+ * existed before this deployment (typically the deployer does not hold
+ * GOVERNOR_ROLE on them — true on networks where RM was deployed by separate
+ * horizon-Ignition infrastructure; the dynamic role check is the source of truth):
+ *
+ * - RC.setPauseGuardian
+ * - RM.setDefaultReclaimAddress (only when RM has been upgraded)
+ * - RM.setRevertOnIneligible (driven by config; only when RM has been upgraded)
+ */
+async function collectExistingContractConfig(
+ env: Environment,
+ builder: TxBuilder,
+ client: PublicClient,
+ pauseGuardian: string,
+ settings: ResolvedSettings,
+): Promise {
+ let added = 0
+
+ // RC.setPauseGuardian
+ const rc = env.getOrNull(Contracts.horizon.RecurringCollector.name)
+ if (rc) {
+ const isGuardian = (await client.readContract({
+ address: rc.address as `0x${string}`,
+ abi: RECURRING_COLLECTOR_PAUSE_ABI,
+ functionName: 'pauseGuardians',
+ args: [pauseGuardian as `0x${string}`],
+ })) as boolean
+ if (!isGuardian) {
+ builder.addTx({
+ to: rc.address,
+ value: '0',
+ data: encodeFunctionData({
+ abi: RECURRING_COLLECTOR_PAUSE_ABI,
+ functionName: 'setPauseGuardian',
+ args: [pauseGuardian as `0x${string}`, true],
+ }),
+ })
+ env.showMessage(` + ${Contracts.horizon.RecurringCollector.name}.setPauseGuardian(${pauseGuardian})`)
+ added++
+ }
+ }
+
+ // RM.setDefaultReclaimAddress — only after RM upgrade lands in the same batch
+ const reclaim = env.getOrNull(Contracts.issuance.ReclaimedRewards.name)
+ const rm = env.getOrNull(Contracts.horizon.RewardsManager.name)
+ if (reclaim && rm) {
+ const reclaimRMCheck = await checkReclaimRMIntegration(client, rm.address, reclaim.address)
+ if (!reclaimRMCheck.done && reclaimRMCheck.reason !== 'RM not upgraded') {
+ builder.addTx({
+ to: rm.address,
+ value: '0',
+ data: encodeFunctionData({
+ abi: REWARDS_MANAGER_ABI,
+ functionName: 'setDefaultReclaimAddress',
+ args: [reclaim.address as `0x${string}`],
+ }),
+ })
+ env.showMessage(` + ${Contracts.horizon.RewardsManager.name}.setDefaultReclaimAddress(${reclaim.address})`)
+ added++
+ }
+ }
+
+ // RM.setRevertOnIneligible — driven by config; only after RM upgrade lands
+ if (rm) {
+ const desiredRevert = settings.rewardsManager.revertOnIneligible
+ const revertCheck = await checkRMRevertOnIneligible(client, rm.address, desiredRevert)
+ if (!revertCheck.done && revertCheck.reason !== 'RM not upgraded') {
+ builder.addTx({
+ to: rm.address,
+ value: '0',
+ data: encodeFunctionData({
+ abi: REWARDS_MANAGER_ABI,
+ functionName: 'setRevertOnIneligible',
+ args: [desiredRevert],
+ }),
+ })
+ env.showMessage(` + ${Contracts.horizon.RewardsManager.name}.setRevertOnIneligible(${desiredRevert})`)
+ added++
+ }
+ }
+
+ return added
+}
+
+// ============================================================================
+// Group 3 — Deferred new-contract config (IA, DA, RAM, Reclaim, REO A/B)
+// ============================================================================
+
+/**
+ * Bundle the configure items on new contracts that the deployer couldn't
+ * perform during `02_configure` because it lacks `GOVERNOR_ROLE` on the
+ * proxy (typical when forking an existing deployment whose proxies were
+ * already transferred).
+ */
+async function collectDeferredNewContractConfig(
+ env: Environment,
+ builder: TxBuilder,
+ client: PublicClient,
+ targetChainId: number,
+ governor: string,
+ pauseGuardian: string,
+): Promise {
+ const grantHelper = createRoleGrantHelper(env, builder, client)
+ let added = 0
+
+ // IA: rate + roles
+ const ia = env.getOrNull(Contracts.issuance.IssuanceAllocator.name)
+ const rm = env.getOrNull(Contracts.horizon.RewardsManager.name)
+ if (ia && rm) {
+ const iaCheck = await checkIAConfigured(client, ia.address, rm.address, governor, pauseGuardian)
+ if (!iaCheck.done && iaCheck.reason !== 'RM.issuancePerBlock is 0') {
+ const rmRate = (await client.readContract({
+ address: rm.address as `0x${string}`,
+ abi: REWARDS_MANAGER_DEPRECATED_ABI,
+ functionName: 'issuancePerBlock',
+ })) as bigint
+ const iaRate = (await client.readContract({
+ address: ia.address as `0x${string}`,
+ abi: ISSUANCE_ALLOCATOR_ABI,
+ functionName: 'getIssuancePerBlock',
+ })) as bigint
+ // The outer iaCheck already returns when RM rate is 0, so rmRate > 0n here.
+ if (iaRate !== rmRate) {
+ builder.addTx({
+ to: ia.address,
+ value: '0',
+ data: encodeFunctionData({
+ abi: ISSUANCE_ALLOCATOR_ABI,
+ functionName: 'setIssuancePerBlock',
+ args: [rmRate],
+ }),
+ })
+ env.showMessage(` + IA.setIssuancePerBlock(${formatGRT(rmRate)})`)
+ added++
+ }
+ added += await grantHelper(ia.address, 'IA', 'GOVERNOR_ROLE', governor, 'governor')
+ added += await grantHelper(ia.address, 'IA', 'PAUSE_ROLE', pauseGuardian, 'pauseGuardian')
+ }
+ }
+
+ // DA: roles
+ const da = env.getOrNull(Contracts.issuance.DefaultAllocation.name)
+ if (da) {
+ const daCheck = await checkDefaultAllocationConfigured(client, da.address, governor, pauseGuardian)
+ if (!daCheck.done) {
+ added += await grantHelper(da.address, 'DA', 'GOVERNOR_ROLE', governor, 'governor')
+ added += await grantHelper(da.address, 'DA', 'PAUSE_ROLE', pauseGuardian, 'pauseGuardian')
+ }
+ }
+
+ // RAM: roles + setIssuanceAllocator
+ const ram = env.getOrNull(Contracts.issuance.RecurringAgreementManager.name)
+ const rcDep = env.getOrNull(Contracts.horizon.RecurringCollector.name)
+ const ss = env.getOrNull(Contracts['subgraph-service'].SubgraphService.name)
+ if (ram && rcDep && ss) {
+ const ramCheck = await checkRAMConfigured(
+ client,
+ ram.address,
+ rcDep.address,
+ ss.address,
+ ia?.address ?? '',
+ governor,
+ pauseGuardian,
+ )
+ if (!ramCheck.done) {
+ added += await grantHelper(ram.address, 'RAM', 'COLLECTOR_ROLE', rcDep.address, 'RC')
+ added += await grantHelper(ram.address, 'RAM', 'DATA_SERVICE_ROLE', ss.address, 'SS')
+ added += await grantHelper(ram.address, 'RAM', 'GOVERNOR_ROLE', governor, 'governor')
+ added += await grantHelper(ram.address, 'RAM', 'PAUSE_ROLE', pauseGuardian, 'pauseGuardian')
+ if (ia) {
+ try {
+ const currentIA = (await client.readContract({
+ address: ram.address as `0x${string}`,
+ abi: ISSUANCE_TARGET_ABI,
+ functionName: 'getIssuanceAllocator',
+ })) as string
+ if (currentIA.toLowerCase() !== ia.address.toLowerCase()) {
+ builder.addTx({
+ to: ram.address,
+ value: '0',
+ data: encodeFunctionData({
+ abi: ISSUANCE_TARGET_ABI,
+ functionName: 'setIssuanceAllocator',
+ args: [ia.address as `0x${string}`],
+ }),
+ })
+ env.showMessage(` + RAM.setIssuanceAllocator(${ia.address})`)
+ added++
+ }
+ } catch {
+ /* getter not available */
+ }
+ }
+ }
+ }
+
+ // Reclaim: roles only — RM integration is handled by collectExistingContractConfig
+ const reclaim = env.getOrNull(Contracts.issuance.ReclaimedRewards.name)
+ if (reclaim) {
+ const reclaimRoles = await checkReclaimRoles(client, reclaim.address, governor, pauseGuardian)
+ if (!reclaimRoles.done) {
+ added += await grantHelper(reclaim.address, 'Reclaim', 'GOVERNOR_ROLE', governor, 'governor')
+ added += await grantHelper(reclaim.address, 'Reclaim', 'PAUSE_ROLE', pauseGuardian, 'pauseGuardian')
+ }
+ }
+
+ // REO A/B: params + roles. Driven by the same condition list as `04_configure`.
+ const issuanceBook = graph.getIssuanceAddressBook(targetChainId)
+ if (issuanceBook.entryExists('NetworkOperator')) {
+ const reoConditions = await getREOConditions(env)
+ for (const [label, entry] of [
+ ['REO-A', Contracts.issuance.RewardsEligibilityOracleA],
+ ['REO-B', Contracts.issuance.RewardsEligibilityOracleB],
+ ] as const) {
+ const reoDep = env.getOrNull(entry.name)
+ if (!reoDep) continue
+ const reoConfig = await checkConfigurationStatus(client, reoDep.address, reoConditions)
+ if (reoConfig.allOk) continue
+ for (let i = 0; i < reoConditions.length; i++) {
+ if (reoConfig.conditions[i].ok) continue
+ const cond = reoConditions[i]
+ if (cond.type === 'role') {
+ added += await grantHelper(reoDep.address, label, cond.roleGetter, cond.targetAccount, cond.description)
+ } else {
+ builder.addTx({
+ to: reoDep.address,
+ value: '0',
+ data: encodeFunctionData({
+ abi: cond.abi as readonly unknown[],
+ functionName: cond.setter,
+ args: [cond.target],
+ }),
+ })
+ env.showMessage(` + ${label}.${cond.setter}(${cond.target})`)
+ added++
+ }
+ }
+ }
+ }
+
+ return added
+}
+
+/**
+ * Returns a closure that, when called, adds a `grantRole` TX if the role is
+ * not already held. Returns 1 if a TX was added, 0 otherwise.
+ */
+function createRoleGrantHelper(env: Environment, builder: TxBuilder, client: PublicClient) {
+ return async function addRoleGrantIfNeeded(
+ contractAddr: string,
+ contractName: string,
+ roleName: string,
+ account: string,
+ accountLabel: string,
+ ): Promise {
+ try {
+ const role = (await client.readContract({
+ address: contractAddr as `0x${string}`,
+ abi: [
+ { inputs: [], name: roleName, outputs: [{ type: 'bytes32' }], stateMutability: 'view', type: 'function' },
+ ],
+ functionName: roleName,
+ })) as `0x${string}`
+ const has = (await client.readContract({
+ address: contractAddr as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [role, account as `0x${string}`],
+ })) as boolean
+ if (has) return 0
+ builder.addTx({
+ to: contractAddr,
+ value: '0',
+ data: encodeFunctionData({
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'grantRole',
+ args: [role, account as `0x${string}`],
+ }),
+ })
+ env.showMessage(` + ${contractName}.grantRole(${roleName}, ${accountLabel})`)
+ return 1
+ } catch {
+ /* role getter not available — skip */
+ return 0
+ }
+ }
+}
diff --git a/packages/deployment/deploy/gip/0088/upgrade/10_status.ts b/packages/deployment/deploy/gip/0088/upgrade/10_status.ts
new file mode 100644
index 000000000..fdf49394d
--- /dev/null
+++ b/packages/deployment/deploy/gip/0088/upgrade/10_status.ts
@@ -0,0 +1,331 @@
+import { IISSUANCE_TARGET_INTERFACE_ID } from '@graphprotocol/deployment/lib/abis.js'
+import { getAddressBookForType, getTargetChainIdFromEnv } from '@graphprotocol/deployment/lib/address-book-utils.js'
+import { checkConfigurationStatus } from '@graphprotocol/deployment/lib/apply-configuration.js'
+import {
+ getREOConditions,
+ getREOTransferGovernanceConditions,
+ isRewardsManagerUpgraded,
+} from '@graphprotocol/deployment/lib/contract-checks.js'
+import { Contracts, type RegistryEntry } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { getGovernor, getPauseGuardian } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { getResolvedSettingsForEnv } from '@graphprotocol/deployment/lib/deployment-config.js'
+import { ComponentTags, GoalTags, noTagsRequested } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { getDeployer, getProxyAdminAddress } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import {
+ checkDefaultAllocationConfigured,
+ checkDeployerRevoked,
+ checkIAConfigured,
+ checkProxyAdminTransferred,
+ checkRAMConfigured,
+ checkReclaimRMIntegration,
+ checkReclaimRoles,
+ checkRMRevertOnIneligible,
+} from '@graphprotocol/deployment/lib/preconditions.js'
+import { showDetailedComponentStatus, showPendingGovernanceTxs } from '@graphprotocol/deployment/lib/status-detail.js'
+import { checkAllProxyStates, getContractStatusLine, runFullSync } from '@graphprotocol/deployment/lib/sync-utils.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+import type { PublicClient } from 'viem'
+
+/**
+ * GIP-0088:upgrade status — full deployment state with next-step guidance
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags GIP-0088:upgrade --network
+ */
+const func: DeployScriptModule = async (env) => {
+ if (noTagsRequested()) return
+
+ // The upgrade status reads every contract in every address book — easier to
+ // run a full sync than to enumerate them.
+ await runFullSync(env)
+
+ const client = graph.getPublicClient(env) as PublicClient
+ const targetChainId = await getTargetChainIdFromEnv(env)
+
+ env.showMessage('\n========== GIP-0088 Upgrade ==========')
+
+ // --- Proxy upgrades ---
+ env.showMessage('\nProxy upgrades:')
+
+ const upgradeContracts: RegistryEntry[] = [
+ Contracts.horizon.RewardsManager,
+ Contracts.horizon.HorizonStaking,
+ Contracts['subgraph-service'].SubgraphService,
+ Contracts['subgraph-service'].DisputeManager,
+ Contracts.horizon.PaymentsEscrow,
+ Contracts.horizon.L2Curation,
+ ]
+
+ const rm = env.getOrNull('RewardsManager')
+
+ for (const contract of upgradeContracts) {
+ const ab = getAddressBookForType(contract.addressBook, targetChainId)
+
+ const result = await getContractStatusLine(client, contract.addressBook, ab, contract.name)
+ env.showMessage(` ${result.line}`)
+
+ if (contract === Contracts.horizon.RewardsManager && result.exists && rm) {
+ const upgraded = await isRewardsManagerUpgraded(client, rm.address)
+ env.showMessage(` ${upgraded ? '✓' : '✗'} implements IIssuanceTarget (${IISSUANCE_TARGET_INTERFACE_ID})`)
+ }
+ }
+
+ const { anyCodeChanged, anyPending } = checkAllProxyStates(targetChainId)
+
+ // --- New contracts ---
+ env.showMessage('\nNew contracts:')
+ await showDetailedComponentStatus(env, Contracts.horizon.RecurringCollector, { showHints: false })
+ await showDetailedComponentStatus(env, Contracts.issuance.IssuanceAllocator, { showHints: false })
+ await showDetailedComponentStatus(env, Contracts.issuance.DefaultAllocation, { showHints: false })
+ await showDetailedComponentStatus(env, Contracts.issuance.RecurringAgreementManager, { showHints: false })
+ await showDetailedComponentStatus(env, Contracts.issuance.ReclaimedRewards, { showHints: false })
+ await showDetailedComponentStatus(env, Contracts.issuance.RewardsEligibilityOracleA, { showHints: false })
+
+ // --- Next step ---
+ // Uses the same precondition checks as the action scripts (shared code, not copies)
+ const ia = env.getOrNull('IssuanceAllocator')
+ const da = env.getOrNull('DefaultAllocation')
+ const reoA = env.getOrNull('RewardsEligibilityOracleA')
+ const reoB = env.getOrNull('RewardsEligibilityOracleB')
+ const ram = env.getOrNull('RecurringAgreementManager')
+ const reclaim = env.getOrNull('ReclaimedRewards')
+ const rc = env.getOrNull('RecurringCollector')
+ const ss = env.getOrNull('SubgraphService')
+
+ const anyNewContractMissing = !ia || !da || !reoA || !reoB || !ram || !reclaim
+
+ if (anyNewContractMissing || !rm || (anyCodeChanged && !anyPending)) {
+ env.showMessage(`\n → Next: --tags GIP-0088:upgrade,deploy`)
+ const missing = [
+ !ia && 'IssuanceAllocator',
+ !da && 'DefaultAllocation',
+ !reoA && 'REO-A',
+ !reoB && 'REO-B',
+ !ram && 'RAM',
+ !reclaim && 'Reclaim',
+ !rm && 'RM',
+ ].filter(Boolean)
+ if (missing.length > 0) env.showMessage(` Missing: ${missing.join(', ')}`)
+ if (anyCodeChanged && !anyPending) env.showMessage(` Code changed without pending implementation`)
+ } else {
+ const governor = await getGovernor(env)
+ const pauseGuardian = await getPauseGuardian(env)
+
+ // Deployer address: from namedAccounts when key is loaded, otherwise infer
+ // from ProxyAdmin owner — if not governor, it's the deployer.
+ let deployer = getDeployer(env)
+ if (!deployer) {
+ try {
+ const proxyAdminAddr = await getProxyAdminAddress(client, ia.address)
+ const owner = (await client.readContract({
+ address: proxyAdminAddr as `0x${string}`,
+ abi: [
+ { inputs: [], name: 'owner', outputs: [{ type: 'address' }], stateMutability: 'view', type: 'function' },
+ ],
+ functionName: 'owner',
+ })) as string
+ if (owner.toLowerCase() !== governor.toLowerCase()) deployer = owner
+ } catch {
+ // ProxyAdmin not readable — deployer stays undefined
+ }
+ }
+
+ // Check configure state
+ // When deployer is available, classify issues as deployer-fixable vs deferred.
+ // When not (status-only run without deploy key), all issues are unclassified.
+ const configIssues: string[] = []
+ const deferredIssues: string[] = []
+
+ // Helper: check if deployer has GOVERNOR_ROLE on a contract
+ // Returns false when deployer is not configured (status-only run without deploy key)
+ async function deployerHasGovernorRole(contractAddress: string): Promise {
+ if (!deployer) return false
+ try {
+ const role = (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: [
+ {
+ inputs: [],
+ name: 'GOVERNOR_ROLE',
+ outputs: [{ type: 'bytes32' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ ],
+ functionName: 'GOVERNOR_ROLE',
+ })) as `0x${string}`
+ return (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: [
+ {
+ inputs: [{ type: 'bytes32' }, { type: 'address' }],
+ name: 'hasRole',
+ outputs: [{ type: 'bool' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ ],
+ functionName: 'hasRole',
+ args: [role, deployer as `0x${string}`],
+ })) as boolean
+ } catch {
+ return false
+ }
+ }
+
+ // Helper: classify a failing config check
+ async function classifyConfigIssue(label: string, reason: string, contractAddress: string): Promise {
+ if (await deployerHasGovernorRole(contractAddress)) {
+ configIssues.push(`${label}: ${reason}`)
+ } else {
+ deferredIssues.push(`${label}: ${reason}`)
+ }
+ }
+
+ // Check each new contract
+ const iaConfig = await checkIAConfigured(client, ia.address, rm.address, governor, pauseGuardian)
+ if (!iaConfig.done && iaConfig.reason !== 'RM.issuancePerBlock is 0') {
+ await classifyConfigIssue('IA', iaConfig.reason!, ia.address)
+ }
+
+ const daConfig = await checkDefaultAllocationConfigured(client, da.address, governor, pauseGuardian)
+ if (!daConfig.done) {
+ await classifyConfigIssue('DA', daConfig.reason!, da.address)
+ }
+
+ if (rc && ss) {
+ const ramConfig = await checkRAMConfigured(
+ client,
+ ram.address,
+ rc.address,
+ ss.address,
+ ia.address,
+ governor,
+ pauseGuardian,
+ )
+ if (!ramConfig.done) {
+ await classifyConfigIssue('RAM', ramConfig.reason!, ram.address)
+ }
+ }
+
+ const reclaimRolesCheck = await checkReclaimRoles(client, reclaim.address, governor, pauseGuardian)
+ if (!reclaimRolesCheck.done) {
+ await classifyConfigIssue('Reclaim', reclaimRolesCheck.reason!, reclaim.address)
+ }
+
+ // RM.setDefaultReclaimAddress — governance-only (target is RM, not Reclaim).
+ // Always deferred to the upgrade governance batch, never blocks configure/transfer.
+ const reclaimRMCheck = await checkReclaimRMIntegration(client, rm.address, reclaim.address)
+ if (!reclaimRMCheck.done && reclaimRMCheck.reason !== 'RM not upgraded') {
+ deferredIssues.push(`Reclaim: ${reclaimRMCheck.reason}`)
+ }
+
+ // RM.setRevertOnIneligible — config-driven; same deferred-only treatment as
+ // setDefaultReclaimAddress (target is RM, governance-only setter).
+ const settings = await getResolvedSettingsForEnv(env)
+ const revertCheck = await checkRMRevertOnIneligible(client, rm.address, settings.rewardsManager.revertOnIneligible)
+ if (!revertCheck.done && revertCheck.reason !== 'RM not upgraded') {
+ deferredIssues.push(`RM: ${revertCheck.reason}`)
+ }
+
+ // REO configure
+ const issuanceBook = graph.getIssuanceAddressBook(targetChainId)
+ const hasNetworkOperator = issuanceBook.entryExists('NetworkOperator')
+ if (hasNetworkOperator) {
+ const reoConditions = await getREOConditions(env)
+ for (const [label, addr] of [
+ ['REO-A', reoA.address],
+ ['REO-B', reoB.address],
+ ] as const) {
+ const reoConfig = await checkConfigurationStatus(client, addr, reoConditions)
+ if (!reoConfig.allOk) {
+ const failing = reoConfig.conditions.filter((c) => !c.ok).map((c) => c.name)
+ await classifyConfigIssue(label, failing.join(', '), addr)
+ }
+ }
+ } else {
+ deferredIssues.push('NetworkOperator not configured')
+ }
+
+ const anyConfigIssues = configIssues.length > 0 || deferredIssues.length > 0
+
+ // Check transfer state
+ // ProxyAdmin ownership is deployer-independent (checks owner vs governor).
+ // Deployer GOVERNOR_ROLE revocation needs the deployer address — checked
+ // when available, skipped otherwise (ProxyAdmin transfer is the primary signal).
+ let proxyAdminsTransferred = true
+
+ for (const contract of [ia, da, ram, reclaim, reoA, reoB]) {
+ try {
+ const proxyAdminAddr = await getProxyAdminAddress(client, contract.address)
+ const paCheck = await checkProxyAdminTransferred(client, proxyAdminAddr, governor)
+ if (!paCheck.done) proxyAdminsTransferred = false
+ } catch {
+ // ProxyAdmin not readable — skip
+ }
+ }
+
+ let deployerRolesRevoked = true
+ if (deployer) {
+ for (const contract of [ia, da, ram, reclaim]) {
+ const revoked = await checkDeployerRevoked(client, contract.address, deployer)
+ if (!revoked.done) deployerRolesRevoked = false
+ }
+ if (hasNetworkOperator) {
+ const reoTransferConds = getREOTransferGovernanceConditions(deployer)
+ const reoATransfer = await checkConfigurationStatus(client, reoA.address, reoTransferConds)
+ if (!reoATransfer.allOk) deployerRolesRevoked = false
+ const reoBTransfer = await checkConfigurationStatus(client, reoB.address, reoTransferConds)
+ if (!reoBTransfer.allOk) deployerRolesRevoked = false
+ }
+ }
+
+ const needsTransfer = !proxyAdminsTransferred || !deployerRolesRevoked
+
+ // Next-step guidance
+ // Lifecycle: deploy → configure → transfer → upgrade
+ // ProxyAdmin not transferred ⇒ deployer still has control ⇒ configure/transfer phase
+ // ProxyAdmin transferred ⇒ remaining issues need governance ⇒ upgrade phase
+ if (anyConfigIssues && !proxyAdminsTransferred) {
+ env.showMessage(`\n → Next: --tags GIP-0088:upgrade,configure`)
+ for (const issue of configIssues) env.showMessage(` ${issue}`)
+ if (deferredIssues.length > 0) {
+ env.showMessage(` Deferred (governance TX):`)
+ for (const issue of deferredIssues) env.showMessage(` ${issue}`)
+ }
+ } else if (needsTransfer) {
+ env.showMessage(`\n → Next: --tags GIP-0088:upgrade,transfer`)
+ } else if (anyPending || anyConfigIssues) {
+ env.showMessage(`\n → Next: --tags GIP-0088:upgrade,upgrade`)
+ if (deferredIssues.length > 0) {
+ env.showMessage(` Deferred config (governance TX):`)
+ for (const issue of deferredIssues) env.showMessage(` ${issue}`)
+ }
+ }
+ }
+
+ showPendingGovernanceTxs(env)
+ env.showMessage(`\n Actions: --tags GIP-0088:upgrade,`)
+ env.showMessage('')
+}
+
+func.tags = [GoalTags.GIP_0088_UPGRADE]
+func.dependencies = [
+ // Upgrade contracts
+ ComponentTags.RECURRING_COLLECTOR,
+ ComponentTags.REWARDS_MANAGER,
+ ComponentTags.HORIZON_STAKING,
+ ComponentTags.SUBGRAPH_SERVICE,
+ ComponentTags.DISPUTE_MANAGER,
+ ComponentTags.PAYMENTS_ESCROW,
+ ComponentTags.L2_CURATION,
+ // New contracts (shown in status)
+ ComponentTags.ISSUANCE_ALLOCATOR,
+ ComponentTags.DEFAULT_ALLOCATION,
+ ComponentTags.RECURRING_AGREEMENT_MANAGER,
+ ComponentTags.REWARDS_ELIGIBILITY_A,
+]
+func.skip = async () => noTagsRequested()
+
+export default func
diff --git a/packages/deployment/deploy/horizon/curation/01_deploy.ts b/packages/deployment/deploy/horizon/curation/01_deploy.ts
new file mode 100644
index 000000000..1a0d9c9b0
--- /dev/null
+++ b/packages/deployment/deploy/horizon/curation/01_deploy.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createImplementationDeployModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createImplementationDeployModule(Contracts.horizon.L2Curation)
diff --git a/packages/deployment/deploy/horizon/curation/02_upgrade.ts b/packages/deployment/deploy/horizon/curation/02_upgrade.ts
new file mode 100644
index 000000000..efb44379c
--- /dev/null
+++ b/packages/deployment/deploy/horizon/curation/02_upgrade.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createUpgradeModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createUpgradeModule(Contracts.horizon.L2Curation)
diff --git a/packages/deployment/deploy/horizon/curation/09_end.ts b/packages/deployment/deploy/horizon/curation/09_end.ts
new file mode 100644
index 000000000..bd06ed9ad
--- /dev/null
+++ b/packages/deployment/deploy/horizon/curation/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts.horizon.L2Curation)
diff --git a/packages/deployment/deploy/horizon/curation/10_status.ts b/packages/deployment/deploy/horizon/curation/10_status.ts
new file mode 100644
index 000000000..8a6d9f944
--- /dev/null
+++ b/packages/deployment/deploy/horizon/curation/10_status.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createStatusModule(Contracts.horizon.L2Curation)
diff --git a/packages/deployment/deploy/horizon/payments-escrow/01_deploy.ts b/packages/deployment/deploy/horizon/payments-escrow/01_deploy.ts
new file mode 100644
index 000000000..91d2db38b
--- /dev/null
+++ b/packages/deployment/deploy/horizon/payments-escrow/01_deploy.ts
@@ -0,0 +1,58 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { deployImplementation, getImplementationConfig } from '@graphprotocol/deployment/lib/deploy-implementation.js'
+import { ComponentTags, DeploymentActions, shouldSkipAction } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+
+// PaymentsEscrow Implementation Deployment
+//
+// Deploys a new PaymentsEscrow implementation if artifact bytecode differs from on-chain.
+//
+// Workflow:
+// 1. Read current immutable values from on-chain contract
+// 2. Compare artifact bytecode with on-chain bytecode (accounting for immutables)
+// 3. If different, deploy new implementation
+// 4. Store as "pendingImplementation" in horizon/addresses.json
+// 5. Upgrade task (separate) handles TX generation and execution
+
+const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.DEPLOY)) return
+ await syncComponentsFromRegistry(env, [Contracts.horizon.Controller, Contracts.horizon.PaymentsEscrow])
+
+ const controllerDep = env.getOrNull('Controller')
+ const escrowDep = env.getOrNull('PaymentsEscrow')
+
+ if (!controllerDep || !escrowDep) {
+ throw new Error('Missing required contract deployments (Controller, PaymentsEscrow) after sync.')
+ }
+
+ // Read current immutable value from on-chain contract
+ const client = graph.getPublicClient(env)
+ const thawingPeriod = await client.readContract({
+ address: escrowDep.address as `0x${string}`,
+ abi: [
+ {
+ name: 'WITHDRAW_ESCROW_THAWING_PERIOD',
+ type: 'function',
+ inputs: [],
+ outputs: [{ name: '', type: 'uint256' }],
+ stateMutability: 'view',
+ },
+ ],
+ functionName: 'WITHDRAW_ESCROW_THAWING_PERIOD',
+ })
+
+ env.showMessage(` PaymentsEscrow WITHDRAW_ESCROW_THAWING_PERIOD: ${thawingPeriod}`)
+
+ await deployImplementation(
+ env,
+ getImplementationConfig('horizon', 'PaymentsEscrow', {
+ constructorArgs: [controllerDep.address, thawingPeriod],
+ }),
+ )
+}
+
+func.tags = [ComponentTags.PAYMENTS_ESCROW]
+func.skip = async () => shouldSkipAction(DeploymentActions.DEPLOY)
+export default func
diff --git a/packages/deployment/deploy/horizon/payments-escrow/02_upgrade.ts b/packages/deployment/deploy/horizon/payments-escrow/02_upgrade.ts
new file mode 100644
index 000000000..25c8f13e1
--- /dev/null
+++ b/packages/deployment/deploy/horizon/payments-escrow/02_upgrade.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createUpgradeModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createUpgradeModule(Contracts.horizon.PaymentsEscrow)
diff --git a/packages/deployment/deploy/horizon/payments-escrow/09_end.ts b/packages/deployment/deploy/horizon/payments-escrow/09_end.ts
new file mode 100644
index 000000000..95272ed2d
--- /dev/null
+++ b/packages/deployment/deploy/horizon/payments-escrow/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts.horizon.PaymentsEscrow)
diff --git a/packages/deployment/deploy/horizon/payments-escrow/10_status.ts b/packages/deployment/deploy/horizon/payments-escrow/10_status.ts
new file mode 100644
index 000000000..267692139
--- /dev/null
+++ b/packages/deployment/deploy/horizon/payments-escrow/10_status.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createStatusModule(Contracts.horizon.PaymentsEscrow)
diff --git a/packages/deployment/deploy/horizon/recurring-collector/01_deploy.ts b/packages/deployment/deploy/horizon/recurring-collector/01_deploy.ts
new file mode 100644
index 000000000..4f96b4c35
--- /dev/null
+++ b/packages/deployment/deploy/horizon/recurring-collector/01_deploy.ts
@@ -0,0 +1,48 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { getResolvedSettingsForEnv } from '@graphprotocol/deployment/lib/deployment-config.js'
+import { ComponentTags, DeploymentActions, shouldSkipAction } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { deployProxyContract } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+
+/**
+ * Deploy RecurringCollector proxy and implementation
+ *
+ * Deploys OZ v5 TransparentUpgradeableProxy with atomic initialization.
+ * Deployer is the initial ProxyAdmin owner; ownership is transferred to
+ * the protocol governor in a separate governance step.
+ *
+ * RecurringCollector constructor takes (controller, revokeSignerThawingPeriod).
+ * initialize(eip712Name, eip712Version) sets up EIP-712 domain and pausability.
+ *
+ * On subsequent runs (proxy already deployed), deploys new implementation
+ * and stores it as pendingImplementation for governance upgrade.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags RecurringCollector:deploy --network
+ */
+const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.DEPLOY)) return
+ await syncComponentsFromRegistry(env, [Contracts.horizon.Controller, Contracts.horizon.RecurringCollector])
+
+ const controllerDep = env.getOrNull('Controller')
+ if (!controllerDep) {
+ throw new Error('Missing Controller deployment after sync.')
+ }
+
+ const settings = await getResolvedSettingsForEnv(env)
+ const { revokeSignerThawingPeriod, eip712Name, eip712Version } = settings.recurringCollector
+
+ env.showMessage(`\n📦 Deploying ${Contracts.horizon.RecurringCollector.name}`)
+
+ await deployProxyContract(env, {
+ contract: Contracts.horizon.RecurringCollector,
+ constructorArgs: [controllerDep.address, revokeSignerThawingPeriod],
+ initializeArgs: [eip712Name, eip712Version],
+ })
+}
+
+func.tags = [ComponentTags.RECURRING_COLLECTOR]
+func.skip = async () => shouldSkipAction(DeploymentActions.DEPLOY)
+
+export default func
diff --git a/packages/deployment/deploy/horizon/recurring-collector/02_upgrade.ts b/packages/deployment/deploy/horizon/recurring-collector/02_upgrade.ts
new file mode 100644
index 000000000..f58136aad
--- /dev/null
+++ b/packages/deployment/deploy/horizon/recurring-collector/02_upgrade.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createUpgradeModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createUpgradeModule(Contracts.horizon.RecurringCollector)
diff --git a/packages/deployment/deploy/horizon/recurring-collector/04_configure.ts b/packages/deployment/deploy/horizon/recurring-collector/04_configure.ts
new file mode 100644
index 000000000..023e95ef3
--- /dev/null
+++ b/packages/deployment/deploy/horizon/recurring-collector/04_configure.ts
@@ -0,0 +1,62 @@
+import { RECURRING_COLLECTOR_PAUSE_ABI } from '@graphprotocol/deployment/lib/abis.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { canSignAsGovernor, getPauseGuardian } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { requireContract } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { graph, tx } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+import { encodeFunctionData } from 'viem'
+
+/**
+ * Configure RecurringCollector — set pause guardian
+ *
+ * RC uses Controller-based access control: setPauseGuardian requires
+ * msg.sender == Controller.getGovernor(). If the deployer is the
+ * Controller governor (e.g. testnet), this script sets it directly.
+ * Otherwise it reports the gap — the upgrade step (04_upgrade.ts)
+ * bundles it as a governance TX.
+ *
+ * Idempotent: checks on-chain state, skips if already set.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags RecurringCollector:configure --network
+ */
+export default createActionModule(Contracts.horizon.RecurringCollector, DeploymentActions.CONFIGURE, async (env) => {
+ const client = graph.getPublicClient(env) as PublicClient
+ const rc = requireContract(env, Contracts.horizon.RecurringCollector)
+ const pauseGuardian = await getPauseGuardian(env)
+
+ env.showMessage(`\n========== Configure ${Contracts.horizon.RecurringCollector.name} ==========`)
+
+ const isGuardian = (await client.readContract({
+ address: rc.address as `0x${string}`,
+ abi: RECURRING_COLLECTOR_PAUSE_ABI,
+ functionName: 'pauseGuardians',
+ args: [pauseGuardian as `0x${string}`],
+ })) as boolean
+
+ if (isGuardian) {
+ env.showMessage(` ✓ Pause guardian already set\n`)
+ return
+ }
+
+ const { canSign } = await canSignAsGovernor(env)
+ if (!canSign) {
+ env.showMessage(` ○ Pause guardian not set — will be configured in upgrade step (governance TX)\n`)
+ return
+ }
+
+ env.showMessage('\n🔨 Setting pause guardian as governor...\n')
+ const txFn = tx(env)
+ await txFn({
+ account: 'governor',
+ to: rc.address as `0x${string}`,
+ data: encodeFunctionData({
+ abi: RECURRING_COLLECTOR_PAUSE_ABI,
+ functionName: 'setPauseGuardian',
+ args: [pauseGuardian as `0x${string}`, true],
+ }),
+ })
+ env.showMessage(` ✓ setPauseGuardian(${pauseGuardian})\n`)
+})
diff --git a/packages/deployment/deploy/horizon/recurring-collector/05_transfer_governance.ts b/packages/deployment/deploy/horizon/recurring-collector/05_transfer_governance.ts
new file mode 100644
index 000000000..672cc47d5
--- /dev/null
+++ b/packages/deployment/deploy/horizon/recurring-collector/05_transfer_governance.ts
@@ -0,0 +1,69 @@
+import { OZ_PROXY_ADMIN_ABI } from '@graphprotocol/deployment/lib/abis.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { getGovernor } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import {
+ getProxyAdminAddress,
+ requireContract,
+ requireDeployer,
+} from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { graph, tx } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+import { encodeFunctionData } from 'viem'
+
+/**
+ * Transfer RecurringCollector ProxyAdmin to protocol governor
+ *
+ * RC doesn't use BaseUpgradeable GOVERNOR_ROLE — only ProxyAdmin needs transfer.
+ *
+ * Idempotent: checks current owner, skips if already governor.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags RecurringCollector,transfer --network
+ */
+export default createActionModule(Contracts.horizon.RecurringCollector, DeploymentActions.TRANSFER, async (env) => {
+ const client = graph.getPublicClient(env) as PublicClient
+ const deployer = requireDeployer(env)
+ const governor = await getGovernor(env)
+ const rc = requireContract(env, Contracts.horizon.RecurringCollector)
+
+ env.showMessage(`\n========== Transfer ${Contracts.horizon.RecurringCollector.name} ==========`)
+
+ // Read ProxyAdmin from ERC1967 slot
+ const proxyAdminAddress = await getProxyAdminAddress(client, rc.address)
+
+ const currentOwner = (await client.readContract({
+ address: proxyAdminAddress as `0x${string}`,
+ abi: OZ_PROXY_ADMIN_ABI,
+ functionName: 'owner',
+ })) as string
+
+ if (currentOwner.toLowerCase() === governor.toLowerCase()) {
+ env.showMessage(` ✓ ProxyAdmin already owned by governor\n`)
+ return
+ }
+
+ if (currentOwner.toLowerCase() !== deployer.toLowerCase()) {
+ env.showMessage(` ○ ProxyAdmin owned by ${currentOwner}, not deployer — skipping\n`)
+ return
+ }
+
+ env.showMessage(` Transferring ProxyAdmin ownership to governor...`)
+ env.showMessage(` ProxyAdmin: ${proxyAdminAddress}`)
+ env.showMessage(` From: ${deployer}`)
+ env.showMessage(` To: ${governor}`)
+
+ const txFn = tx(env)
+ await txFn({
+ account: deployer,
+ to: proxyAdminAddress as `0x${string}`,
+ data: encodeFunctionData({
+ abi: OZ_PROXY_ADMIN_ABI,
+ functionName: 'transferOwnership',
+ args: [governor as `0x${string}`],
+ }),
+ })
+
+ env.showMessage(` ✓ ProxyAdmin ownership transferred to governor\n`)
+})
diff --git a/packages/deployment/deploy/horizon/recurring-collector/09_end.ts b/packages/deployment/deploy/horizon/recurring-collector/09_end.ts
new file mode 100644
index 000000000..5240c729c
--- /dev/null
+++ b/packages/deployment/deploy/horizon/recurring-collector/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts.horizon.RecurringCollector)
diff --git a/packages/deployment/deploy/horizon/recurring-collector/10_status.ts b/packages/deployment/deploy/horizon/recurring-collector/10_status.ts
new file mode 100644
index 000000000..da1ecafc3
--- /dev/null
+++ b/packages/deployment/deploy/horizon/recurring-collector/10_status.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createStatusModule(Contracts.horizon.RecurringCollector)
diff --git a/packages/deployment/deploy/horizon/staking/01_deploy.ts b/packages/deployment/deploy/horizon/staking/01_deploy.ts
new file mode 100644
index 000000000..3b9f1c9d4
--- /dev/null
+++ b/packages/deployment/deploy/horizon/staking/01_deploy.ts
@@ -0,0 +1,15 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createImplementationDeployModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createImplementationDeployModule(
+ Contracts.horizon.HorizonStaking,
+ (env) => {
+ const controller = env.getOrNull('Controller')
+ const subgraphService = env.getOrNull('SubgraphService')
+ if (!controller || !subgraphService) {
+ throw new Error('Missing required contract deployments (Controller, SubgraphService) after sync.')
+ }
+ return [controller.address, subgraphService.address]
+ },
+ { prerequisites: [Contracts.horizon.Controller, Contracts['subgraph-service'].SubgraphService] },
+)
diff --git a/packages/deployment/deploy/horizon/staking/02_upgrade.ts b/packages/deployment/deploy/horizon/staking/02_upgrade.ts
new file mode 100644
index 000000000..d7abe8bbe
--- /dev/null
+++ b/packages/deployment/deploy/horizon/staking/02_upgrade.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createUpgradeModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createUpgradeModule(Contracts.horizon.HorizonStaking)
diff --git a/packages/deployment/deploy/horizon/staking/09_end.ts b/packages/deployment/deploy/horizon/staking/09_end.ts
new file mode 100644
index 000000000..d374f7e79
--- /dev/null
+++ b/packages/deployment/deploy/horizon/staking/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts.horizon.HorizonStaking)
diff --git a/packages/deployment/deploy/horizon/staking/10_status.ts b/packages/deployment/deploy/horizon/staking/10_status.ts
new file mode 100644
index 000000000..22c2a940d
--- /dev/null
+++ b/packages/deployment/deploy/horizon/staking/10_status.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createStatusModule(Contracts.horizon.HorizonStaking)
diff --git a/packages/deployment/deploy/rewards/eligibility/a/01_deploy.ts b/packages/deployment/deploy/rewards/eligibility/a/01_deploy.ts
new file mode 100644
index 000000000..1bde8305b
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/a/01_deploy.ts
@@ -0,0 +1,12 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { requireDeployer, requireGraphToken } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createProxyDeployModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createProxyDeployModule(
+ Contracts.issuance.RewardsEligibilityOracleA,
+ (env) => ({
+ constructorArgs: [requireGraphToken(env).address],
+ initializeArgs: [requireDeployer(env)],
+ }),
+ { prerequisites: [Contracts.horizon.L2GraphToken] },
+)
diff --git a/packages/deployment/deploy/rewards/eligibility/a/02_upgrade.ts b/packages/deployment/deploy/rewards/eligibility/a/02_upgrade.ts
new file mode 100644
index 000000000..063a33cae
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/a/02_upgrade.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createUpgradeModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createUpgradeModule(Contracts.issuance.RewardsEligibilityOracleA)
diff --git a/packages/deployment/deploy/rewards/eligibility/a/04_configure.ts b/packages/deployment/deploy/rewards/eligibility/a/04_configure.ts
new file mode 100644
index 000000000..26bb1e7c7
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/a/04_configure.ts
@@ -0,0 +1,39 @@
+import { applyConfiguration } from '@graphprotocol/deployment/lib/apply-configuration.js'
+import { checkREORole, getREOConditions } from '@graphprotocol/deployment/lib/contract-checks.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { requireContracts, requireDeployer } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+
+/**
+ * Configure RewardsEligibilityOracleA (params + roles)
+ *
+ * Deployer executes directly (has GOVERNOR_ROLE from deploy).
+ * If deployer doesn't have the role, skips — upgrade step handles it.
+ */
+export default createActionModule(
+ Contracts.issuance.RewardsEligibilityOracleA,
+ DeploymentActions.CONFIGURE,
+ async (env) => {
+ const [reo] = requireContracts(env, [Contracts.issuance.RewardsEligibilityOracleA])
+ const client = graph.getPublicClient(env) as PublicClient
+ const deployer = requireDeployer(env)
+
+ const deployerRole = await checkREORole(client, reo.address, 'GOVERNOR_ROLE', deployer)
+ if (!deployerRole.hasRole) {
+ env.showMessage(
+ `\n ○ ${Contracts.issuance.RewardsEligibilityOracleA.name}: deployer does not have GOVERNOR_ROLE — skipping\n`,
+ )
+ return
+ }
+
+ await applyConfiguration(env, client, await getREOConditions(env), {
+ contractName: Contracts.issuance.RewardsEligibilityOracleA.name,
+ contractAddress: reo.address,
+ canExecuteDirectly: true,
+ executor: deployer,
+ })
+ },
+)
diff --git a/packages/deployment/deploy/rewards/eligibility/a/05_transfer_governance.ts b/packages/deployment/deploy/rewards/eligibility/a/05_transfer_governance.ts
new file mode 100644
index 000000000..e09593859
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/a/05_transfer_governance.ts
@@ -0,0 +1,45 @@
+import { applyConfiguration, checkConfigurationStatus } from '@graphprotocol/deployment/lib/apply-configuration.js'
+import { getREOConditions, getREOTransferGovernanceConditions } from '@graphprotocol/deployment/lib/contract-checks.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import {
+ requireContracts,
+ requireDeployer,
+ transferProxyAdminOwnership,
+} from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+
+/**
+ * Transfer governance of RewardsEligibilityOracleA
+ */
+export default createActionModule(
+ Contracts.issuance.RewardsEligibilityOracleA,
+ DeploymentActions.TRANSFER,
+ async (env) => {
+ const deployer = requireDeployer(env)
+ const [reo] = requireContracts(env, [Contracts.issuance.RewardsEligibilityOracleA])
+ const client = graph.getPublicClient(env) as PublicClient
+
+ // 1. Verify preconditions (same conditions as step 4)
+ env.showMessage(`\n📋 Verifying ${Contracts.issuance.RewardsEligibilityOracleA.name} configuration...\n`)
+ const status = await checkConfigurationStatus(client, reo.address, await getREOConditions(env))
+ for (const r of status.conditions) env.showMessage(` ${r.message}`)
+ if (!status.allOk) {
+ env.showMessage('\n ○ Configuration incomplete — skipping transfer\n')
+ return
+ }
+
+ // 2. Apply: revoke deployer's GOVERNOR_ROLE
+ await applyConfiguration(env, client, getREOTransferGovernanceConditions(deployer), {
+ contractName: `${Contracts.issuance.RewardsEligibilityOracleA.name}-transfer-governance`,
+ contractAddress: reo.address,
+ canExecuteDirectly: true,
+ executor: deployer,
+ })
+
+ // 3. Transfer ProxyAdmin ownership to governor
+ await transferProxyAdminOwnership(env, Contracts.issuance.RewardsEligibilityOracleA)
+ },
+)
diff --git a/packages/deployment/deploy/rewards/eligibility/a/09_end.ts b/packages/deployment/deploy/rewards/eligibility/a/09_end.ts
new file mode 100644
index 000000000..dd53f54ec
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/a/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts.issuance.RewardsEligibilityOracleA)
diff --git a/packages/deployment/deploy/rewards/eligibility/a/10_status.ts b/packages/deployment/deploy/rewards/eligibility/a/10_status.ts
new file mode 100644
index 000000000..a42b58304
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/a/10_status.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createStatusModule(Contracts.issuance.RewardsEligibilityOracleA)
diff --git a/packages/deployment/deploy/rewards/eligibility/b/01_deploy.ts b/packages/deployment/deploy/rewards/eligibility/b/01_deploy.ts
new file mode 100644
index 000000000..c360d882a
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/b/01_deploy.ts
@@ -0,0 +1,12 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { requireDeployer, requireGraphToken } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createProxyDeployModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createProxyDeployModule(
+ Contracts.issuance.RewardsEligibilityOracleB,
+ (env) => ({
+ constructorArgs: [requireGraphToken(env).address],
+ initializeArgs: [requireDeployer(env)],
+ }),
+ { prerequisites: [Contracts.horizon.L2GraphToken] },
+)
diff --git a/packages/deployment/deploy/rewards/eligibility/b/02_upgrade.ts b/packages/deployment/deploy/rewards/eligibility/b/02_upgrade.ts
new file mode 100644
index 000000000..1863d2847
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/b/02_upgrade.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createUpgradeModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createUpgradeModule(Contracts.issuance.RewardsEligibilityOracleB)
diff --git a/packages/deployment/deploy/rewards/eligibility/b/04_configure.ts b/packages/deployment/deploy/rewards/eligibility/b/04_configure.ts
new file mode 100644
index 000000000..e06307f45
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/b/04_configure.ts
@@ -0,0 +1,39 @@
+import { applyConfiguration } from '@graphprotocol/deployment/lib/apply-configuration.js'
+import { checkREORole, getREOConditions } from '@graphprotocol/deployment/lib/contract-checks.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { requireContracts, requireDeployer } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+
+/**
+ * Configure RewardsEligibilityOracleB (params + roles)
+ *
+ * Deployer executes directly (has GOVERNOR_ROLE from deploy).
+ * If deployer doesn't have the role, skips — upgrade step handles it.
+ */
+export default createActionModule(
+ Contracts.issuance.RewardsEligibilityOracleB,
+ DeploymentActions.CONFIGURE,
+ async (env) => {
+ const [reo] = requireContracts(env, [Contracts.issuance.RewardsEligibilityOracleB])
+ const client = graph.getPublicClient(env) as PublicClient
+ const deployer = requireDeployer(env)
+
+ const deployerRole = await checkREORole(client, reo.address, 'GOVERNOR_ROLE', deployer)
+ if (!deployerRole.hasRole) {
+ env.showMessage(
+ `\n ○ ${Contracts.issuance.RewardsEligibilityOracleB.name}: deployer does not have GOVERNOR_ROLE — skipping\n`,
+ )
+ return
+ }
+
+ await applyConfiguration(env, client, await getREOConditions(env), {
+ contractName: Contracts.issuance.RewardsEligibilityOracleB.name,
+ contractAddress: reo.address,
+ canExecuteDirectly: true,
+ executor: deployer,
+ })
+ },
+)
diff --git a/packages/deployment/deploy/rewards/eligibility/b/05_transfer_governance.ts b/packages/deployment/deploy/rewards/eligibility/b/05_transfer_governance.ts
new file mode 100644
index 000000000..87bcb281e
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/b/05_transfer_governance.ts
@@ -0,0 +1,45 @@
+import { applyConfiguration, checkConfigurationStatus } from '@graphprotocol/deployment/lib/apply-configuration.js'
+import { getREOConditions, getREOTransferGovernanceConditions } from '@graphprotocol/deployment/lib/contract-checks.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import {
+ requireContracts,
+ requireDeployer,
+ transferProxyAdminOwnership,
+} from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+
+/**
+ * Transfer governance of RewardsEligibilityOracleB
+ */
+export default createActionModule(
+ Contracts.issuance.RewardsEligibilityOracleB,
+ DeploymentActions.TRANSFER,
+ async (env) => {
+ const deployer = requireDeployer(env)
+ const [reo] = requireContracts(env, [Contracts.issuance.RewardsEligibilityOracleB])
+ const client = graph.getPublicClient(env) as PublicClient
+
+ // 1. Verify preconditions (same conditions as step 4)
+ env.showMessage(`\n📋 Verifying ${Contracts.issuance.RewardsEligibilityOracleB.name} configuration...\n`)
+ const status = await checkConfigurationStatus(client, reo.address, await getREOConditions(env))
+ for (const r of status.conditions) env.showMessage(` ${r.message}`)
+ if (!status.allOk) {
+ env.showMessage('\n ○ Configuration incomplete — skipping transfer\n')
+ return
+ }
+
+ // 2. Apply: revoke deployer's GOVERNOR_ROLE
+ await applyConfiguration(env, client, getREOTransferGovernanceConditions(deployer), {
+ contractName: `${Contracts.issuance.RewardsEligibilityOracleB.name}-transfer-governance`,
+ contractAddress: reo.address,
+ canExecuteDirectly: true,
+ executor: deployer,
+ })
+
+ // 3. Transfer ProxyAdmin ownership to governor
+ await transferProxyAdminOwnership(env, Contracts.issuance.RewardsEligibilityOracleB)
+ },
+)
diff --git a/packages/deployment/deploy/rewards/eligibility/b/09_end.ts b/packages/deployment/deploy/rewards/eligibility/b/09_end.ts
new file mode 100644
index 000000000..3a11b891a
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/b/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts.issuance.RewardsEligibilityOracleB)
diff --git a/packages/deployment/deploy/rewards/eligibility/b/10_status.ts b/packages/deployment/deploy/rewards/eligibility/b/10_status.ts
new file mode 100644
index 000000000..f8a4d48a8
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/b/10_status.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createStatusModule(Contracts.issuance.RewardsEligibilityOracleB)
diff --git a/packages/deployment/deploy/rewards/eligibility/mock/01_deploy.ts b/packages/deployment/deploy/rewards/eligibility/mock/01_deploy.ts
new file mode 100644
index 000000000..0d687127c
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/mock/01_deploy.ts
@@ -0,0 +1,12 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { requireDeployer, requireGraphToken } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createProxyDeployModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createProxyDeployModule(
+ Contracts.issuance.RewardsEligibilityOracleMock,
+ (env) => ({
+ constructorArgs: [requireGraphToken(env).address],
+ initializeArgs: [requireDeployer(env)],
+ }),
+ { prerequisites: [Contracts.horizon.L2GraphToken] },
+)
diff --git a/packages/deployment/deploy/rewards/eligibility/mock/02_upgrade.ts b/packages/deployment/deploy/rewards/eligibility/mock/02_upgrade.ts
new file mode 100644
index 000000000..74e2374b8
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/mock/02_upgrade.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createUpgradeModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createUpgradeModule(Contracts.issuance.RewardsEligibilityOracleMock)
diff --git a/packages/deployment/deploy/rewards/eligibility/mock/05_transfer_governance.ts b/packages/deployment/deploy/rewards/eligibility/mock/05_transfer_governance.ts
new file mode 100644
index 000000000..6be92ce32
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/mock/05_transfer_governance.ts
@@ -0,0 +1,39 @@
+import { applyConfiguration } from '@graphprotocol/deployment/lib/apply-configuration.js'
+import { getREOTransferGovernanceConditions } from '@graphprotocol/deployment/lib/contract-checks.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import {
+ requireContracts,
+ requireDeployer,
+ transferProxyAdminOwnership,
+} from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+
+/**
+ * Transfer governance of MockRewardsEligibilityOracle
+ *
+ * Revokes deployer's GOVERNOR_ROLE and transfers ProxyAdmin ownership
+ * to the protocol governor.
+ */
+export default createActionModule(
+ Contracts.issuance.RewardsEligibilityOracleMock,
+ DeploymentActions.TRANSFER,
+ async (env) => {
+ const deployer = requireDeployer(env)
+ const [reo] = requireContracts(env, [Contracts.issuance.RewardsEligibilityOracleMock])
+ const client = graph.getPublicClient(env) as PublicClient
+
+ // Revoke deployer's GOVERNOR_ROLE
+ await applyConfiguration(env, client, getREOTransferGovernanceConditions(deployer), {
+ contractName: `${Contracts.issuance.RewardsEligibilityOracleMock.name}-transfer-governance`,
+ contractAddress: reo.address,
+ canExecuteDirectly: true,
+ executor: deployer,
+ })
+
+ // Transfer ProxyAdmin ownership to governor
+ await transferProxyAdminOwnership(env, Contracts.issuance.RewardsEligibilityOracleMock)
+ },
+)
diff --git a/packages/deployment/deploy/rewards/eligibility/mock/06_integrate.ts b/packages/deployment/deploy/rewards/eligibility/mock/06_integrate.ts
new file mode 100644
index 000000000..2bd1ed3ac
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/mock/06_integrate.ts
@@ -0,0 +1,39 @@
+import { applyConfiguration } from '@graphprotocol/deployment/lib/apply-configuration.js'
+import { createRMIntegrationCondition } from '@graphprotocol/deployment/lib/contract-checks.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { canSignAsGovernor } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { ComponentTags, DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { requireContracts } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+
+/**
+ * Integrate MockRewardsEligibilityOracle with RewardsManager (testnet only)
+ *
+ * Points RewardsManager at the mock so indexers can control their own eligibility.
+ */
+export default createActionModule(
+ Contracts.issuance.RewardsEligibilityOracleMock,
+ DeploymentActions.INTEGRATE,
+ async (env) => {
+ const [reo, rm] = requireContracts(env, [
+ Contracts.issuance.RewardsEligibilityOracleMock,
+ Contracts.horizon.RewardsManager,
+ ])
+ const client = graph.getPublicClient(env) as PublicClient
+
+ const { governor, canSign } = await canSignAsGovernor(env)
+
+ await applyConfiguration(env, client, [createRMIntegrationCondition(reo.address)], {
+ contractName: `${Contracts.horizon.RewardsManager.name}-MockREO`,
+ contractAddress: rm.address,
+ canExecuteDirectly: canSign,
+ executor: governor,
+ })
+ },
+ {
+ extraDependencies: [ComponentTags.REWARDS_MANAGER],
+ prerequisites: [Contracts.horizon.RewardsManager],
+ },
+)
diff --git a/packages/deployment/deploy/rewards/eligibility/mock/09_end.ts b/packages/deployment/deploy/rewards/eligibility/mock/09_end.ts
new file mode 100644
index 000000000..98cacd97f
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/mock/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts.issuance.RewardsEligibilityOracleMock)
diff --git a/packages/deployment/deploy/rewards/eligibility/mock/10_status.ts b/packages/deployment/deploy/rewards/eligibility/mock/10_status.ts
new file mode 100644
index 000000000..611316b02
--- /dev/null
+++ b/packages/deployment/deploy/rewards/eligibility/mock/10_status.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createStatusModule(Contracts.issuance.RewardsEligibilityOracleMock)
diff --git a/packages/deployment/deploy/rewards/manager/01_deploy.ts b/packages/deployment/deploy/rewards/manager/01_deploy.ts
new file mode 100644
index 000000000..2223ce0ed
--- /dev/null
+++ b/packages/deployment/deploy/rewards/manager/01_deploy.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createImplementationDeployModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createImplementationDeployModule(Contracts.horizon.RewardsManager)
diff --git a/packages/deployment/deploy/rewards/manager/02_upgrade.ts b/packages/deployment/deploy/rewards/manager/02_upgrade.ts
new file mode 100644
index 000000000..5c888723b
--- /dev/null
+++ b/packages/deployment/deploy/rewards/manager/02_upgrade.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createUpgradeModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createUpgradeModule(Contracts.horizon.RewardsManager)
diff --git a/packages/deployment/deploy/rewards/manager/09_end.ts b/packages/deployment/deploy/rewards/manager/09_end.ts
new file mode 100644
index 000000000..ae4996ffd
--- /dev/null
+++ b/packages/deployment/deploy/rewards/manager/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts.horizon.RewardsManager)
diff --git a/packages/deployment/deploy/rewards/manager/10_status.ts b/packages/deployment/deploy/rewards/manager/10_status.ts
new file mode 100644
index 000000000..4b47d40bb
--- /dev/null
+++ b/packages/deployment/deploy/rewards/manager/10_status.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createStatusModule(Contracts.horizon.RewardsManager)
diff --git a/packages/deployment/deploy/rewards/reclaim/01_deploy.ts b/packages/deployment/deploy/rewards/reclaim/01_deploy.ts
new file mode 100644
index 000000000..3ee161636
--- /dev/null
+++ b/packages/deployment/deploy/rewards/reclaim/01_deploy.ts
@@ -0,0 +1,45 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { ComponentTags, DeploymentActions, shouldSkipAction } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { deployProxyContract, requireDeployer } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+
+/**
+ * Deploy DirectAllocation proxy as default reclaim address
+ *
+ * This script deploys a single DirectAllocation proxy instance used as the
+ * default reclaim address on RewardsManager for all reclaim reasons.
+ * The proxy uses the DirectAllocation_Implementation deployed by direct-allocation-impl.
+ *
+ * Deployed contracts:
+ * - ReclaimedRewards
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags RewardsReclaim:deploy --network
+ */
+
+const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.DEPLOY)) return
+ await syncComponentsFromRegistry(env, [
+ Contracts.issuance.DirectAllocation_Implementation,
+ Contracts.horizon.RewardsManager,
+ Contracts.issuance.ReclaimedRewards,
+ ])
+
+ env.showMessage(`\n📦 Deploying DirectAllocation reclaim address proxy...`)
+ env.showMessage(` Shared implementation: ${Contracts.issuance.DirectAllocation_Implementation.name}`)
+
+ await deployProxyContract(env, {
+ contract: Contracts.issuance.ReclaimedRewards,
+ sharedImplementation: Contracts.issuance.DirectAllocation_Implementation,
+ initializeArgs: [requireDeployer(env)],
+ })
+
+ env.showMessage('\n✓ Reclaim address deployment complete')
+}
+
+func.tags = [ComponentTags.REWARDS_RECLAIM]
+func.dependencies = [ComponentTags.DIRECT_ALLOCATION_IMPL, ComponentTags.REWARDS_MANAGER]
+func.skip = async () => shouldSkipAction(DeploymentActions.DEPLOY)
+
+export default func
diff --git a/packages/deployment/deploy/rewards/reclaim/02_upgrade.ts b/packages/deployment/deploy/rewards/reclaim/02_upgrade.ts
new file mode 100644
index 000000000..bc27987a0
--- /dev/null
+++ b/packages/deployment/deploy/rewards/reclaim/02_upgrade.ts
@@ -0,0 +1,36 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { ComponentTags, DeploymentActions, shouldSkipAction } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import { upgradeImplementation } from '@graphprotocol/deployment/lib/upgrade-implementation.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+
+// ReclaimedRewards Upgrade
+//
+// Upgrades ReclaimedRewards proxy to DirectAllocation implementation via per-proxy ProxyAdmin.
+//
+// Workflow:
+// 1. Check for pending implementation in address book (set by direct-allocation-impl)
+// 2. Generate governance TX (upgradeAndCall to per-proxy ProxyAdmin)
+// 3. Fork mode: execute via governor impersonation
+// 4. Production: output TX file for Safe execution
+//
+// Usage:
+// FORK_NETWORK=arbitrumSepolia npx hardhat deploy --tags RewardsReclaim:upgrade --network localhost
+
+const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.UPGRADE)) return
+ await syncComponentsFromRegistry(env, [
+ Contracts.issuance.DirectAllocation_Implementation,
+ Contracts.issuance.ReclaimedRewards,
+ ])
+ await upgradeImplementation(env, Contracts.issuance.ReclaimedRewards, {
+ implementationName: 'DirectAllocation',
+ })
+ await syncComponentsFromRegistry(env, [Contracts.issuance.ReclaimedRewards])
+}
+
+func.tags = [ComponentTags.REWARDS_RECLAIM]
+func.dependencies = [ComponentTags.DIRECT_ALLOCATION_IMPL]
+func.skip = async () => shouldSkipAction(DeploymentActions.UPGRADE)
+
+export default func
diff --git a/packages/deployment/deploy/rewards/reclaim/04_configure.ts b/packages/deployment/deploy/rewards/reclaim/04_configure.ts
new file mode 100644
index 000000000..ad1afee4d
--- /dev/null
+++ b/packages/deployment/deploy/rewards/reclaim/04_configure.ts
@@ -0,0 +1,144 @@
+import { ACCESS_CONTROL_ENUMERABLE_ABI, REWARDS_MANAGER_ABI } from '@graphprotocol/deployment/lib/abis.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { getGovernor, getPauseGuardian } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { ComponentTags, DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { requireContract, requireDeployer } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { checkReclaimConfigured } from '@graphprotocol/deployment/lib/preconditions.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { graph, read, tx } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+import { encodeFunctionData } from 'viem'
+
+/**
+ * Configure ReclaimedRewards — role grants only
+ *
+ * Grants GOVERNOR_ROLE to protocol governor and PAUSE_ROLE to pause guardian.
+ * Deployer executes directly (has GOVERNOR_ROLE from deploy).
+ * If deployer doesn't have the role, skips — upgrade step handles it.
+ *
+ * RM.setDefaultReclaimAddress is a governance TX bundled in the upgrade step.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags RewardsReclaim:configure --network
+ */
+export default createActionModule(
+ Contracts.issuance.ReclaimedRewards,
+ DeploymentActions.CONFIGURE,
+ async (env) => {
+ const client = graph.getPublicClient(env) as PublicClient
+ const readFn = read(env)
+ const deployer = requireDeployer(env)
+ const governor = await getGovernor(env)
+ const pauseGuardian = await getPauseGuardian(env)
+
+ const rewardsManager = requireContract(env, Contracts.horizon.RewardsManager)
+ const reclaimedRewards = requireContract(env, Contracts.issuance.ReclaimedRewards)
+
+ env.showMessage(`\n========== Configure ${Contracts.issuance.ReclaimedRewards.name} ==========`)
+ env.showMessage(`ReclaimedRewards: ${reclaimedRewards.address}`)
+
+ // Check if fully configured (shared precondition check)
+ const precondition = await checkReclaimConfigured(
+ client,
+ rewardsManager.address,
+ reclaimedRewards.address,
+ governor,
+ pauseGuardian,
+ )
+ if (precondition.done) {
+ env.showMessage(`\n✅ ${Contracts.issuance.ReclaimedRewards.name} already configured\n`)
+ return
+ }
+
+ // Check role grants
+ env.showMessage('\n📋 Checking configuration...\n')
+
+ const GOVERNOR_ROLE = (await readFn(reclaimedRewards, { functionName: 'GOVERNOR_ROLE' })) as `0x${string}`
+ const PAUSE_ROLE = (await readFn(reclaimedRewards, { functionName: 'PAUSE_ROLE' })) as `0x${string}`
+
+ const governorHasRole = (await client.readContract({
+ address: reclaimedRewards.address as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [GOVERNOR_ROLE, governor as `0x${string}`],
+ })) as boolean
+ env.showMessage(` Governor GOVERNOR_ROLE: ${governorHasRole ? '✓' : '✗'}`)
+
+ const pauseGuardianHasRole = (await client.readContract({
+ address: reclaimedRewards.address as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [PAUSE_ROLE, pauseGuardian as `0x${string}`],
+ })) as boolean
+ env.showMessage(` PauseGuardian PAUSE_ROLE: ${pauseGuardianHasRole ? '✓' : '✗'}`)
+
+ // RM integration status (informational — handled by upgrade step)
+ try {
+ const currentDefault = (await client.readContract({
+ address: rewardsManager.address as `0x${string}`,
+ abi: REWARDS_MANAGER_ABI,
+ functionName: 'getDefaultReclaimAddress',
+ })) as string
+ const rmOk = currentDefault.toLowerCase() === reclaimedRewards.address.toLowerCase()
+ env.showMessage(` RM default reclaim: ${rmOk ? '✓' : '○ will be set in upgrade step (governance TX)'}`)
+ } catch {
+ env.showMessage(` RM default reclaim: ○ RM not upgraded — will be set in upgrade step`)
+ }
+
+ // Execute role grants as deployer
+ const deployerHasRole = (await client.readContract({
+ address: reclaimedRewards.address as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [GOVERNOR_ROLE, deployer as `0x${string}`],
+ })) as boolean
+
+ if (!deployerHasRole) {
+ env.showMessage(
+ `\n ○ Deployer does not have GOVERNOR_ROLE — skipping role grants (governance TX in upgrade step)\n`,
+ )
+ return
+ }
+
+ const txs: Array<{ to: string; data: `0x${string}`; label: string }> = []
+
+ if (!governorHasRole) {
+ txs.push({
+ to: reclaimedRewards.address,
+ data: encodeFunctionData({
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'grantRole',
+ args: [GOVERNOR_ROLE, governor as `0x${string}`],
+ }),
+ label: `grantRole(GOVERNOR_ROLE, ${governor})`,
+ })
+ }
+
+ if (!pauseGuardianHasRole) {
+ txs.push({
+ to: reclaimedRewards.address,
+ data: encodeFunctionData({
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'grantRole',
+ args: [PAUSE_ROLE, pauseGuardian as `0x${string}`],
+ }),
+ label: `grantRole(PAUSE_ROLE, ${pauseGuardian})`,
+ })
+ }
+
+ if (txs.length > 0) {
+ env.showMessage('\n🔨 Executing role grants as deployer...\n')
+ const txFn = tx(env)
+ for (const t of txs) {
+ await txFn({ account: deployer, to: t.to as `0x${string}`, data: t.data })
+ env.showMessage(` ✓ ${t.label}`)
+ }
+ }
+
+ env.showMessage(`\n✅ ${Contracts.issuance.ReclaimedRewards.name} role grants complete\n`)
+ },
+ {
+ extraDependencies: [ComponentTags.REWARDS_MANAGER],
+ prerequisites: [Contracts.horizon.RewardsManager],
+ },
+)
diff --git a/packages/deployment/deploy/rewards/reclaim/05_transfer_governance.ts b/packages/deployment/deploy/rewards/reclaim/05_transfer_governance.ts
new file mode 100644
index 000000000..bdcd728b2
--- /dev/null
+++ b/packages/deployment/deploy/rewards/reclaim/05_transfer_governance.ts
@@ -0,0 +1,56 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import {
+ requireContract,
+ requireDeployer,
+ transferProxyAdminOwnership,
+} from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { checkDeployerRevoked } from '@graphprotocol/deployment/lib/preconditions.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { execute, graph, read } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { PublicClient } from 'viem'
+
+/**
+ * Transfer ReclaimedRewards governance from deployer
+ *
+ * - Revoke GOVERNOR_ROLE from deployment account
+ * - Transfer ProxyAdmin ownership to governor
+ *
+ * Role grants (GOVERNOR_ROLE, PAUSE_ROLE) happen in 04_configure.ts.
+ * This script only revokes deployer access.
+ *
+ * Idempotent: checks on-chain state, skips if already transferred.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags RewardsReclaim,transfer --network
+ */
+export default createActionModule(Contracts.issuance.ReclaimedRewards, DeploymentActions.TRANSFER, async (env) => {
+ const readFn = read(env)
+ const executeFn = execute(env)
+ const client = graph.getPublicClient(env) as PublicClient
+ const deployer = requireDeployer(env)
+ const reclaim = requireContract(env, Contracts.issuance.ReclaimedRewards)
+
+ env.showMessage(`\n========== Transfer ${Contracts.issuance.ReclaimedRewards.name} ==========`)
+
+ // Check if deployer GOVERNOR_ROLE already revoked (shared precondition check)
+ const precondition = await checkDeployerRevoked(client, reclaim.address, deployer)
+ if (precondition.done) {
+ env.showMessage(`✓ Deployer GOVERNOR_ROLE already revoked`)
+ } else {
+ const GOVERNOR_ROLE = (await readFn(reclaim, { functionName: 'GOVERNOR_ROLE' })) as `0x${string}`
+
+ env.showMessage(`🔨 Revoking deployer GOVERNOR_ROLE...`)
+ await executeFn(reclaim, {
+ account: deployer,
+ functionName: 'revokeRole',
+ args: [GOVERNOR_ROLE, deployer],
+ })
+ env.showMessage(` ✓ revokeRole(GOVERNOR_ROLE) executed`)
+ }
+
+ // Transfer ProxyAdmin ownership to governor
+ await transferProxyAdminOwnership(env, Contracts.issuance.ReclaimedRewards)
+
+ env.showMessage(`\n✅ ${Contracts.issuance.ReclaimedRewards.name} governance transferred!\n`)
+})
diff --git a/packages/deployment/deploy/rewards/reclaim/09_end.ts b/packages/deployment/deploy/rewards/reclaim/09_end.ts
new file mode 100644
index 000000000..46d6aa2dc
--- /dev/null
+++ b/packages/deployment/deploy/rewards/reclaim/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts.issuance.ReclaimedRewards)
diff --git a/packages/deployment/deploy/rewards/reclaim/10_status.ts b/packages/deployment/deploy/rewards/reclaim/10_status.ts
new file mode 100644
index 000000000..c5f778ac9
--- /dev/null
+++ b/packages/deployment/deploy/rewards/reclaim/10_status.ts
@@ -0,0 +1,14 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { ComponentTags } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+import { showDetailedComponentStatus } from '@graphprotocol/deployment/lib/status-detail.js'
+
+/**
+ * RewardsReclaim status - show detailed state of reclaim contract
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags RewardsReclaim --network
+ */
+export default createStatusModule(ComponentTags.REWARDS_RECLAIM, async (env) => {
+ await showDetailedComponentStatus(env, Contracts.issuance.ReclaimedRewards)
+})
diff --git a/packages/deployment/deploy/service/dispute/01_deploy.ts b/packages/deployment/deploy/service/dispute/01_deploy.ts
new file mode 100644
index 000000000..3158750b9
--- /dev/null
+++ b/packages/deployment/deploy/service/dispute/01_deploy.ts
@@ -0,0 +1,12 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createImplementationDeployModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createImplementationDeployModule(
+ Contracts['subgraph-service'].DisputeManager,
+ (env) => {
+ const controller = env.getOrNull('Controller')
+ if (!controller) throw new Error('Missing Controller deployment after sync.')
+ return [controller.address]
+ },
+ { prerequisites: [Contracts.horizon.Controller] },
+)
diff --git a/packages/deployment/deploy/service/dispute/02_upgrade.ts b/packages/deployment/deploy/service/dispute/02_upgrade.ts
new file mode 100644
index 000000000..99c75d9e3
--- /dev/null
+++ b/packages/deployment/deploy/service/dispute/02_upgrade.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createUpgradeModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createUpgradeModule(Contracts['subgraph-service'].DisputeManager)
diff --git a/packages/deployment/deploy/service/dispute/09_end.ts b/packages/deployment/deploy/service/dispute/09_end.ts
new file mode 100644
index 000000000..5a1afb1a4
--- /dev/null
+++ b/packages/deployment/deploy/service/dispute/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts['subgraph-service'].DisputeManager)
diff --git a/packages/deployment/deploy/service/dispute/10_status.ts b/packages/deployment/deploy/service/dispute/10_status.ts
new file mode 100644
index 000000000..1039074c0
--- /dev/null
+++ b/packages/deployment/deploy/service/dispute/10_status.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createStatusModule(Contracts['subgraph-service'].DisputeManager)
diff --git a/packages/deployment/deploy/service/subgraph/01_deploy.ts b/packages/deployment/deploy/service/subgraph/01_deploy.ts
new file mode 100644
index 000000000..ff1b46b95
--- /dev/null
+++ b/packages/deployment/deploy/service/subgraph/01_deploy.ts
@@ -0,0 +1,146 @@
+import { linkArtifactLibraries } from '@graphprotocol/deployment/lib/artifact-loaders.js'
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import {
+ deployImplementation,
+ getImplementationConfig,
+ loadArtifactFromSource,
+} from '@graphprotocol/deployment/lib/deploy-implementation.js'
+import { ComponentTags, DeploymentActions, shouldSkipAction } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
+import { deploy } from '@graphprotocol/deployment/rocketh/deploy.js'
+import type { DeployScriptModule } from '@rocketh/core/types'
+
+// SubgraphService Implementation Deployment
+//
+// SubgraphService uses external Solidity libraries that must be deployed first
+// and linked into the implementation bytecode before deployment.
+//
+// Library dependency order:
+// 1. StakeClaims (standalone, from horizon)
+// 2. AllocationHandler (standalone)
+// 3. IndexingAgreementDecoderRaw (standalone)
+// 4. IndexingAgreementDecoder (links IndexingAgreementDecoderRaw)
+// 5. IndexingAgreement (links IndexingAgreementDecoder)
+// 6. SubgraphService (links all above)
+//
+// Workflow:
+// 1. Deploy libraries in dependency order
+// 2. Deploy SS implementation with linked libraries
+// 3. Store as "pendingImplementation" in subgraph-service/addresses.json
+// 4. Upgrade task (separate) handles TX generation and execution
+
+const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.DEPLOY)) return
+ await syncComponentsFromRegistry(env, [
+ Contracts.horizon.Controller,
+ Contracts['subgraph-service'].DisputeManager,
+ Contracts.horizon.GraphTallyCollector,
+ Contracts.horizon.L2Curation,
+ Contracts.horizon.RecurringCollector,
+ Contracts['subgraph-service'].SubgraphService,
+ ])
+
+ // Get constructor args from imported deployments
+ const controllerDep = env.getOrNull('Controller')
+ const disputeManagerDep = env.getOrNull('DisputeManager')
+ const graphTallyCollectorDep = env.getOrNull('GraphTallyCollector')
+ const curationDep = env.getOrNull('L2Curation')
+ const recurringCollectorDep = env.getOrNull('RecurringCollector')
+
+ if (!controllerDep || !disputeManagerDep || !graphTallyCollectorDep || !curationDep || !recurringCollectorDep) {
+ throw new Error(
+ 'Missing required contract deployments after sync ' +
+ '(Controller, DisputeManager, GraphTallyCollector, L2Curation, RecurringCollector).',
+ )
+ }
+
+ // Deploy libraries in dependency order
+ const deployFn = deploy(env)
+ const deployer = env.namedAccounts.deployer
+ if (!deployer) throw new Error('No deployer account configured')
+
+ env.showMessage('\n📚 Deploying SubgraphService libraries...')
+
+ // 1. StakeClaims (from horizon, standalone)
+ const stakeClaimsArtifact = loadArtifactFromSource({
+ type: 'horizon',
+ path: 'contracts/data-service/libraries/StakeClaims.sol/StakeClaims',
+ })
+ const stakeClaims = await deployFn('StakeClaims', {
+ account: deployer,
+ artifact: stakeClaimsArtifact,
+ args: [],
+ })
+ env.showMessage(` StakeClaims: ${stakeClaims.address}`)
+
+ // 2. AllocationHandler (standalone)
+ const allocationHandlerArtifact = loadArtifactFromSource({
+ type: 'subgraph-service',
+ name: 'libraries/AllocationHandler',
+ })
+ const allocationHandler = await deployFn('AllocationHandler', {
+ account: deployer,
+ artifact: allocationHandlerArtifact,
+ args: [],
+ })
+ env.showMessage(` AllocationHandler: ${allocationHandler.address}`)
+
+ // 3. IndexingAgreementDecoderRaw (standalone)
+ const decoderRawArtifact = loadArtifactFromSource({
+ type: 'subgraph-service',
+ name: 'libraries/IndexingAgreementDecoderRaw',
+ })
+ const decoderRaw = await deployFn('IndexingAgreementDecoderRaw', {
+ account: deployer,
+ artifact: decoderRawArtifact,
+ args: [],
+ })
+ env.showMessage(` IndexingAgreementDecoderRaw: ${decoderRaw.address}`)
+
+ // 4. IndexingAgreementDecoder (links IndexingAgreementDecoderRaw)
+ // Pre-link libraries into artifact so rocketh stores linked bytecode
+ // (rocketh's bytecode comparison breaks for unlinked artifacts — see linkArtifactLibraries)
+ const decoderArtifact = linkArtifactLibraries(
+ loadArtifactFromSource({ type: 'subgraph-service', name: 'libraries/IndexingAgreementDecoder' }),
+ { IndexingAgreementDecoderRaw: decoderRaw.address as `0x${string}` },
+ )
+ const decoder = await deployFn('IndexingAgreementDecoder', { account: deployer, artifact: decoderArtifact, args: [] })
+ env.showMessage(` IndexingAgreementDecoder: ${decoder.address}`)
+
+ // 5. IndexingAgreement (links IndexingAgreementDecoder)
+ const indexingAgreementArtifact = linkArtifactLibraries(
+ loadArtifactFromSource({ type: 'subgraph-service', name: 'libraries/IndexingAgreement' }),
+ { IndexingAgreementDecoder: decoder.address as `0x${string}` },
+ )
+ const indexingAgreement = await deployFn('IndexingAgreement', {
+ account: deployer,
+ artifact: indexingAgreementArtifact,
+ args: [],
+ })
+ env.showMessage(` IndexingAgreement: ${indexingAgreement.address}`)
+
+ env.showMessage(' ✓ Libraries deployed\n')
+
+ // 6. Deploy SubgraphService implementation with all libraries linked
+ const config = getImplementationConfig('subgraph-service', 'SubgraphService', {
+ constructorArgs: [
+ controllerDep.address,
+ disputeManagerDep.address,
+ graphTallyCollectorDep.address,
+ curationDep.address,
+ recurringCollectorDep.address,
+ ],
+ })
+
+ await deployImplementation(env, config, {
+ StakeClaims: stakeClaims.address,
+ AllocationHandler: allocationHandler.address,
+ IndexingAgreement: indexingAgreement.address,
+ IndexingAgreementDecoder: decoder.address,
+ })
+}
+
+func.tags = [ComponentTags.SUBGRAPH_SERVICE]
+func.dependencies = [ComponentTags.RECURRING_COLLECTOR]
+func.skip = async () => shouldSkipAction(DeploymentActions.DEPLOY)
+export default func
diff --git a/packages/deployment/deploy/service/subgraph/02_upgrade.ts b/packages/deployment/deploy/service/subgraph/02_upgrade.ts
new file mode 100644
index 000000000..1395af76c
--- /dev/null
+++ b/packages/deployment/deploy/service/subgraph/02_upgrade.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createUpgradeModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createUpgradeModule(Contracts['subgraph-service'].SubgraphService)
diff --git a/packages/deployment/deploy/service/subgraph/04_configure.ts b/packages/deployment/deploy/service/subgraph/04_configure.ts
new file mode 100644
index 000000000..61dfc3f17
--- /dev/null
+++ b/packages/deployment/deploy/service/subgraph/04_configure.ts
@@ -0,0 +1,22 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { DeploymentActions } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+/**
+ * Configure SubgraphService
+ *
+ * In the current contract version, RecurringCollector is set as an immutable
+ * constructor argument — no runtime authorization is needed.
+ *
+ * This script is a no-op placeholder for future configuration needs.
+ *
+ * Usage:
+ * pnpm hardhat deploy --tags SubgraphService:configure --network
+ */
+export default createActionModule(
+ Contracts['subgraph-service'].SubgraphService,
+ DeploymentActions.CONFIGURE,
+ async (env) => {
+ env.showMessage(`\n✅ SubgraphService: RecurringCollector is set at construction time, no configuration needed\n`)
+ },
+)
diff --git a/packages/deployment/deploy/service/subgraph/09_end.ts b/packages/deployment/deploy/service/subgraph/09_end.ts
new file mode 100644
index 000000000..786490018
--- /dev/null
+++ b/packages/deployment/deploy/service/subgraph/09_end.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createEndModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createEndModule(Contracts['subgraph-service'].SubgraphService)
diff --git a/packages/deployment/deploy/service/subgraph/10_status.ts b/packages/deployment/deploy/service/subgraph/10_status.ts
new file mode 100644
index 000000000..aa66de54e
--- /dev/null
+++ b/packages/deployment/deploy/service/subgraph/10_status.ts
@@ -0,0 +1,4 @@
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { createStatusModule } from '@graphprotocol/deployment/lib/script-factories.js'
+
+export default createStatusModule(Contracts['subgraph-service'].SubgraphService)
diff --git a/packages/deployment/docs/Architecture.md b/packages/deployment/docs/Architecture.md
new file mode 100644
index 000000000..2704a722f
--- /dev/null
+++ b/packages/deployment/docs/Architecture.md
@@ -0,0 +1,61 @@
+# Deployment Package Architecture
+
+Unified deployment package for Graph Protocol contracts.
+
+## Design Principles
+
+- **No local Solidity sources** - Uses external artifacts from sibling packages
+- **Single deployment system** - All protocol contracts deployed from one place
+- **Component organization** - Deploy scripts organized by component (issuance, contracts, subgraph-service)
+
+## Structure
+
+```
+packages/deployment/
+├── deploy/ # hardhat-deploy / rocketh scripts
+│ ├── common/ # 00_sync.ts
+│ ├── horizon/ # RM, HS, PE, L2Curation, RC
+│ ├── service/ # SubgraphService, DisputeManager
+│ ├── allocate/ # IssuanceAllocator, DefaultAllocation, DirectAllocation
+│ ├── agreement/ # RecurringAgreementManager
+│ ├── rewards/ # RewardsEligibilityOracle (A/B/mock), Reclaim
+│ └── gip/0088/ # GIP-0088 goal orchestration (upgrade phase + activation)
+├── lib/ # Shared utilities (preconditions, contract registry, tags, ABIs, ...)
+├── tasks/ # Hardhat tasks (deploy:*)
+├── docs/ # This documentation
+└── test/ # Unit tests (bytecode, registry, tx-builder, ...)
+```
+
+## Tags
+
+Two-dimensional tag model. See [`lib/deployment-tags.ts`](../lib/deployment-tags.ts) for the source of truth.
+
+| Kind | Examples | Purpose |
+| --------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
+| Special | `sync` | Sync address books, import contracts |
+| Component | `IssuanceAllocator`, `RewardsManager`, `RecurringAgreementManager`, `RewardsEligibilityOracleA`, ... | One per deployable contract |
+| Action verb | `deploy`, `upgrade`, `configure`, `transfer`, `integrate`, `all` | Combined with a component or goal tag to gate work |
+| Goal scope | `GIP-0088`, `GIP-0088:upgrade` | Multi-component orchestration for a deployment |
+| Activation goal | `GIP-0088:eligibility-integrate`, `GIP-0088:issuance-connect`, `GIP-0088:issuance-allocate` | Per-step governance TX for the activation phases |
+| Optional goal | `GIP-0088:eligibility-revert`, `GIP-0088:issuance-close-guard` | Excluded from `--tags ...,all` — must be requested explicitly |
+
+## External Artifacts
+
+Artifacts are loaded directly in deploy scripts via `require.resolve()`:
+
+```typescript
+import { createRequire } from 'node:module'
+const require = createRequire(import.meta.url)
+
+// Load artifact from sibling package
+const artifactPath =
+ require.resolve('@graphprotocol/horizon/artifacts/contracts/RewardsManager.sol/RewardsManager.json')
+const artifact = JSON.parse(readFileSync(artifactPath, 'utf-8'))
+```
+
+This approach (vs Hardhat v2's `external: {}` config) allows more control over which artifacts are loaded and when.
+
+## See Also
+
+- [GovernanceWorkflow.md](./GovernanceWorkflow.md) - Governance execution
+- [Design.md](./Design.md) - Technical design documentation
diff --git a/packages/deployment/docs/DeploymentSetup.md b/packages/deployment/docs/DeploymentSetup.md
new file mode 100644
index 000000000..4b4fd4f4d
--- /dev/null
+++ b/packages/deployment/docs/DeploymentSetup.md
@@ -0,0 +1,224 @@
+# Deployment Setup and Flow
+
+Quick reference for setting up and running deployments on testnet/mainnet.
+
+## Prerequisites
+
+- Node.js 18+
+- pnpm
+- Foundry (for fork testing): `curl -L https://foundry.paradigm.xyz | bash && foundryup`
+
+## Initial Setup
+
+### 1. Install Dependencies
+
+```bash
+pnpm install
+pnpm build
+```
+
+### 2. Configure Secrets (Keystore)
+
+Use Hardhat's encrypted keystore for secure secret storage.
+Keys are network-specific:
+
+```bash
+# Deployer keys (required per network)
+npx hardhat keystore set ARBITRUM_SEPOLIA_DEPLOYER_KEY
+npx hardhat keystore set ARBITRUM_ONE_DEPLOYER_KEY
+
+# Governor keys for EOA execution (testnet only)
+npx hardhat keystore set ARBITRUM_SEPOLIA_GOVERNOR_KEY
+```
+
+**Keystore commands:**
+
+```bash
+npx hardhat keystore list # View stored keys
+npx hardhat keystore get # Retrieve a value
+npx hardhat keystore delete # Remove a secret
+npx hardhat keystore path # Show keystore location
+npx hardhat keystore change-password # Update password
+```
+
+**Development keystore** (no password, for non-sensitive values):
+
+```bash
+npx hardhat keystore set --dev ARBITRUM_SEPOLIA_DEPLOYER_KEY
+```
+
+**Environment override** (CI/CD):
+
+```bash
+export ARBITRUM_SEPOLIA_DEPLOYER_KEY=0x...
+```
+
+### 3. Verify Setup
+
+```bash
+npx hardhat deploy:check-deployer --network arbitrumSepolia
+```
+
+## Deployment Flow (Testnet/Mainnet)
+
+### Step 1: Check Status
+
+```bash
+npx hardhat deploy:status --network arbitrumSepolia
+```
+
+### Step 2: Sync Address Books
+
+Always sync first to ensure local state matches on-chain:
+
+```bash
+npx hardhat deploy --skip-prompts --network arbitrumSepolia --tags sync
+```
+
+### Step 3: Deploy
+
+```bash
+npx hardhat deploy --skip-prompts --network arbitrumSepolia --tags
+```
+
+If governance action is required, the deployment will:
+
+1. Generate TX batch in `txs/arbitrumSepolia/*.json`
+2. Exit with code 1 (expected - waiting for governance)
+
+### Step 4: Execute Governance
+
+**EOA Governor (testnet):**
+
+```bash
+# If stored in keystore, just run directly (prompts for password)
+npx hardhat deploy:execute-governance --network arbitrumSepolia
+
+# Or via environment variable
+ARBITRUM_SEPOLIA_GOVERNOR_KEY=0x... npx hardhat deploy:execute-governance --network arbitrumSepolia
+```
+
+**Safe Multisig (mainnet):**
+
+1. Go to [Safe Transaction Builder](https://app.safe.global/)
+2. Connect governor Safe wallet
+3. Apps > Transaction Builder > Upload JSON
+4. Select `txs/arbitrumSepolia/*.json`
+5. Create batch > Collect signatures > Execute
+
+### Step 5: Sync After Governance
+
+```bash
+npx hardhat deploy --skip-prompts --network arbitrumSepolia --tags sync
+```
+
+### Step 6: Continue Deployment
+
+Re-run the deploy command - it will continue from where it left off:
+
+```bash
+npx hardhat deploy --skip-prompts --network arbitrumSepolia --tags
+```
+
+## Quick Reference
+
+| Network | Chain ID | RPC (default) |
+| --------------- | -------- | ---------------------------------------- |
+| localNetwork | 1337 | `http://chain:8545` |
+| arbitrumSepolia | 421614 | |
+| arbitrumOne | 42161 | |
+
+| Key Pattern | Purpose | Storage |
+| ------------------------ | ---------------------- | ------------------- |
+| `_DEPLOYER_KEY` | Contract deployment | Keystore or env var |
+| `_GOVERNOR_KEY` | EOA governor execution | Keystore or env var |
+| `ARBISCAN_API_KEY` | Contract verification | Keystore or env var |
+| `ARBITRUM_ONE_RPC` | Custom RPC URL | Environment |
+| `ARBITRUM_SEPOLIA_RPC` | Custom RPC URL | Environment |
+
+`` = `ARBITRUM_SEPOLIA` or `ARBITRUM_ONE`
+
+## Contract Verification
+
+Since deployment uses external artifacts, **verify from the source package**:
+
+```bash
+# Set API key (in source package or deployment package)
+npx hardhat keystore set ARBISCAN_API_KEY
+
+# Verify from source package (has source code + compiler settings)
+cd packages/horizon
+npx hardhat verify --network arbitrumSepolia
+```
+
+For deploy scripts that run verification automatically, export the API key:
+
+```bash
+export ARBISCAN_API_KEY=$(npx hardhat keystore get ARBISCAN_API_KEY)
+npx hardhat deploy --skip-prompts --network arbitrumSepolia --tags
+```
+
+## Tagging Deployments (WIP)
+
+> This convention is a work in progress — feedback and changes welcome.
+
+After a deployment is committed, create an annotated git tag to record the deployment.
+Tags use `deploy/{mainnet|testnet}/YYYY-MM-DD` format. The annotation is auto-generated
+from address book diffs, listing which contracts changed.
+
+**Requires:** `jq` (`sudo apt install jq` / `brew install jq`)
+
+### Usage
+
+```bash
+# Preview first
+./scripts/tag-deployment.sh \
+ --deployer "packages/deployment --tags RewardsManager" \
+ --network arbitrumSepolia \
+ --base main \
+ --dry-run
+
+# Create the tag
+./scripts/tag-deployment.sh \
+ --deployer "packages/deployment --tags RewardsManager" \
+ --network arbitrumSepolia \
+ --base main
+
+# Push
+git push origin deploy/testnet/2026-03-02
+```
+
+The `--deployer` argument is free-form — describe what performed the deployment:
+
+- `"packages/deployment --tags RewardsManager,SubgraphService"`
+- `"packages/horizon ignition migrate"`
+- `"manual: forge script DeployFoo"`
+
+### Workflow
+
+1. Deploy contracts and update address books
+2. Commit the address book changes
+3. Run `tag-deployment.sh` (tag must point to a finalized commit)
+4. Push branch and tag
+
+### Options
+
+| Option | Description |
+| ------------------- | --------------------------------------------- |
+| `--deployer ` | What performed the deployment (required) |
+| `--network ` | `arbitrumOne` or `arbitrumSepolia` (required) |
+| `--base [` | Git ref to diff against (default: `HEAD~1`) |
+| `--dry-run` | Preview without creating tag |
+| `--sign` | Force-sign the tag with `-s` |
+
+### Viewing tags
+
+```bash
+git tag -l 'deploy/*' # List all deployment tags
+git show --no-patch deploy/testnet/... # View tag annotation
+```
+
+## See Also
+
+- [LocalForkTesting.md](./LocalForkTesting.md) - Fork-based testing workflow
+- [GovernanceWorkflow.md](./GovernanceWorkflow.md) - Detailed governance execution
diff --git a/packages/deployment/docs/Design.md b/packages/deployment/docs/Design.md
new file mode 100644
index 000000000..6eec92811
--- /dev/null
+++ b/packages/deployment/docs/Design.md
@@ -0,0 +1,244 @@
+# Deployment Package Design
+
+High-level architecture for the unified deployment system.
+
+**See also:**
+
+- [Architecture.md](./Architecture.md) - Package structure and organization
+- [deploy/ImplementationPrinciples.md](./deploy/ImplementationPrinciples.md) - Deploy script patterns and conventions
+
+## Components
+
+**Deployed by this package:**
+
+- IssuanceAllocator - Upgradeable proxy managing issuance distribution
+- RewardsEligibilityOracle - Upgradeable proxy for eligibility verification
+- ReclaimedRewards (DirectAllocation) - Upgradeable proxy for default reclaim address
+- RecurringAgreementManager - Upgradeable proxy for agreement-based payments
+
+**Referenced contracts** (already deployed):
+
+- RewardsManager (from @graphprotocol/contracts or @graphprotocol/horizon)
+- GraphToken (from @graphprotocol/contracts)
+- GraphProxyAdmin (from @graphprotocol/contracts or @graphprotocol/horizon)
+
+## Directory Structure
+
+```
+packages/deployment/
+├── deploy/ # Numbered deployment scripts (rocketh + hardhat-deploy)
+│ ├── common/ # 00_sync.ts
+│ ├── horizon/ # RewardsManager, HorizonStaking, PaymentsEscrow, L2Curation, RecurringCollector
+│ ├── service/ # SubgraphService, DisputeManager
+│ ├── allocate/ # IssuanceAllocator, DefaultAllocation, DirectAllocation impl
+│ ├── agreement/ # RecurringAgreementManager
+│ ├── rewards/ # RewardsEligibilityOracle (A/B/mock), Reclaim
+│ └── gip/0088/ # GIP-0088 goal orchestration
+├── lib/ # Shared utilities (preconditions, registry, tags, ABIs, governance)
+├── tasks/ # Hardhat tasks (deploy:*)
+├── docs/ # Architecture and operational documentation
+│ └── deploy/ # Deploy-script principles and per-component design notes
+└── test/ # Unit tests
+```
+
+## Governance Model
+
+### Three-Phase Workflow
+
+1. **Prepare** (permissionless) - Deploy new implementations, generate TX batches
+2. **Execute** (governance) - Execute Safe TX batch for state transitions
+3. **Verify** (permissionless) - Verify integration, sync address books
+
+### Proxy Administration
+
+Two distinct proxy patterns coexist:
+
+- **Legacy `GraphProxy`** (custom Graph Protocol pattern) — used by RewardsManager, HorizonStaking, L2Curation, EpochManager. A single shared `GraphProxyAdmin` (owned by governance) controls upgrades for all of them.
+- **OZ v5 `TransparentUpgradeableProxy`** — used by every new contract this package deploys (IssuanceAllocator, DefaultAllocation, ReclaimedRewards, RecurringAgreementManager, RewardsEligibilityOracle A/B, RecurringCollector, SubgraphService, DisputeManager, PaymentsEscrow). Each proxy gets its own per-proxy `ProxyAdmin` created by the proxy constructor; ownership is transferred to governance in the transfer step.
+
+```mermaid
+graph TB
+ Gov[Governance Multi-sig]
+ GraphAdmin[GraphProxyAdmin]
+
+ subgraph "Legacy GraphProxy"
+ RM[RewardsManager]
+ HS[HorizonStaking]
+ L2C[L2Curation]
+ end
+
+ subgraph "OZ v5 TransparentUpgradeableProxy]
(per-proxy admin)"
+ IA[IssuanceAllocator]
+ DA[DefaultAllocation]
+ Reclaim[ReclaimedRewards]
+ RAM[RecurringAgreementManager]
+ REO[RewardsEligibilityOracle A/B]
+ RC[RecurringCollector]
+ end
+
+ Gov -->|owns| GraphAdmin
+ GraphAdmin -->|upgrades| RM
+ GraphAdmin -->|upgrades| HS
+ GraphAdmin -->|upgrades| L2C
+
+ Gov -.->|owns each per-proxy admin| IA
+ Gov -.->|owns each per-proxy admin| DA
+ Gov -.->|owns each per-proxy admin| Reclaim
+ Gov -.->|owns each per-proxy admin| RAM
+ Gov -.->|owns each per-proxy admin| REO
+ Gov -.->|owns each per-proxy admin| RC
+```
+
+**Key principle:** Every proxy admin is governance-owned. Legacy contracts share a single `GraphProxyAdmin`; new contracts each have their own per-proxy admin created at construction.
+
+## Contract Integration
+
+### RewardsEligibilityOracle Integration
+
+```mermaid
+graph LR
+ REO[RewardsEligibilityOracle]
+ RM[RewardsManager]
+ Oracles[Off-chain Oracles]
+
+ Oracles -->|set eligibility| REO
+ RM -->|check eligibility| REO
+```
+
+**Integration:** `RewardsManager.setProviderEligibilityOracle(REO)` via governance
+
+### IssuanceAllocator Integration
+
+```mermaid
+graph TB
+ GT[GraphToken]
+ IA[IssuanceAllocator]
+
+ subgraph "Allocator Minting"
+ RAM[RecurringAgreementManager]
+ end
+
+ subgraph "Self Minting"
+ RM[RewardsManager]
+ end
+
+ GT -->|minting authority| IA
+ IA -->|distributes to| RAM
+ IA -->|allocates to| RM
+```
+
+**Integration:**
+
+- `RewardsManager.setIssuanceAllocator(IA)` via governance
+- `GraphToken.addMinter(IA)` via governance
+
+### Contract Dependencies
+
+```mermaid
+graph TD
+ GraphToken[GraphToken]
+ RewardsManager[RewardsManager]
+
+ RewardsEligibilityOracle[RewardsEligibilityOracle]
+ IssuanceAllocator[IssuanceAllocator]
+ RecurringAgreementManager[RecurringAgreementManager]
+
+ RewardsManager -.->|queries| RewardsEligibilityOracle
+ IssuanceAllocator -.->|integrates with| RewardsManager
+ IssuanceAllocator -.->|mints from| GraphToken
+ IssuanceAllocator -.->|distributes to| RecurringAgreementManager
+ RecurringAgreementManager -.->|funds| PaymentsEscrow
+```
+
+## Address Book Management
+
+### Pending Implementation Pattern
+
+Deployment tracks both active and pending implementations:
+
+```json
+{
+ "IssuanceAllocator": {
+ "address": "0x9fE46...",
+ "implementation": {
+ "address": "0xe7f17..."
+ },
+ "pendingImplementation": {
+ "address": "0x5FbDB...",
+ "readyForUpgrade": true
+ }
+ }
+}
+```
+
+### Upgrade Workflow
+
+```mermaid
+sequenceDiagram
+ participant Deployer
+ participant AB as Address Book
+ participant Proxy
+ participant Gov as Governance
+
+ Note over Deployer,Gov: Phase 1: Prepare
+ Deployer->>AB: Deploy new implementation
+ AB->>AB: Set pendingImplementation
+
+ Note over Deployer,Gov: Phase 2: Execute
+ Deployer->>Gov: Generate Safe TX batch
+ Gov->>Proxy: Execute upgrade
+ Proxy->>Proxy: Update implementation pointer
+
+ Note over Deployer,Gov: Phase 3: Verify
+ Deployer->>AB: Sync (--tags sync)
+ AB->>AB: Move pending → active
+```
+
+## Deployment Workflow
+
+### Proxy Deployment and Upgrade
+
+```mermaid
+sequenceDiagram
+ participant Deployer
+ participant Deploy as rocketh
+ participant Admin as ProxyAdmin (per-proxy)
+ participant Impl as Implementation
+ participant Proxy as TransparentUpgradeableProxy
+ participant Gov as Governance
+
+ Note over Deployer,Gov: Initial Deployment
+ Deployer->>Deploy: --tags Component,deploy
+ Deploy->>Impl: Deploy implementation
+ Deploy->>Proxy: Deploy proxy (constructor creates per-proxy Admin)
+ Proxy->>Impl: Initialize with deployer as governor
+
+ Note over Deployer,Gov: Configure
+ Deployer->>Deploy: --tags Component,configure
+ Deploy->>Proxy: Set params, grant roles to gov + pause guardian
+
+ Note over Deployer,Gov: Transfer
+ Deployer->>Deploy: --tags Component,transfer
+ Deploy->>Proxy: Revoke deployer GOVERNOR_ROLE
+ Deploy->>Admin: Transfer ProxyAdmin ownership to Gov
+
+ Note over Deployer,Gov: Implementation Upgrade
+ Deployer->>Deploy: --tags Component,upgrade
+ Deploy->>Impl: Deploy new implementation
+ Deploy->>Deploy: Save governance TX batch
+ Gov->>Admin: Execute upgrade TX
+ Admin->>Proxy: upgradeAndCall(newImpl)
+
+ Note over Deployer,Gov: Sync
+ Deployer->>Deploy: --tags sync
+ Deploy->>Proxy: Read current implementation
+ Deploy->>Deploy: Update address book (pending → active)
+```
+
+## Conventions
+
+- TypeScript throughout (.ts)
+- TitleCase for documentation
+- Deploy script patterns: [ImplementationPrinciples.md](./deploy/ImplementationPrinciples.md)
+- Deploy scripts sync the contracts they touch immediately before/after their action via `syncComponentFromRegistry`/`syncComponentsFromRegistry`. The full
+ global sync is opt-in via `npx hardhat deploy:sync` and is no longer an automatic dependency of every component script.
diff --git a/packages/deployment/docs/Gip0088.md b/packages/deployment/docs/Gip0088.md
new file mode 100644
index 000000000..3afd7d815
--- /dev/null
+++ b/packages/deployment/docs/Gip0088.md
@@ -0,0 +1,241 @@
+# GIP-0088: Deployment Guide
+
+Protocol upgrade deploying the Issuance Allocator, Rewards Eligibility Oracle, and on-chain indexing agreements, as specified by [GIP-0088](https://github.com/graphprotocol/graph-improvement-proposals/blob/main/gips/0088.md).
+
+## Related GIPs
+
+| GIP | Title | What it specifies |
+| ----------------------------------------------------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------- |
+| [GIP-0076](https://github.com/graphprotocol/graph-improvement-proposals/blob/main/gips/0076.md) | Issuance Allocator | Contract spec: governance-controlled issuance distribution across self-minting and allocator-minting targets |
+| [GIP-0079](https://github.com/graphprotocol/graph-improvement-proposals/blob/main/gips/0079.md) | Rewards Eligibility Oracle | Contract spec: quality-of-service gating on indexing rewards via authorized oracle |
+| [GIP-0086](https://github.com/graphprotocol/graph-improvement-proposals/blob/main/gips/0086.md) | RM and SS Upgrade | Contract upgrades: RM gains eligibility oracle hook + issuance allocator integration; SS gains agreement support |
+| [GIP-0087](https://github.com/graphprotocol/graph-improvement-proposals/blob/main/gips/0087.md) | On-Chain Indexing Agreements | Contract spec: RecurringCollector, RecurringAgreementManager, indexing agreement lifecycle in SubgraphService |
+| [GIP-0088](https://github.com/graphprotocol/graph-improvement-proposals/blob/main/gips/0088.md) | IA Deployment and IP Config | **Deployment proposal**: deploy IA (0076), connect to upgraded RM (0086), allocate to RAM (0087) |
+
+## Contracts
+
+### New contracts (deploy)
+
+| Contract | Package | GIP | Purpose |
+| ------------------------------ | -------- | ---- | ----------------------------------------------------------- |
+| IssuanceAllocator | issuance | 0076 | Governance-managed issuance distribution across targets |
+| DefaultAllocation | issuance | 0076 | Default target safety net for unallocated issuance |
+| ReclaimedRewards | issuance | 0076 | Default reclaim destination for reclaimed rewards |
+| RecurringCollector | horizon | 0087 | EIP-712 collector for recurring payment agreement lifecycle |
+| RecurringAgreementManager | issuance | 0087 | Protocol-funded indexing agreements and escrow management |
+| RewardsEligibilityOracle (A/B) | issuance | 0079 | Quality-of-service gating on indexing rewards |
+
+### Existing contracts (upgrade implementation)
+
+| Contract | Package | GIP | Key changes |
+| --------------- | ---------------- | --------- | ------------------------------------------------------------------------------------------------- |
+| RewardsManager | contracts | 0086 | `setIssuanceAllocator()`, `IProviderEligibility` integration, `revertOnIneligible`, reclaim infra |
+| SubgraphService | subgraph-service | 0086/0087 | Indexing agreement lifecycle, `enforceService`, `recurringCollector` integration |
+| DisputeManager | subgraph-service | 0086/0087 | `createIndexingFeeDisputeV1()`, removes legacy dispute creation |
+| HorizonStaking | horizon | 0086 | Removes HorizonStakingExtension, consolidates functionality |
+| PaymentsEscrow | horizon | 0087 | `adjustThaw()` for payer thaw modification |
+| L2Curation | contracts | 0086 | Removes staking as authorized `collect()` caller |
+
+## Deploy Scripts
+
+### GIP-0088 scripts (`deploy/gip/0088/`)
+
+**Upgrade phase** (`upgrade/`) — deploys, configures, transfers, and upgrades ALL contracts:
+
+| Script | `--tags` | What it does |
+| -------------- | ---------------------------- | ------------------------------------------------------------------------------------- |
+| `01_deploy` | `GIP-0088:upgrade,deploy` | Deploy all new contracts + implementations |
+| `02_configure` | `GIP-0088:upgrade,configure` | Deployer-only configure: role grants and params on contracts where deployer is gov |
+| `03_transfer` | `GIP-0088:upgrade,transfer` | Transfer governance of new contracts (revoke deployer role + ProxyAdmin to gov) |
+| `04_upgrade` | `GIP-0088:upgrade,upgrade` | Bundle proxy upgrades + all deferred configure into one governance TX batch (details) |
+| `10_status` | `GIP-0088:upgrade` | Show upgrade state and next step |
+
+`04_upgrade` builds a single governance TX batch containing:
+
+| Group | Items |
+| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Proxy upgrades | Iterates registry; for any deployable proxy with `pendingImplementation`, adds the proxy upgrade TX |
+| Existing-contract config | `RC.setPauseGuardian`, `RM.setDefaultReclaimAddress` |
+| Deferred new-contract config | IA: `setIssuancePerBlock`, role grants. DA: role grants. RAM: role grants + `setIssuanceAllocator`. Reclaim: role grants. REO A/B: params + role grants |
+
+Items in groups 2 and 3 are added only when not already on-chain. The bundle exists because configure runs as the deployer and skips anything that requires `GOVERNOR_ROLE` on contracts the deployer doesn't yet control (or that depend on RM being upgraded).
+
+**Activation goals** — governance TXs that change protocol behaviour (after upgrade complete):
+
+| Script | `--tags` | What it does |
+| ----------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `eligibility_integrate` | `GIP-0088:eligibility-integrate` | `RM.setProviderEligibilityOracle(REO_A)` |
+| `issuance_connect` | `GIP-0088:issuance-connect` | `GraphToken.addMinter(IA)` → `RM.setIssuanceAllocator(IA)` → `IA.setTargetAllocation(RM, 0, rate)` (RM as 100% self-minting target) → `IA.setDefaultTarget(DA)` (safety net) |
+| `issuance_allocate` | `GIP-0088:issuance-allocate` | `IA.setTargetAllocation(RAM, allocatorRate, selfRate)` (rates from `config/.json5`) |
+
+**Optional goals** — not planned for initial deployment:
+
+| Script | `--tags` | What it does |
+| ---------------------- | ------------------------------- | ------------------------------------------------------- |
+| `eligibility_revert` | `GIP-0088:eligibility-revert` | `RM.setRevertOnIneligible(true)` |
+| `issuance_close_guard` | `GIP-0088:issuance-close-guard` | `SS.setBlockClosingAllocationWithActiveAgreement(true)` |
+
+**Overall** — `09_end` (`GIP-0088,all`) verifies all non-optional goals. `10_status` (`GIP-0088`) shows full deployment state.
+
+### Component lifecycle scripts
+
+Each contract has its own lifecycle scripts under `deploy/`. The GIP-0088 upgrade phase depends on component tags — it orchestrates the component scripts rather than duplicating their logic.
+
+## Deployment Process
+
+### How `--tags` drives the deployment
+
+The upgrade phase tag (`GIP-0088:upgrade`) combined with an action verb (`deploy`, `configure`, `transfer`, `upgrade`) selects which lifecycle step runs. Activation goals have their own tags.
+
+- `--tags GIP-0088:upgrade,deploy` — deploy all contracts
+- `--tags GIP-0088:upgrade,configure` — configure all contracts
+- `--tags GIP-0088:upgrade,transfer` — transfer to governance control
+- `--tags GIP-0088:upgrade,upgrade` — generate proxy upgrade TX batch
+- `--tags GIP-0088:upgrade` — show status and next step
+- `--tags GIP-0088:eligibility-integrate` — integrate REO with RM (governance TX)
+- `--tags GIP-0088:issuance-connect` — connect IA to RM + minter role (governance TX)
+- `--tags GIP-0088:issuance-allocate` — allocate issuance to RAM (governance TX)
+- `--tags GIP-0088` — overall status
+
+All scripts are idempotent — they check on-chain state and skip if already done. Scripts do not presume a particular starting state.
+
+Sync runs automatically as a dependency of all scripts.
+
+### Deployment sequence
+
+```bash
+# Deploy and configure all contracts
+pnpm hardhat deploy --tags GIP-0088:upgrade,deploy --network
+pnpm hardhat deploy --tags GIP-0088:upgrade,configure --network
+
+# Check status before transferring governance
+pnpm hardhat deploy --tags GIP-0088:upgrade --network
+
+# Transfer governance — after this, deployer has no special access
+pnpm hardhat deploy --tags GIP-0088:upgrade,transfer --network
+
+# Generate proxy upgrade governance TX batch
+pnpm hardhat deploy --tags GIP-0088:upgrade,upgrade --network
+# → execute governance TXs (see Environments below)
+
+# Activation goals (each generates governance TXs independently)
+pnpm hardhat deploy --tags GIP-0088:eligibility-integrate --network
+pnpm hardhat deploy --tags GIP-0088:issuance-connect --network
+pnpm hardhat deploy --tags GIP-0088:issuance-allocate --network
+# → execute governance TXs
+
+# Verify
+pnpm hardhat deploy --tags GIP-0088 --network
+```
+
+### Preconditions
+
+Each script checks its own preconditions and skips if not met. Scripts do not presume a particular starting state — they are goal-seeking, not sequential steps.
+
+#### Deploy (`GIP-0088:upgrade,deploy`)
+
+| Contract | Precondition | Notes |
+| ------------------------------------------ | ------------ | ----------------------------------------------------- |
+| RC | — | No dependencies |
+| SS implementation | RC deployed | SS has RC address baked into bytecode via `Directory` |
+| RM, HS, DM, PE, L2Curation implementations | — | No deploy-time dependencies |
+| IA, DefaultAllocation, Reclaim | — | Independent |
+| RAM | — | Independent |
+| REO A, REO B | — | Independent |
+
+#### Configure (`GIP-0088:upgrade,configure`)
+
+| Contract | Precondition | Notes |
+| -------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
+| RC | Deployed | setPauseGuardian |
+| IA | Deployed, 0 < RM.issuancePerBlock | Rates, RM as 100% self-minting target, grant governor/pause roles |
+| DA | Deployed (+ IA deployed) | Grant governor/pause roles, set as IA default target |
+| REO A/B | Deployed | Grant governor/pause/operator roles. Validation enabled by operator post-deploy. |
+| RAM | Deployed (+ RC, SS, IA deployed) | Grant governor/pause/collector/data-service roles, set issuance allocator |
+| Reclaim | Deployed | Grant governor/pause roles |
+| Reclaim | RM upgraded | Sets RM.defaultReclaimAddress — skips if RM not yet upgraded (handled by `04_upgrade` instead) |
+
+#### Transfer (`GIP-0088:upgrade,transfer`)
+
+| Contract | Precondition | Notes |
+| -------- | ------------------------------- | --------------------------------------------------------------------------- |
+| RC | Deployed | ProxyAdmin only — RC has no `GOVERNOR_ROLE`. Skips if owner is not deployer |
+| IA | Configured | Revokes deployer GOVERNOR_ROLE, transfers ProxyAdmin |
+| DA | Configured | Revokes deployer GOVERNOR_ROLE, transfers ProxyAdmin |
+| RAM | Configured | Revokes deployer GOVERNOR_ROLE, transfers ProxyAdmin |
+| Reclaim | Configured | Revokes deployer GOVERNOR_ROLE, transfers ProxyAdmin |
+| REO A | Configured (all conditions met) | Revokes deployer GOVERNOR_ROLE, transfers ProxyAdmin |
+| REO B | Configured (all conditions met) | Revokes deployer GOVERNOR_ROLE, transfers ProxyAdmin |
+
+#### Upgrade (`GIP-0088:upgrade,upgrade`)
+
+State-driven: builds a single governance TX batch from three groups. Each group skips items already on-chain.
+
+| Group | Items |
+| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Proxy upgrades | Iterates registry; for any deployable proxy with `pendingImplementation`, adds proxy upgrade TX |
+| Existing-contract config | `RC.setPauseGuardian(pauseGuardian)`; `RM.setDefaultReclaimAddress(reclaim)` (only after RM upgrade — bundle order means RM upgrade executes first in the same batch) |
+| Deferred new-contract config | IA: `setIssuancePerBlock`, `grantRole(GOVERNOR/PAUSE)`. DA: `grantRole(GOVERNOR/PAUSE)`. RAM: `grantRole(COLLECTOR/DATA_SERVICE/GOVERNOR/PAUSE)` + `setIssuanceAllocator`. Reclaim: `grantRole(GOVERNOR/PAUSE)`. REO A/B: param setters + role grants. |
+
+These deferred items exist because configure runs as the deployer and skips items requiring `GOVERNOR_ROLE` on contracts the deployer doesn't yet control, or items that depend on RM being upgraded.
+
+#### Activation goals
+
+| Goal | Precondition | Notes |
+| ----------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `eligibility-integrate` | RM upgraded, REO A deployed, oracle not already set | `RM.setProviderEligibilityOracle(REO_A)`. Skips if any oracle already set (does not override). |
+| `issuance-connect` | RM upgraded, IA deployed + configured (rate matches RM) | Builds TX batch in order: `GraphToken.addMinter(IA)` → `RM.setIssuanceAllocator(IA)` → `IA.setTargetAllocation(RM, 0, rate)` → `IA.setDefaultTarget(DA)`. Order matters: `setTargetAllocation` calls `RM.onIssuanceChange` which requires the allocator already be set. **Exits on invariant failure** (IA rate ≠ RM rate). |
+| `issuance-allocate` | IA deployed, RAM deployed, issuance-connect done | `IA.setTargetAllocation(RAM, allocatorMintingRate, selfMintingRate)`. Rates from `config/.json5`, skips if both are 0. |
+
+#### Optional goals
+
+| Goal | Precondition | Notes |
+| ---------------------- | -------------------------------------- | ----------------------------------------------------- |
+| `eligibility-revert` | RM upgraded (supports IRewardsManager) | RM.setRevertOnIneligible(true) |
+| `issuance-close-guard` | SS upgraded | SS.setBlockClosingAllocationWithActiveAgreement(true) |
+
+### Environments
+
+The same commands apply to all environments. What differs is how governance TXs are executed.
+
+| Environment | Governance execution | Speed |
+| ----------------- | ------------------------------------------------- | -------- |
+| Fork (localhost) | `deploy:execute-governance` impersonates governor | Instant |
+| Testnet (Sepolia) | `deploy:execute-governance` signs with EOA key | ~minutes |
+| Mainnet (Arb One) | TX batch uploaded to Safe for council multisig | ~days |
+
+#### Fork testing
+
+Validates the full flow using account impersonation. See [LocalForkTesting.md](LocalForkTesting.md).
+
+```bash
+anvil --fork-url --chain-id 31337
+pnpm hardhat deploy:reset-fork --network localhost
+
+# Deploy, configure, transfer
+pnpm hardhat deploy --tags GIP-0088:upgrade,deploy --network localhost --skip-prompts
+pnpm hardhat deploy --tags GIP-0088:upgrade,configure --network localhost --skip-prompts
+pnpm hardhat deploy --tags GIP-0088:upgrade,transfer --network localhost --skip-prompts
+
+# Proxy upgrades
+pnpm hardhat deploy --tags GIP-0088:upgrade,upgrade --network localhost --skip-prompts
+pnpm hardhat deploy:execute-governance --network localhost
+
+# Activation
+pnpm hardhat deploy --tags GIP-0088:eligibility-integrate --network localhost --skip-prompts
+pnpm hardhat deploy:execute-governance --network localhost
+pnpm hardhat deploy --tags GIP-0088:issuance-connect --network localhost --skip-prompts
+pnpm hardhat deploy:execute-governance --network localhost
+pnpm hardhat deploy --tags GIP-0088:issuance-allocate --network localhost --skip-prompts
+pnpm hardhat deploy:execute-governance --network localhost
+
+# Verify
+pnpm hardhat deploy --tags GIP-0088 --network localhost --skip-prompts
+```
+
+## See Also
+
+- [GovernanceWorkflow.md](GovernanceWorkflow.md) — governance TX generation and execution across environments
+- [LocalForkTesting.md](LocalForkTesting.md) — fork mode testing setup and workflow
+- [Architecture.md](Architecture.md) — deployment package architecture
+- [deploy/ImplementationPrinciples.md](deploy/ImplementationPrinciples.md) — patterns and rules for deploy scripts
diff --git a/packages/deployment/docs/GovernanceWorkflow.md b/packages/deployment/docs/GovernanceWorkflow.md
new file mode 100644
index 000000000..7b4ade2ed
--- /dev/null
+++ b/packages/deployment/docs/GovernanceWorkflow.md
@@ -0,0 +1,373 @@
+# Governance Transaction Workflow
+
+This document explains how governance transactions are executed in different deployment modes.
+
+## Overview
+
+Graph Protocol uses a Governor (typically a Safe multisig) to control protocol upgrades and configuration. The deployment system generates transaction batches that must be executed by the Governor.
+
+## Fork Mode (Testing)
+
+In fork mode, governance transactions can be executed automatically via account impersonation for testing purposes.
+
+### Setup
+
+```bash
+# Ephemeral: run deployment directly (state lost on exit)
+FORK_NETWORK=arbitrumSepolia npx hardhat deploy --tags IssuanceAllocator:deploy --network fork
+
+# Or persistent: start anvil in Terminal 1, run deploys in Terminal 2
+# See LocalForkTesting.md for persistent fork setup
+```
+
+### Execution
+
+When a deployment generates a governance TX batch:
+
+1. The TX batch is saved to `fork/fork/arbitrumSepolia/txs/*.json`
+2. The script returns (it does **not** exit) — subsequent scripts in the run keep going and check their own preconditions, so a single command can produce several TX batches
+3. Execute the saved governance TXs:
+
+ ```bash
+ npx hardhat deploy:execute-governance --network fork
+ ```
+
+4. This uses `hardhat_impersonateAccount` to execute as the governor
+5. Re-run the deployment command to continue past the governance boundary
+
+## Testnet Mode with EOA Governor
+
+**Note:** Safe Transaction Builder may not be available on all testnets (e.g., Arbitrum Sepolia may not be supported). For testnet deployments, use an EOA governor or fork mode for testing.
+
+If your testnet governor is an EOA (regular wallet) rather than a Safe multisig, you can execute governance transactions directly using the governor's private key.
+
+### Setup
+
+```bash
+export DEPLOYER_PRIVATE_KEY=0xYOUR_DEPLOYER_KEY
+export GOVERNOR_PRIVATE_KEY=0xYOUR_GOVERNOR_KEY
+```
+
+### Execution
+
+When a deployment generates a governance TX batch:
+
+1. The TX batch is saved to `txs/arbitrumSepolia/*.json`
+2. Execute directly with the governor private key:
+
+ ```bash
+ npx hardhat deploy:execute-governance --network arbitrumSepolia
+ ```
+
+3. The system will:
+ - Detect that governor is an EOA
+ - Use GOVERNOR_PRIVATE_KEY to sign and send transactions
+ - Move executed batches to `executed/` subdirectory
+4. Continue with deployments
+
+**Note:** This only works when the governor is an EOA. If the governor is a Safe multisig, you must use the Safe UI workflow below.
+
+### Testing Safe Transaction Builder Format
+
+Even with an EOA governor, you can validate the Safe Transaction Builder JSON format:
+
+1. Transaction batch files are always created in `txs//*.json`
+2. These files use Safe Transaction Builder format (work with both EOA and Safe)
+3. To test the format before mainnet:
+ - Go to
+ - Apps → Transaction Builder
+ - Upload the JSON file
+ - Review decoded transactions
+ - (Don't execute - this is just format validation)
+
+## Mainnet/Production Mode with Safe Multisig
+
+On mainnet (and testnets where Safe is deployed), governance transactions with Safe multisig governors MUST be executed via Safe UI.
+
+**Important:** Safe Transaction Builder is not available on all networks. Check to verify your network is supported. For testnets without Safe support (like Arbitrum Sepolia), use an EOA governor or fork mode for testing.
+
+### Workflow
+
+#### 1. Deploy and Generate TX Batches
+
+```bash
+export DEPLOYER_PRIVATE_KEY=0xYOUR_PRIVATE_KEY
+npx hardhat deploy --tags IssuanceAllocator:deploy --network arbitrumSepolia
+```
+
+When governance action is required, the deployment will:
+
+- Generate a TX batch file in `txs/arbitrumSepolia/*.json`
+- Display the file path
+- Return (not exit) — the run continues and other scripts check their own preconditions
+
+#### 2. Review the TX Batch
+
+The generated JSON file contains all transaction details:
+
+```json
+{
+ "version": "1.0",
+ "chainId": "421614",
+ "createdAt": 1234567890,
+ "meta": {
+ "name": "IssuanceAllocator activation",
+ "description": "..."
+ },
+ "transactions": [
+ {
+ "to": "0x...",
+ "value": "0",
+ "data": "0x...",
+ "contractMethod": {...},
+ "contractInputsValues": {...}
+ }
+ ]
+}
+```
+
+#### 3. Execute via Safe Transaction Builder
+
+1. Go to [Safe Transaction Builder](https://app.safe.global/)
+2. Connect to your Safe wallet (the one configured as Governor)
+3. Navigate to "Transaction Builder" in the Safe UI
+4. Click "Upload a JSON" and select the governance TX batch file
+5. Review all transactions:
+ - Verify target addresses
+ - Check function calls and parameters
+ - Ensure chain ID matches your network
+6. Create the transaction batch
+7. Collect required signatures from Safe signers
+8. Execute the transaction batch
+
+#### 4. Sync After Execution
+
+After the transactions are executed on-chain, sync the address books:
+
+```bash
+npx hardhat deploy --tags sync --network arbitrumSepolia
+```
+
+This updates the address books with the new on-chain state.
+
+#### 5. Continue Deployment
+
+Re-run the original deployment command:
+
+```bash
+npx hardhat deploy --tags IssuanceAllocator:deploy --network arbitrumSepolia
+```
+
+The deployment will detect that governance has executed and continue to the next steps.
+
+## Common Governance Operations
+
+### Contract Upgrades
+
+```bash
+# 1. Deploy new implementation
+npx hardhat deploy --tags RewardsManager:deploy --network arbitrumSepolia
+
+# This generates: txs/arbitrumSepolia/upgrade-RewardsManager.json
+
+# 2. Execute via Safe UI (see workflow above)
+
+# 3. Sync and verify
+npx hardhat deploy --tags sync --network arbitrumSepolia
+```
+
+### Configuration Changes
+
+```bash
+# Deploy and configure (generates governance TX if needed)
+npx hardhat deploy --tags IssuanceActivation --network arbitrumSepolia
+
+# Execute via Safe UI
+
+# Sync and continue
+npx hardhat deploy --tags sync --network arbitrumSepolia
+```
+
+## Governance TX File Locations
+
+The location of governance TX files depends on the deployment mode:
+
+### Fork Mode
+
+```
+fork///txs/*.json
+```
+
+Example: `fork/fork/arbitrumSepolia/txs/upgrade-RewardsManager.json`
+
+### Testnet/Mainnet
+
+```
+txs//*.json
+```
+
+Example: `txs/arbitrumSepolia/upgrade-RewardsManager.json`
+
+After execution, files are moved to:
+
+```
+txs//executed/*.json
+```
+
+## Execution Modes
+
+| Mode | When Used | Execution Method | Environment Variables |
+| ---------------------- | ------------------------- | ---------------------------------------- | ------------------------------ |
+| **Fork Impersonation** | Local testing | Automatic via hardhat_impersonateAccount | `FORK_NETWORK=arbitrumSepolia` |
+| **EOA Direct** | Testnet with EOA governor | Automatic with private key | `GOVERNOR_PRIVATE_KEY=0x...` |
+| **Safe Multisig** | Production/mainnet | Manual via Safe Transaction Builder | None (auto-detected) |
+
+**Fork mode is network-aware**: `FORK_NETWORK` is automatically ignored on real networks (arbitrumSepolia, arbitrumOne). Fork mode only activates on local networks (localhost, fork, hardhat), so you don't need to unset it when switching to real deployments.
+
+**Transaction batch files** (Safe Transaction Builder JSON format) are always created in `txs//*.json` regardless of execution mode.
+
+### Usage Examples
+
+**Local fork testing (ephemeral):**
+
+```bash
+FORK_NETWORK=arbitrumSepolia npx hardhat deploy:execute-governance --network fork
+```
+
+**Fast testnet iteration (EOA):**
+
+```bash
+export GOVERNOR_PRIVATE_KEY=0xYOUR_KEY
+npx hardhat deploy:execute-governance --network arbitrumSepolia
+```
+
+**Production deployment (Safe):**
+
+```bash
+npx hardhat deploy:execute-governance --network arbitrumOne
+# Follow Safe Transaction Builder instructions in output
+```
+
+## Safety Features
+
+### Automatic Governor Detection
+
+The `deploy:execute-governance` command automatically detects the governor type:
+
+**For Safe Multisig Governors:**
+
+```bash
+npx hardhat deploy:execute-governance --network arbitrumSepolia
+
+# Output:
+# ❌ Cannot execute governance TXs on arbitrumSepolia (governor is a Safe multisig)
+# Governor address: 0x...
+# Governance transactions must be executed via Safe UI
+```
+
+**For EOA Governors (without private key):**
+
+```bash
+npx hardhat deploy:execute-governance --network arbitrumSepolia
+
+# Output:
+# ❌ Cannot execute governance TXs on arbitrumSepolia
+# Governor address: 0x... (EOA)
+# To execute governance TXs as EOA governor, set GOVERNOR_PRIVATE_KEY
+```
+
+**For EOA Governors (with private key):**
+
+```bash
+export GOVERNOR_PRIVATE_KEY=0xYOUR_GOVERNOR_KEY
+npx hardhat deploy:execute-governance --network arbitrumSepolia
+
+# Output:
+# 🔓 Executing 1 governance TX batch(es)...
+# Governor: 0x... (EOA)
+```
+
+### No Exit on Governance Save
+
+When a script generates a governance TX batch, it **returns** rather than exiting. This:
+
+- Lets a single command produce multiple governance TX batches in one run (one per script that needs governance authority)
+- Avoids implicit ordering coupling — every script checks its own on-chain preconditions and skips if they aren't met
+- Is normal flow, not an error condition
+
+To detect "needs governance" in CI/CD, check whether any files exist under `txs//` after a run, or use the goal status scripts (`--tags GIP-0088`).
+
+## Troubleshooting
+
+### "No deployer account configured"
+
+You need to set `DEPLOYER_PRIVATE_KEY`:
+
+```bash
+export DEPLOYER_PRIVATE_KEY=0xYOUR_PRIVATE_KEY
+npx hardhat deploy --network arbitrumSepolia
+```
+
+### "Cannot execute governance TXs" with Safe multisig
+
+This is correct behavior for Safe multisig governors. Execute the TXs via Safe UI instead of the CLI command.
+
+### "Cannot execute governance TXs" with EOA governor
+
+Set the `GOVERNOR_PRIVATE_KEY` environment variable:
+
+```bash
+export GOVERNOR_PRIVATE_KEY=0xYOUR_GOVERNOR_KEY
+npx hardhat deploy:execute-governance --network arbitrumSepolia
+```
+
+### "Chain ID mismatch"
+
+The TX batch file's `chainId` must match the network you're executing on:
+
+- arbitrumSepolia: 421614
+- arbitrumOne: 42161
+
+Regenerate the TX batch if you deployed to the wrong network.
+
+### TX Batch Already Exists
+
+If you re-run a deployment, it will overwrite the existing TX batch file with the same name. This is by design - the latest deployment's TX batch is always canonical.
+
+### "Safe not available on this network"
+
+Safe Transaction Builder is not deployed on all networks. If your network isn't supported:
+
+**For testnet deployments:**
+
+- Use an EOA governor with `GOVERNOR_PRIVATE_KEY`
+- Or test in fork mode: `FORK_NETWORK=arbitrumOne` (fork mainnet instead)
+
+**Supported networks:** Check and select your network from the dropdown. If it's not listed, Safe is not available.
+
+**Example - Arbitrum Sepolia:** Safe may not be available. Use EOA governor:
+
+```bash
+export GOVERNOR_PRIVATE_KEY=0xYOUR_TESTNET_GOVERNOR_KEY
+npx hardhat deploy:execute-governance --network arbitrumSepolia
+```
+
+## Testing Governance Workflows
+
+Before executing on mainnet, always test in fork mode:
+
+```bash
+# 1. Deploy (generates governance TXs)
+export FORK_NETWORK=arbitrumOne
+npx hardhat deploy --tags IssuanceAllocator:deploy --network fork
+
+# 2. Execute governance TXs automatically
+npx hardhat deploy:execute-governance --network fork
+
+# 3. Verify state
+npx hardhat deploy:status --network fork
+```
+
+For persistent fork testing (state survives across commands), see [LocalForkTesting.md](./LocalForkTesting.md).
+
+This tests the full governance workflow without touching real funds or requiring actual Safe signatures.
diff --git a/packages/deployment/docs/LocalForkTesting.md b/packages/deployment/docs/LocalForkTesting.md
new file mode 100644
index 000000000..f7247fba4
--- /dev/null
+++ b/packages/deployment/docs/LocalForkTesting.md
@@ -0,0 +1,170 @@
+# Local Fork Testing
+
+Fork testing allows simulating deployments against real network state without spending gas or requiring governance permissions.
+
+## Ephemeral Fork (single session)
+
+State is lost when the command exits. Good for quick testing.
+
+```bash
+# Run full deployment flow against forked arbitrumSepolia
+FORK_NETWORK=arbitrumSepolia npx hardhat deploy --tags sync,RewardsManager:deploy --network fork
+```
+
+## Persistent Fork (multiple sessions)
+
+State persists between commands. Good for iterative testing.
+
+```bash
+# Terminal 1 - start persistent forked node using anvil (Foundry)
+# Use --chain-id 31337 so hardhat's localhost network can connect
+anvil --fork-url https://sepolia-rollup.arbitrum.io/rpc --chain-id 31337
+```
+
+```bash
+# Terminal 2 - run deploys against it
+npx hardhat deploy:reset-fork --network localhost
+npx hardhat deploy:status --network localhost
+npx hardhat deploy --network localhost --skip-prompts --tags sync
+npx hardhat deploy --network localhost --skip-prompts --tags RewardsManager
+npx hardhat deploy:execute-governance --network localhost
+```
+
+Or for Arbitrum One:
+
+```bash
+anvil --fork-url https://arb1.arbitrum.io/rpc --chain-id 31337
+```
+
+**Important**:
+
+- Terminal 1: Use anvil (from Foundry) instead of `hardhat node` - Hardhat v3's node command doesn't properly support the `--fork` flag
+- Terminal 1: Use `--chain-id 31337` - anvil defaults to the forked chain's ID (421614) but hardhat's localhost expects 31337
+
+### Fork Network Detection
+
+The fork network (which chain is being forked) is **auto-detected** from anvil's RPC metadata. When you run against localhost, deploy scripts query `anvil_nodeInfo` to get the fork URL and match it against known network RPC hostnames.
+
+You can also set `FORK_NETWORK` explicitly to override auto-detection:
+
+```bash
+export FORK_NETWORK=arbitrumSepolia
+```
+
+**Safe on real networks**: `FORK_NETWORK` is automatically ignored when running against real networks (`--network arbitrumSepolia`, `--network arbitrumOne`). Fork mode only activates on local networks (localhost, fork, hardhat), so you don't need to unset `FORK_NETWORK` when switching between fork testing and real deployments.
+
+## Architecture
+
+```
+fork/ # Fork state (outside deployments/ to avoid rocketh conflicts)
+└── / # Rocketh environment (fork, localhost)
+ └── / # Fork source network
+ ├── horizon-addresses.json
+ ├── subgraph-service-addresses.json
+ ├── issuance-addresses.json
+ └── txs/
+ └── upgrade-*.json
+
+deployments/ # Managed by rocketh (deployment records, .chain files)
+└── /
+ └── ...
+```
+
+**Fork state organization:**
+
+- Fork state is stored under `fork///`
+ - Separate from `deployments/` so rocketh manages its own directory cleanly
+ - `` is the rocketh environment (fork, localhost)
+ - `` is the source network being forked (arbitrumSepolia, arbitrumOne)
+- This prevents addresses from wrong network being used if fork target changes
+- Address books and governance TXs are stored together
+- State persists across fork sessions (rocketh's data is ephemeral, this is not)
+
+## Key Points
+
+| Setting | Value | Purpose |
+| --------------------- | ---------------------------------- | -------------------------------------------------------------- |
+| `FORK_NETWORK` | `arbitrumSepolia` or `arbitrumOne` | Override auto-detected fork network (ignored on real networks) |
+| `SHOW_ADDRESSES` | `0`, `1` (default), or `2` | Address display: none/short/full |
+| `--network fork` | in-process EDR | Ephemeral, fast startup |
+| `--network localhost` | external node | Persistent state |
+
+## Configuration
+
+### Address Display
+
+Control how addresses are shown in sync output with `SHOW_ADDRESSES`:
+
+```bash
+# Show full addresses (default)
+SHOW_ADDRESSES=2 npx hardhat deploy --tags sync --network fork
+
+# Show truncated addresses (0x1234567890...)
+SHOW_ADDRESSES=1 npx hardhat deploy --tags sync --network fork
+
+# Hide addresses completely
+SHOW_ADDRESSES=0 npx hardhat deploy --tags sync --network fork
+```
+
+**Output examples:**
+
+```
+# SHOW_ADDRESSES=2 (default - full addresses)
+✓ SubgraphService @ 0xc24A3dAC5d06d771f657A48B20cE1a671B78f26b → 0xEc11f71070503D29098149195f95FEb1B1CeF93E
+
+# SHOW_ADDRESSES=1 (truncated)
+✓ SubgraphService @ 0xc24A3dAC... → 0xEc11f710...
+
+# SHOW_ADDRESSES=0 (hidden)
+✓ SubgraphService
+```
+
+## Reset Fork State
+
+```bash
+# Use the reset task (deletes entire network directory)
+npx hardhat deploy:reset-fork --network localhost
+# Or for ephemeral fork:
+npx hardhat deploy:reset-fork --network fork
+```
+
+## Limitations
+
+- **On-chain state**: Only persists with persistent node (anvil)
+- **rocketh deployment files**: Don't persist for forks (by design)
+- **Contract size**: Fork allows unlimited contract size (Arbitrum supports >24KB)
+
+## Prerequisites
+
+- **Foundry**: Install via `curl -L https://foundry.paradigm.xyz | bash && foundryup`
+
+## Local Network
+
+The `localNetwork` network targets a Graph local network at chain ID 1337.
+Unlike fork mode, contracts are deployed fresh from scratch.
+
+```bash
+# Deploy a single contract via its component lifecycle
+npx hardhat deploy --tags IssuanceAllocator,deploy --network localNetwork
+
+# Or run the full GIP-0088 upgrade phase
+npx hardhat deploy --tags GIP-0088:upgrade,deploy --network localNetwork
+```
+
+**Key differences from fork mode:**
+
+- Chain ID 1337 (not 31337)
+- No `FORK_NETWORK` env var needed
+- Address books use `addresses-local-network.json` files that the dev environment must provide
+- Deployer is also governor (direct execution, no governance batch files)
+- Uses standard test mnemonic (`test test test ... junk`)
+
+**Environment:**
+
+- RPC: `http://chain:8545` (override with `LOCAL_NETWORK_RPC`)
+- Address books must be populated by an upstream step that deploys Horizon + SubgraphService
+- This package then deploys contracts on top (e.g., issuance)
+
+## See Also
+
+- [GovernanceWorkflow.md](./GovernanceWorkflow.md) - Production deployment flow
diff --git a/packages/deployment/docs/SyncBytecodeDetectionFix.md b/packages/deployment/docs/SyncBytecodeDetectionFix.md
new file mode 100644
index 000000000..5c4498fd1
--- /dev/null
+++ b/packages/deployment/docs/SyncBytecodeDetectionFix.md
@@ -0,0 +1,149 @@
+# Sync Bytecode Detection Fix
+
+## Issues Identified
+
+### Issue 1: Local Bytecode Changes Ignored
+
+**Problem**: Deploy incorrectly reported "implementation unchanged" when local bytecode had actually changed.
+
+**Evidence**:
+
+```
+Local artifact: 0x9c25d2f93e6a2a34cc19d00224872e288a8392d5d99b2df680b7e978d148d450
+On-chain: 0xfafdeb48fae37e277e007e7b977f3cd124065ac1c27ed5208982c2965cf07008
+Address book: 0x4805a902756c8f4421c2a2710dcc76885ffd01d7777bbe6cab010fe9748b7efa
+```
+
+All three hashes are different, yet deploy said "unchanged", meaning local changes would be ignored.
+
+### Issue 2: Confusing Sync Behavior
+
+**Problem**: Sync showed "code changed" but didn't handle the state appropriately:
+
+1. Showed △ (code changed) indicator
+2. But didn't sync implementation to rocketh
+3. Saved proxy record with wrong bytecode
+4. This confused rocketh's change detection
+
+## Root Causes
+
+### Cause 1: Missing/Stale Bytecode Hash
+
+When the address book had no bytecode hash (or wrong hash):
+
+- Sync detected "code changed" ([sync-utils.ts:475-477](../lib/sync-utils.ts#L475-L477))
+- But only synced to rocketh if hash matched ([sync-utils.ts:653](../lib/sync-utils.ts#L653))
+- This left rocketh with incomplete/wrong state
+
+### Cause 2: Wrong Bytecode Stored for Proxy
+
+The sync step saved the **implementation's bytecode** under the **proxy's deployment record**:
+
+- Lines 508-532: Created proxy record with implementation artifact bytecode
+- This is wrong - proxy should have its own bytecode (or none)
+- Rocketh then compared wrong bytecode and gave incorrect results
+
+## Fixes Applied
+
+### Fix 1: Hash Comparison and Stale Record Cleanup ([sync-utils.ts:645-679](../lib/sync-utils.ts#L645-L679))
+
+When sync processes an implementation:
+
+1. **Compare local artifact hash to address-book-stored hash**
+2. **If hashes match**: sync the implementation record to rocketh normally
+3. **If hashes don't match**: overwrite any stale rocketh record with empty bytecode, forcing a fresh deployment
+
+ ```typescript
+ if (storedHash && localHash) {
+ hashMatches = storedHash === localHash
+ }
+
+ // Clean up stale rocketh record if hash doesn't match
+ if (!hashMatches && existingImpl) {
+ // Overwrite stale record with empty bytecode - forces fresh deployment
+ await env.save(`${spec.name}_Implementation`, {
+ address: existingImpl.address,
+ bytecode: '0x',
+ deployedBytecode: undefined,
+ ...
+ })
+ }
+ ```
+
+This ensures rocketh correctly detects when local code has changed and triggers a new deployment.
+
+### Fix 2: Don't Store Wrong Bytecode for Proxy ([sync-utils.ts:508-532](../lib/sync-utils.ts#L508-L532))
+
+Changed proxy record creation to **NOT include implementation bytecode**:
+
+```typescript
+// Before:
+bytecode: artifact.bytecode // ← Wrong! This is implementation bytecode
+deployedBytecode: artifact.deployedBytecode
+
+// After:
+bytecode: '0x' // ← Correct! Proxy record doesn't need bytecode
+deployedBytecode: undefined
+```
+
+This ensures rocketh only uses implementation bytecode for the actual implementation record.
+
+## Expected Behavior After Fix
+
+### Scenario 1: Local Matches Address Book
+
+When local artifact hash matches the stored hash, sync proceeds normally and rocketh
+correctly reports the implementation as unchanged.
+
+### Scenario 2: Local Code Changed
+
+**Before**:
+
+```
+△ SubgraphService @ 0xc24A3dAC... → 0x2af1b0ed... (code changed)
+✓ SubgraphService implementation unchanged ← WRONG!
+```
+
+**After**:
+
+```
+△ SubgraphService @ 0xc24A3dAC... → 0x2af1b0ed... (local code changed)
+📋 New SubgraphService implementation deployed: 0x... ← NEW!
+ Storing as pending implementation...
+```
+
+Deploy correctly detects the change and deploys new implementation.
+
+### Scenario 3: Stale Rocketh Record
+
+When the hash doesn't match and a stale rocketh record exists, sync overwrites it
+with empty bytecode. This forces the next deploy to create a fresh implementation
+record rather than incorrectly reporting "unchanged".
+
+## Testing
+
+To verify the fix works:
+
+```bash
+# Clean build
+cd packages/deployment
+pnpm build
+
+# Run sync - should now show clearer messages
+npx hardhat deploy --skip-prompts --network arbitrumSepolia --tags sync
+
+# Run deploy - should correctly detect local changes
+npx hardhat deploy --skip-prompts --network arbitrumSepolia --tags SubgraphService
+```
+
+## Migration Notes
+
+- **No manual migration needed** - stale rocketh records are cleaned up automatically
+- First sync after fix will detect hash mismatches and clear stale records
+- Subsequent deploys will create fresh implementation records
+
+## Related Files
+
+- [sync-utils.ts](../lib/sync-utils.ts) - Main fix implementation
+- [deploy-implementation.ts](../lib/deploy-implementation.ts) - Deploy logic (unchanged, now works correctly)
+- [check-bytecode.ts](../scripts/check-bytecode.ts) - Diagnostic script for manual verification
diff --git a/packages/deployment/docs/SyncSpecification.md b/packages/deployment/docs/SyncSpecification.md
new file mode 100644
index 000000000..92e146636
--- /dev/null
+++ b/packages/deployment/docs/SyncSpecification.md
@@ -0,0 +1,285 @@
+# Sync Specification
+
+This document defines the bidirectional sync behavior between address books and rocketh deployment records.
+
+## Data Structures
+
+### Address Book Entry (Proxied Contract)
+
+```json
+{
+ "ContractName": {
+ "address": "0x...", // Proxy address
+ "proxy": "graph|transparent",
+ "proxyAdmin": "0x...", // Inline or via separate entry
+ "implementation": "0x...", // Current on-chain implementation
+ "implementationDeployment": {
+ "txHash": "0x...",
+ "argsData": "0x...",
+ "bytecodeHash": "0x...", // Hash of deployed bytecode (metadata stripped)
+ "blockNumber": 12345
+ },
+ "pendingImplementation": {
+ // Optional: deployed but not yet upgraded
+ "address": "0x...",
+ "deployment": {
+ // Same structure as implementationDeployment
+ "txHash": "0x...",
+ "argsData": "0x...",
+ "bytecodeHash": "0x...",
+ "blockNumber": 12346
+ }
+ }
+ }
+}
+```
+
+### Rocketh Deployment Record
+
+```typescript
+{
+ address: "0x...",
+ abi: [...],
+ bytecode: "0x...", // Creation bytecode
+ deployedBytecode: "0x...", // Runtime bytecode (for change detection)
+ argsData: "0x...", // Encoded constructor args
+ metadata: "...",
+ transaction?: { hash: "0x..." },
+ receipt?: { blockNumber: 12345 }
+}
+```
+
+### Rocketh Record Names
+
+For a proxied contract `ContractName`:
+
+- `ContractName` - The proxy contract
+- `ContractName_Proxy` - Alias for proxy (some patterns use this)
+- `ContractName_Implementation` - The implementation contract
+- `ContractName_ProxyAdmin` - The proxy admin
+
+## Sync Direction Rules
+
+### Address Book → Rocketh
+
+**When**: Sync step runs, address book has data rocketh doesn't have.
+
+**What syncs**:
+
+- Proxy address → `ContractName` and `ContractName_Proxy`
+- Proxy admin address → `ContractName_ProxyAdmin`
+- Implementation address → `ContractName_Implementation`
+
+**Implementation address selection**:
+
+1. If `pendingImplementation.address` exists → use pending address
+2. Else → use `implementation` address
+
+**Bytecode hash gating**:
+
+- **Only sync implementation if `bytecodeHash` matches local artifact**
+- No stored hash → don't sync (can't verify consistency)
+- Hash mismatch → don't sync, add "impl outdated" note
+
+**Rationale**: Syncing stale bytecode to rocketh would make rocketh think the deployed code matches local, preventing necessary redeployment.
+
+### Rocketh → Address Book (Backfill)
+
+**When**: Rocketh has deployment metadata that address book lacks.
+
+**What backfills**:
+
+- `txHash`, `argsData`, `bytecodeHash`, `blockNumber`
+
+**Determining "newer"** (blockNumber comparison):
+
+1. Address book has no metadata → rocketh is newer
+2. Rocketh has blockNumber, address book doesn't → rocketh is newer
+3. Rocketh blockNumber > address book blockNumber → rocketh is newer
+
+**Where to write**:
+
+- For current implementation → `implementationDeployment`
+- For pending implementation → `pendingImplementation.deployment`
+
+## Implementation Lifecycle
+
+### State Transitions
+
+```
+┌─────────────────────────────────────────┐
+│ Initial Deployment │
+│ (deploy creates implementation) │
+└──────────────────┬──────────────────────┘
+ │ deploy script
+ ▼
+┌─────────────────────────────────────────┐
+│ implementation: 0xIMPL │
+│ implementationDeployment: {...} │
+└──────────────────┬──────────────────────┘
+ │ code changes, deploy new impl
+ ▼
+┌─────────────────────────────────────────┐
+│ implementation: 0xIMPL │ (unchanged until upgrade)
+│ implementationDeployment: {...} │
+│ pendingImplementation: { │ (new impl awaiting governance)
+│ address: 0xNEW, │
+│ deployment: {...} │
+│ } │
+└──────────────────┬──────────────────────┘
+ │ governance upgrade TX executed
+ ▼
+┌─────────────────────────────────────────┐
+│ implementation: 0xNEW │ (promoted from pending)
+│ implementationDeployment: {...} │ (metadata from pending)
+│ (pendingImplementation cleared) │
+└─────────────────────────────────────────┘
+```
+
+### Sync Sequence (Logical Order)
+
+When sync runs, execute in this order:
+
+#### Step 1: Reconcile on-chain address
+
+```
+IF on-chain impl != address book impl:
+ → Update address book impl to match on-chain
+ → Wipe stale implementationDeployment (address changed, metadata invalid)
+ → Note: This handles external upgrades (from other deployment systems)
+```
+
+#### Step 2: Promote pending if upgraded
+
+```
+IF pendingImplementation.address == implementation (on-chain):
+ → Move pendingImplementation.deployment → implementationDeployment
+ → Clear pendingImplementation
+ → Add "upgraded" sync note
+```
+
+#### Step 3: Sync rocketh ↔ address book
+
+After steps 1-2, address book has correct addresses. Now sync:
+
+- Pick implementation to sync (pending if exists, else current)
+- If bytecodeHash matches local → sync to rocketh
+- If rocketh has newer metadata → backfill to address book
+
+This sequence ensures:
+
+- Address book always reflects on-chain reality first
+- Pending metadata is preserved when promoted
+- Rocketh sync naturally goes to the correct location
+
+## Implementation Sync Decision Tree
+
+```
+ ┌─────────────────┐
+ │ Has implAddress?│
+ └────────┬────────┘
+ │
+ ┌─────────────┴─────────────┐
+ │ No │ Yes
+ ▼ ▼
+ ┌──────────┐ ┌─────────────────┐
+ │ Skip │ │ Get storedHash │
+ │ (no impl)│ │ from deployment │
+ └──────────┘ └────────┬────────┘
+ │
+ ┌────────────┴────────────┐
+ │ storedHash exists? │
+ └────────────┬────────────┘
+ │
+ ┌────────────────────┴────────────────────┐
+ │ No │ Yes
+ ▼ ▼
+ ┌──────────────┐ ┌─────────────────────┐
+ │ Don't sync │ │ Compare to local │
+ │ (unverified) │ │ artifact hash │
+ └──────────────┘ └──────────┬──────────┘
+ │
+ ┌────────────────────┴────────────────────┐
+ │ Match? │
+ └────────────────────┬────────────────────┘
+ │
+ ┌──────────────────────────────┴──────────────────────────────┐
+ │ Yes │ No
+ ▼ ▼
+ ┌────────────────────┐ ┌─────────────────────┐
+ │ Sync to rocketh │ │ Don't sync │
+ │ + backfill if newer│ │ Add "impl outdated" │
+ └────────────────────┘ └─────────────────────┘
+```
+
+## Backfill Decision (Rocketh → Address Book)
+
+Only runs after successful sync (hash matched). Determines which direction has newer data:
+
+```
+ ┌────────────────────────────────┐
+ │ Rocketh has argsData != '0x'? │
+ └───────────────┬────────────────┘
+ │
+ ┌─────────────┴─────────────┐
+ │ No │ Yes
+ ▼ ▼
+ ┌──────────┐ ┌───────────────────────────┐
+ │ No │ │ Address book has metadata?│
+ │ backfill │ └─────────────┬─────────────┘
+ └──────────┘ │
+ ┌────────────────┴────────────────┐
+ │ No │ Yes
+ ▼ ▼
+ ┌─────────────────┐ ┌─────────────────────────────┐
+ │ Backfill │ │ Compare blockNumbers │
+ │ (book is empty) │ └──────────────┬──────────────┘
+ └─────────────────┘ │
+ ┌─────────────────┴─────────────────┐
+ │ rocketh.blockNumber > │
+ │ book.blockNumber? │
+ └─────────────────┬─────────────────┘
+ │
+ ┌──────────────────────┴──────────────────────┐
+ │ Yes │ No
+ ▼ ▼
+ ┌─────────────────┐ ┌──────────────────┐
+ │ Backfill │ │ No backfill │
+ │ (rocketh newer) │ │ (book is newer) │
+ └─────────────────┘ └──────────────────┘
+```
+
+## Summary
+
+| Scenario | Action |
+| --------------------------- | ----------------------------------------- |
+| No impl address | Skip |
+| Impl exists, no stored hash | Don't sync (unverified) |
+| Impl exists, hash mismatch | Don't sync, note "impl outdated" |
+| Impl exists, hash matches | Sync to rocketh |
+| After sync, rocketh newer | Backfill to address book |
+| Pending upgraded on-chain | Promote pending to current, clear pending |
+
+## Key Invariants
+
+1. **Bytecode hash is required for sync** - Without it, we can't verify the implementation matches local artifacts
+2. **Pending takes precedence** - If pending exists with matching hash, sync pending (not current)
+3. **On-chain is authoritative for addresses** - Sync reads actual implementation from chain
+4. **BlockNumber determines recency** - Higher block number = newer deployment
+5. **Backfill goes to correct location** - Current impl → `implementationDeployment`, pending → `pendingImplementation.deployment`
+
+## Future Enhancements
+
+### Upgrade Timing Tracking
+
+Currently, deployment metadata tracks when the implementation was _deployed_ (`blockNumber`, `timestamp`), but not when the proxy was _upgraded_ to use it. These are separate events:
+
+1. **Deploy** - New implementation contract created (currently tracked)
+2. **Upgrade** - Proxy switched to use the new implementation (not tracked)
+
+A future enhancement could add `upgradedAt: { blockNumber, timestamp }` to `implementationDeployment` to capture when the proxy actually started using the implementation. This would require either:
+
+- Querying the chain for the upgrade transaction when promoting pending
+- Recording detection time (less accurate but simpler)
+
+This information would be useful for audit trails and understanding the timeline between deployment and activation.
diff --git a/packages/deployment/docs/address-book/LayerAnalysis.md b/packages/deployment/docs/address-book/LayerAnalysis.md
new file mode 100644
index 000000000..e1bd8399a
--- /dev/null
+++ b/packages/deployment/docs/address-book/LayerAnalysis.md
@@ -0,0 +1,47 @@
+# Layer Analysis: Future Work
+
+## Current State
+
+**Layer 1 (AddressBookOps)**: ✅ Complete - pure local storage operations.
+
+## Potential Future Layers
+
+### Layer 2: Network-Linked Operations
+
+Combine on-chain queries with address book updates. Currently scattered in `sync-utils.ts`.
+
+```typescript
+class NetworkAddressBookOps {
+ constructor(
+ private ops: AddressBookOps,
+ private client: PublicClient,
+ ) {}
+
+ async syncImplementationFromChain(name, proxyAddress, proxyType): Promise {
+ const impl = await getOnChainImplementation(this.client, proxyAddress, proxyType)
+ this.ops.setImplementationAndClearIfMatches(name, impl)
+ }
+
+ async syncProxyAdminFromChain(name, proxyAddress): Promise {
+ const admin = await getOnChainProxyAdmin(this.client, proxyAddress)
+ this.ops.setProxyAdmin(name, admin)
+ }
+}
+```
+
+### Layer 3+: Higher-Level Abstractions
+
+| Layer | Purpose | Status |
+| ------- | ----------------------------- | ----------------------------- |
+| Layer 3 | Rocketh state sync | Exists in `sync-utils.ts` |
+| Layer 4 | Deploy + address book update | Scattered in deploy scripts |
+| Layer 5 | Integrated deploy-and-sync | Does not exist |
+| Layer 6 | State assessment + governance | Partial in `upgrade-utils.ts` |
+
+## Design Rationale
+
+Layer 1 is pure local storage because:
+
+- **Testability**: No mocked RPC clients needed
+- **Flexibility**: Callers choose when/how to fetch on-chain data
+- **Composability**: Higher layers can wrap Layer 1
diff --git a/packages/deployment/docs/address-book/README.md b/packages/deployment/docs/address-book/README.md
new file mode 100644
index 000000000..c7ffe7616
--- /dev/null
+++ b/packages/deployment/docs/address-book/README.md
@@ -0,0 +1,58 @@
+# AddressBook Operations
+
+## Overview
+
+`AddressBookOps` wraps the base `AddressBook` class from toolshed, providing data-centric operations for managing contract addresses. Deployment code only sees `AddressBookOps` - the base class is internal.
+
+**Layer 1 only**: Pure local storage operations with no on-chain interactions.
+
+## Usage
+
+```typescript
+import { graph } from '../rocketh/deploy.js'
+
+// Get AddressBookOps directly from factory functions
+const addressBook = graph.getIssuanceAddressBook(chainId)
+
+// Write operations
+addressBook.setProxy('RewardsManager', proxyAddr, implAddr, adminAddr, 'transparent')
+addressBook.setPendingImplementationWithMetadata('RewardsManager', newImplAddr, deploymentMetadata)
+
+// Read operations
+const entry = addressBook.getEntry('RewardsManager')
+```
+
+## API
+
+### Write Operations
+
+| Method | Purpose |
+| ------------------------------------------------------------ | ---------------------------------------- |
+| `setContract(name, address)` | Non-proxied contract |
+| `setProxy(name, proxy, impl, admin, type)` | All proxy fields |
+| `setImplementation(name, impl)` | Active implementation |
+| `setProxyAdmin(name, admin)` | Proxy admin |
+| `setPendingImplementationWithMetadata(name, impl, metadata)` | Pending implementation |
+| `promotePendingImplementation(name)` | Move pending → active |
+| `clearPendingImplementation(name)` | Clear pending |
+| `setImplementationAndClearIfMatches(name, impl)` | Set impl + auto-clear pending if matches |
+
+### Read Operations
+
+| Method | Purpose |
+| ------------------------------ | ------------------------------------ |
+| `getEntry(name)` | Get address book entry |
+| `entryExists(name)` | Check if entry exists |
+| `listPendingImplementations()` | List contracts with pending upgrades |
+| `isContractName(name)` | Type predicate for contract names |
+
+### Types
+
+```typescript
+// For union types where contract name would be inferred as `never`
+type AnyAddressBookOps = AddressBookOps
+```
+
+## Next Steps
+
+See [LayerAnalysis.md](./LayerAnalysis.md) for potential Layer 2 (network-linked operations) design.
diff --git a/packages/deployment/docs/deploy/ImplementationPrinciples.md b/packages/deployment/docs/deploy/ImplementationPrinciples.md
new file mode 100644
index 000000000..9226611a9
--- /dev/null
+++ b/packages/deployment/docs/deploy/ImplementationPrinciples.md
@@ -0,0 +1,610 @@
+# Deployment Script Implementation Principles
+
+This document defines the core principles and patterns for writing deployment scripts. Found in the `deploy/` directory where you work on these scripts.
+
+## Script Numbering and Structure
+
+### Principle: Numbered Scripts Follow Standard Objectives
+
+**Rule**: Component deployments use numbered scripts (`01_*.ts`, `02_*.ts`, etc.) with standardized objectives.
+
+**Numbering principles:**
+
+1. **Script names describe what is done** - Filename indicates the action (e.g., `01_deploy.ts`, `02_upgrade.ts`, `03_configure.ts`)
+2. **Avoid redundant naming** - Don't repeat information in number and name (use `01_deploy.ts`, not `01_deploy_contract.ts`)
+3. **Final script is always 09_end.ts** - Standardized end state aggregate provides completion tag, intermediate steps (01-08) vary by component complexity
+
+**Standard step objectives:**
+
+- **01_deploy.ts** - Deploy proxy + implementation, initialize with deployer
+ - Sync the contract being deployed (and any contracts it reads) immediately
+ before acting via `syncComponentFromRegistry` /
+ `syncComponentsFromRegistry`. The script factories
+ (`createProxyDeployModule`, `createImplementationDeployModule`,
+ `createUpgradeModule`, etc.) handle this automatically.
+ - For a global pre-deploy reconciliation, use `npx hardhat deploy:sync`
+ explicitly — it is no longer pulled in as an automatic dependency.
+ - Each script should declare its own prerequisites explicitly, not rely on transitive dependencies
+- **02_upgrade.ts** - Handle proxy upgrades via governance (generates TX batch)
+- **04_configure.ts** - Deployer-only configure: role grants and params on contracts where the deployer is governor
+- **05_transfer_governance.ts** - Revoke deployer GOVERNOR_ROLE; transfer ProxyAdmin to protocol governor
+- **06_integrate.ts** (optional) - Wire the contract into the rest of the protocol
+- **09_end.ts** - End state aggregate (only has dependencies and verification, no execution)
+- **10_status.ts** - Read-only status display (see below)
+
+The `03_*` slot is intentionally left empty so that `02_upgrade` can be inserted as a clearly distinct phase without renumbering. The `04_configure` numbering is the actual convention used throughout the tree.
+
+### Principle: Status Scripts Are Read-Only
+
+**Rule**: `10_status.ts` scripts MUST be purely read-only. They MUST NOT make on-chain changes, write transactions, or modify any state.
+
+**Why**: When `--tags ` is run without an action verb, only status scripts execute. Users rely on this for safe inspection of deployment state at any time — during planning, mid-deployment, and in production. Any mutation in a status script would violate this trust and could cause unintended state changes.
+
+**How it works**:
+
+1. Status scripts use `createStatusModule()`, which gates on `noTagsRequested()` — they only run when tags are present but no action verb is included
+2. Stage scripts (01-08) use `shouldSkipAction(verb)` — they skip when their action verb is absent from `--tags`
+3. Combined: `--tags GIP-0088` alone runs only `10_status.ts` (status reads on-chain directly and does not need a global sync first)
+
+**Pattern**:
+
+```typescript
+// Component status — delegates to showDetailedComponentStatus (reads only)
+export default createStatusModule(Contracts.issuance.IssuanceAllocator)
+
+// Goal status — custom handler, must only use readContract/getCode
+export default createStatusModule(GoalTags.GIP_0088, async (env) => {
+ const client = graph.getPublicClient(env) as PublicClient
+ // ✅ Read on-chain state and display
+ const value = await client.readContract({ ... })
+ env.showMessage(` ${value ? '✓' : '✗'} check description`)
+ // ❌ NEVER: execute(), tx(), deploy(), process.exit(1), TxBuilder
+})
+```
+
+**Invariant**: If a script is named `10_status.ts`, it contains zero writes. No exceptions.
+
+#### Example: RewardsEligibilityOracle (simple - 4 steps)
+
+```
+01_deploy.ts - Deploy proxy + implementation
+02_upgrade.ts - Handle proxy upgrades (governance TX batch)
+04_configure.ts - Deployer-only configure (params, role grants)
+09_end.ts - End state aggregate
+10_status.ts - Read-only status display
+```
+
+#### Example: RewardsEligibilityOracle (full lifecycle)
+
+```
+01_deploy.ts - Deploy proxy + implementation
+02_upgrade.ts - Handle proxy upgrades
+04_configure.ts - Configure params + role grants
+05_transfer_governance.ts - Revoke deployer role + transfer ProxyAdmin
+06_integrate.ts - Wire into RewardsManager (governance TX)
+09_end.ts - End state aggregate
+10_status.ts - Read-only status display
+```
+
+**Note:** Step `03_*` is intentionally left empty so `02_upgrade` stays a clearly separate phase. Steps 04-08 are flexible and vary by component. Always use `09_end.ts` for the aggregate and `10_status.ts` for read-only status.
+
+#### Tag structure in deployment-tags.ts
+
+```typescript
+// Component tags are PascalCase contract names matching the registry
+ComponentTags = {
+ REWARDS_ELIGIBILITY_A: 'RewardsEligibilityOracleA',
+ // ...
+}
+
+// Action verbs are appended via --tags Component,verb
+// e.g. --tags RewardsEligibilityOracleA,deploy
+```
+
+## Exit Codes and Flow Control
+
+### Principle: Scripts Are Goal-Seeking, Not Sequential Steps
+
+**Rule**: Each script checks its own preconditions and skips if not met. Scripts return (not exit) when work cannot proceed — subsequent scripts check their own state independently.
+
+**Rationale**: Scripts run in sequence but must not assume a particular starting state. Each script is idempotent and goal-seeking: it checks on-chain state, does what's needed, and returns.
+
+**Examples**:
+
+```typescript
+// CORRECT: Save governance TX and return (allows subsequent scripts to run)
+saveGovernanceTx(env, builder, `ContractName activation`)
+// Returns — subsequent scripts check their own preconditions
+
+// CORRECT: Skip when precondition not met
+if (!prerequisiteMet) {
+ env.showMessage(' ○ Prerequisite not met — skipping')
+ return
+}
+
+// CORRECT: Use shared precondition check to skip if done
+const precondition = await checkIAConfigured(client, ia.address, rm.address)
+if (precondition.done) {
+ env.showMessage('✅ Already configured')
+ return
+}
+```
+
+### When to Use Exit Code 1
+
+Use `process.exit(1)` only for:
+
+- **Migration invariant violations** (data corruption risk, e.g. IA rate != RM rate before connection)
+- **Verification failures** in `09_end` scripts
+- **Sync failures** (can't proceed without address books)
+
+Do NOT use `process.exit(1)` for:
+
+- Governance TX generation (use `saveGovernanceTx` which returns)
+- Preconditions not met (return/skip, let subsequent scripts check their own preconditions)
+- Configuration already correct (idempotent check passed)
+- Script successfully completed its work
+
+### When to Throw Exceptions
+
+Throw exceptions for:
+
+- Unexpected errors (network failures, contract not found)
+- Invalid configuration
+- Programming errors
+- Truly exceptional conditions
+
+```typescript
+// Exception for unexpected error
+if (!deployer) {
+ throw new Error('No deployer account configured')
+}
+
+// Clean exit for expected state
+if (!upgraded) {
+ env.showMessage('Prerequisite not met')
+ process.exit(1)
+}
+```
+
+## Idempotency
+
+### Principle: All Deployment Steps Must Be Idempotent
+
+**Rule**: Every deployment script MUST check current on-chain state and skip actions already completed.
+
+**Pattern**:
+
+```typescript
+const func: DeployScriptModule = async (env) => {
+ // 1. Check current state
+ const checks = {
+ configA: false,
+ configB: false,
+ }
+
+ // Read on-chain state
+ checks.configA = await readCurrentStateA()
+ checks.configB = await readCurrentStateB()
+
+ // 2. If all checks pass, exit early
+ if (Object.values(checks).every(Boolean)) {
+ env.showMessage('✅ Already configured\n')
+ return
+ }
+
+ // 3. Execute only missing steps
+ if (!checks.configA) {
+ await executeConfigA()
+ }
+ if (!checks.configB) {
+ await executeConfigB()
+ }
+}
+```
+
+## Import Patterns
+
+### Principle: Use Package Imports for Shared Utilities
+
+**Rule**: Import shared utilities from `@graphprotocol/deployment` package, not relative paths.
+
+**Why**: Package imports are clearer, more maintainable, and work correctly with TypeScript path mapping.
+
+**Pattern**:
+
+```typescript
+import type { DeployScriptModule } from '@rocketh/core/types'
+import type { PublicClient } from 'viem'
+
+// Deployment helpers (rocketh specific)
+import { deploy, execute, read, tx, graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+
+// Contract utilities
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { requireContract, requireContracts } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+
+// Governance utilities
+import { getGovernor, getPauseGuardian } from '@graphprotocol/deployment/lib/controller-utils.js'
+import { TxBuilder } from '@graphprotocol/deployment/lib/tx-builder.js'
+import { getGovernanceTxDir } from '@graphprotocol/deployment/lib/execute-governance.js'
+
+// Contract checks
+import { requireRewardsManagerUpgraded } from '@graphprotocol/deployment/lib/contract-checks.js'
+
+// ABIs
+import { REWARDS_MANAGER_ABI, GRAPH_TOKEN_ABI } from '@graphprotocol/deployment/lib/abis.js'
+
+// Tags
+import { Tags, ComponentTags, actionTag } from '@graphprotocol/deployment/lib/deployment-tags.js'
+```
+
+**Anti-pattern** (don't do this):
+
+```typescript
+// ❌ Relative imports make code hard to move and unclear about package boundaries
+import { Contracts } from '../../lib/contract-registry.js'
+import { TxBuilder } from '../../lib/tx-builder.js'
+```
+
+## Shared Utilities
+
+### Principle: Use Shared Functions for Common Patterns
+
+**Rule**: Always use shared utilities instead of duplicating code.
+
+### Deployer Pattern
+
+```typescript
+import { requireDeployer } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+
+// ✅ GOOD: Use utility
+const deployer = requireDeployer(env)
+
+// ❌ BAD: Manual check repeated everywhere
+const deployer = env.namedAccounts.deployer
+if (!deployer) {
+ throw new Error('No deployer account configured')
+}
+```
+
+### Address Book Pattern
+
+```typescript
+// Get target chain ID (handles fork mode)
+const targetChainId = graph.getTargetChainId()
+
+// Get address books (fork-aware)
+const horizonAddressBook = graph.getHorizonAddressBook(targetChainId)
+const issuanceAddressBook = graph.getIssuanceAddressBook(targetChainId)
+
+// Get contract from registry
+const contract = requireContract(env, Contracts.RewardsManager)
+```
+
+### Viem Client Pattern
+
+```typescript
+// Get viem public client
+const client = graph.getPublicClient(env) as PublicClient
+
+// Read contract state
+const value = (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: CONTRACT_ABI,
+ functionName: 'someFunction',
+ args: [arg1, arg2],
+})) as ReturnType
+```
+
+## Governance Transaction Generation
+
+### Principle: Standard Pattern for Governance TXs
+
+**Pattern**:
+
+```typescript
+import {
+ createGovernanceTxBuilder,
+ executeTxBatchDirect,
+ saveGovernanceTx,
+} from '@graphprotocol/deployment/lib/execute-governance.js'
+import { canSignAsGovernor } from '@graphprotocol/deployment/lib/controller-utils.js'
+
+const { governor, canSign } = await canSignAsGovernor(env)
+
+// Create TX builder (handles chainId, outputDir, template automatically)
+const builder = await createGovernanceTxBuilder(env, `action-${contractName}`, {
+ name: 'Human Readable Name',
+ description: 'What this TX batch does',
+})
+
+// Add transactions
+builder.addTx({ to: contractAddress, value: '0', data: encodedCalldata })
+env.showMessage(` + ContractName.functionName(args)`)
+
+// Execute directly if possible, otherwise save for governance
+if (canSign) {
+ await executeTxBatchDirect(env, builder, governor)
+} else {
+ saveGovernanceTx(env, builder, `${contractName} activation`)
+}
+// Returns — does NOT exit. Subsequent scripts check their own preconditions.
+```
+
+### Metadata Standards
+
+All governance TX batches should include descriptive metadata:
+
+```typescript
+meta: {
+ name: 'Contract Upgrade', // Short, human-readable title
+ description: 'Upgrade ContractName proxy to new implementation', // What it does
+}
+```
+
+## Fork Mode Patterns
+
+### Principle: Scripts Must Work in Both Fork and Production Modes
+
+**Pattern**:
+
+```typescript
+// Use target chain ID (handles fork)
+const targetChainId = graph.getTargetChainId()
+
+// Use fork-aware address books
+const addressBook = graph.getIssuanceAddressBook(targetChainId)
+
+// Check if in fork mode (optional - for conditional behavior)
+const isFork = graph.isForkMode()
+
+// Governance TX directory is fork-aware
+const outputDir = getGovernanceTxDir(env.name)
+// Returns: fork/localhost/arbitrumOne/txs/ (fork)
+// or txs/arbitrumOne/ (production)
+```
+
+## Script Structure
+
+### Standard Script Template
+
+```typescript
+import type { DeployScriptModule } from '@rocketh/core/types'
+import type { PublicClient } from 'viem'
+
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { Tags, ComponentTags } from '@graphprotocol/deployment/lib/deployment-tags.js'
+import { requireContracts, requireDeployer } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
+
+/**
+ * Script purpose and description
+ *
+ * Details about what this script does.
+ * Prerequisites if any.
+ *
+ * Usage:
+ * npx hardhat deploy --tags script-tag --network
+ */
+const func: DeployScriptModule = async (env) => {
+ // 1. Get named accounts
+ const deployer = requireDeployer(env)
+
+ // 2. Get required contracts
+ const [contractA, contractB] = requireContracts(env, [Contracts.ContractA, Contracts.ContractB])
+
+ // 3. Get viem client
+ const client = graph.getPublicClient(env) as PublicClient
+
+ // 4. Check prerequisites
+ await requireSomePrerequisite(env)
+
+ // 5. Show script header
+ env.showMessage('\n========== Script Name ==========')
+ env.showMessage(`Contract: ${contractA.address}\n`)
+
+ // 6. Check current state (idempotency)
+ const checks = {
+ checkA: await checkStateA(),
+ checkB: await checkStateB(),
+ }
+
+ if (Object.values(checks).every(Boolean)) {
+ env.showMessage('✅ Already configured\n')
+ return
+ }
+
+ // 7. Execute missing steps
+ if (!checks.checkA) {
+ await executeA()
+ }
+
+ // 8. Show completion
+ env.showMessage('\n✅ Complete!\n')
+}
+
+// 9. Configure tags and dependencies
+func.tags = Tags.scriptTag
+func.dependencies = [ComponentTags.PREREQUISITE]
+
+export default func
+```
+
+## Error Messages
+
+### Principle: Clear, Actionable Error Messages with Dynamic Values
+
+**Rule**: Use contract names from registry and tag constants - never hardcode them in messages.
+
+**Why**: Hardcoded values break when contracts are renamed or tags change, and make code harder to maintain.
+
+**Pattern**:
+
+```typescript
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+// ✅ GOOD: Uses contract name from registry
+const contract = Contracts.RewardsManager
+env.showMessage(`\n❌ ${contract.name} has not been upgraded yet`)
+env.showMessage(` The on-chain ${contract.name} does not support IERC165/IIssuanceTarget`)
+env.showMessage(` Run: npx hardhat deploy:execute-governance --network ${env.name}`)
+env.showMessage(` (This will execute the pending ${contract.name} upgrade TX)\n`)
+
+// ❌ BAD: Hardcoded contract name
+env.showMessage(`\n❌ RewardsManager has not been upgraded yet`)
+env.showMessage(` Run: npx hardhat deploy:execute-governance --network ${env.name}`)
+
+// ✅ GOOD: Shows what was found vs expected
+env.showMessage(` IA integrated: ${checks.iaIntegrated ? '✓' : '✗'} (current: ${currentIA})`)
+
+// ❌ BAD: Vague error without context
+env.showMessage('⚠️ Something is not ready')
+
+// ❌ BAD: Just shows boolean without explanation
+env.showMessage(` IA integrated: ${checks.iaIntegrated}`)
+```
+
+## Contract Registry
+
+### Principle: Use Contract Registry for Type Safety
+
+**Pattern**:
+
+```typescript
+import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+import { requireContract } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
+
+// GOOD: Type-safe, refactorable, discoverable
+const contract = requireContract(env, Contracts.RewardsManager)
+
+// BAD: String literal (typos, hard to refactor)
+const contract = requireContract(env, 'RewardsManager')
+
+// Registry provides:
+// - Type safety
+// - Metadata (proxy type, address book, proxy admin)
+// - Discoverability (IDE autocomplete)
+```
+
+## Documentation
+
+### Principle: Every Script Has Clear Documentation
+
+**Requirements**:
+
+```typescript
+/**
+ * Brief description of what this script does
+ *
+ * Longer description with:
+ * - Prerequisites
+ * - What actions it performs
+ * - Whether it's idempotent
+ * - Whether it generates governance TXs
+ *
+ * Corresponds to: IssuanceAllocatorDeployment.md step X (if applicable)
+ *
+ * Usage:
+ * npx hardhat deploy --tags script-tag --network
+ * FORK_NETWORK=arbitrumOne npx hardhat deploy --tags script-tag --network localhost
+ */
+```
+
+### Principle: Deployment Documentation in docs/deploy/
+
+**Rule**: Deployment documentation should be placed in `docs/deploy/`, mirroring the deploy script structure.
+
+**Why not colocate?** The rocketh/hardhat-deploy script loader auto-loads all files in the `deploy/` directory. Placing `.md` files there causes loader errors. There's no extension filtering option available.
+
+**Structure**:
+
+```
+deploy/ docs/deploy/
+ allocate/ IssuanceAllocatorDeployment.md
+ allocator/ DirectAllocationDeployment.md
+ 01_deploy.ts rewards/
+ 02_upgrade.ts RewardsEligibilityOracleDeployment.md
+ 09_end.ts
+ rewards/
+ eligibility/
+ 01_deploy.ts
+ 02_upgrade.ts
+ 09_end.ts
+```
+
+**Cross-referencing**:
+
+- Contract documentation (in `packages/issuance/contracts/`) should link to deployment documentation
+- Deployment documentation should link back to contract documentation
+- General framework documentation stays in `packages/deployment/docs/`
+
+**Example references**:
+
+```markdown
+
+
+For deployment instructions, see [IssuanceAllocatorDeployment.md](../../../deployment/docs/deploy/IssuanceAllocatorDeployment.md).
+
+
+
+For contract architecture and technical details, see [IssuanceAllocator.md](../../../issuance/contracts/allocate/IssuanceAllocator.md).
+```
+
+**Rationale**: While colocation would be ideal, the deploy loader limitation requires this separation. The `docs/deploy/` structure mirrors deployment organization to maintain logical association.
+
+## Testing
+
+### Principle: Scripts Should Be Testable
+
+**Pattern**:
+
+```typescript
+// Make scripts testable by:
+// 1. Using shared utilities (mockable)
+// 2. Checking state before executing
+// 3. Being idempotent
+// 4. Providing clear output
+
+// Example test flow:
+// 1. Run script first time -> executes actions
+// 2. Run script second time -> skips (idempotent)
+// 3. Check on-chain state matches expected
+```
+
+## Summary
+
+### Key Principles Checklist
+
+For every deployment script:
+
+- [ ] Uses `return` (not `process.exit`) for precondition skips and governance TX saves
+- [ ] Throws exceptions only for unexpected errors
+- [ ] Is idempotent (checks state, skips if done)
+- [ ] Uses package imports (`@graphprotocol/deployment`) not relative paths
+- [ ] Uses shared utilities from `lib/`
+- [ ] Uses `Contracts` registry for type safety and dynamic contract names
+- [ ] Uses tag constants (never hardcodes tag strings)
+- [ ] Works in both fork and production modes
+- [ ] Has clear, actionable error messages with dynamic values
+- [ ] Includes comprehensive documentation
+- [ ] Follows standard script structure (01_deploy, 02_upgrade, ..., 09_end, 10_status)
+- [ ] Properly configures tags and dependencies
+- [ ] End state script is always `09_end.ts` with only dependencies
+- [ ] `10_status.ts` is purely read-only (zero writes, zero TXs, zero exits)
+
+### Anti-Patterns to Avoid
+
+❌ Using `process.exit(1)` for precondition skips or governance TX saves (use `return`)
+❌ Duplicating precondition checks instead of using shared functions from `lib/preconditions.ts`
+❌ Duplicating code instead of using shared utilities
+❌ Using relative imports (`../../lib/`) instead of package imports
+❌ Using string literals instead of `Contracts` registry
+❌ Hardcoding contract names in error messages (use `Contracts.X.name`)
+❌ Hardcoding contract names in TX batch filenames (use `Contracts.X.name`)
+❌ Hardcoding tag strings in messages (use tag constants)
+❌ Hardcoding chain IDs instead of using `getTargetChainId()`
+❌ Direct address book imports instead of `graph.get*AddressBook()`
+❌ Vague error messages without actionable next steps
+❌ Non-idempotent scripts that fail on re-run
+❌ Using non-standard end script numbering (use `09_end.ts` always)
+❌ Any mutation (write, TX, deploy, exit) in a `10_status.ts` script
diff --git a/packages/deployment/docs/deploy/IssuanceAllocatorDeployment.md b/packages/deployment/docs/deploy/IssuanceAllocatorDeployment.md
new file mode 100644
index 000000000..60a110de5
--- /dev/null
+++ b/packages/deployment/docs/deploy/IssuanceAllocatorDeployment.md
@@ -0,0 +1,82 @@
+# IssuanceAllocator Deployment
+
+This document describes how `IssuanceAllocator` is deployed by this package. For contract architecture, behaviour, and technical details, see [IssuanceAllocator.md](../../../issuance/contracts/allocate/IssuanceAllocator.md).
+
+For the goal-level GIP-0088 workflow that orchestrates IA together with the rest of the upgrade, see [Gip0088.md](../Gip0088.md).
+
+## Component overview
+
+`IssuanceAllocator` is a deployable proxy in the `issuance` address book:
+
+- Pattern: OpenZeppelin v5 `TransparentUpgradeableProxy` with a per-proxy `ProxyAdmin` created in the constructor.
+- Access control: `BaseUpgradeable` (`GOVERNOR_ROLE`, `PAUSE_ROLE`).
+- Component tag: `IssuanceAllocator`. Lifecycle actions: `deploy`, `upgrade`, `configure`, `transfer`.
+- Default target: a separate `DefaultAllocation` proxy ([../../deploy/allocate/default/](../../deploy/allocate/default/)) that holds any unallocated issuance as a safety net.
+
+## Lifecycle scripts
+
+| Script | Tag | Actor | Purpose |
+| -------------------------------------------------------------------------------------- | ----------------------------- | ---------- | -------------------------------------------------------------------------- |
+| [01_deploy.ts](../../deploy/allocate/allocator/01_deploy.ts) | `IssuanceAllocator,deploy` | Deployer | Deploy proxy + implementation, initialize with deployer as governor |
+| [02_upgrade.ts](../../deploy/allocate/allocator/02_upgrade.ts) | `IssuanceAllocator,upgrade` | Governance | Build governance TX batch upgrading the proxy to its pendingImplementation |
+| [04_configure.ts](../../deploy/allocate/allocator/04_configure.ts) | `IssuanceAllocator,configure` | Deployer | Set issuance rate (matches RM), grant `GOVERNOR_ROLE` and `PAUSE_ROLE` |
+| [06_transfer_governance.ts](../../deploy/allocate/allocator/06_transfer_governance.ts) | `IssuanceAllocator,transfer` | Deployer | Revoke deployer `GOVERNOR_ROLE`, transfer per-proxy ProxyAdmin to gov |
+| [09_end.ts](../../deploy/allocate/allocator/09_end.ts) | `IssuanceAllocator,all` | - | Aggregate end state — verifies upgrade has been executed |
+| [10_status.ts](../../deploy/allocate/allocator/10_status.ts) | `IssuanceAllocator` | - | Read-only status display |
+
+`03_*`, `05_*`, and `07_08_*` slots are intentionally empty (per [ImplementationPrinciples.md](ImplementationPrinciples.md)).
+
+## What does NOT happen here
+
+The following operations are part of GIP-0088 activation, not the IA component lifecycle. They live in [../../deploy/gip/0088/](../../deploy/gip/0088/) and are governance TXs:
+
+- `IA.setTargetAllocation(RM, 0, rate)` — registers RM as the 100% self-minting target
+- `IA.setDefaultTarget(DA)` — wires the safety net
+- `RM.setIssuanceAllocator(IA)` — RM starts querying IA for its issuance rate
+- `GraphToken.addMinter(IA)` — gives IA minter authority (only needed for allocator-minting targets)
+- `IA.setTargetAllocation(RAM, allocatorRate, selfRate)` — distributes issuance to `RecurringAgreementManager`
+
+These are bundled into the `GIP-0088:upgrade,upgrade` and `GIP-0088:issuance-connect` / `GIP-0088:issuance-allocate` governance batches. See [Gip0088.md](../Gip0088.md) for the full picture.
+
+## Single-component usage
+
+```bash
+# Read-only status
+pnpm hardhat deploy --tags IssuanceAllocator --network
+
+# Lifecycle steps
+pnpm hardhat deploy --tags IssuanceAllocator,deploy --network
+pnpm hardhat deploy --tags IssuanceAllocator,configure --network
+pnpm hardhat deploy --tags IssuanceAllocator,transfer --network
+pnpm hardhat deploy --tags IssuanceAllocator,upgrade --network
+```
+
+The same scripts run as part of the goal-level GIP-0088 flow when invoked via `--tags GIP-0088:upgrade,`.
+
+## Verification checklist
+
+Run `--tags IssuanceAllocator` (component status) or `--tags GIP-0088:upgrade` (goal status) to inspect on-chain state. The status output already covers everything below — this list is for reviewing a finished deployment by hand.
+
+### Bytecode
+
+- Implementation bytecode matches the expected `IssuanceAllocator` contract
+
+### Access control
+
+- Protocol governor holds `GOVERNOR_ROLE`
+- Pause guardian holds `PAUSE_ROLE`
+- Deployer does **not** hold `GOVERNOR_ROLE` (asserted by `checkDeployerRevoked` in the transfer step)
+- Per-proxy `ProxyAdmin` is owned by the protocol governor
+
+### Configuration
+
+- `getIssuancePerBlock()` matches `RewardsManager.issuancePerBlock()`
+- `paused()` is `false`
+
+### Activation (GIP-0088)
+
+- `RewardsManager.getIssuanceAllocator()` returns the IA address
+- `GraphToken.isMinter(IA)` is `true` (only when allocator-minting targets exist)
+- `getTargetAllocation(RM)` shows `selfMintingRate == issuancePerBlock`, `allocatorMintingRate == 0`
+- `getTargetAllocation(RAM)` matches `config/.json5` rates
+- Default target points at `DefaultAllocation`
diff --git a/packages/deployment/docs/deploy/RewardsEligibilityOracleDeployment.md b/packages/deployment/docs/deploy/RewardsEligibilityOracleDeployment.md
new file mode 100644
index 000000000..e8e9d2968
--- /dev/null
+++ b/packages/deployment/docs/deploy/RewardsEligibilityOracleDeployment.md
@@ -0,0 +1,105 @@
+# RewardsEligibilityOracle Deployment
+
+Deployment guide for RewardsEligibilityOracle (REO).
+
+**Related:**
+
+- [Contract specification](../../../issuance/contracts/eligibility/RewardsEligibilityOracle.md) - architecture, operations, troubleshooting
+- [GovernanceWorkflow.md](../GovernanceWorkflow.md) - Safe TX execution
+
+## Prerequisites
+
+- GraphToken deployed
+- Controller deployed (provides governor, pause guardian addresses)
+- `NetworkOperator` entry in issuance address book (for OPERATOR_ROLE)
+
+## Deployment Scripts
+
+All scripts are idempotent.
+
+| Script | Tag | Actor | Purpose |
+| --------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------- | ----------------------------------------- |
+| [01_deploy.ts](../../deploy/rewards/eligibility/01_deploy.ts) | `RewardsEligibilityOracle{A,B}:deploy` | Deployer | Deploy proxy + implementation |
+| [02_upgrade.ts](../../deploy/rewards/eligibility/02_upgrade.ts) | `RewardsEligibilityOracle{A,B}:upgrade` | Governance | Upgrade implementation |
+| [04_configure.ts](../../deploy/rewards/eligibility/04_configure.ts) | `RewardsEligibilityOracle{A,B}:configure` | Deployer/Governance | Set parameters |
+| [05_transfer_governance.ts](../../deploy/rewards/eligibility/05_transfer_governance.ts) | `RewardsEligibilityOracle{A,B}:transfer` | Deployer | Revoke deployer role, transfer ProxyAdmin |
+| [09_end.ts](../../deploy/rewards/eligibility/09_end.ts) | `RewardsEligibilityOracle{A,B}` | - | Aggregate (deploy, upgrade, configure) |
+
+Integration with `RewardsManager` is **not** a per-component lifecycle action. Only one of REO-A or REO-B is integrated at a time, which is a goal-level decision. Use the GIP-0088 activation tag instead:
+
+```bash
+pnpm hardhat deploy --tags GIP-0088:eligibility-integrate --network
+```
+
+The testnet-only `MockRewardsEligibilityOracle` is a separate, opt-in path with its own per-component [`06_integrate.ts`](../../deploy/rewards/eligibility/mock/06_integrate.ts). It is **not** part of any GIP-0088 phase tag, so `--tags all` will not pull it in — it runs only when `RewardsEligibilityOracleMock` is named explicitly:
+
+```bash
+pnpm hardhat deploy --tags RewardsEligibilityOracleMock,integrate --network
+```
+
+Intentionally kept off the default deployment path and outside the governance-tx flow; not intended for mainnet. Its governance-tx batch name is `RewardsManager-MockREO` (vs `RewardsManager-REO` for the GIP-0088 A/B activation) so the two cannot collide on the same filesystem.
+
+### Quick Start
+
+```bash
+# Read-only status (no --tags = no mutations)
+pnpm hardhat deploy --tags RewardsEligibilityOracleA --network
+
+# Individual steps
+pnpm hardhat deploy --tags RewardsEligibilityOracleA,deploy --network
+pnpm hardhat deploy --tags RewardsEligibilityOracleA,configure --network
+pnpm hardhat deploy --tags RewardsEligibilityOracleA,transfer --network
+
+# Integrate (only one of A/B at a time — goal-level)
+pnpm hardhat deploy --tags GIP-0088:eligibility-integrate --network
+```
+
+## Verification Checklist
+
+### Deployment
+
+- [ ] Contract deployed via transparent proxy
+- [ ] Implementation verified on block explorer
+
+### Access Control
+
+- [ ] Governor has GOVERNOR_ROLE
+- [ ] Deployer does NOT have GOVERNOR_ROLE
+- [ ] Pause guardian has PAUSE_ROLE
+- [ ] Operator has OPERATOR_ROLE
+
+### Configuration
+
+- [ ] `eligibilityPeriod` = 14 days (1,209,600 seconds)
+- [ ] `oracleUpdateTimeout` = 7 days (604,800 seconds)
+
+### Integration
+
+- [ ] `RewardsManager.getProviderEligibilityOracle()` returns REO address
+
+## Configuration Parameters
+
+| Parameter | Default | Purpose |
+| ------------------------------ | ------- | --------------------------------------- |
+| `eligibilityPeriod` | 14 days | How long indexer eligibility lasts |
+| `oracleUpdateTimeout` | 7 days | Failsafe timeout for oracle updates |
+| `eligibilityValidationEnabled` | false | Global enable/disable (set by operator) |
+
+## Roles
+
+| Role | Purpose | Assigned To |
+| ------------- | ----------------------------------------- | -------------------------- |
+| GOVERNOR_ROLE | Grant/revoke operator, governance actions | Protocol governance |
+| OPERATOR_ROLE | Configure parameters, manage oracle roles | Network operator |
+| ORACLE_ROLE | Renew indexer eligibility | Oracle services (multiple) |
+| PAUSE_ROLE | Pause contract | Pause guardian |
+
+## Post-Deployment
+
+After deployment completes, the **operator** must:
+
+1. Grant ORACLE_ROLE to oracle services
+2. Verify oracles are renewing eligibility
+3. Enable eligibility validation when ready
+
+See [Contract specification - Operations](../../../issuance/contracts/eligibility/RewardsEligibilityOracle.md#operations) for detailed operational guidance, monitoring, and troubleshooting.
diff --git a/packages/deployment/docs/plans/AddressBookEnhancement.md b/packages/deployment/docs/plans/AddressBookEnhancement.md
new file mode 100644
index 000000000..de5ca17f7
--- /dev/null
+++ b/packages/deployment/docs/plans/AddressBookEnhancement.md
@@ -0,0 +1,448 @@
+# Address Book Enhancement Plan
+
+## Overview
+
+Extend the address book to store minimal deployment metadata that enables:
+
+1. Complete rocketh record reconstruction during sync
+2. Contract verification without original deployment records
+3. Deterministic change detection (has local bytecode changed since deployment?)
+4. Pre-flight validation of deployment state
+5. Bidirectional sync with conflict detection (using blockNumber comparison)
+
+## Current State
+
+### AddressBookEntry (toolshed)
+
+```ts
+type AddressBookEntry = {
+ address: string
+ proxy?: 'graph' | 'transparent'
+ proxyAdmin?: string
+ implementation?: string
+ pendingImplementation?: PendingImplementation
+}
+
+type PendingImplementation = {
+ address: string
+ deployedAt: string // ISO 8601 timestamp
+ txHash?: string // already has txHash!
+ readyForUpgrade?: boolean
+}
+```
+
+### Problem
+
+- Sync creates minimal rocketh records with `argsData: '0x'`, `metadata: ''`
+- Verification fails because constructor args are lost
+- Bytecode comparison gymnastics required to detect changes
+- No audit trail (txHash) for main contract/implementation deployments
+- `pendingImplementation` has partial metadata but missing argsData/bytecodeHash
+
+## Proposed Changes
+
+### 1. Extend AddressBookEntry Type
+
+**File:** `packages/toolshed/src/deployments/address-book.ts`
+
+```ts
+type DeploymentMetadata = {
+ /** Deployment transaction hash - enables recovery of all tx details */
+ txHash: string
+ /** ABI-encoded constructor arguments */
+ argsData: string
+ /** keccak256 of deployed bytecode (sans CBOR) for change detection */
+ bytecodeHash: string
+ /** Block number of deployment - useful for sync conflict detection */
+ blockNumber?: number
+ /** Block timestamp (ISO 8601) - human readable deployment time */
+ timestamp?: string
+}
+
+type AddressBookEntry = {
+ address: string
+ proxy?: 'graph' | 'transparent'
+ proxyAdmin?: string
+ implementation?: string
+ pendingImplementation?: PendingImplementation
+ /** Deployment metadata for non-proxied contracts */
+ deployment?: DeploymentMetadata
+ /** Deployment metadata for proxy contract (proxied contracts only) */
+ proxyDeployment?: DeploymentMetadata
+ /** Deployment metadata for implementation (proxied contracts only) */
+ implementationDeployment?: DeploymentMetadata
+}
+
+type PendingImplementation = {
+ address: string
+ deployedAt: string // keep for backwards compat
+ txHash?: string // already exists
+ readyForUpgrade?: boolean
+ /** Full deployment metadata (new) */
+ deployment?: DeploymentMetadata
+}
+```
+
+**Field usage:**
+
+- Non-proxied contract: `deployment`
+- Proxied contract: `proxyDeployment` + `implementationDeployment`
+- Pending upgrade: `pendingImplementation.deployment`
+
+### 2. Update Address Book Validation
+
+**File:** `packages/toolshed/src/deployments/address-book.ts`
+
+Update `_assertAddressBookEntry` to allow new fields:
+
+```ts
+const allowedFields = [
+ 'address',
+ 'implementation',
+ 'proxyAdmin',
+ 'proxy',
+ 'pendingImplementation',
+ 'deployment',
+ 'proxyDeployment',
+ 'implementationDeployment', // new
+]
+```
+
+### 3. Add AddressBookOps Methods
+
+**File:** `packages/deployment/lib/address-book-ops.ts`
+
+```ts
+/**
+ * Set deployment metadata for a contract
+ */
+setDeploymentMetadata(
+ name: ContractName,
+ metadata: DeploymentMetadata
+): void
+
+/**
+ * Set implementation deployment metadata (for proxied contracts)
+ */
+setImplementationDeploymentMetadata(
+ name: ContractName,
+ metadata: DeploymentMetadata
+): void
+
+/**
+ * Get deployment metadata
+ */
+getDeploymentMetadata(name: ContractName): DeploymentMetadata | undefined
+
+/**
+ * Check if deployment metadata exists and is complete
+ */
+hasCompleteDeploymentMetadata(name: ContractName): boolean
+```
+
+### 4. Bytecode Hash Utility
+
+**File:** `packages/deployment/lib/bytecode-utils.ts` (extend existing)
+
+Existing utilities to leverage:
+
+- `stripMetadata(bytecode)` - already strips CBOR suffix
+- `bytecodeMatches(artifact, onChain)` - compares with immutable masking
+- `findImmutablePositions(bytecode)` - finds PUSH32 zero placeholders
+
+Add new utility:
+
+```ts
+import { keccak256 } from 'ethers'
+import { stripMetadata } from './bytecode-utils.js'
+
+/**
+ * Compute bytecode hash for change detection
+ * Strips CBOR metadata suffix for stable comparison across recompilations
+ */
+export function computeBytecodeHash(bytecode: string): string {
+ const stripped = stripMetadata(bytecode)
+ return keccak256(stripped)
+}
+```
+
+### 5. Enhanced Sync Process
+
+**File:** `packages/deployment/lib/sync-utils.ts`
+
+#### 5.1 Change Detection Before Sync (Bidirectional)
+
+Sync can flow in two directions:
+
+1. **Chain → Address Book**: On-chain state is newer (e.g., deployed via this package)
+2. **Address Book → Rocketh**: Address book has metadata to reconstruct records
+
+Use `blockNumber` to determine which is authoritative when both exist.
+
+```ts
+async function shouldSyncContract(
+ env: Environment,
+ spec: ContractSpec,
+ addressBook: AddressBookOps,
+ direction: 'toAddressBook' | 'toRocketh',
+): Promise<{ sync: boolean; reason: string }> {
+ const existing = addressBook.getEntry(spec.name)
+
+ // No existing entry - must sync
+ if (!existing) {
+ return { sync: true, reason: 'new contract' }
+ }
+
+ // Address changed - must sync
+ if (existing.address.toLowerCase() !== spec.address.toLowerCase()) {
+ return { sync: true, reason: 'address changed' }
+ }
+
+ // Check bytecode hash if available
+ const deployment = existing.deployment ?? existing.implementationDeployment
+ if (deployment?.bytecodeHash) {
+ const artifact = loadArtifact(spec.name)
+ const localHash = computeBytecodeHash(artifact.deployedBytecode)
+ if (deployment.bytecodeHash !== localHash) {
+ return { sync: false, reason: 'local bytecode changed - manual intervention required' }
+ }
+ }
+
+ // For bidirectional sync, compare blockNumbers if both exist
+ if (direction === 'toAddressBook' && deployment?.blockNumber) {
+ const rockethRecord = env.getOrNull(spec.name)
+ if (rockethRecord?.receipt?.blockNumber) {
+ const rockethBlock = parseInt(rockethRecord.receipt.blockNumber)
+ if (deployment.blockNumber >= rockethBlock) {
+ return { sync: false, reason: 'address book is current or newer' }
+ }
+ }
+ }
+
+ // No changes detected
+ return { sync: false, reason: 'unchanged' }
+}
+```
+
+#### 5.2 Complete Record Reconstruction
+
+```ts
+async function reconstructRockethRecord(
+ env: Environment,
+ spec: ContractSpec,
+ addressBook: AddressBookOps,
+): Promise {
+ const entry = addressBook.getEntry(spec.name)
+ const artifact = loadArtifact(spec.name)
+ const deployment = entry.deployment
+
+ // Verify we can reconstruct
+ if (!deployment) {
+ throw new Error(`Missing deployment metadata for ${spec.name}`)
+ }
+
+ // Verify bytecode hasn't changed
+ const localHash = computeBytecodeHash(artifact.deployedBytecode)
+ if (deployment.bytecodeHash !== localHash) {
+ throw new Error(`Local bytecode differs from deployed for ${spec.name}`)
+ }
+
+ // Optionally fetch tx details for complete record
+ const tx = deployment.txHash ? await env.network.provider.getTransaction(deployment.txHash) : undefined
+
+ return {
+ address: entry.address,
+ abi: artifact.abi,
+ bytecode: artifact.bytecode,
+ deployedBytecode: artifact.deployedBytecode,
+ argsData: deployment.argsData,
+ metadata: artifact.metadata ?? '',
+ transaction: tx
+ ? {
+ hash: deployment.txHash,
+ nonce: tx.nonce.toString(),
+ origin: tx.from,
+ }
+ : undefined,
+ receipt: deployment.blockNumber
+ ? {
+ blockNumber: deployment.blockNumber.toString(),
+ }
+ : undefined,
+ }
+}
+```
+
+### 6. Pre-flight Validation
+
+**File:** `packages/deployment/lib/deployment-validation.ts` (new)
+
+```ts
+export interface ValidationResult {
+ contract: string
+ status: 'valid' | 'warning' | 'error'
+ message: string
+}
+
+/**
+ * Validate deployment records can be reconstructed
+ * Run before any deployment to catch issues early
+ */
+export async function validateDeploymentRecords(
+ env: Environment,
+ addressBook: AddressBookOps,
+ contracts: string[],
+): Promise {
+ const results: ValidationResult[] = []
+
+ for (const name of contracts) {
+ if (!addressBook.entryExists(name)) {
+ results.push({ contract: name, status: 'valid', message: 'not deployed' })
+ continue
+ }
+
+ const entry = addressBook.getEntry(name)
+
+ // Check address has code
+ const code = await env.network.provider.getCode(entry.address)
+ if (code === '0x') {
+ results.push({
+ contract: name,
+ status: 'error',
+ message: `no code at ${entry.address}`,
+ })
+ continue
+ }
+
+ // Check deployment metadata exists
+ if (!entry.deployment) {
+ results.push({
+ contract: name,
+ status: 'warning',
+ message: 'missing deployment metadata (legacy entry)',
+ })
+ continue
+ }
+
+ // Verify bytecode hash
+ const artifact = loadArtifact(name)
+ const localHash = computeBytecodeHash(artifact.deployedBytecode)
+ if (entry.deployment.bytecodeHash !== localHash) {
+ results.push({
+ contract: name,
+ status: 'warning',
+ message: 'local bytecode differs from deployed',
+ })
+ continue
+ }
+
+ // Verify argsData matches tx (optional, requires chain lookup)
+ if (entry.deployment.txHash) {
+ const tx = await env.network.provider.getTransaction(entry.deployment.txHash)
+ if (tx) {
+ const extractedArgs = tx.data.slice(artifact.bytecode.length)
+ if (extractedArgs !== entry.deployment.argsData) {
+ results.push({
+ contract: name,
+ status: 'error',
+ message: 'argsData mismatch with deployment tx',
+ })
+ continue
+ }
+ }
+ }
+
+ results.push({ contract: name, status: 'valid', message: 'ok' })
+ }
+
+ return results
+}
+```
+
+### 7. Update Deploy Scripts
+
+**File:** `packages/deployment/rocketh/deploy.ts` and deploy scripts
+
+After successful deployment, persist metadata to address book:
+
+```ts
+// In deployment helper after successful deploy
+const deploymentMetadata: DeploymentMetadata = {
+ txHash: result.transaction.hash,
+ argsData: result.argsData,
+ bytecodeHash: computeBytecodeHash(artifact.deployedBytecode),
+ blockNumber: result.receipt.blockNumber,
+}
+
+addressBook.setDeploymentMetadata(contractName, deploymentMetadata)
+```
+
+## Implementation Order
+
+1. **Phase 1: Types & Utilities**
+ - Extend `AddressBookEntry` type in toolshed
+ - Add `DeploymentMetadata` type
+ - Extend `PendingImplementation` with deployment field
+ - Add `computeBytecodeHash` utility (uses existing `stripMetadata`)
+ - Update address book validation for new fields
+
+2. **Phase 2: AddressBookOps**
+ - Add new methods for deployment metadata
+ - Unit tests for new methods
+
+3. **Phase 3: Sync Enhancement**
+ - Change detection before sync (bidirectional)
+ - Record reconstruction from metadata
+ - Preserve existing metadata (don't overwrite without change)
+ - Use blockNumber for conflict resolution
+
+4. **Phase 4: Validation**
+ - Implement pre-flight validation
+ - Add validation task/command
+ - Integrate into deploy flow
+
+5. **Phase 5: Deploy Integration**
+ - Update deploy helpers to persist metadata
+ - Capture block timestamp for human readability
+ - Test end-to-end deploy → sync → verify flow
+
+**Note on existing entries:** Contracts already deployed without metadata will simply not have the new fields. They cannot be reconstructed anyway if bytecode has changed. New deployments will automatically capture full metadata going forward.
+
+## Size Impact
+
+Per-contract addition to address book:
+
+- `txHash`: 66 chars
+- `argsData`: variable (typically 66-200 chars)
+- `bytecodeHash`: 66 chars
+- `blockNumber`: ~10 chars (optional)
+- `timestamp`: ~24 chars (optional, ISO 8601)
+
+**Total: ~250-400 bytes per contract** (vs 40-60KB for full rocketh records)
+
+## Testing Strategy
+
+1. Unit tests for bytecode hash computation
+2. Unit tests for record reconstruction
+3. Integration tests for sync with metadata
+4. E2E tests for deploy → validate → verify flow
+5. Test handling of legacy entries (without metadata)
+
+## Open Questions
+
+1. Should `bytecodeHash` include or exclude CBOR metadata?
+ - **Recommendation: exclude** (stable across recompilations)
+ - Use existing `stripMetadata()` before hashing
+
+2. Should validation be blocking or warning-only?
+ - **Recommendation: configurable**, default to warning
+ - Critical errors (no code at address) should block
+
+3. Should `timestamp` use block timestamp or deployment time?
+ - **Recommendation: block timestamp** (deterministic, from chain)
+ - Format: ISO 8601 for human readability
+
+4. How to handle immutables in bytecodeHash?
+ - **Recommendation: hash artifact bytecode** (with zeros for immutables)
+ - This detects source changes, not deployment-time value changes
+ - Use `bytecodeMatches()` for full comparison when needed
diff --git a/packages/deployment/hardhat.config.ts b/packages/deployment/hardhat.config.ts
new file mode 100644
index 000000000..43256ddca
--- /dev/null
+++ b/packages/deployment/hardhat.config.ts
@@ -0,0 +1,321 @@
+import * as path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import hardhatEthers from '@nomicfoundation/hardhat-ethers'
+import hardhatKeystore from '@nomicfoundation/hardhat-keystore'
+import hardhatVerify from '@nomicfoundation/hardhat-verify'
+import type { HardhatUserConfig } from 'hardhat/config'
+import { configVariable } from 'hardhat/config'
+import hardhatDeploy from 'hardhat-deploy'
+
+import checkDeployerTask from './tasks/check-deployer.js'
+// Import tasks (HH v3 task API)
+import deploymentStatusTask from './tasks/deployment-status.js'
+import { ethBalanceTask, ethCheckKeyTask, ethFundTask } from './tasks/eth-tasks.js'
+import executeGovernanceTask from './tasks/execute-governance.js'
+import grantRoleTask from './tasks/grant-role.js'
+import { grtBalanceTask, grtMintTask, grtStatusTask, grtTransferTask } from './tasks/grt-tasks.js'
+import listPendingTask from './tasks/list-pending-implementations.js'
+import listRolesTask from './tasks/list-roles.js'
+import { reoDisableTask, reoEnableTask, reoIndexersTask, reoStatusTask } from './tasks/reo-tasks.js'
+import resetForkTask from './tasks/reset-fork.js'
+import revokeRoleTask from './tasks/revoke-role.js'
+import { ssStatusTask } from './tasks/ss-tasks.js'
+import syncTask from './tasks/sync.js'
+import verifyContractTask from './tasks/verify-contract.js'
+
+// ESM compatibility
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+// Package paths
+const packageRoot = __dirname
+
+// Hardhat v3 does not auto-set HARDHAT_NETWORK (v2 did).
+// isLocalNetworkMode() in address-book-utils.ts relies on this env var to
+// select addresses-local-network.json over addresses.json.
+const networkArg = process.argv.find((_, i, a) => a[i - 1] === '--network')
+if (networkArg === 'localNetwork') {
+ process.env.HARDHAT_NETWORK = 'localNetwork'
+}
+
+// RPC URLs with defaults
+const ARBITRUM_ONE_RPC = process.env.ARBITRUM_ONE_RPC || 'https://arb1.arbitrum.io/rpc'
+const ARBITRUM_SEPOLIA_RPC = process.env.ARBITRUM_SEPOLIA_RPC || 'https://sepolia-rollup.arbitrum.io/rpc'
+
+/**
+ * Convert network name to env var prefix: arbitrumSepolia → ARBITRUM_SEPOLIA
+ */
+function networkToEnvPrefix(networkName: string): string {
+ return networkName.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase()
+}
+
+/**
+ * Get deployer key name for a network.
+ * Always uses network-specific key (e.g., ARBITRUM_SEPOLIA_DEPLOYER_KEY).
+ *
+ * Keystore: npx hardhat keystore set ARBITRUM_SEPOLIA_DEPLOYER_KEY
+ * Env var: export ARBITRUM_SEPOLIA_DEPLOYER_KEY=0x...
+ */
+function getDeployerKeyName(networkName: string): string {
+ const prefix = networkToEnvPrefix(networkName)
+ return `${prefix}_DEPLOYER_KEY`
+}
+
+/**
+ * Parse --tags from process.argv.
+ * Returns null when --tags is not present.
+ */
+function parseTagsFromArgv(): string[] | null {
+ const argv = process.argv
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i]
+ if (a === '--tags') {
+ if (i + 1 >= argv.length) return null
+ return argv[i + 1].split(',')
+ }
+ if (a.startsWith('--tags=')) {
+ return a.slice('--tags='.length).split(',')
+ }
+ }
+ return null
+}
+
+/**
+ * Detect whether the current invocation needs a deployer account.
+ *
+ * The deployer key is only needed when the `deploy` task is invoked with
+ * action verbs in `--tags` that perform mutations (deploy, upgrade, configure,
+ * transfer, integrate, all). Status-only runs (`--tags Component` without
+ * action verbs) are read-only and don't need the deployer key.
+ *
+ * Other tasks (reo:enable, grant-role, eth:fund, ...) resolve keys at
+ * execution time via resolveConfigVar(), and read-only tasks need no key
+ * at all.
+ *
+ * Gating configVariable() on this lets the hardhat-keystore plugin prompt for
+ * the password only when the user actually runs a mutating deploy action,
+ * instead of on every `deploy` invocation.
+ */
+function getTaskName(): string | null {
+ for (const arg of process.argv.slice(2)) {
+ if (arg.startsWith('-')) continue
+ return arg
+ }
+ return null
+}
+
+function needsDeployerAccount(): boolean {
+ // Non-deploy tasks resolve keys at runtime; deploy:sync is read-only
+ if (getTaskName() !== 'deploy') return false
+
+ // Status-only runs (no action verbs in --tags) don't need a signer
+ const tags = parseTagsFromArgv()
+ if (!tags) return false
+
+ const ACTION_VERBS = ['deploy', 'upgrade', 'configure', 'transfer', 'integrate', 'all']
+ return tags.some((tag) => ACTION_VERBS.includes(tag))
+}
+
+/**
+ * Dummy private key used when no real deployer key is needed.
+ *
+ * Rocketh requires at least one account to resolve namedAccounts.deployer.
+ * For status-only runs we provide this throwaway key so environment creation
+ * succeeds without prompting the keystore. The resulting address
+ * (0x7E5F...95Bdf) is filtered out by getDeployer() — status scripts infer
+ * the real deployer from the ProxyAdmin owner on-chain.
+ */
+const DUMMY_DEPLOYER_KEY = '0x0000000000000000000000000000000000000000000000000000000000000001'
+
+/**
+ * Get accounts config for a network.
+ *
+ * When the deploy task is invoked with action verbs (deploy, upgrade, etc.),
+ * returns a configVariable so the hardhat-keystore plugin resolves the
+ * deployer key from the keystore (with env-var fallback).
+ *
+ * For status-only deploy runs and all other tasks, returns a dummy key so
+ * rocketh can initialise namedAccounts without a keystore prompt. Signing
+ * tasks resolve keys themselves via resolveConfigVar().
+ *
+ * Set the key via either:
+ * npx hardhat keystore set ARBITRUM_SEPOLIA_DEPLOYER_KEY
+ * export ARBITRUM_SEPOLIA_DEPLOYER_KEY=0x...
+ */
+const getNetworkAccounts = (networkName: string) => {
+ if (!needsDeployerAccount()) return [DUMMY_DEPLOYER_KEY]
+ const keyName = getDeployerKeyName(networkName)
+ if (networkName === networkArg && !process.env[keyName]) {
+ console.log(`\n Deployer key: ${keyName}`)
+ console.log(` Set via: npx hardhat keystore set ${keyName}\n`)
+ }
+ return [configVariable(keyName)]
+}
+
+// Fork network detection (HARDHAT_FORK is the standard for hardhat-deploy v2)
+const FORK_NETWORK = process.env.HARDHAT_FORK || process.env.FORK_NETWORK
+
+const config: HardhatUserConfig = {
+ // Register HH v3 plugins
+ plugins: [hardhatEthers, hardhatKeystore, hardhatVerify, hardhatDeploy],
+
+ // Register tasks
+ tasks: [
+ checkDeployerTask,
+ deploymentStatusTask,
+ ethBalanceTask,
+ ethCheckKeyTask,
+ ethFundTask,
+ executeGovernanceTask,
+ grantRoleTask,
+ grtBalanceTask,
+ grtMintTask,
+ grtStatusTask,
+ grtTransferTask,
+ listPendingTask,
+ listRolesTask,
+ reoDisableTask,
+ reoEnableTask,
+ reoIndexersTask,
+ reoStatusTask,
+ ssStatusTask,
+ syncTask,
+ resetForkTask,
+ revokeRoleTask,
+ verifyContractTask,
+ ],
+
+ // Chain descriptors for fork execution and local development
+ chainDescriptors: {
+ // Graph Local Network (chainId 1337)
+ 1337: {
+ name: 'Graph Local Network',
+ hardforkHistory: {
+ berlin: { blockNumber: 0 },
+ london: { blockNumber: 0 },
+ merge: { blockNumber: 0 },
+ shanghai: { blockNumber: 0 },
+ cancun: { blockNumber: 0 },
+ },
+ },
+ // Local hardhat network (for non-fork runs)
+ 31337: {
+ name: 'Hardhat Local',
+ hardforkHistory: {
+ berlin: { blockNumber: 0 },
+ london: { blockNumber: 0 },
+ merge: { blockNumber: 0 },
+ shanghai: { blockNumber: 0 },
+ cancun: { blockNumber: 0 },
+ },
+ },
+ // Arbitrum Sepolia
+ 421614: {
+ name: 'Arbitrum Sepolia',
+ hardforkHistory: {
+ berlin: { blockNumber: 0 },
+ london: { blockNumber: 0 },
+ merge: { blockNumber: 0 },
+ shanghai: { blockNumber: 0 },
+ cancun: { blockNumber: 0 },
+ },
+ },
+ // Arbitrum One
+ 42161: {
+ name: 'Arbitrum One',
+ hardforkHistory: {
+ berlin: { blockNumber: 0 },
+ london: { blockNumber: 0 },
+ merge: { blockNumber: 0 },
+ shanghai: { blockNumber: 0 },
+ cancun: { blockNumber: 0 },
+ },
+ },
+ },
+
+ // No local solidity sources - deployment uses external artifacts only
+ // Verification should be done from the source package (e.g., packages/horizon)
+ paths: {
+ tests: path.join(packageRoot, 'test'),
+ artifacts: path.join(packageRoot, 'artifacts'),
+ cache: path.join(packageRoot, 'cache'),
+ },
+ networks: {
+ // Hardhat network - uses chainId 31337 even when forking (rocketh/hardhat-deploy v2 expects this)
+ // The FORK_NETWORK env var determines which network to fork, but chainId stays 31337
+ hardhat: {
+ type: 'edr-simulated',
+ chainId: 31337,
+ accounts: {
+ mnemonic: 'myth like bonus scare over problem client lizard pioneer submit female collect',
+ },
+ forking: FORK_NETWORK
+ ? {
+ url: FORK_NETWORK === 'arbitrumSepolia' ? ARBITRUM_SEPOLIA_RPC : ARBITRUM_ONE_RPC,
+ enabled: true,
+ }
+ : undefined,
+ },
+ localhost: {
+ type: 'http',
+ url: 'http://127.0.0.1:8545',
+ chainId: 31337,
+ },
+ // Fork network for hardhat-deploy v2 (HARDHAT_FORK env var)
+ fork: {
+ type: 'edr-simulated',
+ chainId: 31337,
+ accounts: {
+ mnemonic: 'myth like bonus scare over problem client lizard pioneer submit female collect',
+ },
+ forking: FORK_NETWORK
+ ? {
+ url: FORK_NETWORK === 'arbitrumSepolia' ? ARBITRUM_SEPOLIA_RPC : ARBITRUM_ONE_RPC,
+ enabled: true,
+ }
+ : undefined,
+ },
+ // Graph Local Network — chainId 1337, contracts deployed fresh by an
+ // upstream step that populates addresses-local-network.json files.
+ localNetwork: {
+ type: 'http',
+ url: process.env.LOCAL_NETWORK_RPC || 'http://chain:8545',
+ chainId: 1337,
+ accounts: {
+ mnemonic: 'test test test test test test test test test test test junk',
+ },
+ },
+ arbitrumOne: {
+ type: 'http',
+ chainId: 42161,
+ url: ARBITRUM_ONE_RPC,
+ accounts: getNetworkAccounts('arbitrumOne'),
+ },
+ arbitrumSepolia: {
+ type: 'http',
+ chainId: 421614,
+ url: ARBITRUM_SEPOLIA_RPC,
+ accounts: getNetworkAccounts('arbitrumSepolia'),
+ },
+ },
+ // Named accounts are configured in rocketh/config.ts for hardhat-deploy v2
+ // External artifacts are loaded via direct imports in deploy scripts
+
+ // Contract verification config (hardhat-verify v3)
+ // API key from keystore, gated to deploy:verify to avoid prompting on every task.
+ // Set via: npx hardhat keystore set ARBISCAN_API_KEY
+ verify: {
+ etherscan: {
+ apiKey: getTaskName() === 'deploy:verify' ? configVariable('ARBISCAN_API_KEY') : '',
+ },
+ sourcify: {
+ enabled: false,
+ },
+ blockscout: {
+ enabled: false,
+ },
+ },
+}
+
+export default config
diff --git a/packages/deployment/lib/abis.ts b/packages/deployment/lib/abis.ts
new file mode 100644
index 000000000..ece524796
--- /dev/null
+++ b/packages/deployment/lib/abis.ts
@@ -0,0 +1,150 @@
+/**
+ * Shared ABI definitions for contract interactions
+ *
+ * Generated ABIs are produced by `pnpm generate:abis` from contract artifacts.
+ * The contract registry drives which ABIs and interface IDs are generated.
+ * Only ACCESS_CONTROL_ENUMERABLE_ABI is hand-maintained (generic role queries).
+ */
+
+// Re-export all generated typed ABIs, aliases, and interface IDs
+export {
+ CONTROLLER_ABI,
+ DIRECT_ALLOCATION_ABI,
+ GRAPH_PROXY_ADMIN_ABI,
+ GRAPH_TOKEN_ABI,
+ IERC165_ABI,
+ IERC165_INTERFACE_ID,
+ IISSUANCE_TARGET_INTERFACE_ID,
+ INITIALIZE_GOVERNOR_ABI,
+ IREWARDS_MANAGER_INTERFACE_ID,
+ ISSUANCE_ALLOCATOR_ABI,
+ ISSUANCE_TARGET_ABI,
+ OZ_PROXY_ADMIN_ABI,
+ PROVIDER_ELIGIBILITY_MANAGEMENT_ABI,
+ REWARDS_ELIGIBILITY_ORACLE_ABI,
+ REWARDS_MANAGER_ABI,
+ REWARDS_MANAGER_DEPRECATED_ABI,
+ SET_TARGET_ALLOCATION_ABI,
+} from './generated/abis.js'
+
+// ============================================================================
+// Hand-rolled minimal ABIs (not in @graphprotocol/interfaces)
+// ============================================================================
+
+/**
+ * Minimal ABI for RecurringCollector pause guardian management
+ *
+ * RC's pause guardian functions are not part of an interface in
+ * @graphprotocol/interfaces. Used by RC configure and the GIP-0088 upgrade
+ * batch to manage `setPauseGuardian` / `pauseGuardians`.
+ */
+export const RECURRING_COLLECTOR_PAUSE_ABI = [
+ {
+ inputs: [{ name: '_pauseGuardian', type: 'address' }],
+ name: 'pauseGuardians',
+ outputs: [{ type: 'bool' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [
+ { name: '_pauseGuardian', type: 'address' },
+ { name: '_allowed', type: 'bool' },
+ ],
+ name: 'setPauseGuardian',
+ outputs: [],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+] as const
+
+/**
+ * Minimal ABI for SubgraphService allocation close guard
+ *
+ * `blockClosingAllocationWithActiveAgreement` is part of the SS interface but
+ * not generated yet. Used by `GIP-0088:issuance-close-guard` and the goal
+ * status display.
+ */
+export const SUBGRAPH_SERVICE_CLOSE_GUARD_ABI = [
+ {
+ inputs: [],
+ name: 'getBlockClosingAllocationWithActiveAgreement',
+ outputs: [{ type: 'bool' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [{ name: 'enabled', type: 'bool' }],
+ name: 'setBlockClosingAllocationWithActiveAgreement',
+ outputs: [],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+] as const
+
+// ============================================================================
+// Generic ABIs for role enumeration
+// ============================================================================
+
+/**
+ * Minimal ABI for AccessControlEnumerable role queries and management
+ * Works with any contract inheriting from OZ AccessControlEnumerableUpgradeable
+ */
+export const ACCESS_CONTROL_ENUMERABLE_ABI = [
+ // View functions
+ {
+ inputs: [{ name: 'role', type: 'bytes32' }],
+ name: 'getRoleMemberCount',
+ outputs: [{ type: 'uint256' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [
+ { name: 'role', type: 'bytes32' },
+ { name: 'index', type: 'uint256' },
+ ],
+ name: 'getRoleMember',
+ outputs: [{ type: 'address' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [
+ { name: 'role', type: 'bytes32' },
+ { name: 'account', type: 'address' },
+ ],
+ name: 'hasRole',
+ outputs: [{ type: 'bool' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [{ name: 'role', type: 'bytes32' }],
+ name: 'getRoleAdmin',
+ outputs: [{ type: 'bytes32' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ // Write functions (require admin role)
+ {
+ inputs: [
+ { name: 'role', type: 'bytes32' },
+ { name: 'account', type: 'address' },
+ ],
+ name: 'grantRole',
+ outputs: [],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [
+ { name: 'role', type: 'bytes32' },
+ { name: 'account', type: 'address' },
+ ],
+ name: 'revokeRole',
+ outputs: [],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+] as const
diff --git a/packages/deployment/lib/address-book-ops.ts b/packages/deployment/lib/address-book-ops.ts
new file mode 100644
index 000000000..9f6f506f3
--- /dev/null
+++ b/packages/deployment/lib/address-book-ops.ts
@@ -0,0 +1,554 @@
+/**
+ * Data operations for managing address book entries
+ *
+ * This module provides a Layer 1 interface for address book operations.
+ * It focuses on WHAT data is being set, not WHY (deployment, sync, etc.).
+ *
+ * @example
+ * ```typescript
+ * import { graph } from '../rocketh/deploy.js'
+ *
+ * // Get AddressBookOps directly - never see the base AddressBook class
+ * const addressBook = graph.getIssuanceAddressBook(chainId)
+ *
+ * // Read operations
+ * const entry = addressBook.getEntry('RewardsManager')
+ * if (addressBook.entryExists('RewardsManager')) { ... }
+ *
+ * // Write operations
+ * addressBook.setProxy('RewardsManager', proxyAddr, implAddr, adminAddr, 'transparent')
+ * addressBook.setPendingImplementationWithMetadata('RewardsManager', newImplAddr, metadata)
+ * ```
+ */
+
+import type {
+ AddressBook,
+ AddressBookEntry,
+ DeploymentMetadata,
+ PendingImplementation,
+} from '@graphprotocol/toolshed/deployments'
+
+// Re-export types that callers may need
+export type { AddressBookEntry, DeploymentMetadata, PendingImplementation }
+
+/**
+ * Type alias for AddressBookOps with any contract name
+ *
+ * Use this when working with a union of different address book types,
+ * where TypeScript would otherwise infer the contract name as `never`.
+ *
+ * @example
+ * ```typescript
+ * const addressBook: AnyAddressBookOps =
+ * type === 'horizon' ? getHorizonAddressBook() : getIssuanceAddressBook()
+ *
+ * // Now methods work without type errors
+ * addressBook.getEntry(contractName)
+ * ```
+ */
+export type AnyAddressBookOps = AddressBookOps
+
+/**
+ * Data operations for address book management
+ *
+ * Wraps a base AddressBook instance with structured data operations that:
+ * - Use data-centric naming (set/clear, not record/sync)
+ * - Encapsulate field-level business logic
+ * - Enforce type safety
+ * - Maintain consistency
+ *
+ * This is Layer 1 - pure local storage operations with no on-chain interactions.
+ */
+export class AddressBookOps {
+ constructor(private readonly addressBook: AddressBook) {}
+
+ /**
+ * Set contract address
+ *
+ * Use for non-proxied contracts: Controller, EpochManager, GraphToken, etc.
+ *
+ * @example
+ * ```typescript
+ * ops.setContract('Controller', '0x123...')
+ * ```
+ */
+ setContract(name: ContractName, address: string): void {
+ this.addressBook.setEntry(name, { address })
+ }
+
+ /**
+ * Set all proxy-related fields at once
+ *
+ * Sets: address (proxy), proxy type, implementation, and proxyAdmin
+ *
+ * @example
+ * ```typescript
+ * ops.setProxy(
+ * 'RewardsManager',
+ * '0xProxy...',
+ * '0xImpl...',
+ * '0xAdmin...',
+ * 'transparent'
+ * )
+ * ```
+ */
+ setProxy(
+ name: ContractName,
+ proxyAddress: string,
+ implementationAddress: string,
+ proxyAdminAddress: string,
+ proxyType: 'graph' | 'transparent',
+ ): void {
+ this.addressBook.setEntry(name, {
+ address: proxyAddress,
+ proxy: proxyType,
+ proxyAdmin: proxyAdminAddress,
+ implementation: implementationAddress,
+ })
+ }
+
+ /**
+ * Set implementation address (active implementation)
+ *
+ * Updates the active implementation field. Does not affect pendingImplementation.
+ *
+ * @example
+ * ```typescript
+ * ops.setImplementation('RewardsManager', '0xNewImpl...')
+ * ```
+ */
+ setImplementation(name: ContractName, implementationAddress: string): void {
+ const entry = this.addressBook.getEntry(name as string)
+
+ this.addressBook.setEntry(name, {
+ ...entry,
+ implementation: implementationAddress,
+ })
+ }
+
+ /**
+ * Set proxy admin address
+ *
+ * @example
+ * ```typescript
+ * ops.setProxyAdmin('RewardsManager', '0xAdmin...')
+ * ```
+ */
+ setProxyAdmin(name: ContractName, proxyAdminAddress: string): void {
+ const entry = this.addressBook.getEntry(name as string)
+
+ this.addressBook.setEntry(name, {
+ ...entry,
+ proxyAdmin: proxyAdminAddress,
+ })
+ }
+
+ /**
+ * Promote pending implementation to active
+ *
+ * Moves pendingImplementation.address → implementation and clears pendingImplementation.
+ *
+ * @example
+ * ```typescript
+ * ops.promotePendingImplementation('RewardsManager')
+ * ```
+ *
+ * @throws Error if contract not found
+ * @throws Error if no pending implementation exists
+ */
+ promotePendingImplementation(name: ContractName): void {
+ const entry = this.addressBook.getEntry(name as string)
+
+ if (!entry) {
+ throw new Error(`Contract ${name} not found in address book`)
+ }
+
+ if (!entry.pendingImplementation) {
+ throw new Error(`No pending implementation found for ${name}`)
+ }
+
+ this.addressBook.setEntry(name, {
+ ...entry,
+ implementation: entry.pendingImplementation.address,
+ pendingImplementation: undefined,
+ })
+ }
+
+ /**
+ * Clear pending implementation
+ *
+ * Sets pendingImplementation to undefined.
+ *
+ * @example
+ * ```typescript
+ * ops.clearPendingImplementation('RewardsManager')
+ * ```
+ */
+ clearPendingImplementation(name: ContractName): void {
+ const entry = this.addressBook.getEntry(name as string)
+
+ if (!entry) {
+ throw new Error(`Contract ${name} not found in address book`)
+ }
+
+ this.addressBook.setEntry(name, {
+ ...entry,
+ pendingImplementation: undefined,
+ })
+ }
+
+ /**
+ * Set implementation and auto-clear pending if it matches
+ *
+ * This is a convenience method that:
+ * 1. Sets the implementation field to the provided address
+ * 2. If pendingImplementation matches the new implementation, clears it
+ *
+ * This encapsulates the common pattern: "set implementation from on-chain state,
+ * and if pending was applied, clear it."
+ *
+ * @example
+ * ```typescript
+ * // Caller fetches from chain, then updates address book
+ * const onChainImpl = await getImplementationAddress(proxyAddress)
+ * ops.setImplementationAndClearIfMatches('RewardsManager', onChainImpl)
+ * ```
+ */
+ setImplementationAndClearIfMatches(name: ContractName, implementationAddress: string): void {
+ const entry = this.addressBook.getEntry(name as string)
+
+ // Check if pending matches the new implementation
+ const pendingMatches = entry.pendingImplementation?.address.toLowerCase() === implementationAddress.toLowerCase()
+
+ // Update implementation and clear pending if it matches
+ this.addressBook.setEntry(name, {
+ ...entry,
+ implementation: implementationAddress,
+ ...(pendingMatches && { pendingImplementation: undefined }),
+ })
+ }
+
+ // ============================================================================
+ // Deployment Metadata Operations
+ // ============================================================================
+
+ /**
+ * Set deployment metadata for a non-proxied contract
+ *
+ * @example
+ * ```typescript
+ * ops.setDeploymentMetadata('Controller', {
+ * txHash: '0xabc...',
+ * argsData: '0x...',
+ * bytecodeHash: '0x...',
+ * blockNumber: 12345678,
+ * timestamp: '2024-01-15T10:30:00Z',
+ * })
+ * ```
+ */
+ setDeploymentMetadata(name: ContractName, metadata: DeploymentMetadata): void {
+ const entry = this.addressBook.getEntry(name as string)
+
+ this.addressBook.setEntry(name, {
+ ...entry,
+ deployment: metadata,
+ })
+ }
+
+ /**
+ * Set proxy deployment metadata (for proxied contracts)
+ *
+ * @example
+ * ```typescript
+ * ops.setProxyDeploymentMetadata('RewardsManager', {
+ * txHash: '0xabc...',
+ * argsData: '0x...',
+ * bytecodeHash: '0x...',
+ * })
+ * ```
+ */
+ setProxyDeploymentMetadata(name: ContractName, metadata: DeploymentMetadata): void {
+ const entry = this.addressBook.getEntry(name as string)
+
+ this.addressBook.setEntry(name, {
+ ...entry,
+ proxyDeployment: metadata,
+ })
+ }
+
+ /**
+ * Set implementation deployment metadata (for proxied contracts)
+ *
+ * @example
+ * ```typescript
+ * ops.setImplementationDeploymentMetadata('RewardsManager', {
+ * txHash: '0xabc...',
+ * argsData: '0x...',
+ * bytecodeHash: '0x...',
+ * })
+ * ```
+ */
+ setImplementationDeploymentMetadata(name: ContractName, metadata: DeploymentMetadata): void {
+ const entry = this.addressBook.getEntry(name as string)
+
+ this.addressBook.setEntry(name, {
+ ...entry,
+ implementationDeployment: metadata,
+ })
+ }
+
+ /**
+ * Set pending implementation deployment metadata
+ *
+ * Updates only the deployment metadata for an existing pending implementation.
+ * Use this for backfilling metadata when rocketh has newer data than address book.
+ *
+ * @example
+ * ```typescript
+ * ops.setPendingDeploymentMetadata('RewardsManager', {
+ * txHash: '0xabc...',
+ * argsData: '0x...',
+ * bytecodeHash: '0x...',
+ * })
+ * ```
+ */
+ setPendingDeploymentMetadata(name: ContractName, metadata: DeploymentMetadata): void {
+ const entry = this.addressBook.getEntry(name as string)
+
+ if (!entry?.pendingImplementation) {
+ throw new Error(`No pending implementation found for ${name}`)
+ }
+
+ this.addressBook.setEntry(name, {
+ ...entry,
+ pendingImplementation: {
+ ...entry.pendingImplementation,
+ deployment: metadata,
+ },
+ })
+ }
+
+ /**
+ * Set pending implementation with full deployment metadata for verification
+ * and record reconstruction.
+ *
+ * @example
+ * ```typescript
+ * ops.setPendingImplementationWithMetadata('RewardsManager', '0xNewImpl...', {
+ * txHash: '0xabc...',
+ * argsData: '0x...',
+ * bytecodeHash: '0x...',
+ * blockNumber: 12345678,
+ * })
+ * ```
+ */
+ setPendingImplementationWithMetadata(
+ name: ContractName,
+ implementationAddress: string,
+ metadata: DeploymentMetadata,
+ ): void {
+ const entry = this.addressBook.getEntry(name as string)
+
+ if (!entry) {
+ throw new Error(`Contract ${name} not found in address book`)
+ }
+
+ if (!entry.proxy) {
+ throw new Error(`Contract ${name} is not a proxy contract`)
+ }
+
+ const pendingImplementation: PendingImplementation = {
+ address: implementationAddress,
+ deployment: metadata,
+ }
+
+ this.addressBook.setEntry(name, {
+ ...entry,
+ pendingImplementation,
+ })
+ }
+
+ /**
+ * Promote pending implementation to active, preserving deployment metadata
+ *
+ * Moves pendingImplementation to active and transfers deployment metadata
+ * to implementationDeployment.
+ *
+ * @example
+ * ```typescript
+ * ops.promotePendingImplementationWithMetadata('RewardsManager')
+ * ```
+ */
+ promotePendingImplementationWithMetadata(name: ContractName): void {
+ const entry = this.addressBook.getEntry(name as string)
+
+ if (!entry) {
+ throw new Error(`Contract ${name} not found in address book`)
+ }
+
+ if (!entry.pendingImplementation) {
+ throw new Error(`No pending implementation found for ${name}`)
+ }
+
+ this.addressBook.setEntry(name, {
+ ...entry,
+ implementation: entry.pendingImplementation.address,
+ implementationDeployment: entry.pendingImplementation.deployment,
+ pendingImplementation: undefined,
+ })
+ }
+
+ // ============================================================================
+ // Read Operations
+ // ============================================================================
+
+ /**
+ * Get deployment metadata for a contract
+ *
+ * Returns the appropriate deployment metadata based on contract type:
+ * - Non-proxied: returns `deployment`
+ * - Proxied: returns `implementationDeployment` (the active implementation)
+ *
+ * @example
+ * ```typescript
+ * const metadata = addressBook.getDeploymentMetadata('RewardsManager')
+ * if (metadata) {
+ * console.log(`Deployed at block ${metadata.blockNumber}`)
+ * }
+ * ```
+ */
+ getDeploymentMetadata(name: ContractName): DeploymentMetadata | undefined {
+ const entry = this.addressBook.getEntry(name as string)
+ // For proxied contracts, return implementation metadata; for non-proxied, return deployment
+ return entry.proxy ? entry.implementationDeployment : entry.deployment
+ }
+
+ /**
+ * Check if deployment metadata exists and has required fields
+ *
+ * @example
+ * ```typescript
+ * if (addressBook.hasCompleteDeploymentMetadata('RewardsManager')) {
+ * // Safe to reconstruct rocketh record
+ * }
+ * ```
+ */
+ hasCompleteDeploymentMetadata(name: ContractName): boolean {
+ const metadata = this.getDeploymentMetadata(name)
+ if (!metadata) return false
+ return Boolean(metadata.txHash && metadata.argsData && metadata.bytecodeHash)
+ }
+
+ /**
+ * Get an entry from the address book
+ *
+ * @example
+ * ```typescript
+ * const entry = addressBook.getEntry('RewardsManager')
+ * console.log(entry.address, entry.implementation)
+ * ```
+ */
+ getEntry(name: ContractName): AddressBookEntry {
+ return this.addressBook.getEntry(name as string)
+ }
+
+ /**
+ * Check if an entry exists in the address book
+ *
+ * @example
+ * ```typescript
+ * if (addressBook.entryExists('RewardsManager')) {
+ * const entry = addressBook.getEntry('RewardsManager')
+ * }
+ * ```
+ */
+ entryExists(name: ContractName): boolean {
+ return this.addressBook.entryExists(name as string)
+ }
+
+ /**
+ * List all contract names with pending implementations
+ *
+ * @example
+ * ```typescript
+ * const pending = addressBook.listPendingImplementations()
+ * for (const contractName of pending) {
+ * const entry = addressBook.getEntry(contractName)
+ * console.log(`${contractName}: ${entry.pendingImplementation?.address}`)
+ * }
+ * ```
+ */
+ listPendingImplementations(): ContractName[] {
+ const contractsWithPending: ContractName[] = []
+
+ for (const contractName of this.addressBook.listEntries()) {
+ const entry = this.addressBook.getEntry(contractName)
+ if (entry?.pendingImplementation) {
+ contractsWithPending.push(contractName)
+ }
+ }
+
+ return contractsWithPending
+ }
+
+ /**
+ * Check if a name is a valid contract name for this address book
+ *
+ * @example
+ * ```typescript
+ * if (addressBook.isContractName('RewardsManager')) {
+ * // TypeScript knows this is a valid contract name
+ * }
+ * ```
+ */
+ isContractName(name: string): name is ContractName {
+ return this.addressBook.isContractName(name)
+ }
+
+ /**
+ * Set verification URL for a contract's deployment metadata.
+ * For non-proxied contracts, updates `deployment.verified`.
+ * For proxied contracts, updates `proxyDeployment.verified`.
+ *
+ * @example
+ * ```typescript
+ * ops.setVerified('RewardsManager', 'https://arbiscan.io/address/0x123#code')
+ * ```
+ */
+ setVerified(name: ContractName, verificationUrl: string): void {
+ const entry = this.addressBook.getEntry(name as string)
+ if (entry.proxy) {
+ // Proxied contract - set on proxyDeployment
+ this.addressBook.setEntry(name, {
+ ...entry,
+ proxyDeployment: { ...entry.proxyDeployment, verified: verificationUrl } as typeof entry.proxyDeployment,
+ })
+ } else {
+ // Non-proxied contract - set on deployment
+ this.addressBook.setEntry(name, {
+ ...entry,
+ deployment: { ...entry.deployment, verified: verificationUrl } as typeof entry.deployment,
+ })
+ }
+ }
+
+ /**
+ * Set implementation verification URL (for proxied contracts)
+ * Updates `implementationDeployment.verified`.
+ *
+ * @example
+ * ```typescript
+ * ops.setImplementationVerified('RewardsManager', 'https://arbiscan.io/address/0x456#code')
+ * ```
+ */
+ setImplementationVerified(name: ContractName, verificationUrl: string): void {
+ const entry = this.addressBook.getEntry(name as string)
+ this.addressBook.setEntry(name, {
+ ...entry,
+ implementationDeployment: {
+ ...entry.implementationDeployment,
+ verified: verificationUrl,
+ } as typeof entry.implementationDeployment,
+ })
+ }
+}
diff --git a/packages/deployment/lib/address-book-utils.ts b/packages/deployment/lib/address-book-utils.ts
new file mode 100644
index 000000000..086b5eb34
--- /dev/null
+++ b/packages/deployment/lib/address-book-utils.ts
@@ -0,0 +1,461 @@
+/**
+ * Address Book Utilities
+ *
+ * This module provides utilities for working with address books in deployment scripts.
+ * It handles fork mode detection, chain ID resolution, and address book instantiation.
+ *
+ * Structure:
+ * 1. Fork Mode Detection - Check if running in fork mode and get network info
+ * 2. Chain ID Resolution - Get target chain IDs for address book lookups
+ * 3. Fork State Management - Copy address books for fork-local modifications
+ * 4. Address Book Factories - Create AddressBookOps instances for each package
+ */
+
+import { existsSync, mkdirSync, copyFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import path from 'node:path'
+
+import type { Environment } from '@rocketh/core/types'
+import type {
+ GraphHorizonContractName,
+ GraphIssuanceContractName,
+ SubgraphServiceContractName,
+} from '@graphprotocol/toolshed/deployments'
+import {
+ GraphHorizonAddressBook,
+ GraphIssuanceAddressBook,
+ SubgraphServiceAddressBook,
+} from '@graphprotocol/toolshed/deployments'
+
+import { config as rockethConfig } from '../rocketh/config.js'
+import type { AnyAddressBookOps } from './address-book-ops.js'
+import { AddressBookOps } from './address-book-ops.js'
+import type { AddressBookType } from './contract-registry.js'
+
+const require = createRequire(import.meta.url)
+
+// ============================================================================
+// Fork Auto-Detection
+// ============================================================================
+
+/**
+ * Build a map from RPC URL hostname to network name using rocketh config.
+ * Used by autoDetectForkNetwork() to match anvil's forkUrl.
+ */
+function buildRpcHostToNetworkMap(): Map {
+ const map = new Map()
+ const environments = rockethConfig.environments
+ const chains = rockethConfig.chains
+ if (!environments || !chains) return map
+
+ for (const [envName, envConfig] of Object.entries(environments)) {
+ const chainId = (envConfig as { chain: number }).chain
+ const chainConfig = (chains as Record)[chainId] as
+ | { info?: { rpcUrls?: { default?: { http?: readonly string[] } } } }
+ | undefined
+ const rpcUrls = chainConfig?.info?.rpcUrls?.default?.http
+ if (!rpcUrls) continue
+
+ for (const rpcUrl of rpcUrls) {
+ try {
+ const hostname = new URL(rpcUrl).hostname
+ map.set(hostname, { name: envName, chainId })
+ } catch {
+ // Skip invalid URLs
+ }
+ }
+ }
+ return map
+}
+
+/**
+ * Auto-detect the fork network by querying anvil's `anvil_nodeInfo` RPC method.
+ *
+ * If FORK_NETWORK is already set, this is a no-op.
+ * If the provider is an anvil fork, extracts the fork URL and matches it
+ * against known network RPC hostnames from rocketh config.
+ *
+ * On success, sets process.env.FORK_NETWORK so all downstream synchronous
+ * functions (isForkMode, getForkNetwork, etc.) work without changes.
+ *
+ * @param rpcUrl - The RPC URL to query (default: http://127.0.0.1:8545)
+ * @returns The detected network name, or null if not a fork / not detectable
+ */
+export async function autoDetectForkNetwork(rpcUrl = 'http://127.0.0.1:8545'): Promise {
+ // Already set — nothing to do
+ if (process.env.FORK_NETWORK || process.env.HARDHAT_FORK) {
+ return process.env.FORK_NETWORK || process.env.HARDHAT_FORK || null
+ }
+
+ try {
+ const response = await fetch(rpcUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'anvil_nodeInfo', params: [], id: 1 }),
+ })
+ const json = (await response.json()) as {
+ result?: { forkConfig?: { forkUrl?: string } }
+ }
+ const forkUrl = json.result?.forkConfig?.forkUrl
+ if (!forkUrl) return null
+
+ // Match fork URL hostname against known networks
+ const hostMap = buildRpcHostToNetworkMap()
+ const forkHostname = new URL(forkUrl).hostname
+ const match = hostMap.get(forkHostname)
+ if (!match) return null
+
+ // Set env var so all synchronous fork detection works downstream
+ process.env.FORK_NETWORK = match.name
+ return match.name
+ } catch {
+ // Not reachable or not anvil — not a fork
+ return null
+ }
+}
+
+// ============================================================================
+// Fork Mode Detection
+// ============================================================================
+
+/** Network names that are local/test and support fork mode */
+const LOCAL_NETWORKS = new Set(['localhost', 'fork', 'hardhat'])
+
+/**
+ * Check if the current network is a local network.
+ * Uses explicit networkName if provided, falls back to HARDHAT_NETWORK env var.
+ * Returns true if network is unknown (preserves existing behavior for callers
+ * that don't pass context).
+ */
+function isLocalNetwork(networkName?: string): boolean {
+ const name = networkName ?? process.env.HARDHAT_NETWORK
+ if (name === undefined) return true
+ return LOCAL_NETWORKS.has(name)
+}
+
+/**
+ * Check if running in fork mode.
+ *
+ * Fork mode requires both:
+ * 1. FORK_NETWORK or HARDHAT_FORK env var is set
+ * 2. The current network is local (localhost, fork, hardhat)
+ *
+ * This prevents fork mode from activating when running against real networks
+ * even if FORK_NETWORK is still set in the environment.
+ *
+ * @param networkName - Optional network name for explicit check (e.g., env.name).
+ * Falls back to HARDHAT_NETWORK env var if not provided.
+ */
+export function isForkMode(networkName?: string): boolean {
+ if (!isLocalNetwork(networkName)) return false
+ return !!(process.env.HARDHAT_FORK || process.env.FORK_NETWORK)
+}
+
+/**
+ * Get the fork network name from environment.
+ * Returns null if not in fork mode or if running on a real network.
+ *
+ * @param networkName - Optional network name for explicit check.
+ * Falls back to HARDHAT_NETWORK env var if not provided.
+ */
+export function getForkNetwork(networkName?: string): string | null {
+ if (!isLocalNetwork(networkName)) return null
+ return process.env.HARDHAT_FORK || process.env.FORK_NETWORK || null
+}
+
+// ============================================================================
+// Local Network Detection
+// ============================================================================
+
+/**
+ * Check if running against the Graph local network (chainId 1337).
+ *
+ * The local network deploys contracts from scratch. Address books use
+ * addresses-local-network.json files that the orchestrating dev environment
+ * is expected to symlink (or otherwise create) before this code path runs.
+ */
+export function isLocalNetworkMode(): boolean {
+ return process.env.HARDHAT_NETWORK === 'localNetwork'
+}
+
+/**
+ * Get the fork state directory for a given network.
+ * All fork-related state (address books, governance TXs) is stored here.
+ *
+ * Returns: fork///
+ *
+ * Stored outside deployments/ so rocketh manages its own directory cleanly.
+ *
+ * @param envName - Hardhat network name (e.g., 'fork', 'localhost')
+ * @param forkNetwork - Fork network name (e.g., 'arbitrumSepolia', 'arbitrumOne')
+ */
+export function getForkStateDir(envName: string, forkNetwork: string): string {
+ return path.resolve(process.cwd(), 'fork', envName, forkNetwork)
+}
+
+/**
+ * Get the target chain ID for fork mode address book lookups.
+ * Uses rocketh config to map FORK_NETWORK environment variable to actual chain IDs.
+ *
+ * Returns null if not in fork mode - callers should use provider chain ID instead.
+ *
+ * @example
+ * const forkChainId = getForkTargetChainId()
+ * const targetChainId = forkChainId ?? providerChainId
+ */
+export function getForkTargetChainId(networkName?: string): number | null {
+ const forkNetwork = getForkNetwork(networkName)
+ if (!forkNetwork) return null
+
+ // Look up chain ID from rocketh config environments
+ const environments = rockethConfig.environments
+ if (!environments) {
+ throw new Error('rocketh config missing environments')
+ }
+
+ const environment = environments[forkNetwork as keyof typeof environments]
+ if (!environment) {
+ throw new Error(`Unknown fork network: ${forkNetwork}. Not found in rocketh config.`)
+ }
+
+ const chainId = environment.chain
+ if (typeof chainId !== 'number') {
+ throw new Error(`Invalid chain ID for fork network ${forkNetwork}`)
+ }
+
+ return chainId
+}
+
+// ============================================================================
+// Chain ID Resolution
+// ============================================================================
+
+/**
+ * Get the target chain ID for address book and transaction operations.
+ * This is the single canonical function for resolving chain IDs.
+ *
+ * In fork mode: Returns the fork target chain ID (e.g., 42161 for arbitrumOne fork)
+ * In non-fork mode: Returns the provider's actual chain ID
+ *
+ * @param env - Rocketh environment (used to query provider)
+ * @returns The target chain ID to use for address book lookups and transactions
+ *
+ * @example
+ * const targetChainId = await getTargetChainIdFromEnv(env)
+ * const addressBook = getIssuanceAddressBook(targetChainId)
+ */
+export async function getTargetChainIdFromEnv(env: Environment): Promise {
+ const forkChainId = getForkTargetChainId(env.name)
+ if (forkChainId !== null) {
+ return forkChainId
+ }
+
+ // Not in fork mode - get actual chain ID from provider
+ const chainIdHex = await env.network.provider.request({ method: 'eth_chainId' })
+ const providerChainId = Number(chainIdHex)
+
+ // If we're on local chain 31337 without FORK_NETWORK set, the user is most
+ // likely running against an anvil fork. Try auto-detecting once so callers
+ // (per-component sync, status scripts) can resolve the right address book
+ // without requiring the global sync script to have run first.
+ if (providerChainId === 31337 && !getForkNetwork(env.name)) {
+ const detected = await autoDetectForkNetwork()
+ if (detected) {
+ const detectedForkChainId = getForkTargetChainId(env.name)
+ if (detectedForkChainId !== null) return detectedForkChainId
+ }
+ }
+
+ return providerChainId
+}
+
+// ============================================================================
+// Fork State Management
+// ============================================================================
+
+/**
+ * Get the directory for fork-local address book copies.
+ * Uses FORK_NETWORK to determine subdirectory.
+ *
+ * Note: This function doesn't have access to env.name, so it infers the hardhat
+ * network from process.env.HARDHAT_NETWORK (set by Hardhat at runtime).
+ * Falls back to 'localhost' if not set.
+ */
+function getForkAddressBooksDir(): string {
+ const forkNetwork = getForkNetwork()
+ if (!forkNetwork) {
+ throw new Error('getForkAddressBooksDir called but not in fork mode')
+ }
+ // Infer hardhat network from environment (set by hardhat at runtime)
+ const envName = process.env.HARDHAT_NETWORK || 'localhost'
+ return getForkStateDir(envName, forkNetwork)
+}
+
+/**
+ * Ensure fork address book copies exist.
+ * Called once at the start of sync to set up fork-local copies.
+ * Copies canonical address books to fork-state directory on first use.
+ *
+ * @returns Object with paths to the fork-local address books
+ */
+export function ensureForkAddressBooks(): {
+ horizonPath: string
+ subgraphServicePath: string
+ issuancePath: string
+} {
+ const forkNetwork = getForkNetwork()
+ if (!forkNetwork) {
+ throw new Error('ensureForkAddressBooks called but not in fork mode')
+ }
+
+ const forkDir = getForkAddressBooksDir()
+
+ // Create directory if it doesn't exist
+ if (!existsSync(forkDir)) {
+ mkdirSync(forkDir, { recursive: true })
+ }
+
+ const horizonSourcePath = require.resolve('@graphprotocol/horizon/addresses.json')
+ const ssSourcePath = require.resolve('@graphprotocol/subgraph-service/addresses.json')
+ const issuanceSourcePath = require.resolve('@graphprotocol/issuance/addresses.json')
+
+ const horizonForkPath = path.join(forkDir, 'horizon-addresses.json')
+ const ssForkPath = path.join(forkDir, 'subgraph-service-addresses.json')
+ const issuanceForkPath = path.join(forkDir, 'issuance-addresses.json')
+
+ // Copy if fork copies don't exist yet
+ if (!existsSync(horizonForkPath)) {
+ copyFileSync(horizonSourcePath, horizonForkPath)
+ }
+ if (!existsSync(ssForkPath)) {
+ copyFileSync(ssSourcePath, ssForkPath)
+ }
+ if (!existsSync(issuanceForkPath)) {
+ copyFileSync(issuanceSourcePath, issuanceForkPath)
+ }
+
+ return {
+ horizonPath: horizonForkPath,
+ subgraphServicePath: ssForkPath,
+ issuancePath: issuanceForkPath,
+ }
+}
+
+// ============================================================================
+// Address Book Path Utilities
+// ============================================================================
+
+/**
+ * Get the path to the Horizon address book.
+ * In fork mode, returns path to fork-local copy.
+ * In local network mode, returns path to addresses-local-network.json.
+ * In normal mode, returns path to package address book.
+ */
+export function getHorizonAddressBookPath(): string {
+ if (isForkMode()) {
+ const { horizonPath } = ensureForkAddressBooks()
+ return horizonPath
+ }
+ if (isLocalNetworkMode()) {
+ return require.resolve('@graphprotocol/horizon/addresses-local-network.json')
+ }
+ return require.resolve('@graphprotocol/horizon/addresses.json')
+}
+
+/**
+ * Get the path to the SubgraphService address book.
+ * In fork mode, returns path to fork-local copy.
+ * In local network mode, returns path to addresses-local-network.json.
+ * In normal mode, returns path to package address book.
+ */
+export function getSubgraphServiceAddressBookPath(): string {
+ if (isForkMode()) {
+ const { subgraphServicePath } = ensureForkAddressBooks()
+ return subgraphServicePath
+ }
+ if (isLocalNetworkMode()) {
+ return require.resolve('@graphprotocol/subgraph-service/addresses-local-network.json')
+ }
+ return require.resolve('@graphprotocol/subgraph-service/addresses.json')
+}
+
+/**
+ * Get the path to the Issuance address book.
+ * In fork mode, returns path to fork-local copy.
+ * In local network mode, returns path to addresses-local-network.json.
+ * In normal mode, returns path to package address book.
+ */
+export function getIssuanceAddressBookPath(): string {
+ if (isForkMode()) {
+ const { issuancePath } = ensureForkAddressBooks()
+ return issuancePath
+ }
+ if (isLocalNetworkMode()) {
+ return require.resolve('@graphprotocol/issuance/addresses-local-network.json')
+ }
+ return require.resolve('@graphprotocol/issuance/addresses.json')
+}
+
+// ============================================================================
+// Address Book Factories
+// ============================================================================
+
+/**
+ * Get an AddressBookOps instance for Graph Horizon contracts.
+ * Automatically uses fork-local copy in fork mode.
+ *
+ * @param chainId - Target chain ID. In fork mode, uses fork target chain ID if not provided.
+ * In non-fork mode, must be provided by caller (from provider).
+ */
+export function getHorizonAddressBook(chainId?: number): AddressBookOps {
+ const targetChainId = chainId ?? getForkTargetChainId() ?? 31337
+ const baseAddressBook = new GraphHorizonAddressBook(getHorizonAddressBookPath(), targetChainId)
+ return new AddressBookOps(baseAddressBook)
+}
+
+/**
+ * Get an AddressBookOps instance for Subgraph Service contracts.
+ * Automatically uses fork-local copy in fork mode.
+ *
+ * @param chainId - Target chain ID. In fork mode, uses fork target chain ID if not provided.
+ * In non-fork mode, must be provided by caller (from provider).
+ */
+export function getSubgraphServiceAddressBook(chainId?: number): AddressBookOps {
+ const targetChainId = chainId ?? getForkTargetChainId() ?? 31337
+ const baseAddressBook = new SubgraphServiceAddressBook(getSubgraphServiceAddressBookPath(), targetChainId)
+ return new AddressBookOps(baseAddressBook)
+}
+
+/**
+ * Get an AddressBookOps instance for Graph Issuance contracts.
+ * Automatically uses fork-local copy in fork mode.
+ *
+ * @param chainId - Target chain ID. In fork mode, uses fork target chain ID if not provided.
+ * In non-fork mode, must be provided by caller (from provider).
+ */
+export function getIssuanceAddressBook(chainId?: number): AddressBookOps {
+ const targetChainId = chainId ?? getForkTargetChainId() ?? 31337
+ const baseAddressBook = new GraphIssuanceAddressBook(getIssuanceAddressBookPath(), targetChainId)
+ return new AddressBookOps(baseAddressBook)
+}
+
+/**
+ * Get the address book ops for a contract by its declared address book type.
+ *
+ * Single routing point — adding a new address book type will surface as a
+ * TypeScript exhaustiveness error here rather than silently misrouting.
+ */
+export function getAddressBookForType(type: AddressBookType, chainId?: number): AnyAddressBookOps {
+ switch (type) {
+ case 'horizon':
+ return getHorizonAddressBook(chainId)
+ case 'subgraph-service':
+ return getSubgraphServiceAddressBook(chainId)
+ case 'issuance':
+ return getIssuanceAddressBook(chainId)
+ default: {
+ const _exhaustive: never = type
+ throw new Error(`Unknown address book type: ${String(_exhaustive)}`)
+ }
+ }
+}
diff --git a/packages/deployment/lib/apply-configuration.ts b/packages/deployment/lib/apply-configuration.ts
new file mode 100644
index 000000000..8bc4e14a1
--- /dev/null
+++ b/packages/deployment/lib/apply-configuration.ts
@@ -0,0 +1,164 @@
+/**
+ * Apply Configuration Utility
+ *
+ * Generic utility for checking and applying configuration conditions in deploy mode.
+ * Handles the standard pattern: check conditions → generate TXs for gaps → execute or save.
+ * Supports both param conditions (getter/setter) and role conditions (hasRole/grantRole).
+ */
+
+import type { Environment } from '@rocketh/core/types'
+import type { PublicClient } from 'viem'
+import { encodeFunctionData } from 'viem'
+
+import {
+ type ConfigCondition,
+ type ConfigurationStatus,
+ type ParamCondition,
+ type RoleCondition,
+ checkConditions,
+} from './contract-checks.js'
+import { createGovernanceTxBuilder, executeTxBatchDirect, saveGovernanceTx } from './execute-governance.js'
+
+/**
+ * Options for applyConfiguration
+ */
+export interface ApplyConfigurationOptions {
+ /** Contract name (for messages and TX batch naming) */
+ contractName: string
+
+ /** Contract address */
+ contractAddress: string
+
+ /** Whether the caller can execute directly (has required role) */
+ canExecuteDirectly: boolean
+
+ /** Account to execute from (if canExecuteDirectly) */
+ executor?: string
+}
+
+/**
+ * Result of applyConfiguration
+ */
+export interface ApplyConfigurationResult {
+ /** Status of all conditions (T | boolean due to mixed param/role conditions) */
+ status: ConfigurationStatus
+
+ /** Whether any changes were made/proposed */
+ changesNeeded: boolean
+
+ /** Whether changes were executed directly (vs saved for governance) */
+ executedDirectly: boolean
+}
+
+/**
+ * Apply configuration conditions in deploy mode
+ *
+ * Standard flow:
+ * 1. Check all conditions against on-chain state
+ * 2. If all OK, return (no-op)
+ * 3. Build TX batch for conditions that need updating
+ * 4. If canExecuteDirectly: execute TXs and return
+ * 5. If not: save TX batch for governance and exit
+ *
+ * @example
+ * ```typescript
+ * const conditions = createREOConditions()
+ * const result = await applyConfiguration(env, client, conditions, {
+ * contractName: 'RewardsEligibilityOracle',
+ * contractAddress: reoAddress,
+ * canExecuteDirectly: deployerHasGovernorRole,
+ * executor: deployer,
+ * })
+ * ```
+ */
+export async function applyConfiguration(
+ env: Environment,
+ client: PublicClient,
+ conditions: ConfigCondition[],
+ options: ApplyConfigurationOptions,
+): Promise> {
+ const { contractName, contractAddress, canExecuteDirectly, executor } = options
+
+ // 1. Check all conditions
+ env.showMessage(`📋 Checking ${contractName} configuration...\n`)
+
+ const status = await checkConditions(client, contractAddress, conditions)
+
+ // Display results
+ for (const result of status.conditions) {
+ env.showMessage(` ${result.message}`)
+ }
+
+ // 2. If all OK, no-op
+ if (status.allOk) {
+ env.showMessage(`\n✅ ${contractName} configuration already matches target\n`)
+ return { status, changesNeeded: false, executedDirectly: false }
+ }
+
+ // 3. Build TX batch for failing conditions
+ env.showMessage('\n🔨 Building configuration TX batch...\n')
+
+ const builder = await createGovernanceTxBuilder(env, `configure-${contractName}`)
+
+ const failingConditions = conditions.filter((_, i) => !status.conditions[i].ok)
+
+ for (const condition of failingConditions) {
+ if (condition.type === 'role') {
+ // Role condition: fetch role bytes32, then grantRole or revokeRole
+ const roleCondition = condition as RoleCondition
+ const action = roleCondition.action ?? 'grant'
+ const role = (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: roleCondition.abi,
+ functionName: roleCondition.roleGetter,
+ })) as `0x${string}`
+
+ const functionName = action === 'grant' ? 'grantRole' : 'revokeRole'
+ const data = encodeFunctionData({
+ abi: roleCondition.abi,
+ functionName,
+ args: [role, roleCondition.targetAccount as `0x${string}`],
+ })
+ builder.addTx({ to: contractAddress, value: '0', data })
+
+ const formatAccount = roleCondition.formatAccount ?? ((a) => a)
+ env.showMessage(` + ${functionName}(${roleCondition.roleGetter}, ${formatAccount(roleCondition.targetAccount)})`)
+ } else {
+ // Param condition: simple setter call
+ const paramCondition = condition as ParamCondition
+ const data = encodeFunctionData({
+ abi: paramCondition.abi,
+ functionName: paramCondition.setter,
+ args: [paramCondition.target],
+ })
+ builder.addTx({ to: contractAddress, value: '0', data })
+
+ const format = paramCondition.format ?? String
+ env.showMessage(` + ${paramCondition.setter}(${format(paramCondition.target)})`)
+ }
+ }
+
+ // 4/5. Execute or save based on access
+ if (canExecuteDirectly && executor) {
+ env.showMessage('\n🔨 Executing configuration TX batch...\n')
+ await executeTxBatchDirect(env, builder, executor)
+ env.showMessage(`\n✅ ${contractName} configuration updated\n`)
+ return { status, changesNeeded: true, executedDirectly: true }
+ } else {
+ saveGovernanceTx(env, builder, `${contractName} configuration`)
+ return { status, changesNeeded: true, executedDirectly: false }
+ }
+}
+
+/**
+ * Check configuration status only (no TX generation)
+ *
+ * Use this for status checks outside of deploy mode.
+ */
+export async function checkConfigurationStatus(
+ client: PublicClient,
+ contractAddress: string,
+ conditions: ConfigCondition[],
+): Promise> {
+ return checkConditions(client, contractAddress, conditions)
+}
diff --git a/packages/deployment/lib/artifact-loaders.ts b/packages/deployment/lib/artifact-loaders.ts
new file mode 100644
index 000000000..e48c6e587
--- /dev/null
+++ b/packages/deployment/lib/artifact-loaders.ts
@@ -0,0 +1,215 @@
+import { readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+
+import type { Artifact } from '@rocketh/core/types'
+
+import type { LibraryArtifactResolver, LinkReferences } from './bytecode-utils.js'
+
+// Create require for JSON imports in ESM
+const require = createRequire(import.meta.url)
+
+/**
+ * Load artifact from @graphprotocol/contracts package
+ *
+ * @param contractPath - Path within contracts/ (e.g., 'rewards', 'l2/token')
+ * @param contractName - Contract name (e.g., 'RewardsManager', 'L2GraphToken')
+ */
+export function loadContractsArtifact(contractPath: string, contractName: string): Artifact {
+ const artifactPath = require.resolve(
+ `@graphprotocol/contracts/artifacts/contracts/${contractPath}/${contractName}.sol/${contractName}.json`,
+ )
+ const artifact = JSON.parse(readFileSync(artifactPath, 'utf-8'))
+ return {
+ abi: artifact.abi,
+ bytecode: artifact.bytecode as `0x${string}`,
+ deployedBytecode: artifact.deployedBytecode as `0x${string}`,
+ metadata: artifact.metadata || '',
+ }
+}
+
+/**
+ * Load artifact from @graphprotocol/subgraph-service package (Hardhat format)
+ *
+ * @param contractName - Contract name (e.g., 'SubgraphService')
+ */
+export function loadSubgraphServiceArtifact(contractName: string): Artifact {
+ // Support subdirectory names like 'libraries/IndexingAgreement'
+ const baseName = contractName.includes('/') ? contractName.split('/').pop()! : contractName
+ const artifactPath = require.resolve(
+ `@graphprotocol/subgraph-service/artifacts/contracts/${contractName}.sol/${baseName}.json`,
+ )
+ const artifact = JSON.parse(readFileSync(artifactPath, 'utf-8'))
+
+ return {
+ abi: artifact.abi,
+ bytecode: artifact.bytecode as `0x${string}`,
+ deployedBytecode: artifact.deployedBytecode as `0x${string}`,
+ metadata: artifact.metadata || '',
+ linkReferences: artifact.linkReferences,
+ deployedLinkReferences: artifact.deployedLinkReferences,
+ }
+}
+
+/**
+ * Load artifact from @graphprotocol/issuance package
+ *
+ * @param artifactSubpath - Path within artifacts/ (e.g., 'contracts/allocate/IssuanceAllocator.sol/IssuanceAllocator')
+ */
+export function loadIssuanceArtifact(artifactSubpath: string): Artifact {
+ const artifactPath = require.resolve(`@graphprotocol/issuance/artifacts/${artifactSubpath}.json`)
+ const artifact = JSON.parse(readFileSync(artifactPath, 'utf-8'))
+ return {
+ abi: artifact.abi,
+ bytecode: artifact.bytecode as `0x${string}`,
+ deployedBytecode: artifact.deployedBytecode as `0x${string}`,
+ metadata: artifact.metadata || '',
+ linkReferences: artifact.linkReferences,
+ deployedLinkReferences: artifact.deployedLinkReferences,
+ }
+}
+
+/**
+ * Load artifact from @graphprotocol/horizon package build directory
+ *
+ * @param artifactSubpath - Path within build/contracts/ (e.g., '@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol/ProxyAdmin')
+ */
+export function loadHorizonBuildArtifact(artifactSubpath: string): Artifact {
+ const artifactPath = require.resolve(`@graphprotocol/horizon/artifacts/${artifactSubpath}.json`)
+ const artifact = JSON.parse(readFileSync(artifactPath, 'utf-8'))
+ return {
+ abi: artifact.abi,
+ bytecode: artifact.bytecode as `0x${string}`,
+ deployedBytecode: artifact.deployedBytecode as `0x${string}`,
+ metadata: artifact.metadata || '',
+ linkReferences: artifact.linkReferences,
+ deployedLinkReferences: artifact.deployedLinkReferences,
+ }
+}
+
+/**
+ * Load artifact from @openzeppelin/contracts package build directory
+ *
+ * @param contractName - Contract name (e.g., 'ProxyAdmin', 'AccessControl')
+ */
+export function loadOpenZeppelinArtifact(contractName: string): Artifact {
+ const artifactPath = require.resolve(`@openzeppelin/contracts/build/contracts/${contractName}.json`)
+ const artifact = JSON.parse(readFileSync(artifactPath, 'utf-8'))
+ return {
+ abi: artifact.abi,
+ bytecode: artifact.bytecode as `0x${string}`,
+ deployedBytecode: artifact.deployedBytecode as `0x${string}`,
+ metadata: artifact.metadata || '',
+ }
+}
+
+/**
+ * Create a library artifact resolver for a given package.
+ *
+ * Library artifacts live at /artifacts//.json,
+ * mirroring the linkReferences source paths from Hardhat compilation.
+ */
+function createPackageLibraryResolver(packagePrefix: string): LibraryArtifactResolver {
+ return (sourcePath: string, libraryName: string) => {
+ try {
+ const libPath = require.resolve(`${packagePrefix}/${sourcePath}/${libraryName}.json`)
+ const artifact = JSON.parse(readFileSync(libPath, 'utf-8'))
+ return {
+ deployedBytecode: artifact.deployedBytecode as string,
+ deployedLinkReferences: artifact.deployedLinkReferences as LinkReferences | undefined,
+ }
+ } catch {
+ return undefined
+ }
+ }
+}
+
+/**
+ * Get a library artifact resolver for the given artifact source type.
+ * Returns undefined if the source type doesn't support library resolution.
+ */
+export function getLibraryResolver(sourceType: string): LibraryArtifactResolver | undefined {
+ switch (sourceType) {
+ case 'subgraph-service':
+ return createPackageLibraryResolver('@graphprotocol/subgraph-service/artifacts')
+ case 'horizon':
+ return createPackageLibraryResolver('@graphprotocol/horizon/artifacts')
+ case 'issuance':
+ return createPackageLibraryResolver('@graphprotocol/issuance/artifacts')
+ case 'contracts':
+ return createPackageLibraryResolver('@graphprotocol/contracts/artifacts')
+ default:
+ return undefined
+ }
+}
+
+/**
+ * Pre-link library addresses into an artifact's creation bytecode.
+ *
+ * Rocketh's deploy() stores the artifact's bytecode verbatim but compares
+ * against linked bytecode on subsequent runs. For artifacts with library
+ * references this causes a permanent mismatch (unlinked placeholders vs
+ * resolved addresses), triggering a redeploy every time.
+ *
+ * Call this before passing the artifact to rocketh's deploy(). The returned
+ * artifact has fully resolved bytecode and cleared linkReferences, so
+ * rocketh stores what it will compare against next run.
+ *
+ * @param artifact - Artifact with unlinked bytecode and linkReferences
+ * @param libraries - Map of library name → deployed address
+ */
+export function linkArtifactLibraries(artifact: Artifact, libraries: Record): Artifact {
+ let bytecode = artifact.bytecode as string
+
+ if (artifact.linkReferences) {
+ for (const [, fileReferences] of Object.entries(
+ artifact.linkReferences as Record>>,
+ )) {
+ for (const [libName, fixups] of Object.entries(fileReferences)) {
+ const addr = libraries[libName]
+ if (!addr) continue
+ for (const fixup of fixups) {
+ bytecode =
+ bytecode.substring(0, 2 + fixup.start * 2) +
+ addr.substring(2) +
+ bytecode.substring(2 + (fixup.start + fixup.length) * 2)
+ }
+ }
+ }
+ }
+
+ return {
+ ...artifact,
+ bytecode: bytecode as `0x${string}`,
+ linkReferences: undefined,
+ }
+}
+
+/**
+ * Load OpenZeppelin TransparentUpgradeableProxy artifact (v5)
+ */
+export function loadTransparentProxyArtifact(): Artifact {
+ return loadOpenZeppelinArtifact('TransparentUpgradeableProxy')
+}
+
+// Convenience functions for common issuance contracts
+
+/**
+ * Load IssuanceAllocator artifact
+ */
+export function loadIssuanceAllocatorArtifact(): Artifact {
+ return loadIssuanceArtifact('contracts/allocate/IssuanceAllocator.sol/IssuanceAllocator')
+}
+
+/**
+ * Load DirectAllocation artifact
+ */
+export function loadDirectAllocationArtifact(): Artifact {
+ return loadIssuanceArtifact('contracts/allocate/DirectAllocation.sol/DirectAllocation')
+}
+
+/**
+ * Load RewardsEligibilityOracle artifact
+ */
+export function loadRewardsEligibilityOracleArtifact(): Artifact {
+ return loadIssuanceArtifact('contracts/eligibility/RewardsEligibilityOracle.sol/RewardsEligibilityOracle')
+}
diff --git a/packages/deployment/lib/bytecode-utils.ts b/packages/deployment/lib/bytecode-utils.ts
new file mode 100644
index 000000000..f08795b48
--- /dev/null
+++ b/packages/deployment/lib/bytecode-utils.ts
@@ -0,0 +1,149 @@
+import { keccak256, toUtf8Bytes } from 'ethers'
+
+/**
+ * Bytecode utilities for smart contract deployment.
+ *
+ * These utilities handle bytecode hashing for change detection:
+ * - Strip Solidity CBOR metadata (varies between compilations)
+ * - Resolve library placeholders using actual library bytecode
+ * - Compute stable bytecode hash for comparison
+ *
+ * This allows detecting when local artifact code has changed by comparing
+ * stored bytecodeHash with the current artifact's hash.
+ */
+
+/**
+ * Hardhat artifact link references: sourcePath → libraryName → offsets[]
+ */
+export type LinkReferences = Record>>
+
+/**
+ * Resolves a library artifact given its source path and name.
+ * Returns the artifact's deployedBytecode and its own linkReferences (for recursion).
+ */
+export type LibraryArtifactResolver = (
+ sourcePath: string,
+ libraryName: string,
+) => { deployedBytecode: string; deployedLinkReferences?: LinkReferences } | undefined
+
+/**
+ * Strip Solidity metadata from bytecode.
+ * Metadata is CBOR-encoded at the end, with last 2 bytes indicating length.
+ */
+export function stripMetadata(bytecode: string): string {
+ if (!bytecode || bytecode.length < 4) return bytecode
+ // Remove 0x prefix for processing
+ const code = bytecode.startsWith('0x') ? bytecode.slice(2) : bytecode
+ if (code.length < 4) return bytecode
+
+ // Last 2 bytes = metadata length (big-endian)
+ const metadataLength = parseInt(code.slice(-4), 16)
+ // Sanity check: metadata should be reasonable size (< 500 bytes = 1000 hex chars)
+ if (metadataLength > 500 || metadataLength * 2 + 4 > code.length) {
+ return bytecode // Can't strip, return as-is
+ }
+ // Strip metadata + 2-byte length suffix
+ const prefix = bytecode.startsWith('0x') ? '0x' : ''
+ return prefix + code.slice(0, -(metadataLength * 2 + 4))
+}
+
+/**
+ * Compute the Solidity library placeholder hash for a given source path and name.
+ * This is keccak256("sourcePath:libraryName") truncated to 34 hex chars (17 bytes).
+ */
+function libraryPlaceholderHash(sourcePath: string, libraryName: string): string {
+ return keccak256(toUtf8Bytes(`${sourcePath}:${libraryName}`)).slice(2, 36)
+}
+
+/**
+ * Resolve library placeholders in bytecode using actual library bytecode hashes.
+ *
+ * For each library in deployedLinkReferences, computes its bytecode hash
+ * (recursively resolving its own library deps) and substitutes that hash
+ * (truncated to 20 bytes / 40 hex chars) into the placeholder slots.
+ *
+ * This means the final hash reflects both the contract's code and all
+ * transitive library code. If any library changes, the hash changes.
+ */
+function resolveLibraryPlaceholders(
+ bytecode: string,
+ linkReferences: LinkReferences | undefined,
+ resolver: LibraryArtifactResolver | undefined,
+): string {
+ if (!linkReferences || !resolver) {
+ // No link references or no resolver — zero out any remaining placeholders
+ return bytecode.replace(/__\$[0-9a-fA-F]{34}\$__/g, '0'.repeat(40))
+ }
+
+ let result = bytecode
+ for (const [sourcePath, libraries] of Object.entries(linkReferences)) {
+ for (const libraryName of Object.keys(libraries)) {
+ const placeholderHash = libraryPlaceholderHash(sourcePath, libraryName)
+ const placeholder = `__\\$${placeholderHash}\\$__`
+
+ const libArtifact = resolver(sourcePath, libraryName)
+ let replacement: string
+ if (libArtifact) {
+ // Recursively compute the library's bytecode hash (handles nested deps)
+ const libHash = computeBytecodeHashWithLibraries(
+ libArtifact.deployedBytecode,
+ libArtifact.deployedLinkReferences,
+ resolver,
+ )
+ // Use first 40 hex chars (20 bytes) of the hash as the replacement
+ replacement = libHash.slice(2, 42)
+ } else {
+ // Library artifact not available — zero fill
+ replacement = '0'.repeat(40)
+ }
+
+ result = result.replace(new RegExp(placeholder, 'g'), replacement)
+ }
+ }
+
+ // Zero any remaining unresolved placeholders (shouldn't happen but defensive)
+ return result.replace(/__\$[0-9a-fA-F]{34}\$__/g, '0'.repeat(40))
+}
+
+/**
+ * Compute a stable hash of bytecode for change detection, with library resolution.
+ *
+ * Normalizations applied before hashing:
+ * - Strip CBOR metadata suffix (varies between compilations)
+ * - Resolve library placeholders with actual library bytecode hashes
+ *
+ * @param bytecode - The bytecode to hash
+ * @param linkReferences - Artifact's deployedLinkReferences (optional)
+ * @param resolver - Function to load library artifacts (optional)
+ * @returns keccak256 hash of the normalized bytecode
+ */
+function computeBytecodeHashWithLibraries(
+ bytecode: string,
+ linkReferences: LinkReferences | undefined,
+ resolver: LibraryArtifactResolver | undefined,
+): string {
+ const stripped = stripMetadata(bytecode)
+ const resolved = resolveLibraryPlaceholders(stripped, linkReferences, resolver)
+ const prefixed = resolved.startsWith('0x') ? resolved : `0x${resolved}`
+ return keccak256(prefixed)
+}
+
+/**
+ * Compute a stable hash of bytecode for change detection.
+ *
+ * For simple contracts (no library references), pass just the bytecode.
+ * For contracts with external libraries, pass linkReferences and a resolver
+ * to include transitive library code in the hash.
+ *
+ * @param bytecode - The bytecode to hash (typically artifact.deployedBytecode)
+ * @param linkReferences - Artifact's deployedLinkReferences (optional)
+ * @param resolver - Function to load library artifacts for recursive resolution (optional)
+ * @returns keccak256 hash of the bytecode with metadata stripped
+ */
+export function computeBytecodeHash(
+ bytecode: string,
+ linkReferences?: LinkReferences,
+ resolver?: LibraryArtifactResolver,
+): string {
+ return computeBytecodeHashWithLibraries(bytecode, linkReferences, resolver)
+}
diff --git a/packages/deployment/lib/contract-checks.ts b/packages/deployment/lib/contract-checks.ts
new file mode 100644
index 000000000..74e446779
--- /dev/null
+++ b/packages/deployment/lib/contract-checks.ts
@@ -0,0 +1,969 @@
+import type { Environment } from '@rocketh/core/types'
+import type { Abi, PublicClient } from 'viem'
+
+import {
+ ACCESS_CONTROL_ENUMERABLE_ABI,
+ GRAPH_TOKEN_ABI,
+ IERC165_ABI,
+ IERC165_INTERFACE_ID,
+ IISSUANCE_TARGET_INTERFACE_ID,
+ ISSUANCE_TARGET_ABI,
+ PROVIDER_ELIGIBILITY_MANAGEMENT_ABI,
+ REWARDS_ELIGIBILITY_ORACLE_ABI,
+ REWARDS_MANAGER_ABI,
+ REWARDS_MANAGER_DEPRECATED_ABI,
+} from './abis.js'
+import { getTargetChainIdFromEnv } from './address-book-utils.js'
+import { getGovernor, getPauseGuardian } from './controller-utils.js'
+import { graph } from '../rocketh/deploy.js'
+
+/**
+ * Check if a contract supports a specific interface via ERC165
+ *
+ * @param client - Viem public client
+ * @param contractAddress - Contract address to check
+ * @param interfaceId - Interface ID (4 bytes hex string like '0x01ffc9a7')
+ * @returns true if interface is supported, false otherwise
+ */
+export async function supportsInterface(
+ client: PublicClient,
+ contractAddress: string,
+ interfaceId: string,
+): Promise {
+ try {
+ const supported = await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: IERC165_ABI,
+ functionName: 'supportsInterface',
+ args: [interfaceId as `0x${string}`],
+ })
+ return supported as boolean
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Check if RewardsManager has been upgraded to support IIssuanceTarget
+ *
+ * The upgraded RewardsManager implements IERC165 and IIssuanceTarget interfaces.
+ * This check verifies the upgrade by testing for IIssuanceTarget support.
+ *
+ * @param client - Viem public client
+ * @param rmAddress - RewardsManager address
+ * @returns true if upgraded, false otherwise
+ */
+export async function isRewardsManagerUpgraded(client: PublicClient, rmAddress: string): Promise {
+ return supportsInterface(client, rmAddress, IISSUANCE_TARGET_INTERFACE_ID)
+}
+
+/**
+ * Require RewardsManager to be upgraded, exiting if not
+ *
+ * @param client - Viem public client
+ * @param rmAddress - RewardsManager address
+ * @param env - Deployment environment for showing messages
+ * @exits 1 if RewardsManager has not been upgraded (expected prerequisite state)
+ */
+export async function requireRewardsManagerUpgraded(
+ client: PublicClient,
+ rmAddress: string,
+ env: Environment,
+): Promise {
+ const upgraded = await isRewardsManagerUpgraded(client, rmAddress)
+ if (!upgraded) {
+ env.showMessage(`\n❌ RewardsManager has not been upgraded yet`)
+ env.showMessage(` The on-chain RewardsManager does not support IERC165/IIssuanceTarget`)
+ env.showMessage(` Run: npx hardhat deploy:execute-governance --network ${env.name}`)
+ env.showMessage(` (This will execute the pending RewardsManager upgrade TX)\n`)
+ process.exit(1)
+ }
+}
+
+/**
+ * Check IssuanceAllocator activation state
+ *
+ * Returns status of:
+ * - Whether IA is set as issuanceAllocator on RewardsManager
+ * - Whether IA has minter role on GraphToken
+ */
+export interface ActivationStatus {
+ iaIntegrated: boolean
+ iaMinter: boolean
+ currentIssuanceAllocator: string
+}
+
+export async function checkIssuanceAllocatorActivation(
+ client: PublicClient,
+ iaAddress: string,
+ rmAddress: string,
+ gtAddress: string,
+): Promise {
+ // Check RM.issuanceAllocator() == IA
+ const currentIA = (await client.readContract({
+ address: rmAddress as `0x${string}`,
+ abi: ISSUANCE_TARGET_ABI,
+ functionName: 'getIssuanceAllocator',
+ })) as string
+
+ const iaIntegrated = currentIA.toLowerCase() === iaAddress.toLowerCase()
+
+ // Check GraphToken.isMinter(IA)
+ const iaMinter = (await client.readContract({
+ address: gtAddress as `0x${string}`,
+ abi: GRAPH_TOKEN_ABI,
+ functionName: 'isMinter',
+ args: [iaAddress as `0x${string}`],
+ })) as boolean
+
+ return {
+ iaIntegrated,
+ iaMinter,
+ currentIssuanceAllocator: currentIA,
+ }
+}
+
+/**
+ * Check if IssuanceAllocator is fully activated
+ *
+ * @returns true if both integrated with RM and has minter role
+ */
+export async function isIssuanceAllocatorActivated(
+ client: PublicClient,
+ iaAddress: string,
+ rmAddress: string,
+ gtAddress: string,
+): Promise {
+ const status = await checkIssuanceAllocatorActivation(client, iaAddress, rmAddress, gtAddress)
+ return status.iaIntegrated && status.iaMinter
+}
+
+/**
+ * Get issuancePerBlock from RewardsManager
+ */
+export async function getRewardsManagerRawIssuanceRate(client: PublicClient, rmAddress: string): Promise {
+ const rate = (await client.readContract({
+ address: rmAddress as `0x${string}`,
+ abi: REWARDS_MANAGER_DEPRECATED_ABI,
+ functionName: 'issuancePerBlock',
+ })) as bigint
+ return rate
+}
+
+// ============================================================================
+// REO Role Checks
+// ============================================================================
+
+/**
+ * Result of checking OPERATOR_ROLE assignment on an REO instance
+ */
+export interface OperatorRoleCheckResult {
+ /** Whether the check passed (correct assignment state) */
+ ok: boolean
+ /** Number of addresses with OPERATOR_ROLE */
+ count: number
+ /** The expected operator address (null if not configured) */
+ expectedOperator: string | null
+ /** Actual role holders (if enumerable) */
+ actualHolders: string[]
+ /** Human-readable status message */
+ message: string
+}
+
+/**
+ * Check OPERATOR_ROLE assignment on an REO instance
+ *
+ * This is the SINGLE authoritative check for OPERATOR_ROLE correctness.
+ * Used by both deployment scripts and status checks.
+ *
+ * Rules:
+ * - If expectedOperator is provided: exactly 1 holder, must be expectedOperator
+ * - If expectedOperator is null: exactly 0 holders
+ *
+ * @param client - Viem public client
+ * @param reoAddress - REO instance address
+ * @param expectedOperator - Expected operator address (from address book), or null if not configured
+ * @returns Check result with pass/fail status and details
+ */
+export async function checkOperatorRole(
+ client: PublicClient,
+ reoAddress: string,
+ expectedOperator: string | null,
+): Promise {
+ // Get OPERATOR_ROLE constant
+ const operatorRole = (await client.readContract({
+ address: reoAddress as `0x${string}`,
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ functionName: 'OPERATOR_ROLE',
+ })) as `0x${string}`
+
+ // Get role member count
+ const count = Number(
+ (await client.readContract({
+ address: reoAddress as `0x${string}`,
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ functionName: 'getRoleMemberCount',
+ args: [operatorRole],
+ })) as bigint,
+ )
+
+ // Get actual holders
+ const actualHolders: string[] = []
+ for (let i = 0; i < count; i++) {
+ const holder = (await client.readContract({
+ address: reoAddress as `0x${string}`,
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ functionName: 'getRoleMember',
+ args: [operatorRole, BigInt(i)],
+ })) as string
+ actualHolders.push(holder)
+ }
+
+ // Validate based on expected state
+ if (expectedOperator === null) {
+ // No operator configured - must have zero holders
+ if (count === 0) {
+ return {
+ ok: true,
+ count,
+ expectedOperator,
+ actualHolders,
+ message: 'OPERATOR_ROLE: none assigned (NetworkOperator not configured)',
+ }
+ } else {
+ return {
+ ok: false,
+ count,
+ expectedOperator,
+ actualHolders,
+ message: `OPERATOR_ROLE: unexpected holders (${count}) when NetworkOperator not configured: ${actualHolders.join(', ')}`,
+ }
+ }
+ } else {
+ // Operator configured - must have exactly one holder matching expected
+ if (count === 0) {
+ return {
+ ok: false,
+ count,
+ expectedOperator,
+ actualHolders,
+ message: `OPERATOR_ROLE: not assigned (expected ${expectedOperator})`,
+ }
+ } else if (count === 1 && actualHolders[0].toLowerCase() === expectedOperator.toLowerCase()) {
+ return {
+ ok: true,
+ count,
+ expectedOperator,
+ actualHolders,
+ message: `OPERATOR_ROLE: ${expectedOperator}`,
+ }
+ } else if (count === 1) {
+ return {
+ ok: false,
+ count,
+ expectedOperator,
+ actualHolders,
+ message: `OPERATOR_ROLE: wrong holder (expected ${expectedOperator}, got ${actualHolders[0]})`,
+ }
+ } else {
+ return {
+ ok: false,
+ count,
+ expectedOperator,
+ actualHolders,
+ message: `OPERATOR_ROLE: too many holders (${count}): ${actualHolders.join(', ')} (expected only ${expectedOperator})`,
+ }
+ }
+ }
+}
+
+// ============================================================================
+// Generic Configuration Condition Framework
+// ============================================================================
+
+/**
+ * Format seconds as human-readable duration
+ */
+export function formatDuration(seconds: bigint | number): string {
+ const secs = typeof seconds === 'bigint' ? Number(seconds) : seconds
+ const days = secs / 86400
+ if (Number.isInteger(days)) {
+ return `${days} day${days === 1 ? '' : 's'}`
+ }
+ return `${days.toFixed(2)} days`
+}
+
+/**
+ * A parameter condition - checks and sets a simple getter/setter value
+ *
+ * @template T - The type of the configuration value (e.g., bigint, string, boolean)
+ */
+export interface ParamCondition {
+ /** Condition type discriminator */
+ type?: 'param'
+
+ /** Condition name (used in messages and as identifier) */
+ name: string
+
+ /** Human-readable description */
+ description: string
+
+ /** ABI for contract reads/writes */
+ abi: Abi
+
+ /** Function name to read current value */
+ getter: string
+
+ /** Function name to set new value */
+ setter: string
+
+ /** Target value for this condition */
+ target: T
+
+ /** Compare current to target (defaults to strict equality) */
+ compare?: (current: T, target: T) => boolean
+
+ /** Format value for display (defaults to String()) */
+ format?: (value: T) => string
+}
+
+/**
+ * A role condition - checks and grants/revokes a role for an account
+ */
+export interface RoleCondition {
+ /** Condition type discriminator */
+ type: 'role'
+
+ /** Condition name (used in messages and as identifier) */
+ name: string
+
+ /** Human-readable description */
+ description: string
+
+ /** ABI for contract reads/writes */
+ abi: Abi
+
+ /** Function name to get role bytes32 (e.g., 'PAUSE_ROLE') */
+ roleGetter: string
+
+ /** Account that should have/not have the role */
+ targetAccount: string
+
+ /** Action: grant (account should have role) or revoke (account should NOT have role) */
+ action?: 'grant' | 'revoke'
+
+ /** Format account for display (defaults to address) */
+ formatAccount?: (address: string) => string
+}
+
+/**
+ * A single configuration condition - either a param or role condition
+ *
+ * @template T - The type for param conditions (e.g., bigint, string, boolean)
+ */
+export type ConfigCondition = ParamCondition | RoleCondition
+
+/**
+ * Result of checking a single condition
+ */
+export interface ConditionCheckResult {
+ /** Condition name */
+ name: string
+ /** Whether current matches target */
+ ok: boolean
+ /** Current on-chain value */
+ current: T
+ /** Target value */
+ target: T
+ /** Human-readable status message */
+ message: string
+}
+
+/**
+ * Result of checking multiple conditions
+ */
+export interface ConfigurationStatus {
+ /** Individual condition results */
+ conditions: ConditionCheckResult[]
+ /** Whether all conditions passed */
+ allOk: boolean
+}
+
+/**
+ * Check a single condition against on-chain state
+ */
+export async function checkCondition(
+ client: PublicClient,
+ contractAddress: string,
+ condition: ConfigCondition,
+): Promise> {
+ // Handle role conditions
+ if (condition.type === 'role') {
+ const role = (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: condition.abi,
+ functionName: condition.roleGetter,
+ })) as `0x${string}`
+
+ const hasRole = (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: condition.abi,
+ functionName: 'hasRole',
+ args: [role, condition.targetAccount as `0x${string}`],
+ })) as boolean
+
+ const action = condition.action ?? 'grant'
+ const formatAccount = condition.formatAccount ?? ((a) => a)
+
+ // For grant: ok if hasRole=true. For revoke: ok if hasRole=false
+ const ok = action === 'grant' ? hasRole : !hasRole
+ const status = ok ? '✓' : action === 'grant' ? '✗ needs grant' : '✗ needs revoke'
+
+ return {
+ name: condition.name,
+ ok,
+ current: hasRole as T | boolean,
+ target: (action === 'grant') as T | boolean,
+ message: `${condition.description}: ${formatAccount(condition.targetAccount)} ${status}`,
+ }
+ }
+
+ // Handle param conditions (default)
+ const current = (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: condition.abi,
+ functionName: condition.getter,
+ })) as T
+
+ const compare = condition.compare ?? ((a, b) => a === b)
+ const format = condition.format ?? String
+
+ const ok = compare(current, condition.target)
+ const status = ok ? '✓' : '✗ needs update'
+
+ return {
+ name: condition.name,
+ ok,
+ current,
+ target: condition.target,
+ message: `${condition.description}: ${format(current)} [target: ${format(condition.target)}] ${status}`,
+ }
+}
+
+/**
+ * Check multiple conditions against on-chain state
+ *
+ * Use this for status checks outside of deploy mode.
+ */
+export async function checkConditions(
+ client: PublicClient,
+ contractAddress: string,
+ conditions: ConfigCondition[],
+): Promise> {
+ const results = await Promise.all(conditions.map((c) => checkCondition(client, contractAddress, c)))
+
+ return {
+ conditions: results,
+ allOk: results.every((r) => r.ok),
+ }
+}
+
+// ============================================================================
+// REO Conditions
+// ============================================================================
+
+/** Default REO configuration values */
+export const REO_DEFAULTS = {
+ eligibilityPeriod: 14n * 24n * 60n * 60n, // 14 days
+ oracleUpdateTimeout: 7n * 24n * 60n * 60n, // 7 days
+} as const
+
+/**
+ * REO configuration conditions
+ *
+ * Reusable for both deploy-mode configuration and status checks.
+ */
+export function createREOParamConditions(
+ targets: { eligibilityPeriod?: bigint; oracleUpdateTimeout?: bigint } = {},
+): ParamCondition[] {
+ return [
+ {
+ name: 'eligibilityPeriod',
+ description: 'Eligibility period',
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ getter: 'getEligibilityPeriod',
+ setter: 'setEligibilityPeriod',
+ target: targets.eligibilityPeriod ?? REO_DEFAULTS.eligibilityPeriod,
+ format: (v) => `${v} seconds (${formatDuration(v)})`,
+ },
+ {
+ name: 'oracleUpdateTimeout',
+ description: 'Oracle update timeout',
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ getter: 'getOracleUpdateTimeout',
+ setter: 'setOracleUpdateTimeout',
+ target: targets.oracleUpdateTimeout ?? REO_DEFAULTS.oracleUpdateTimeout,
+ format: (v) => `${v} seconds (${formatDuration(v)})`,
+ },
+ ]
+}
+
+/**
+ * REO role condition targets
+ */
+export interface REORoleTargets {
+ /** Account to grant PAUSE_ROLE (pauseGuardian) */
+ pauseGuardian: string
+ /** Account to grant OPERATOR_ROLE (networkOperator) */
+ networkOperator: string
+ /** Account to grant GOVERNOR_ROLE (governor) */
+ governor: string
+}
+
+/**
+ * Create REO role conditions
+ *
+ * Returns conditions for granting:
+ * - PAUSE_ROLE to pauseGuardian
+ * - OPERATOR_ROLE to networkOperator
+ * - GOVERNOR_ROLE to governor
+ */
+export function createREORoleConditions(targets: REORoleTargets): RoleCondition[] {
+ return [
+ {
+ type: 'role',
+ name: 'pauseRole',
+ description: 'PAUSE_ROLE',
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ roleGetter: 'PAUSE_ROLE',
+ targetAccount: targets.pauseGuardian,
+ },
+ {
+ type: 'role',
+ name: 'operatorRole',
+ description: 'OPERATOR_ROLE',
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ roleGetter: 'OPERATOR_ROLE',
+ targetAccount: targets.networkOperator,
+ },
+ {
+ type: 'role',
+ name: 'governorRole',
+ description: 'GOVERNOR_ROLE',
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ roleGetter: 'GOVERNOR_ROLE',
+ targetAccount: targets.governor,
+ },
+ ]
+}
+
+/**
+ * Create all REO conditions (params + roles)
+ *
+ * Low-level factory - prefer getREOConditions(env) which fetches targets automatically.
+ */
+export function createAllREOConditions(
+ paramTargets: { eligibilityPeriod?: bigint; oracleUpdateTimeout?: bigint } = {},
+ roleTargets: REORoleTargets,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+): ConfigCondition[] {
+ // Note: setEligibilityValidation requires OPERATOR_ROLE, not GOVERNOR_ROLE.
+ // It is enabled by the network operator after deployment, not in the configure step.
+ return [...createREOParamConditions(paramTargets), ...createREORoleConditions(roleTargets)]
+}
+
+/**
+ * Create REO deployer revoke condition
+ *
+ * Checks that deployer does NOT have GOVERNOR_ROLE (should be revoked).
+ */
+export function createREODeployerRevokeCondition(deployer: string): RoleCondition {
+ return {
+ type: 'role',
+ name: 'deployerGovernorRoleRevoked',
+ description: 'Deployer GOVERNOR_ROLE',
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ roleGetter: 'GOVERNOR_ROLE',
+ targetAccount: deployer,
+ action: 'revoke',
+ }
+}
+
+// ============================================================================
+// REO Condition Fetchers (single source of truth)
+// ============================================================================
+
+/**
+ * Get REO configuration conditions with targets fetched from environment
+ *
+ * This is the SINGLE SOURCE OF TRUTH for REO conditions.
+ * Fetches governor, pauseGuardian, networkOperator automatically.
+ *
+ * Requires NetworkOperator to be configured in the issuance address book.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function getREOConditions(env: Environment): Promise[]> {
+ const governor = await getGovernor(env)
+ const pauseGuardian = await getPauseGuardian(env)
+ const ab = graph.getIssuanceAddressBook(await getTargetChainIdFromEnv(env))
+
+ const networkOperator = ab.entryExists('NetworkOperator') ? ab.getEntry('NetworkOperator')?.address : null
+ if (!networkOperator) {
+ env.showMessage('\n❌ NetworkOperator not configured in issuance address book')
+ env.showMessage(' Add NetworkOperator to packages/issuance/addresses.json\n')
+ process.exit(1)
+ }
+
+ return createAllREOConditions({}, { governor, pauseGuardian, networkOperator })
+}
+
+/**
+ * Get REO transfer governance conditions (revoke deployer role)
+ *
+ * Single source of truth for transfer-governance step.
+ */
+export function getREOTransferGovernanceConditions(deployer: string): ConfigCondition[] {
+ return [createREODeployerRevokeCondition(deployer)]
+}
+
+// ============================================================================
+// REO Role Checks
+// ============================================================================
+
+/**
+ * Result of checking if an account has a specific role
+ */
+export interface RoleCheckResult {
+ /** Whether the account has the role */
+ hasRole: boolean
+ /** The role being checked (bytes32) */
+ role: `0x${string}`
+ /** The account being checked */
+ account: string
+ /** Human-readable status message */
+ message: string
+}
+
+/**
+ * Check if an account has a specific role on an REO instance
+ */
+export async function checkREORole(
+ client: PublicClient,
+ reoAddress: string,
+ roleName: 'GOVERNOR_ROLE' | 'PAUSE_ROLE' | 'OPERATOR_ROLE' | 'ORACLE_ROLE',
+ account: string,
+): Promise {
+ const role = (await client.readContract({
+ address: reoAddress as `0x${string}`,
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ functionName: roleName,
+ })) as `0x${string}`
+
+ const hasRole = (await client.readContract({
+ address: reoAddress as `0x${string}`,
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ functionName: 'hasRole',
+ args: [role, account as `0x${string}`],
+ })) as boolean
+
+ return {
+ hasRole,
+ role,
+ account,
+ message: `${roleName}: ${hasRole ? '✓' : '✗'} (${account})`,
+ }
+}
+
+// ============================================================================
+// RewardsManager Integration Conditions
+// ============================================================================
+
+/**
+ * Compare addresses (case-insensitive)
+ */
+export function addressEquals(a: string, b: string): boolean {
+ return a.toLowerCase() === b.toLowerCase()
+}
+
+/**
+ * Truncate address for display
+ */
+export function formatAddress(address: string): string {
+ return `${address.slice(0, 6)}...${address.slice(-4)}`
+}
+
+/**
+ * Create RewardsManager integration condition for REO
+ *
+ * Checks that RewardsManager.getProviderEligibilityOracle() == reoAddress
+ */
+export function createRMIntegrationCondition(reoAddress: string): ParamCondition {
+ return {
+ name: 'providerEligibilityOracle',
+ description: 'REO instance',
+ abi: PROVIDER_ELIGIBILITY_MANAGEMENT_ABI,
+ getter: 'getProviderEligibilityOracle',
+ setter: 'setProviderEligibilityOracle',
+ target: reoAddress,
+ compare: addressEquals,
+ format: formatAddress,
+ }
+}
+
+// ============================================================================
+// Generic Role Enumeration (for any BaseUpgradeable contract)
+// ============================================================================
+
+/**
+ * Information about a single role
+ */
+export interface RoleInfo {
+ /** Role name (e.g., 'GOVERNOR_ROLE') */
+ name: string
+ /** Role bytes32 hash */
+ role: `0x${string}`
+ /** Admin role bytes32 hash */
+ adminRole: `0x${string}`
+ /** Number of members with this role */
+ memberCount: number
+ /** Addresses that hold this role */
+ members: string[]
+}
+
+/**
+ * Result of enumerating all roles for a contract
+ */
+export interface RoleEnumerationResult {
+ /** Contract address */
+ contractAddress: string
+ /** All roles that were enumerated */
+ roles: RoleInfo[]
+ /** Roles that failed to read (may not exist on contract) */
+ failedRoles: string[]
+}
+
+/**
+ * Get the bytes32 value of a role constant from a contract
+ *
+ * @param client - Viem public client
+ * @param contractAddress - Contract address
+ * @param roleName - Name of the role constant (e.g., 'GOVERNOR_ROLE')
+ * @returns The bytes32 role value, or null if the role doesn't exist
+ */
+export async function getRoleHash(
+ client: PublicClient,
+ contractAddress: string,
+ roleName: string,
+): Promise<`0x${string}` | null> {
+ try {
+ // Create a minimal ABI for reading the role constant
+ const roleAbi = [
+ {
+ inputs: [],
+ name: roleName,
+ outputs: [{ type: 'bytes32' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ ] as const
+
+ const role = (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: roleAbi,
+ functionName: roleName,
+ })) as `0x${string}`
+
+ return role
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Enumerate all members of a role
+ *
+ * @param client - Viem public client
+ * @param contractAddress - Contract address
+ * @param role - Role bytes32 hash
+ * @returns Array of member addresses
+ */
+export async function enumerateRoleMembers(
+ client: PublicClient,
+ contractAddress: string,
+ role: `0x${string}`,
+): Promise {
+ const count = Number(
+ (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'getRoleMemberCount',
+ args: [role],
+ })) as bigint,
+ )
+
+ const members: string[] = []
+ for (let i = 0; i < count; i++) {
+ const member = (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'getRoleMember',
+ args: [role, BigInt(i)],
+ })) as string
+ members.push(member)
+ }
+
+ return members
+}
+
+/**
+ * Get full role information including admin and members
+ *
+ * @param client - Viem public client
+ * @param contractAddress - Contract address
+ * @param roleName - Name of the role constant (e.g., 'GOVERNOR_ROLE')
+ * @returns RoleInfo or null if role doesn't exist
+ */
+export async function getRoleInfo(
+ client: PublicClient,
+ contractAddress: string,
+ roleName: string,
+): Promise {
+ const role = await getRoleHash(client, contractAddress, roleName)
+ if (!role) {
+ return null
+ }
+
+ const adminRole = (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'getRoleAdmin',
+ args: [role],
+ })) as `0x${string}`
+
+ const members = await enumerateRoleMembers(client, contractAddress, role)
+
+ return {
+ name: roleName,
+ role,
+ adminRole,
+ memberCount: members.length,
+ members,
+ }
+}
+
+/**
+ * Enumerate all roles for a contract
+ *
+ * @param client - Viem public client
+ * @param contractAddress - Contract address
+ * @param roleNames - Array of role constant names to check
+ * @returns RoleEnumerationResult with all role info
+ */
+export async function enumerateContractRoles(
+ client: PublicClient,
+ contractAddress: string,
+ roleNames: readonly string[],
+): Promise {
+ const roles: RoleInfo[] = []
+ const failedRoles: string[] = []
+
+ for (const roleName of roleNames) {
+ const info = await getRoleInfo(client, contractAddress, roleName)
+ if (info) {
+ roles.push(info)
+ } else {
+ failedRoles.push(roleName)
+ }
+ }
+
+ return {
+ contractAddress,
+ roles,
+ failedRoles,
+ }
+}
+
+/**
+ * Check if an account has the admin role for a given role
+ *
+ * @param client - Viem public client
+ * @param contractAddress - Contract address
+ * @param role - Role bytes32 hash
+ * @param account - Account to check
+ * @returns true if account is an admin for the role
+ */
+export async function hasAdminRole(
+ client: PublicClient,
+ contractAddress: string,
+ role: `0x${string}`,
+ account: string,
+): Promise {
+ const adminRole = (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'getRoleAdmin',
+ args: [role],
+ })) as `0x${string}`
+
+ const hasRole = (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [adminRole, account as `0x${string}`],
+ })) as boolean
+
+ return hasRole
+}
+
+/**
+ * Check if an account already has a specific role
+ *
+ * @param client - Viem public client
+ * @param contractAddress - Contract address
+ * @param role - Role bytes32 hash
+ * @param account - Account to check
+ * @returns true if account has the role
+ */
+export async function accountHasRole(
+ client: PublicClient,
+ contractAddress: string,
+ role: `0x${string}`,
+ account: string,
+): Promise {
+ const hasRole = (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [role, account as `0x${string}`],
+ })) as boolean
+
+ return hasRole
+}
+
+/**
+ * Get admin role info for a given role
+ *
+ * @param client - Viem public client
+ * @param contractAddress - Contract address
+ * @param role - Role bytes32 hash
+ * @param knownRoles - Known roles for name resolution
+ * @returns Admin role hash and name (if known)
+ */
+export async function getAdminRoleInfo(
+ client: PublicClient,
+ contractAddress: string,
+ role: `0x${string}`,
+ knownRoles: RoleInfo[],
+): Promise<{ adminRole: `0x${string}`; adminRoleName: string | null; adminMembers: string[] }> {
+ const adminRole = (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'getRoleAdmin',
+ args: [role],
+ })) as `0x${string}`
+
+ const adminRoleName = knownRoles.find((r) => r.role === adminRole)?.name ?? null
+ const adminMembers = await enumerateRoleMembers(client, contractAddress, adminRole)
+
+ return { adminRole, adminRoleName, adminMembers }
+}
diff --git a/packages/deployment/lib/contract-registry.ts b/packages/deployment/lib/contract-registry.ts
new file mode 100644
index 000000000..06b2f640a
--- /dev/null
+++ b/packages/deployment/lib/contract-registry.ts
@@ -0,0 +1,437 @@
+/**
+ * Contract Registry - Single source of truth for contract metadata
+ *
+ * This module consolidates all contract metadata that was previously scattered
+ * across sync scripts, deploy scripts, and utility functions.
+ *
+ * The registry is namespaced by address book to prevent key collisions when
+ * the same contract name appears in multiple address books.
+ */
+
+import { ComponentTags } from './deployment-tags.js'
+
+/**
+ * Artifact source configuration - where to load contract ABI and bytecode from
+ */
+export type ArtifactSource =
+ | { type: 'contracts'; path: string; name: string }
+ | { type: 'subgraph-service'; name: string }
+ | { type: 'horizon'; path: string }
+ | { type: 'issuance'; path: string }
+ | { type: 'openzeppelin'; name: string }
+
+/**
+ * Proxy pattern types
+ * - 'graph': Graph Protocol's custom proxy (upgrade + acceptProxy via GraphProxyAdmin)
+ * - 'transparent': OpenZeppelin TransparentUpgradeableProxy (upgradeAndCall via ProxyAdmin)
+ * - undefined: Not a proxy contract
+ */
+export type ProxyType = 'graph' | 'transparent'
+
+/**
+ * Address book types - which address book a contract belongs to
+ */
+export type AddressBookType = 'horizon' | 'subgraph-service' | 'issuance'
+
+/**
+ * Interface ABI configuration for typed ABI generation.
+ * Maps an export name to an interface in @graphprotocol/interfaces.
+ */
+export interface InterfaceAbiConfig {
+ /** Export name for the generated ABI constant (e.g. 'REWARDS_MANAGER_ABI') */
+ name: string
+ /** Interface name in @graphprotocol/interfaces artifacts (e.g. 'IRewardsManager') */
+ interface: string
+}
+
+/**
+ * Contract metadata specification
+ * Note: addressBook is no longer a field - it's implied by the registry namespace
+ */
+export interface ContractMetadata {
+ /** Address book entry name (if different from registry key) */
+ addressBookName?: string
+
+ /** Artifact source for loading ABI and bytecode */
+ artifact?: ArtifactSource
+
+ /** Proxy type if this is a proxied contract */
+ proxyType?: ProxyType
+
+ /** Name of the proxy admin deployment record */
+ proxyAdminName?: string
+
+ /** If true, contract must exist on-chain (for sync prerequisite check) */
+ prerequisite?: boolean
+
+ /**
+ * If true, contract is deployable by this system
+ * If false/undefined, contract is managed elsewhere (prerequisite or placeholder)
+ * Default: false (must explicitly opt-in)
+ */
+ deployable?: boolean
+
+ /**
+ * If true, entry is an address-only placeholder (code not required)
+ * Use for entries that may be EOA or contract - sync skips bytecode verification.
+ */
+ addressOnly?: boolean
+
+ /**
+ * Role constants exposed by the contract (for role enumeration)
+ * Array of function names that return bytes32 role constants (e.g., 'GOVERNOR_ROLE')
+ * Used by roles:list task to enumerate role holders.
+ */
+ roles?: readonly string[]
+
+ /**
+ * Component tag for deployment lifecycle management.
+ * Used by script factories to derive action tags (deploy, upgrade, etc.)
+ * and dependencies without per-script boilerplate.
+ *
+ * Must match the PascalCase contract name in deployment-tags.ts ComponentTags.
+ * Example: 'PaymentsEscrow' → tags: 'PaymentsEscrow:upgrade', deps: 'PaymentsEscrow:deploy'
+ *
+ * Multiple contracts may share a componentTag when they form a single
+ * deployment unit (e.g., REO A/B instances share 'RewardsEligibility').
+ */
+ componentTag?: string
+
+ /**
+ * Lifecycle actions available for this component beyond the standard deploy+upgrade.
+ * Used by status modules to show available `--tags` actions.
+ *
+ * When omitted, defaults to ['deploy', 'upgrade'] for deployable proxy contracts,
+ * or ['deploy'] for non-proxy deployable contracts.
+ * Always includes 'all' implicitly.
+ */
+ lifecycleActions?: readonly string[]
+
+ /**
+ * Interface ABIs to generate for this contract.
+ * Used by the ABI codegen script to produce typed `as const` exports.
+ * Each entry maps to an interface artifact in @graphprotocol/interfaces.
+ * The codegen also extracts the interfaceId from the factory class.
+ */
+ interfaces?: readonly InterfaceAbiConfig[]
+
+ /**
+ * Generate a typed ABI from the contract's full artifact.
+ * Value is the export name (e.g. 'ISSUANCE_ALLOCATOR_ABI').
+ * Requires `artifact` to be set on this entry.
+ */
+ generateAbi?: string
+
+ /**
+ * Name of the shared implementation entry when this proxy uses an
+ * implementation deployed separately (e.g. DirectAllocation_Implementation).
+ *
+ * Used by the upgrade pipeline to auto-detect when the shared implementation
+ * has been redeployed and set pendingImplementation accordingly.
+ */
+ sharedImplementation?: string
+}
+
+// ============================================================================
+// Horizon Contracts
+// ============================================================================
+
+const HORIZON_CONTRACTS = {
+ RewardsManager: {
+ artifact: { type: 'contracts', path: 'rewards', name: 'RewardsManager' },
+ interfaces: [
+ { name: 'REWARDS_MANAGER_ABI', interface: 'IRewardsManager' },
+ { name: 'REWARDS_MANAGER_DEPRECATED_ABI', interface: 'IRewardsManagerDeprecated' },
+ { name: 'PROVIDER_ELIGIBILITY_MANAGEMENT_ABI', interface: 'IProviderEligibilityManagement' },
+ ],
+ proxyType: 'graph',
+ proxyAdminName: 'GraphProxyAdmin',
+ prerequisite: true,
+ deployable: true,
+ componentTag: ComponentTags.REWARDS_MANAGER,
+ lifecycleActions: ['deploy', 'upgrade'],
+ },
+ GraphProxyAdmin: {
+ interfaces: [{ name: 'GRAPH_PROXY_ADMIN_ABI', interface: 'IGraphProxyAdmin' }],
+ prerequisite: true,
+ },
+ L2GraphToken: {
+ artifact: { type: 'contracts', path: 'l2/token', name: 'L2GraphToken' },
+ interfaces: [{ name: 'GRAPH_TOKEN_ABI', interface: 'IGraphToken' }],
+ prerequisite: true,
+ },
+ Controller: {
+ interfaces: [{ name: 'CONTROLLER_ABI', interface: 'IControllerToolshed' }],
+ prerequisite: true,
+ },
+ GraphTallyCollector: {
+ prerequisite: true,
+ },
+ RecurringCollector: {
+ artifact: { type: 'horizon', path: 'contracts/payments/collectors/RecurringCollector.sol/RecurringCollector' },
+ proxyType: 'transparent',
+ deployable: true,
+ componentTag: ComponentTags.RECURRING_COLLECTOR,
+ lifecycleActions: ['deploy', 'upgrade', 'configure', 'transfer'],
+ },
+ L2Curation: {
+ artifact: { type: 'contracts', path: 'l2/curation', name: 'L2Curation' },
+ proxyType: 'graph',
+ proxyAdminName: 'GraphProxyAdmin',
+ prerequisite: true,
+ deployable: true,
+ componentTag: ComponentTags.L2_CURATION,
+ },
+ HorizonStaking: {
+ artifact: { type: 'horizon', path: 'contracts/staking/HorizonStaking.sol/HorizonStaking' },
+ proxyType: 'graph',
+ proxyAdminName: 'GraphProxyAdmin',
+ prerequisite: true,
+ deployable: true,
+ componentTag: ComponentTags.HORIZON_STAKING,
+ },
+ GraphPayments: {
+ prerequisite: true,
+ },
+ PaymentsEscrow: {
+ artifact: { type: 'horizon', path: 'contracts/payments/PaymentsEscrow.sol/PaymentsEscrow' },
+ proxyType: 'transparent',
+ prerequisite: true,
+ deployable: true,
+ componentTag: ComponentTags.PAYMENTS_ESCROW,
+ },
+ // Contracts deployed by other systems (placeholders for address book type completeness)
+ EpochManager: {},
+ L2GNS: {},
+ L2GraphTokenGateway: {},
+ SubgraphNFT: {},
+} as const satisfies Record
+
+// ============================================================================
+// SubgraphService Contracts
+// ============================================================================
+
+// NOTE: SubgraphService contracts are deployed via Ignition with contract-specific proxy admins.
+// The proxy admin address is stored inline in each contract's address book entry (proxyAdmin field).
+// During sync, deployment records are auto-generated as `${contractName}_ProxyAdmin`.
+const SUBGRAPH_SERVICE_CONTRACTS = {
+ DisputeManager: {
+ artifact: { type: 'subgraph-service', name: 'DisputeManager' },
+ proxyType: 'transparent',
+ // proxyAdminName omitted - auto-generates as DisputeManager_ProxyAdmin
+ prerequisite: true,
+ deployable: true,
+ componentTag: ComponentTags.DISPUTE_MANAGER,
+ },
+ SubgraphService: {
+ artifact: { type: 'subgraph-service', name: 'SubgraphService' },
+ proxyType: 'transparent',
+ // proxyAdminName omitted - auto-generates as SubgraphService_ProxyAdmin
+ prerequisite: true,
+ deployable: true,
+ componentTag: ComponentTags.SUBGRAPH_SERVICE,
+ lifecycleActions: ['deploy', 'upgrade', 'configure'],
+ },
+ // Contracts deployed by other systems (placeholders for address book type completeness)
+ // These exist in the subgraph-service address book but are managed elsewhere
+ L2Curation: {},
+ L2GNS: {},
+ SubgraphNFT: {},
+ LegacyDisputeManager: {},
+ LegacyServiceRegistry: {},
+} as const satisfies Record
+
+// ============================================================================
+// Issuance Contracts
+// ============================================================================
+
+// NOTE: Issuance contracts use OZ v5 TransparentUpgradeableProxy which creates
+// a per-proxy ProxyAdmin in the constructor. The deployer is the initial ProxyAdmin
+// owner to allow post-deployment configuration; ownership is transferred to the
+// protocol governor in the transfer-governance step. The ProxyAdmin address is stored
+// inline in each contract's address book entry (proxyAdmin field), similar to
+// subgraph-service contracts.
+
+// Base roles from BaseUpgradeable - all issuance contracts inherit these
+const BASE_ROLES = ['GOVERNOR_ROLE', 'PAUSE_ROLE', 'OPERATOR_ROLE'] as const
+
+const ISSUANCE_CONTRACTS = {
+ // Address placeholder for network operator (may be EOA or contract)
+ // Used by deployment scripts to grant OPERATOR_ROLE
+ NetworkOperator: { addressOnly: true },
+
+ IssuanceAllocator: {
+ artifact: { type: 'issuance', path: 'contracts/allocate/IssuanceAllocator.sol/IssuanceAllocator' },
+ generateAbi: 'ISSUANCE_ALLOCATOR_ABI',
+ proxyType: 'transparent',
+ // Per-proxy ProxyAdmin - address stored in address book entry's proxyAdmin field
+ deployable: true,
+ roles: BASE_ROLES,
+ componentTag: ComponentTags.ISSUANCE_ALLOCATOR,
+ lifecycleActions: ['deploy', 'upgrade', 'configure', 'transfer'],
+ },
+ RecurringAgreementManager: {
+ artifact: {
+ type: 'issuance',
+ path: 'contracts/agreement/RecurringAgreementManager.sol/RecurringAgreementManager',
+ },
+ proxyType: 'transparent',
+ deployable: true,
+ roles: [...BASE_ROLES, 'DATA_SERVICE_ROLE', 'COLLECTOR_ROLE', 'AGREEMENT_MANAGER_ROLE'] as const,
+ componentTag: ComponentTags.RECURRING_AGREEMENT_MANAGER,
+ lifecycleActions: ['deploy', 'upgrade', 'configure', 'transfer'],
+ },
+ // A/B instances of RewardsEligibilityOracle - both share the same contract artifact
+ // but deploy as independent proxies. Only one is active (integrated with RewardsManager) at a time.
+ RewardsEligibilityOracleA: {
+ artifact: { type: 'issuance', path: 'contracts/eligibility/RewardsEligibilityOracle.sol/RewardsEligibilityOracle' },
+ generateAbi: 'REWARDS_ELIGIBILITY_ORACLE_ABI',
+ proxyType: 'transparent',
+ deployable: true,
+ roles: [...BASE_ROLES, 'ORACLE_ROLE'] as const,
+ componentTag: ComponentTags.REWARDS_ELIGIBILITY_A,
+ // Integration with RewardsManager is a goal-level activation
+ // (--tags GIP-0088:eligibility-integrate), not a per-component lifecycle action.
+ lifecycleActions: ['deploy', 'upgrade', 'configure', 'transfer'],
+ },
+ RewardsEligibilityOracleB: {
+ artifact: { type: 'issuance', path: 'contracts/eligibility/RewardsEligibilityOracle.sol/RewardsEligibilityOracle' },
+ proxyType: 'transparent',
+ deployable: true,
+ roles: [...BASE_ROLES, 'ORACLE_ROLE'] as const,
+ componentTag: ComponentTags.REWARDS_ELIGIBILITY_B,
+ lifecycleActions: ['deploy', 'upgrade', 'configure', 'transfer'],
+ },
+ // Testnet mock REO - indexers control own eligibility, upgradeable for deployment consistency
+ RewardsEligibilityOracleMock: {
+ artifact: {
+ type: 'issuance',
+ path: 'contracts/eligibility/mocks/MockRewardsEligibilityOracle.sol/MockRewardsEligibilityOracle',
+ },
+ proxyType: 'transparent',
+ deployable: true,
+ roles: BASE_ROLES,
+ componentTag: ComponentTags.REWARDS_ELIGIBILITY_MOCK,
+ lifecycleActions: ['deploy', 'upgrade', 'transfer', 'integrate'],
+ },
+ DirectAllocation_Implementation: {
+ artifact: { type: 'issuance', path: 'contracts/allocate/DirectAllocation.sol/DirectAllocation' },
+ generateAbi: 'DIRECT_ALLOCATION_ABI',
+ deployable: true,
+ roles: BASE_ROLES,
+ componentTag: ComponentTags.DIRECT_ALLOCATION_IMPL,
+ },
+ // Default target for IA — safety net for unallocated issuance
+ // Uses DirectAllocation implementation (per-proxy ProxyAdmin)
+ DefaultAllocation: {
+ proxyType: 'transparent',
+ sharedImplementation: 'DirectAllocation_Implementation',
+ deployable: true,
+ roles: BASE_ROLES,
+ componentTag: ComponentTags.DEFAULT_ALLOCATION,
+ lifecycleActions: ['deploy', 'upgrade', 'configure', 'transfer'],
+ },
+ // Default reclaim address — receives reclaimed rewards for all reasons
+ // Uses DirectAllocation implementation (per-proxy ProxyAdmin)
+ ReclaimedRewards: {
+ proxyType: 'transparent',
+ sharedImplementation: 'DirectAllocation_Implementation',
+ deployable: true,
+ roles: BASE_ROLES,
+ componentTag: ComponentTags.REWARDS_RECLAIM,
+ lifecycleActions: ['deploy', 'upgrade', 'configure', 'transfer'],
+ },
+} as const satisfies Record
+
+// ============================================================================
+// Namespaced Registry
+// ============================================================================
+
+/**
+ * Contract registry namespaced by address book
+ * This prevents key collisions when the same contract name appears in multiple address books
+ */
+export const CONTRACT_REGISTRY = {
+ horizon: HORIZON_CONTRACTS,
+ 'subgraph-service': SUBGRAPH_SERVICE_CONTRACTS,
+ issuance: ISSUANCE_CONTRACTS,
+} as const
+
+// Type helpers for the namespaced registry
+export type HorizonContractName = keyof typeof HORIZON_CONTRACTS
+export type SubgraphServiceContractName = keyof typeof SUBGRAPH_SERVICE_CONTRACTS
+export type IssuanceContractName = keyof typeof ISSUANCE_CONTRACTS
+
+/**
+ * Registry entry with contract name and address book embedded
+ */
+export interface RegistryEntry extends ContractMetadata {
+ name: string
+ addressBook: AddressBookType
+}
+
+/**
+ * Contract registry entries namespaced by address book
+ * Use these to pass to deployment functions with full context
+ *
+ * @example
+ * ```typescript
+ * await upgradeImplementation(env, Contracts.horizon.RewardsManager)
+ * await upgradeImplementation(env, Contracts['subgraph-service'].SubgraphService)
+ * ```
+ */
+export const Contracts = {
+ horizon: Object.entries(HORIZON_CONTRACTS).reduce(
+ (acc, [name, metadata]) => {
+ acc[name as HorizonContractName] = { name, addressBook: 'horizon', ...metadata }
+ return acc
+ },
+ {} as Record,
+ ),
+ 'subgraph-service': Object.entries(SUBGRAPH_SERVICE_CONTRACTS).reduce(
+ (acc, [name, metadata]) => {
+ acc[name as SubgraphServiceContractName] = { name, addressBook: 'subgraph-service', ...metadata }
+ return acc
+ },
+ {} as Record,
+ ),
+ issuance: Object.entries(ISSUANCE_CONTRACTS).reduce(
+ (acc, [name, metadata]) => {
+ acc[name as IssuanceContractName] = { name, addressBook: 'issuance', ...metadata }
+ return acc
+ },
+ {} as Record,
+ ),
+} as const
+
+/**
+ * Get contract metadata by address book and name
+ */
+export function getContractMetadata(addressBook: AddressBookType, name: string): ContractMetadata | undefined {
+ const bookRegistry = CONTRACT_REGISTRY[addressBook]
+ return bookRegistry[name as keyof typeof bookRegistry]
+}
+
+/**
+ * Get the address book entry name for a contract
+ * Falls back to the contract name if no override is specified
+ */
+export function getAddressBookEntryName(addressBook: AddressBookType, name: string): string {
+ const metadata = getContractMetadata(addressBook, name)
+ return metadata?.addressBookName ?? name
+}
+
+/**
+ * Get all contracts for a specific address book
+ */
+export function getContractsByAddressBook(addressBook: AddressBookType): Array<[string, ContractMetadata]> {
+ const bookRegistry = CONTRACT_REGISTRY[addressBook]
+ return Object.entries(bookRegistry)
+}
+
+/**
+ * List of proxied issuance contracts (for sync dynamic handling)
+ */
+export const PROXIED_ISSUANCE_CONTRACTS = Object.entries(ISSUANCE_CONTRACTS)
+ .filter(([_, meta]) => 'proxyType' in meta && meta.proxyType === 'transparent')
+ .map(([name]) => name)
diff --git a/packages/deployment/lib/controller-utils.ts b/packages/deployment/lib/controller-utils.ts
new file mode 100644
index 000000000..4dce12c4a
--- /dev/null
+++ b/packages/deployment/lib/controller-utils.ts
@@ -0,0 +1,90 @@
+import type { Environment } from '@rocketh/core/types'
+import type { PublicClient } from 'viem'
+
+import { CONTROLLER_ABI } from './abis.js'
+import { Contracts } from './contract-registry.js'
+import { requireContract } from './issuance-deploy-utils.js'
+import { graph } from '../rocketh/deploy.js'
+
+/**
+ * Check if the provider can sign as the protocol governor
+ *
+ * With a mnemonic (local network), all derived accounts are available via eth_accounts.
+ * With explicit keys (production), only configured accounts are available.
+ *
+ * @param env - Deployment environment
+ * @returns Governor address and whether the provider can sign as governor
+ */
+export async function canSignAsGovernor(env: Environment): Promise<{ governor: string; canSign: boolean }> {
+ const governor = await getGovernor(env)
+ const accounts = (await env.network.provider.request({ method: 'eth_accounts' })) as string[]
+ const canSign = accounts.some((a) => a.toLowerCase() === governor.toLowerCase())
+
+ // Verify the rocketh named account 'governor' matches the on-chain governor.
+ // If they disagree, tx({ account: 'governor' }) would send from the wrong address.
+ if (canSign && env.namedAccounts['governor']) {
+ const named = env.namedAccounts['governor'] as string
+ if (named.toLowerCase() !== governor.toLowerCase()) {
+ throw new Error(
+ `Named account 'governor' (${named}) does not match Controller.getGovernor() (${governor}). ` +
+ `Check rocketh account config — mnemonic index may not match the on-chain governor.`,
+ )
+ }
+ }
+
+ return { governor, canSign }
+}
+
+/**
+ * Get the protocol governor address from the Controller contract
+ *
+ * The Controller contract is the governance registry for the Graph Protocol.
+ * It stores the address of the protocol governor (typically a multi-sig).
+ *
+ * @param env - Deployment environment
+ * @returns Governor address from Controller.getGovernor()
+ */
+export async function getGovernor(env: Environment): Promise {
+ const client = graph.getPublicClient(env) as PublicClient
+
+ // Get Controller from deployments (synced from Horizon address book)
+ const controller = requireContract(env, Contracts.horizon.Controller)
+
+ // Query governor from Controller
+ const governor = (await client.readContract({
+ address: controller.address as `0x${string}`,
+ abi: CONTROLLER_ABI,
+ functionName: 'getGovernor',
+ })) as string
+
+ return governor
+}
+
+/**
+ * Get pause guardian address from the Controller contract
+ *
+ * @param env - Deployment environment
+ * @returns Pause guardian address from Controller.pauseGuardian()
+ */
+export async function getPauseGuardian(env: Environment): Promise {
+ const client = graph.getPublicClient(env) as PublicClient
+ const controller = requireContract(env, Contracts.horizon.Controller)
+
+ // Query pauseGuardian from Controller
+ // Use minimal ABI since pauseGuardian() is auto-generated getter, not in IController interface
+ const pauseGuardian = (await client.readContract({
+ address: controller.address as `0x${string}`,
+ abi: [
+ {
+ inputs: [],
+ name: 'pauseGuardian',
+ outputs: [{ internalType: 'address', name: '', type: 'address' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ ],
+ functionName: 'pauseGuardian',
+ })) as string
+
+ return pauseGuardian
+}
diff --git a/packages/deployment/lib/deploy-implementation.ts b/packages/deployment/lib/deploy-implementation.ts
new file mode 100644
index 000000000..dbaacc92b
--- /dev/null
+++ b/packages/deployment/lib/deploy-implementation.ts
@@ -0,0 +1,437 @@
+import type { Artifact, Environment } from '@rocketh/core/types'
+import { encodeAbiParameters, getAddress } from 'viem'
+
+import { getAddressBookForType, getTargetChainIdFromEnv } from './address-book-utils.js'
+import {
+ getLibraryResolver,
+ linkArtifactLibraries,
+ loadContractsArtifact,
+ loadHorizonBuildArtifact,
+ loadIssuanceArtifact,
+ loadOpenZeppelinArtifact,
+ loadSubgraphServiceArtifact,
+} from './artifact-loaders.js'
+import { computeBytecodeHash } from './bytecode-utils.js'
+import { getContractMetadata, type AddressBookType, type ArtifactSource, type ProxyType } from './contract-registry.js'
+import { buildDeploymentMetadata } from './deployment-metadata.js'
+import { deploy, graph } from '../rocketh/deploy.js'
+
+// Re-export artifact loaders for backwards compatibility
+export { loadContractsArtifact, loadIssuanceArtifact, loadSubgraphServiceArtifact }
+
+// Re-export ArtifactSource for backwards compatibility
+export type { ArtifactSource }
+
+// ERC1967 implementation storage slot (for OZ TransparentUpgradeableProxy)
+const ERC1967_IMPLEMENTATION_SLOT = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc' as const
+
+/**
+ * Read the current implementation address for a proxy contract.
+ *
+ * @param client - Viem public client
+ * @param proxyAddress - Address of the proxy contract
+ * @param proxyType - 'graph' for Graph legacy proxy, 'transparent' for OZ TransparentProxy
+ * @param proxyAdminAddress - Address of the proxy admin (required for graph type)
+ */
+export async function getOnChainImplementation(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ client: any,
+ proxyAddress: string,
+ proxyType: 'graph' | 'transparent',
+ proxyAdminAddress?: string,
+): Promise {
+ if (proxyType === 'transparent') {
+ const implSlotValue = await client.getStorageAt({
+ address: proxyAddress as `0x${string}`,
+ slot: ERC1967_IMPLEMENTATION_SLOT,
+ })
+ return getAddress('0x' + (implSlotValue?.slice(26) ?? ''))
+ } else {
+ const data = await client.readContract({
+ address: proxyAdminAddress as `0x${string}`,
+ abi: [
+ {
+ name: 'getProxyImplementation',
+ type: 'function',
+ inputs: [{ name: '_proxy', type: 'address' }],
+ outputs: [{ name: '', type: 'address' }],
+ stateMutability: 'view',
+ },
+ ],
+ functionName: 'getProxyImplementation',
+ args: [proxyAddress as `0x${string}`],
+ })
+ return data as string
+ }
+}
+
+/**
+ * Configuration for deploying an upgradeable implementation
+ */
+export interface ImplementationDeployConfig {
+ /** Contract name (e.g., 'RewardsManager', 'SubgraphService') */
+ contractName: string
+
+ /**
+ * Artifact source configuration
+ *
+ * For @graphprotocol/contracts:
+ * { type: 'contracts', path: 'rewards', name: 'RewardsManager' }
+ *
+ * For @graphprotocol/subgraph-service (Foundry format):
+ * { type: 'subgraph-service', name: 'SubgraphService' }
+ *
+ * For @graphprotocol/issuance:
+ * { type: 'issuance', path: 'contracts/allocate/DirectAllocation.sol/DirectAllocation' }
+ *
+ * Legacy shorthand (contracts only):
+ * artifactPath: 'rewards' + artifactName defaults to contractName
+ */
+ artifact?: ArtifactSource
+
+ /** @deprecated Use artifact.path instead */
+ artifactPath?: string
+
+ /**
+ * Proxy type
+ * - 'graph': Graph Protocol's custom proxy (upgrade + acceptProxy)
+ * - 'transparent': OpenZeppelin TransparentUpgradeableProxy (upgradeAndCall)
+ *
+ * Default: 'graph'
+ */
+ proxyType?: ProxyType
+
+ /**
+ * Name of the proxy admin deployment record.
+ * e.g., 'GraphProxyAdmin' for legacy GraphProxy contracts.
+ *
+ * Optional: If omitted, defaults to `${contractName}_ProxyAdmin`.
+ * Per-proxy admins (OZ v5 TransparentUpgradeableProxy contracts) follow this
+ * default and store the admin address inline in their address book entry.
+ */
+ proxyAdminName?: string
+
+ /**
+ * Address book to store pending implementation
+ * Default: 'horizon'
+ */
+ addressBook?: AddressBookType
+
+ /** Constructor arguments (default: []) */
+ constructorArgs?: unknown[]
+}
+
+/**
+ * Result of implementation deployment
+ */
+export interface ImplementationDeployResult {
+ /** Whether a new implementation was deployed */
+ deployed: boolean
+
+ /** Address of the implementation (new or existing) */
+ address: string
+
+ /** Whether the bytecode changed (deployment was needed) */
+ bytecodeChanged: boolean
+
+ /** Transaction hash if newly deployed */
+ txHash?: string
+}
+
+/**
+ * Load artifact based on source configuration. Throws if the artifact can't be loaded.
+ */
+export function loadArtifactFromSource(source: ArtifactSource): Artifact {
+ switch (source.type) {
+ case 'contracts':
+ return loadContractsArtifact(source.path, source.name)
+ case 'subgraph-service':
+ return loadSubgraphServiceArtifact(source.name)
+ case 'horizon':
+ return loadHorizonBuildArtifact(source.path)
+ case 'issuance':
+ return loadIssuanceArtifact(source.path)
+ case 'openzeppelin':
+ return loadOpenZeppelinArtifact(source.name)
+ }
+}
+
+/**
+ * Like {@link loadArtifactFromSource}, but returns `undefined` instead of throwing.
+ * Intended for sync-style flows where a missing artifact shouldn't abort the pass.
+ */
+export function tryLoadArtifactFromSource(source: ArtifactSource | undefined): Artifact | undefined {
+ if (!source) return undefined
+ try {
+ return loadArtifactFromSource(source)
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * Compute the bytecode hash for an artifact source, with library resolution.
+ *
+ * This is the canonical bytecodeHash recorded in address-book deployment metadata —
+ * deploy-time, sync backfill, and `checkShouldSync` all agree on this fingerprint.
+ *
+ * Use this instead of calling `computeBytecodeHash` directly on rocketh's linked
+ * `deployedBytecode`: linked bytecode has real library addresses substituted in,
+ * so its hash diverges from the placeholder-fingerprint hash this helper produces
+ * for any library-using contract.
+ *
+ * Throws if the artifact cannot be loaded.
+ */
+export function computeArtifactBytecodeHash(source: ArtifactSource): string {
+ const artifact = loadArtifactFromSource(source)
+ return computeBytecodeHash(
+ artifact.deployedBytecode ?? '0x',
+ artifact.deployedLinkReferences,
+ getLibraryResolver(source.type),
+ )
+}
+
+/**
+ * Like {@link computeArtifactBytecodeHash}, but returns `undefined` instead of
+ * throwing if the artifact can't be loaded. Intended for sync-style flows where
+ * a missing artifact shouldn't abort the whole pass.
+ */
+export function tryComputeArtifactBytecodeHash(source: ArtifactSource | undefined): string | undefined {
+ if (!source) return undefined
+ try {
+ return computeArtifactBytecodeHash(source)
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * Build ImplementationDeployConfig from registry metadata
+ *
+ * This helper reduces boilerplate in deploy scripts by using the centralized
+ * contract registry for artifact paths, proxy patterns, and address books.
+ *
+ * @param addressBook - Which address book the contract belongs to
+ * @param contractName - The contract name (key in CONTRACT_REGISTRY[addressBook])
+ * @param overrides - Optional overrides (e.g., constructorArgs)
+ * @returns Configuration ready for deployImplementation()
+ *
+ * @example
+ * ```typescript
+ * // Simple usage - all config from registry
+ * await deployImplementation(env, getImplementationConfig('horizon', 'RewardsManager'))
+ *
+ * // With constructor args
+ * await deployImplementation(env, getImplementationConfig('subgraph-service', 'SubgraphService', {
+ * constructorArgs: [controller, disputeManager, tallyCollector, curation],
+ * }))
+ * ```
+ */
+export function getImplementationConfig(
+ addressBook: AddressBookType,
+ contractName: string,
+ overrides?: Partial>,
+): ImplementationDeployConfig {
+ const metadata = getContractMetadata(addressBook, contractName)
+ if (!metadata) {
+ throw new Error(`Contract '${contractName}' not found in ${addressBook} registry`)
+ }
+
+ return {
+ contractName,
+ artifact: metadata.artifact,
+ proxyType: metadata.proxyType,
+ proxyAdminName: metadata.proxyAdminName, // undefined if not in registry (will auto-generate)
+ addressBook,
+ ...overrides,
+ }
+}
+
+/**
+ * Check if a contract has implementation deployment config in the registry
+ */
+export function hasImplementationConfig(addressBook: AddressBookType, contractName: string): boolean {
+ const metadata = getContractMetadata(addressBook, contractName)
+ return !!metadata?.artifact
+}
+
+/**
+ * Deploy an upgradeable contract implementation with bytecode change detection
+ *
+ * This function handles the common pattern for deploying Graph Protocol
+ * upgradeable implementations:
+ *
+ * 1. Verify prerequisites (proxy and admin exist from sync)
+ * 2. Compare artifact bytecode with on-chain (accounting for metadata/immutables)
+ * 3. Deploy new implementation if bytecode changed
+ * 4. Store as pendingImplementation in address book for governance upgrade
+ *
+ * @example Graph Legacy (RewardsManager, Staking, Curation):
+ * ```typescript
+ * await deployImplementation(env, {
+ * contractName: 'RewardsManager',
+ * artifactPath: 'rewards',
+ * proxyAdminName: 'GraphProxyAdmin',
+ * })
+ * ```
+ *
+ * @example OZ Transparent (SubgraphService):
+ * ```typescript
+ * await deployImplementation(env, {
+ * contractName: 'SubgraphService',
+ * artifact: { type: 'subgraph-service', name: 'SubgraphService' },
+ * proxyType: 'transparent',
+ * proxyAdminName: 'SubgraphService_ProxyAdmin',
+ * addressBook: 'subgraph-service',
+ * constructorArgs: [controller, disputeManager, tallyCollector, curation],
+ * })
+ * ```
+ */
+export async function deployImplementation(
+ env: Environment,
+ config: ImplementationDeployConfig,
+ libraries?: Record,
+): Promise {
+ const { contractName, proxyAdminName, constructorArgs = [], proxyType = 'graph', addressBook = 'horizon' } = config
+
+ // Resolve artifact source (support legacy artifactPath for backwards compatibility)
+ const artifactSource: ArtifactSource = config.artifact ?? {
+ type: 'contracts',
+ path: config.artifactPath!,
+ name: contractName,
+ }
+
+ const deployFn = deploy(env)
+
+ // Get deployer account
+ const deployer = env.namedAccounts.deployer
+ if (!deployer) {
+ throw new Error('No deployer account configured')
+ }
+
+ // Create viem client for on-chain queries
+ const client = graph.getPublicClient(env)
+
+ // 1) Verify imports completed (sync step must have run)
+ const proxy = env.getOrNull(contractName)
+ if (!proxy) {
+ throw new Error(`${contractName} not imported. Run sync step first.`)
+ }
+
+ // Auto-generate proxy admin deployment record name if not provided
+ const proxyAdminDeploymentName = proxyAdminName ?? `${contractName}_ProxyAdmin`
+ const proxyAdmin = env.getOrNull(proxyAdminDeploymentName)
+ if (!proxyAdmin) {
+ throw new Error(`${proxyAdminDeploymentName} not imported. Run sync step first.`)
+ }
+
+ // 2) Load artifact (pre-link libraries so rocketh stores linked bytecode)
+ const rawArtifact = loadArtifactFromSource(artifactSource)
+ const artifact = libraries
+ ? linkArtifactLibraries(rawArtifact, libraries as Record)
+ : rawArtifact
+ const implDeploymentName = `${contractName}_Implementation`
+
+ // Get address book to check pending implementation
+ const targetChainId = await getTargetChainIdFromEnv(env)
+ const addressBookInstance = getAddressBookForType(addressBook, targetChainId)
+
+ // Compute local artifact bytecode hash (for storing with deployment)
+ const localBytecodeHash = computeArtifactBytecodeHash(artifactSource)
+
+ // 3) Pre-check: skip deployment if bytecodeHash and constructor args match
+ // Rocketh's comparison can false-positive when sync creates bare records (e.g., wrong
+ // argsData, unlinked library bytecodes). The content-aware bytecodeHash handles both
+ // cases — it strips CBOR metadata and resolves library references by content hash.
+ const contractEntry = addressBookInstance.entryExists(contractName)
+ ? addressBookInstance.getEntry(contractName)
+ : null
+ const pendingImpl = contractEntry?.pendingImplementation
+ const storedMetadata = pendingImpl?.deployment ?? addressBookInstance.getDeploymentMetadata(contractName)
+
+ if (storedMetadata?.bytecodeHash && storedMetadata.bytecodeHash === localBytecodeHash) {
+ // Bytecode matches — also verify constructor args (immutable values)
+ let argsMatch = !storedMetadata.argsData // no stored args = can't compare, assume match
+ if (storedMetadata.argsData) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const constructorDef = (artifact.abi as any[])?.find((item: any) => item.type === 'constructor')
+ const localArgsData =
+ constructorDef?.inputs?.length && constructorArgs.length
+ ? encodeAbiParameters(constructorDef.inputs, constructorArgs as readonly unknown[])
+ : '0x'
+ argsMatch = localArgsData === storedMetadata.argsData
+ }
+
+ if (argsMatch) {
+ const existingAddress = pendingImpl?.address ?? contractEntry?.implementation
+ if (existingAddress) {
+ env.showMessage(`\n✓ ${contractName} implementation unchanged`)
+ return {
+ deployed: false,
+ address: existingAddress,
+ bytecodeChanged: false,
+ }
+ }
+ }
+ }
+
+ // 4) Deploy implementation - let rocketh decide based on its own records
+ // Sync handles pending: if pending hash matches local, rocketh has bytecode to compare
+ // If pending hash differs, sync skipped bytecode so rocketh will deploy fresh
+ // Libraries are pre-linked into the artifact (step 2) so rocketh stores linked
+ // bytecode — its CBOR-stripping comparison then matches on subsequent runs.
+ const impl = await deployFn(implDeploymentName, { account: deployer, artifact, args: constructorArgs })
+
+ if (!impl.newlyDeployed) {
+ env.showMessage(`\n✓ ${contractName} implementation unchanged`)
+ return {
+ deployed: false,
+ address: impl.address,
+ bytecodeChanged: false,
+ }
+ }
+
+ // 4) Get current on-chain implementation
+ const currentOnChainImpl = await getOnChainImplementation(client, proxy.address, proxyType, proxyAdmin.address)
+
+ env.showMessage(`\n📋 New ${contractName} implementation deployed: ${impl.address}`)
+ env.showMessage(` Current on-chain implementation: ${currentOnChainImpl}`)
+ env.showMessage(` Storing as pending implementation...`)
+
+ // 5) Store as pending implementation in address book with full deployment metadata
+ // (addressBookInstance already obtained above for bytecode hash check)
+
+ // Get block info for timestamp
+ let blockNumber: number | undefined
+ let timestamp: string | undefined
+ if (impl.transaction?.hash) {
+ try {
+ const receipt = await client.getTransactionReceipt({ hash: impl.transaction.hash as `0x${string}` })
+ if (receipt?.blockNumber) {
+ blockNumber = Number(receipt.blockNumber)
+ const block = await client.getBlock({ blockNumber: receipt.blockNumber })
+ if (block?.timestamp) {
+ timestamp = new Date(Number(block.timestamp) * 1000).toISOString()
+ }
+ }
+ } catch {
+ // Block info lookup failed - not critical
+ }
+ }
+
+ // Store with full deployment metadata for verification and reconstruction
+ const metadata = buildDeploymentMetadata(impl, localBytecodeHash, { blockNumber, timestamp })
+ if (metadata) {
+ addressBookInstance.setPendingImplementationWithMetadata(contractName, impl.address, metadata)
+ }
+
+ env.showMessage(`✓ Pending implementation stored with deployment metadata.`)
+ env.showMessage(` Run upgrade task to generate TX and execute.`)
+
+ return {
+ deployed: true,
+ address: impl.address,
+ bytecodeChanged: true,
+ txHash: impl.transaction?.hash,
+ }
+}
diff --git a/packages/deployment/lib/deploy-standalone.ts b/packages/deployment/lib/deploy-standalone.ts
new file mode 100644
index 000000000..4593cbaa0
--- /dev/null
+++ b/packages/deployment/lib/deploy-standalone.ts
@@ -0,0 +1,71 @@
+import type { Environment } from '@rocketh/core/types'
+
+import type { RegistryEntry } from './contract-registry.js'
+import { loadArtifactFromSource } from './deploy-implementation.js'
+import { requireDeployer } from './issuance-deploy-utils.js'
+import { deploy, graph } from '../rocketh/deploy.js'
+
+/**
+ * Configuration for deploying a standalone (non-proxy) contract
+ */
+export interface StandaloneDeployConfig {
+ /** Contract registry entry (provides addressBook and artifact config) */
+ contract: RegistryEntry
+ /** Constructor arguments */
+ constructorArgs?: unknown[]
+}
+
+/**
+ * Deploy a standalone (non-proxy) contract and update the address book
+ *
+ * This utility handles the common pattern for deploying contracts that
+ * are not behind a proxy (e.g., helper contracts).
+ *
+ * - Loads artifact from registry metadata
+ * - Deploys via rocketh (idempotent - skips if bytecode unchanged)
+ * - Updates the appropriate address book (horizon or issuance)
+ *
+ * @example
+ * ```typescript
+ * await deployStandaloneContract(env, {
+ * contract: Contracts.horizon.GraphTallyCollector,
+ * constructorArgs: [controllerAddress],
+ * })
+ * ```
+ */
+export async function deployStandaloneContract(
+ env: Environment,
+ config: StandaloneDeployConfig,
+): Promise<{ address: string; newlyDeployed: boolean }> {
+ const { contract, constructorArgs = [] } = config
+
+ if (!contract.artifact) {
+ throw new Error(`No artifact configured for ${contract.name} in registry`)
+ }
+
+ const deployer = requireDeployer(env)
+ const artifact = loadArtifactFromSource(contract.artifact)
+ const deployFn = deploy(env)
+
+ const result = await deployFn(contract.name, {
+ account: deployer,
+ artifact,
+ args: constructorArgs,
+ })
+
+ if (result.newlyDeployed) {
+ env.showMessage(`\n✓ ${contract.name} deployed at ${result.address}`)
+ } else {
+ env.showMessage(`\n✓ ${contract.name} unchanged at ${result.address}`)
+ }
+
+ await graph.updateAddressBookForContract(env, contract, {
+ name: contract.name,
+ address: result.address,
+ })
+
+ return {
+ address: result.address,
+ newlyDeployed: !!result.newlyDeployed,
+ }
+}
diff --git a/packages/deployment/lib/deployment-config.ts b/packages/deployment/lib/deployment-config.ts
new file mode 100644
index 000000000..b26cfb29e
--- /dev/null
+++ b/packages/deployment/lib/deployment-config.ts
@@ -0,0 +1,131 @@
+import { readFileSync } from 'node:fs'
+import { resolve, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { Environment } from '@rocketh/core/types'
+import JSON5 from 'json5'
+
+import { getTargetChainIdFromEnv } from './address-book-utils.js'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+
+/** Chain ID to config file name mapping */
+const CHAIN_CONFIG_MAP: Record = {
+ 1337: 'localNetwork',
+ 42161: 'arbitrumOne',
+ 421614: 'arbitrumSepolia',
+}
+
+/**
+ * Raw on-disk shape of `config/.json5`. Every field is optional —
+ * networks override only what they need; the rest comes from `DEFAULT_SETTINGS`.
+ */
+interface DeploymentConfigFile {
+ IssuanceAllocator?: {
+ ramAllocatorMintingGrtPerBlock?: string
+ ramSelfMintingGrtPerBlock?: string
+ }
+ RewardsManager?: {
+ revertOnIneligible?: boolean
+ }
+ RecurringCollector?: {
+ revokeSignerThawingPeriod?: string
+ eip712Name?: string
+ eip712Version?: string
+ }
+}
+
+/**
+ * Fully-resolved deployment settings for a given chain.
+ *
+ * Every field is concrete — defaults from `DEFAULT_SETTINGS` are applied for
+ * any field a network's config file omits. Consumers (deploy scripts and
+ * status checks) read this directly without per-call `??` fallbacks, so the
+ * "expected value" lives in exactly one place per field.
+ */
+export interface ResolvedSettings {
+ rewardsManager: {
+ /** Revert on reward claim attempts by ineligible indexers. */
+ revertOnIneligible: boolean
+ }
+ issuanceAllocator: {
+ /** GRT/block minted by IA and routed to RAM. `'0'` means unconfigured (skip allocation). */
+ ramAllocatorMintingGrtPerBlock: string
+ /** GRT/block self-minted by RAM. `'0'` means RAM does not self-mint. */
+ ramSelfMintingGrtPerBlock: string
+ }
+ recurringCollector: {
+ /** Signer revocation thaw period in seconds (constructor arg). */
+ revokeSignerThawingPeriod: string
+ /** EIP-712 domain name (init arg). */
+ eip712Name: string
+ /** EIP-712 domain version (init arg). */
+ eip712Version: string
+ }
+}
+
+const DEFAULT_SETTINGS: ResolvedSettings = {
+ rewardsManager: {
+ revertOnIneligible: true,
+ },
+ issuanceAllocator: {
+ ramAllocatorMintingGrtPerBlock: '0',
+ ramSelfMintingGrtPerBlock: '0',
+ },
+ recurringCollector: {
+ revokeSignerThawingPeriod: '28800', // ~1 day at 3s blocks
+ eip712Name: 'RecurringCollector',
+ eip712Version: '1',
+ },
+}
+
+function loadConfigFile(chainId: number): DeploymentConfigFile {
+ const networkName = CHAIN_CONFIG_MAP[chainId]
+ if (!networkName) return {}
+
+ const configPath = resolve(__dirname, '..', 'config', `${networkName}.json5`)
+ try {
+ const raw = readFileSync(configPath, 'utf-8')
+ return JSON5.parse(raw)
+ } catch {
+ return {}
+ }
+}
+
+/**
+ * Get fully-resolved deployment settings for a chain.
+ *
+ * Reads `config/.json5` (if present) and applies `DEFAULT_SETTINGS`
+ * for any field the network omits. Pure / sync — safe to call from non-deploy
+ * contexts (e.g. the status task). Returns full defaults for unknown chains.
+ */
+export function getResolvedSettings(chainId: number): ResolvedSettings {
+ const file = loadConfigFile(chainId)
+ return {
+ rewardsManager: {
+ revertOnIneligible: file.RewardsManager?.revertOnIneligible ?? DEFAULT_SETTINGS.rewardsManager.revertOnIneligible,
+ },
+ issuanceAllocator: {
+ ramAllocatorMintingGrtPerBlock:
+ file.IssuanceAllocator?.ramAllocatorMintingGrtPerBlock ??
+ DEFAULT_SETTINGS.issuanceAllocator.ramAllocatorMintingGrtPerBlock,
+ ramSelfMintingGrtPerBlock:
+ file.IssuanceAllocator?.ramSelfMintingGrtPerBlock ??
+ DEFAULT_SETTINGS.issuanceAllocator.ramSelfMintingGrtPerBlock,
+ },
+ recurringCollector: {
+ revokeSignerThawingPeriod:
+ file.RecurringCollector?.revokeSignerThawingPeriod ??
+ DEFAULT_SETTINGS.recurringCollector.revokeSignerThawingPeriod,
+ eip712Name: file.RecurringCollector?.eip712Name ?? DEFAULT_SETTINGS.recurringCollector.eip712Name,
+ eip712Version: file.RecurringCollector?.eip712Version ?? DEFAULT_SETTINGS.recurringCollector.eip712Version,
+ },
+ }
+}
+
+/**
+ * Convenience wrapper for deploy scripts that have an `env` but not a chainId.
+ */
+export async function getResolvedSettingsForEnv(env: Environment): Promise {
+ const chainId = await getTargetChainIdFromEnv(env)
+ return getResolvedSettings(chainId)
+}
diff --git a/packages/deployment/lib/deployment-metadata.ts b/packages/deployment/lib/deployment-metadata.ts
new file mode 100644
index 000000000..936b445c3
--- /dev/null
+++ b/packages/deployment/lib/deployment-metadata.ts
@@ -0,0 +1,59 @@
+import type { DeploymentMetadata } from '@graphprotocol/toolshed/deployments'
+
+/**
+ * Subset of rocketh's `Deployment` / `DeployContractResult` shape needed
+ * to materialize a `DeploymentMetadata` entry. `receipt.blockNumber` may be
+ * a hex string (`DeployResult`), a bigint (viem receipt) or a number.
+ */
+type DeploymentResult = {
+ transaction?: { hash?: string }
+ argsData?: string
+ receipt?: { blockNumber?: `0x${string}` | bigint | number }
+}
+
+/**
+ * Optional overrides for fields rocketh's result may not carry directly.
+ * `blockNumber` overrides any value extracted from `result.receipt.blockNumber`.
+ */
+type MetadataOverrides = {
+ blockNumber?: number
+ timestamp?: string
+}
+
+/**
+ * Coerce rocketh's `receipt.blockNumber` (hex string, bigint, or number) to a plain
+ * number. Returns `undefined` for missing values. Use this everywhere instead of
+ * inline `parseInt`/`Number` so the conversion stays consistent.
+ */
+export function toBlockNumber(raw: `0x${string}` | bigint | number | undefined): number | undefined {
+ if (raw === undefined) return undefined
+ if (typeof raw === 'string') return Number(BigInt(raw))
+ return Number(raw)
+}
+
+/**
+ * Build a `DeploymentMetadata` entry from a rocketh deployment result.
+ *
+ * Returns `undefined` when the essential fields (txHash, argsData) are missing —
+ * callers should skip recording rather than write a half-populated entry with
+ * an empty sentinel txHash.
+ *
+ * @param result - Rocketh deployment / pending-impl result
+ * @param bytecodeHash - Pre-computed bytecode hash (hashing inputs vary by caller)
+ * @param overrides - Optional blockNumber / timestamp (e.g. fetched from a separate receipt query)
+ */
+export function buildDeploymentMetadata(
+ result: DeploymentResult,
+ bytecodeHash: string,
+ overrides?: MetadataOverrides,
+): DeploymentMetadata | undefined {
+ if (!result.transaction?.hash || !result.argsData) return undefined
+ const blockNumber = overrides?.blockNumber ?? toBlockNumber(result.receipt?.blockNumber)
+ return {
+ txHash: result.transaction.hash,
+ argsData: result.argsData,
+ bytecodeHash,
+ ...(blockNumber !== undefined && { blockNumber }),
+ ...(overrides?.timestamp && { timestamp: overrides.timestamp }),
+ }
+}
diff --git a/packages/deployment/lib/deployment-tags.ts b/packages/deployment/lib/deployment-tags.ts
new file mode 100644
index 000000000..9db4bbdad
--- /dev/null
+++ b/packages/deployment/lib/deployment-tags.ts
@@ -0,0 +1,150 @@
+/**
+ * Deployment Tag Library
+ *
+ * Tags select components, skip functions gate actions:
+ * - Component tags: PascalCase contract name (e.g., 'IssuanceAllocator')
+ * - Action verbs: deploy, upgrade, configure, transfer, integrate, all
+ * - Phase scopes: GIP-NNNN:phase (e.g., 'GIP-0088:upgrade')
+ * - Activation goals: GIP-NNNN:phase-action (e.g., 'GIP-0088:eligibility-integrate')
+ *
+ * Usage: --tags IssuanceAllocator,deploy → matches component, deploy runs, others skip
+ */
+
+/**
+ * Action suffixes for deployment scripts
+ */
+export const DeploymentActions = {
+ DEPLOY: 'deploy',
+ UPGRADE: 'upgrade',
+ CONFIGURE: 'configure',
+ TRANSFER: 'transfer',
+ INTEGRATE: 'integrate',
+ ALL: 'all',
+} as const
+
+/**
+ * Core component tags (PascalCase contract names matching the registry)
+ */
+export const ComponentTags = {
+ // Core contracts with full lifecycle (deploy + upgrade + configure)
+ ISSUANCE_ALLOCATOR: 'IssuanceAllocator',
+ DEFAULT_ALLOCATION: 'DefaultAllocation',
+ REWARDS_RECLAIM: 'RewardsReclaim',
+
+ // Implementations and support contracts
+ DIRECT_ALLOCATION_IMPL: 'DirectAllocation_Implementation',
+ REWARDS_ELIGIBILITY_A: 'RewardsEligibilityOracleA',
+ REWARDS_ELIGIBILITY_B: 'RewardsEligibilityOracleB',
+ REWARDS_ELIGIBILITY_MOCK: 'RewardsEligibilityOracleMock',
+
+ // Horizon contracts
+ RECURRING_COLLECTOR: 'RecurringCollector',
+ REWARDS_MANAGER: 'RewardsManager',
+ HORIZON_STAKING: 'HorizonStaking',
+ PAYMENTS_ESCROW: 'PaymentsEscrow',
+
+ // SubgraphService contracts
+ SUBGRAPH_SERVICE: 'SubgraphService',
+ DISPUTE_MANAGER: 'DisputeManager',
+
+ // Legacy contracts (graph proxy, upgrade only)
+ L2_CURATION: 'L2Curation',
+
+ // Issuance agreement contracts
+ RECURRING_AGREEMENT_MANAGER: 'RecurringAgreementManager',
+} as const
+
+/**
+ * Goal tags - deployment goals that orchestrate component lifecycles
+ *
+ * Two-dimensional: phase scope × action verbs.
+ * - Phase scopes select which contracts (`GIP-0088:upgrade`, `GIP-0088:eligibility`, etc.)
+ * - Action verbs select which lifecycle step (`deploy`, `configure`, `transfer`, `upgrade`)
+ * - Activation goals are phase-scoped governance TXs (`GIP-0088:eligibility-integrate`)
+ * - Optional goals bypass the `all` wildcard
+ *
+ * Combined: `--tags GIP-0088:issuance,deploy`
+ */
+export const GoalTags = {
+ // Overall GIP scope (status + verification)
+ GIP_0088: 'GIP-0088',
+
+ // Upgrade phase (deploy, configure, transfer, upgrade — combined with action verbs)
+ GIP_0088_UPGRADE: 'GIP-0088:upgrade',
+
+ // Activation goals (governance TXs — after upgrade complete)
+ GIP_0088_ELIGIBILITY_INTEGRATE: 'GIP-0088:eligibility-integrate',
+ GIP_0088_ISSUANCE_CONNECT: 'GIP-0088:issuance-connect',
+ GIP_0088_ISSUANCE_ALLOCATE: 'GIP-0088:issuance-allocate',
+
+ // Optional goals (not activated by `all`)
+ GIP_0088_ISSUANCE_CLOSE_GUARD: 'GIP-0088:issuance-close-guard',
+} as const
+
+/**
+ * Special tags
+ */
+export const SpecialTags = {
+ SYNC: 'sync',
+} as const
+
+/**
+ * Parse the value of --tags from argv.
+ *
+ * Supports both `--tags foo,bar` (space) and `--tags=foo,bar` (equals).
+ * Returns null when not present or when the space form has no following arg.
+ */
+function parseTagsArg(): string[] | null {
+ const argv = process.argv
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i]
+ if (a === '--tags') {
+ if (i + 1 >= argv.length) return null
+ return argv[i + 1].split(',')
+ }
+ if (a.startsWith('--tags=')) {
+ return a.slice('--tags='.length).split(',')
+ }
+ }
+ return null
+}
+
+/**
+ * Check whether --tags was specified on the command line.
+ *
+ * Returns true (skip) when no --tags are present. Used by status modules
+ * to skip when the user didn't request any specific component.
+ */
+export function noTagsRequested(): boolean {
+ return parseTagsArg() === null
+}
+
+/**
+ * Check whether a deploy script should skip based on action verbs in --tags.
+ *
+ * Returns true (skip) when:
+ * - No --tags specified at all (safety: require explicit tags for mutations)
+ * - The verb is not present in the requested tags
+ *
+ * The 'all' verb is a wildcard: `--tags Component,all` activates every action
+ * (deploy, upgrade, configure, transfer, integrate) plus the end verification.
+ *
+ * Used by script factories and custom deploy scripts to gate mutations.
+ */
+export function shouldSkipAction(verb: string): boolean {
+ const tags = parseTagsArg()
+ if (tags === null) return true
+ return !tags.includes(verb) && !tags.includes(DeploymentActions.ALL)
+}
+
+/**
+ * Check whether an optional goal should skip.
+ *
+ * Unlike `shouldSkipAction`, this does NOT respond to the `all` wildcard.
+ * Optional goals only run when their specific tag is explicitly requested.
+ */
+export function shouldSkipOptionalGoal(goalTag: string): boolean {
+ const tags = parseTagsArg()
+ if (tags === null) return true
+ return !tags.includes(goalTag)
+}
diff --git a/packages/deployment/lib/deployment-validation.ts b/packages/deployment/lib/deployment-validation.ts
new file mode 100644
index 000000000..e811b3f8e
--- /dev/null
+++ b/packages/deployment/lib/deployment-validation.ts
@@ -0,0 +1,312 @@
+/**
+ * Pre-flight validation for deployment records
+ *
+ * Validates that deployment records can be reconstructed and are consistent
+ * with on-chain state. Run before deployments to catch issues early.
+ */
+
+import type { DeploymentMetadata } from '@graphprotocol/toolshed/deployments'
+
+import type { AnyAddressBookOps } from './address-book-ops.js'
+import type { ArtifactSource } from './contract-registry.js'
+import { computeBytecodeHash } from './bytecode-utils.js'
+import {
+ getLibraryResolver,
+ loadContractsArtifact,
+ loadIssuanceArtifact,
+ loadOpenZeppelinArtifact,
+ loadSubgraphServiceArtifact,
+} from './artifact-loaders.js'
+
+/**
+ * Result of validating a single contract
+ */
+export interface ValidationResult {
+ /** Contract name */
+ contract: string
+ /** Validation status */
+ status: 'valid' | 'warning' | 'error'
+ /** Human-readable message */
+ message: string
+ /** Additional details for debugging */
+ details?: Record
+}
+
+/**
+ * Options for validation
+ */
+export interface ValidationOptions {
+ /** Whether to perform on-chain checks (requires provider) */
+ checkOnChain?: boolean
+ /** Whether to verify argsData matches transaction input */
+ verifyArgsData?: boolean
+}
+
+/**
+ * Load artifact from source type
+ */
+function loadArtifact(source: ArtifactSource) {
+ switch (source.type) {
+ case 'contracts':
+ return loadContractsArtifact(source.path, source.name)
+ case 'subgraph-service':
+ return loadSubgraphServiceArtifact(source.name)
+ case 'issuance':
+ return loadIssuanceArtifact(source.path)
+ case 'openzeppelin':
+ return loadOpenZeppelinArtifact(source.name)
+ }
+}
+
+/**
+ * Validate deployment metadata is complete
+ */
+function validateMetadataComplete(metadata: DeploymentMetadata | undefined): {
+ valid: boolean
+ missing: string[]
+} {
+ if (!metadata) {
+ return { valid: false, missing: ['all fields'] }
+ }
+
+ const missing: string[] = []
+ if (!metadata.txHash) missing.push('txHash')
+ if (!metadata.argsData) missing.push('argsData')
+ if (!metadata.bytecodeHash) missing.push('bytecodeHash')
+
+ return { valid: missing.length === 0, missing }
+}
+
+/**
+ * Validate a single contract's deployment record
+ *
+ * Checks:
+ * 1. Entry exists in address book
+ * 2. Deployment metadata exists and is complete
+ * 3. Bytecode hash matches local artifact
+ * 4. (Optional) Address has code on-chain
+ * 5. (Optional) argsData matches transaction input
+ */
+export async function validateContract(
+ addressBook: AnyAddressBookOps,
+ contractName: string,
+ artifact: ArtifactSource,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ client?: any,
+ options: ValidationOptions = {},
+): Promise {
+ // Check if entry exists
+ if (!addressBook.entryExists(contractName)) {
+ return {
+ contract: contractName,
+ status: 'valid',
+ message: 'not deployed (no entry)',
+ }
+ }
+
+ const entry = addressBook.getEntry(contractName)
+
+ // Check if address is valid
+ if (!entry.address || entry.address === '0x0000000000000000000000000000000000000000') {
+ return {
+ contract: contractName,
+ status: 'valid',
+ message: 'not deployed (zero address)',
+ }
+ }
+
+ // Check deployment metadata
+ const metadata = addressBook.getDeploymentMetadata(contractName)
+ const metadataCheck = validateMetadataComplete(metadata)
+
+ if (!metadataCheck.valid) {
+ return {
+ contract: contractName,
+ status: 'warning',
+ message: `missing deployment metadata: ${metadataCheck.missing.join(', ')}`,
+ details: { address: entry.address, missingFields: metadataCheck.missing },
+ }
+ }
+
+ // Load artifact and verify bytecode hash
+ let loadedArtifact
+ try {
+ loadedArtifact = loadArtifact(artifact)
+ } catch {
+ return {
+ contract: contractName,
+ status: 'warning',
+ message: 'could not load artifact for bytecode comparison',
+ details: { artifactSource: artifact },
+ }
+ }
+
+ if (loadedArtifact?.deployedBytecode && metadata?.bytecodeHash) {
+ const libResolver = getLibraryResolver(artifact.type)
+ const localHash = computeBytecodeHash(
+ loadedArtifact.deployedBytecode,
+ loadedArtifact.deployedLinkReferences,
+ libResolver,
+ )
+ if (metadata.bytecodeHash !== localHash) {
+ return {
+ contract: contractName,
+ status: 'warning',
+ message: 'local bytecode differs from deployed version',
+ details: {
+ address: entry.address,
+ storedHash: metadata.bytecodeHash,
+ localHash,
+ },
+ }
+ }
+ }
+
+ // Optional: Check on-chain state
+ if (options.checkOnChain && client) {
+ try {
+ const code = await client.getCode({ address: entry.address as `0x${string}` })
+ if (!code || code === '0x') {
+ return {
+ contract: contractName,
+ status: 'error',
+ message: 'no code at address on-chain',
+ details: { address: entry.address },
+ }
+ }
+ } catch (error) {
+ return {
+ contract: contractName,
+ status: 'error',
+ message: `failed to check on-chain code: ${(error as Error).message}`,
+ details: { address: entry.address },
+ }
+ }
+
+ // Optional: Verify argsData matches transaction
+ if (options.verifyArgsData && metadata?.txHash && metadata?.argsData && loadedArtifact?.bytecode) {
+ try {
+ const tx = await client.getTransaction({ hash: metadata.txHash as `0x${string}` })
+ if (tx?.input) {
+ // Extract args from tx input (after bytecode)
+ const bytecodeLength = loadedArtifact.bytecode.length
+ const extractedArgs = '0x' + tx.input.slice(bytecodeLength)
+
+ if (extractedArgs.toLowerCase() !== metadata.argsData.toLowerCase()) {
+ return {
+ contract: contractName,
+ status: 'error',
+ message: 'argsData mismatch with deployment transaction',
+ details: {
+ txHash: metadata.txHash,
+ storedArgs: metadata.argsData,
+ extractedArgs,
+ },
+ }
+ }
+ }
+ } catch {
+ // Transaction lookup failed - not a critical error
+ }
+ }
+ }
+
+ return {
+ contract: contractName,
+ status: 'valid',
+ message: 'ok',
+ details: {
+ address: entry.address,
+ hasMetadata: true,
+ bytecodeHashMatches: true,
+ },
+ }
+}
+
+/**
+ * Validate multiple contracts
+ *
+ * @param addressBook - Address book ops instance
+ * @param contracts - List of contracts with their artifact sources
+ * @param client - Optional viem client for on-chain checks
+ * @param options - Validation options
+ */
+export async function validateContracts(
+ addressBook: AnyAddressBookOps,
+ contracts: Array<{ name: string; artifact: ArtifactSource }>,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ client?: any,
+ options: ValidationOptions = {},
+): Promise {
+ const results: ValidationResult[] = []
+
+ for (const { name, artifact } of contracts) {
+ const result = await validateContract(addressBook, name, artifact, client, options)
+ results.push(result)
+ }
+
+ return results
+}
+
+/**
+ * Summary of validation results
+ */
+export interface ValidationSummary {
+ /** Total contracts checked */
+ total: number
+ /** Contracts with valid status */
+ valid: number
+ /** Contracts with warnings */
+ warnings: number
+ /** Contracts with errors */
+ errors: number
+ /** Whether all checks passed (no errors) */
+ success: boolean
+ /** Individual results */
+ results: ValidationResult[]
+}
+
+/**
+ * Summarize validation results
+ */
+export function summarizeValidation(results: ValidationResult[]): ValidationSummary {
+ const summary: ValidationSummary = {
+ total: results.length,
+ valid: 0,
+ warnings: 0,
+ errors: 0,
+ success: true,
+ results,
+ }
+
+ for (const result of results) {
+ switch (result.status) {
+ case 'valid':
+ summary.valid++
+ break
+ case 'warning':
+ summary.warnings++
+ break
+ case 'error':
+ summary.errors++
+ summary.success = false
+ break
+ }
+ }
+
+ return summary
+}
+
+/**
+ * Format validation results for display
+ */
+export function formatValidationResults(results: ValidationResult[]): string[] {
+ const lines: string[] = []
+
+ for (const result of results) {
+ const icon = result.status === 'valid' ? '✓' : result.status === 'warning' ? '⚠' : '❌'
+ lines.push(`${icon} ${result.contract}: ${result.message}`)
+ }
+
+ return lines
+}
diff --git a/packages/deployment/lib/execute-governance.ts b/packages/deployment/lib/execute-governance.ts
new file mode 100644
index 000000000..e39cde9cc
--- /dev/null
+++ b/packages/deployment/lib/execute-governance.ts
@@ -0,0 +1,494 @@
+import type { Environment } from '@rocketh/core/types'
+import fs from 'fs'
+import path from 'path'
+import { createPublicClient, createWalletClient, custom, http, parseEther } from 'viem'
+import { privateKeyToAccount } from 'viem/accounts'
+
+import { getForkNetwork, getForkStateDir, getTargetChainIdFromEnv, isForkMode } from './address-book-utils.js'
+import { getGovernor } from './controller-utils.js'
+import type { BuilderTx } from './tx-builder.js'
+import { TxBuilder } from './tx-builder.js'
+
+/**
+ * Convert network name to env var prefix: arbitrumSepolia → ARBITRUM_SEPOLIA
+ */
+function networkToEnvPrefix(networkName: string): string {
+ return networkName.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase()
+}
+
+interface SafeTxBatch {
+ version: string
+ chainId: string
+ createdAt: number
+ meta?: unknown
+ transactions: BuilderTx[]
+}
+
+/**
+ * Get governance TX directory path
+ *
+ * In fork mode: fork///txs/
+ * In normal mode: txs//
+ *
+ * Stored outside deployments/ so rocketh manages its own directory cleanly.
+ *
+ * @param networkName - Network name (e.g., 'fork', 'localhost', 'arbitrumSepolia')
+ */
+export function getGovernanceTxDir(networkName: string): string {
+ const forkNetwork = getForkNetwork(networkName)
+ if (forkNetwork) {
+ return path.join(getForkStateDir(networkName, forkNetwork), 'txs')
+ }
+ return path.resolve(process.cwd(), 'txs', networkName)
+}
+
+/**
+ * Count pending governance TX batch files
+ *
+ * @param networkName - Network name (e.g., 'fork', 'arbitrumSepolia')
+ */
+export function countPendingGovernanceTxs(networkName: string): number {
+ const txDir = getGovernanceTxDir(networkName)
+ if (!fs.existsSync(txDir)) {
+ return 0
+ }
+ return fs.readdirSync(txDir).filter((f) => f.endsWith('.json') && !f.startsWith('.')).length
+}
+
+/**
+ * Check if a specific governance TX file exists
+ *
+ * @param networkName - Network name (e.g., 'fork', 'arbitrumSepolia')
+ * @param name - TX file name (without .json extension)
+ */
+export function hasGovernanceTx(networkName: string, name: string): boolean {
+ const txFile = path.join(getGovernanceTxDir(networkName), `${name}.json`)
+ return fs.existsSync(txFile)
+}
+
+/**
+ * Check for pending upgrade TX and exit if found
+ *
+ * Standard pattern for contract "ready" steps that depend on governance execution.
+ * Call this at the start of the final deploy step for any upgradeable contract.
+ *
+ * @param env - Deployment environment
+ * @param contractName - Contract name (used to derive TX filename: upgrade-{contractName})
+ */
+export function requireUpgradeExecuted(env: Environment, contractName: string): void {
+ const txName = `upgrade-${contractName}`
+ if (hasGovernanceTx(env.name, txName)) {
+ const txFile = path.join(getGovernanceTxDir(env.name), `${txName}.json`)
+ env.showMessage(`\n⏳ ${contractName} pending governance (${txFile})`)
+ env.showMessage(` Run: npx hardhat deploy:execute-governance --network ${env.name}`)
+ process.exit(1)
+ }
+}
+
+/**
+ * Create a TxBuilder configured for governance transactions
+ *
+ * Standard pattern for creating governance TX builders with correct:
+ * - Target chain ID (handles fork mode)
+ * - Output directory (handles fork mode)
+ * - Template path (uses default)
+ *
+ * @param env - Deployment environment
+ * @param name - TX batch name (without .json extension)
+ * @param meta - Optional metadata for the TX batch
+ * @returns Configured TxBuilder instance
+ */
+export async function createGovernanceTxBuilder(
+ env: Environment,
+ name: string,
+ meta?: { name?: string; description?: string },
+): Promise {
+ const targetChainId = await getTargetChainIdFromEnv(env)
+ const outputDir = getGovernanceTxDir(env.name)
+
+ return new TxBuilder(targetChainId, {
+ outputDir,
+ name,
+ meta,
+ })
+}
+
+/**
+ * Save governance TX batch and exit with code 1
+ *
+ * Standard completion pattern for scripts that generate governance TX batches.
+ * Saves the TX batch to file and displays a message.
+ * Returns the saved file path so the caller can continue.
+ *
+ * Subsequent scripts that depend on this TX being executed should check
+ * their own preconditions and exit if not met.
+ *
+ * @param env - Deployment environment
+ * @param builder - TX builder with batched transactions
+ * @param contractName - Optional contract name for contextual message
+ * @returns Path to the saved TX file
+ */
+export function saveGovernanceTx(
+ env: Environment,
+ builder: { saveToFile: () => string },
+ contractName?: string,
+): string {
+ const txFile = builder.saveToFile()
+ env.showMessage(` ✓ Governance TX saved: ${txFile}`)
+
+ if (contractName) {
+ env.showMessage(` ${contractName} requires governance execution`)
+ }
+ env.showMessage(` Run: npx hardhat deploy:execute-governance --network ${env.name}`)
+
+ return txFile
+}
+
+/**
+ * @deprecated Use `saveGovernanceTx` instead. This function exits the process.
+ */
+export function saveGovernanceTxAndExit(
+ env: Environment,
+ builder: { saveToFile: () => string },
+ contractName?: string,
+): never {
+ saveGovernanceTx(env, builder, contractName)
+ process.exit(1)
+}
+
+/**
+ * Execute a TX builder batch directly and save to executed/ folder
+ *
+ * Use this when the caller has authority to execute (e.g., deployer has GOVERNOR_ROLE).
+ * This maintains the consistent pattern of ALWAYS creating a TX batch, but executing
+ * it inline when possible.
+ *
+ * @param env - Deployment environment
+ * @param builder - TX builder with batched transactions
+ * @param account - Account to execute from (deployer address)
+ * @returns Number of transactions executed
+ */
+export async function executeTxBatchDirect(env: Environment, builder: TxBuilder, account: string): Promise {
+ const transactions = builder.getTransactions()
+ if (transactions.length === 0) {
+ return 0
+ }
+
+ // Create viem clients
+ const publicClient = createPublicClient({
+ transport: custom(env.network.provider),
+ })
+ const walletClient = createWalletClient({
+ transport: custom(env.network.provider),
+ })
+
+ // Execute each transaction
+ for (let i = 0; i < transactions.length; i++) {
+ const tx = transactions[i]
+ env.showMessage(` ${i + 1}/${transactions.length} TX to ${tx.to.slice(0, 10)}...`)
+
+ const hash = await walletClient.sendTransaction({
+ chain: null,
+ account: account as `0x${string}`,
+ to: tx.to as `0x${string}`,
+ data: tx.data as `0x${string}`,
+ value: BigInt(tx.value),
+ })
+ await publicClient.waitForTransactionReceipt({ hash })
+ env.showMessage(` ✓ TX hash: ${hash}`)
+ }
+
+ // Save to executed/ folder for audit trail
+ const txDir = getGovernanceTxDir(env.name)
+ const executedDir = path.join(txDir, 'executed')
+ if (!fs.existsSync(executedDir)) {
+ fs.mkdirSync(executedDir, { recursive: true })
+ }
+
+ // Save with original filename in executed/
+ const originalFile = builder.outputFile
+ const filename = path.basename(originalFile)
+ const executedFile = path.join(executedDir, filename)
+ fs.writeFileSync(executedFile, JSON.stringify({ transactions }, null, 2) + '\n')
+ env.showMessage(` ✓ Saved to ${executedFile}`)
+
+ return transactions.length
+}
+
+export interface ExecuteGovernanceOptions {
+ /** Optional TX batch name filter */
+ name?: string
+ /** Governor private key (from keystore or env var) */
+ governorPrivateKey?: string
+ /** Lazy resolver for governor key - defers keystore access until actually needed */
+ resolveGovernorKey?: () => Promise
+}
+
+export async function executeGovernanceTxs(env: Environment, options?: ExecuteGovernanceOptions): Promise {
+ const { name, governorPrivateKey, resolveGovernorKey } = options ?? {}
+ // Determine TX directory - in fork mode, also check source network's TX directory
+ const forkNetwork = getForkNetwork(env.name)
+ let txDir = getGovernanceTxDir(env.name)
+ let sourceNetworkFallback = false
+
+ if (
+ !fs.existsSync(txDir) ||
+ fs.readdirSync(txDir).filter((f) => f.endsWith('.json') && !f.startsWith('.')).length === 0
+ ) {
+ // Fork-state directory empty - check source network's TX directory
+ if (forkNetwork) {
+ const sourceNetworkTxDir = path.resolve(process.cwd(), 'txs', forkNetwork)
+ if (
+ fs.existsSync(sourceNetworkTxDir) &&
+ fs.readdirSync(sourceNetworkTxDir).filter((f) => f.endsWith('.json') && !f.startsWith('.')).length > 0
+ ) {
+ txDir = sourceNetworkTxDir
+ sourceNetworkFallback = true
+ env.showMessage(`\n📂 Using source network TXs: ${txDir}`)
+ }
+ }
+ }
+
+ if (!fs.existsSync(txDir)) {
+ env.showMessage(`\n✓ No governance TXs directory: ${txDir}`)
+ if (forkNetwork) {
+ env.showMessage(` (Also checked: txs/${forkNetwork}/)`)
+ }
+ return 0
+ }
+
+ // Find pending TX batch files (optionally filtered by name)
+ let files: string[]
+ if (name) {
+ const specificFile = `${name}.json`
+ files = fs.existsSync(path.join(txDir, specificFile)) ? [specificFile] : []
+ } else {
+ files = fs.readdirSync(txDir).filter((f) => f.endsWith('.json') && !f.startsWith('.'))
+ }
+ if (files.length === 0) {
+ env.showMessage(`\n✓ No pending governance TXs`)
+ if (forkNetwork && !sourceNetworkFallback) {
+ env.showMessage(` (Also checked: txs/${forkNetwork}/)`)
+ }
+ return 0
+ }
+
+ // Get governor address from Controller
+ const governor = (await getGovernor(env)) as `0x${string}`
+
+ // Create viem client for checking governor type
+ const publicClient = createPublicClient({
+ transport: custom(env.network.provider),
+ })
+
+ // Check if in fork mode (network-aware: ignores FORK_NETWORK on real networks)
+ const inForkMode = isForkMode(env.name)
+
+ if (!inForkMode) {
+ // Not in fork mode - check if governor is EOA or Safe
+ const governorCode = await publicClient.getCode({ address: governor })
+ const isContract = governorCode && governorCode !== '0x'
+
+ // Governor private key passed from task (resolved from keystore or env var)
+
+ if (isContract) {
+ // Governor is a Safe multisig - require Safe UI workflow
+ env.showMessage(`\n📋 Safe multisig governance execution required`)
+ env.showMessage(` Governor address: ${governor}`)
+ env.showMessage(`\nExecute via Safe Transaction Builder:`)
+ env.showMessage(`\n1. Go to https://app.safe.global/`)
+ env.showMessage(` - Connect wallet`)
+ env.showMessage(` - Select the governor Safe (${governor})`)
+ env.showMessage(` - Navigate to: Apps → Transaction Builder`)
+ env.showMessage(`\n2. Click "Upload a JSON" and select:`)
+ for (const file of files) {
+ env.showMessage(` - ${path.join(txDir, file)}`)
+ }
+ env.showMessage(`\n3. Review decoded transactions`)
+ env.showMessage(`4. Create batch → Collect signatures → Execute`)
+ env.showMessage(`\n5. After on-chain execution, sync address books:`)
+ env.showMessage(` npx hardhat deploy --tags sync --network ${env.name}`)
+ env.showMessage(`\nNote: If Safe is not available on ${env.name}, test in fork mode:`)
+ env.showMessage(` FORK_NETWORK=arbitrumOne npx hardhat deploy:execute-governance --network fork\n`)
+ return 0
+ }
+
+ // Governor is an EOA - resolve key now (deferred to avoid keystore prompt in fork mode)
+ const resolvedKey = governorPrivateKey ?? (await resolveGovernorKey?.())
+ if (!resolvedKey) {
+ const keyName = `${networkToEnvPrefix(env.name)}_GOVERNOR_KEY`
+ env.showMessage(`\n❌ Cannot execute governance TXs on ${env.name}`)
+ env.showMessage(` Governor address: ${governor} (EOA)`)
+ env.showMessage(`\nTo execute with EOA private key:`)
+ env.showMessage(` npx hardhat keystore set ${keyName}`)
+ env.showMessage(` npx hardhat deploy:execute-governance --network ${env.name}`)
+ env.showMessage(`\nOr via environment variable:`)
+ env.showMessage(` export ${keyName}=0x...`)
+ env.showMessage(`\nTo test with Safe Transaction Builder (validation only):`)
+ env.showMessage(` 1. Go to https://app.safe.global/`)
+ env.showMessage(` 2. Apps → Transaction Builder → Upload JSON`)
+ env.showMessage(` 3. Select: ${path.join(txDir, files[0])}`)
+ env.showMessage(` 4. Review decoded transactions (don't execute)`)
+ env.showMessage(`\nOr test in fork mode:`)
+ env.showMessage(` FORK_NETWORK=${env.name} npx hardhat deploy:execute-governance --network fork\n`)
+ return 0
+ }
+
+ // Have private key - execute as EOA
+ env.showMessage(`\n🔓 Executing ${files.length} governance TX batch(es)...`)
+ env.showMessage(` Governor: ${governor} (EOA)`)
+ return await executeWithEOA(env, publicClient, files, txDir, resolvedKey)
+ }
+
+ // Fork mode - use impersonation
+ env.showMessage(`\n🔓 Executing ${files.length} governance TX batch(es) via impersonation...`)
+ env.showMessage(` (Fork mode - impersonating governor for testing)`)
+ env.showMessage(` Governor: ${governor}`)
+ return await executeWithImpersonation(env, publicClient, files, txDir, governor)
+}
+
+/**
+ * Execute governance TXs using EOA private key (testnet with EOA governor)
+ */
+async function executeWithEOA(
+ env: Environment,
+ publicClient: ReturnType,
+ files: string[],
+ txDir: string,
+ privateKey: string,
+): Promise {
+ // Create wallet from private key
+ const account = privateKeyToAccount(privateKey as `0x${string}`)
+
+ // Create wallet client with the account
+ const walletClient = createWalletClient({
+ account,
+ transport: custom(env.network.provider),
+ })
+
+ let executedCount = 0
+ const executedDir = path.join(txDir, 'executed')
+
+ for (const file of files) {
+ const filePath = path.join(txDir, file)
+ env.showMessage(`\n 📋 ${file}`)
+
+ try {
+ const batchContents = fs.readFileSync(filePath, 'utf8')
+ const batch: SafeTxBatch = JSON.parse(batchContents)
+
+ // Execute each transaction
+ for (let i = 0; i < batch.transactions.length; i++) {
+ const tx = batch.transactions[i]
+ env.showMessage(` ${i + 1}/${batch.transactions.length} TX to ${tx.to.slice(0, 10)}...`)
+
+ const hash = await walletClient.sendTransaction({
+ chain: null,
+ to: tx.to as `0x${string}`,
+ data: tx.data as `0x${string}`,
+ value: BigInt(tx.value),
+ })
+ await publicClient.waitForTransactionReceipt({ hash })
+ env.showMessage(` ✓ TX hash: ${hash}`)
+ }
+
+ // Move to executed directory
+ if (!fs.existsSync(executedDir)) {
+ fs.mkdirSync(executedDir, { recursive: true })
+ }
+ fs.renameSync(filePath, path.join(executedDir, file))
+ env.showMessage(` ✓ Executed and moved to executed/`)
+ executedCount++
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error)
+ env.showMessage(` ✗ Failed: ${errorMessage.slice(0, 80)}...`)
+ throw error
+ }
+ }
+
+ env.showMessage(`\n✅ Executed ${executedCount} governance TX batch(es)`)
+ return executedCount
+}
+
+/**
+ * Execute governance TXs using impersonation (fork mode only)
+ */
+async function executeWithImpersonation(
+ env: Environment,
+ publicClient: ReturnType,
+ files: string[],
+ txDir: string,
+ governor: `0x${string}`,
+): Promise {
+ const walletClient = createWalletClient({
+ transport: custom(env.network.provider),
+ })
+
+ // Use provider.request for hardhat-specific RPC methods
+ const request = env.network.provider.request.bind(env.network.provider) as (args: {
+ method: string
+ params: unknown[]
+ }) => Promise
+
+ // Impersonate governor
+ await request({
+ method: 'hardhat_impersonateAccount',
+ params: [governor],
+ })
+
+ // Fund governor with ETH for gas
+ const tenEth = '0x' + parseEther('10').toString(16)
+ await request({
+ method: 'hardhat_setBalance',
+ params: [governor, tenEth],
+ })
+
+ let executedCount = 0
+ const executedDir = path.join(txDir, 'executed')
+
+ for (const file of files) {
+ const filePath = path.join(txDir, file)
+ env.showMessage(`\n 📋 ${file}`)
+
+ try {
+ const batchContents = fs.readFileSync(filePath, 'utf8')
+ const batch: SafeTxBatch = JSON.parse(batchContents)
+
+ // Execute each transaction
+ for (let i = 0; i < batch.transactions.length; i++) {
+ const tx = batch.transactions[i]
+ env.showMessage(` ${i + 1}/${batch.transactions.length} TX to ${tx.to.slice(0, 10)}...`)
+
+ const hash = await walletClient.sendTransaction({
+ chain: null,
+ account: governor,
+ to: tx.to as `0x${string}`,
+ data: tx.data as `0x${string}`,
+ value: BigInt(tx.value),
+ })
+ await publicClient.waitForTransactionReceipt({ hash })
+ }
+
+ // Move to executed directory
+ if (!fs.existsSync(executedDir)) {
+ fs.mkdirSync(executedDir, { recursive: true })
+ }
+ fs.renameSync(filePath, path.join(executedDir, file))
+ env.showMessage(` ✓ Executed and moved to executed/`)
+ executedCount++
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error)
+ env.showMessage(` ✗ Failed: ${errorMessage.slice(0, 80)}...`)
+ throw error
+ }
+ }
+
+ // Stop impersonating
+ await request({
+ method: 'hardhat_stopImpersonatingAccount',
+ params: [governor],
+ })
+
+ env.showMessage(`\n✅ Executed ${executedCount} governance TX batch(es)`)
+ return executedCount
+}
diff --git a/packages/deployment/lib/format.ts b/packages/deployment/lib/format.ts
new file mode 100644
index 000000000..fd1bf1359
--- /dev/null
+++ b/packages/deployment/lib/format.ts
@@ -0,0 +1,10 @@
+/**
+ * Formatting helpers for human-readable display of on-chain values.
+ */
+
+import { formatEther } from 'viem'
+
+/** Format a wei amount as GRT (e.g. `6036500000000000000n` → `"6.0365 GRT"`). */
+export function formatGRT(wei: bigint): string {
+ return `${formatEther(wei)} GRT`
+}
diff --git a/packages/deployment/lib/issuance-deploy-utils.ts b/packages/deployment/lib/issuance-deploy-utils.ts
new file mode 100644
index 000000000..704bde2e2
--- /dev/null
+++ b/packages/deployment/lib/issuance-deploy-utils.ts
@@ -0,0 +1,646 @@
+import type { DeploymentMetadata } from '@graphprotocol/toolshed/deployments'
+import type { Environment } from '@rocketh/core/types'
+import type { PublicClient } from 'viem'
+import { encodeFunctionData } from 'viem'
+
+import { Contracts, type RegistryEntry } from './contract-registry.js'
+import { getGovernor } from './controller-utils.js'
+import { loadTransparentProxyArtifact } from './artifact-loaders.js'
+import { INITIALIZE_GOVERNOR_ABI, OZ_PROXY_ADMIN_ABI } from './abis.js'
+import { computeBytecodeHash } from './bytecode-utils.js'
+import { getAddressBookForType, getTargetChainIdFromEnv } from './address-book-utils.js'
+import {
+ computeArtifactBytecodeHash,
+ deployImplementation,
+ getImplementationConfig,
+ getOnChainImplementation,
+ loadArtifactFromSource,
+} from './deploy-implementation.js'
+import { buildDeploymentMetadata } from './deployment-metadata.js'
+import { deploy, execute, graph } from '../rocketh/deploy.js'
+
+/** ERC1967 admin slot: keccak256("eip1967.proxy.admin") - 1 */
+const ERC1967_ADMIN_SLOT = '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103'
+
+/**
+ * Require deployer account to be configured
+ *
+ * Standard pattern for checking deployer account exists in namedAccounts.
+ * Throws an error if deployer is not configured.
+ *
+ * @param env - Deployment environment
+ * @returns The deployer address
+ */
+export function requireDeployer(env: Environment): string {
+ const deployer = env.namedAccounts.deployer
+ if (!deployer) {
+ throw new Error('No deployer account configured')
+ }
+ return deployer
+}
+
+/**
+ * Address derived from the dummy private key (0x…001) used for status-only runs.
+ * Filtered out so status scripts don't mistake it for the real deployer.
+ */
+const DUMMY_DEPLOYER_ADDRESS = '0x7e5f4552091a69125d5dfcb7b8c2659029395bdf'
+
+/**
+ * Get deployer address if available (non-throwing).
+ *
+ * Returns undefined when the deploy key is not loaded (e.g. status-only runs
+ * where the keystore password is not prompted). Status scripts infer the real
+ * deployer from the ProxyAdmin owner on-chain instead.
+ */
+export function getDeployer(env: Environment): string | undefined {
+ const deployer = env.namedAccounts.deployer
+ if (!deployer || deployer.toLowerCase() === DUMMY_DEPLOYER_ADDRESS) return undefined
+ return deployer
+}
+
+/**
+ * Require a contract deployment to exist, throwing a helpful error if not found
+ */
+export function requireContract(env: Environment, contract: RegistryEntry) {
+ const deployment = env.getOrNull(contract.name)
+ if (!deployment) {
+ throw new Error(`${contract.name} not deployed. Run required deploy tags first.`)
+ }
+ return deployment
+}
+
+/**
+ * Require L2GraphToken from deployments (synced from Horizon address book)
+ * Provides specific error message about running sync
+ */
+export function requireGraphToken(env: Environment) {
+ const deployment = env.getOrNull(Contracts.horizon.L2GraphToken.name)
+ if (!deployment) {
+ throw new Error(
+ `Missing deployments/${env.name}/${Contracts.horizon.L2GraphToken.name}.json. ` +
+ `Run sync to import ${Contracts.horizon.L2GraphToken.name} address from Horizon address book.`,
+ )
+ }
+ return deployment
+}
+
+/**
+ * Require multiple contract deployments to exist
+ * Lists all missing contracts in error message
+ */
+export function requireContracts(env: Environment, contracts: RegistryEntry[]) {
+ const missing: string[] = []
+ const deployments = contracts.map((c) => {
+ const deployment = env.getOrNull(c.name)
+ if (!deployment) {
+ missing.push(c.name)
+ }
+ return deployment
+ })
+
+ if (missing.length > 0) {
+ throw new Error(`${missing.join(', ')} not deployed. Run required deploy tags first.`)
+ }
+
+ return deployments as NonNullable<(typeof deployments)[number]>[]
+}
+
+/**
+ * Get proxy infrastructure (implementation) for a proxied contract
+ */
+export function getProxyInfrastructure(env: Environment, contract: RegistryEntry) {
+ const implDep = env.getOrNull(`${contract.name}_Implementation`)
+ return { implementation: implDep }
+}
+
+/**
+ * Read per-proxy ProxyAdmin address from ERC1967 admin slot
+ * OZ v5 TransparentUpgradeableProxy creates its own ProxyAdmin stored in this slot
+ */
+export async function getProxyAdminAddress(client: PublicClient, proxyAddress: string): Promise {
+ const adminSlotData = await client.getStorageAt({
+ address: proxyAddress as `0x${string}`,
+ slot: ERC1967_ADMIN_SLOT as `0x${string}`,
+ })
+ if (!adminSlotData) {
+ throw new Error(`Failed to read admin slot from proxy ${proxyAddress}`)
+ }
+ return `0x${adminSlotData.slice(-40)}`
+}
+
+/**
+ * Show standard deployment status message
+ */
+export function showDeploymentStatus(
+ env: Environment,
+ contract: RegistryEntry,
+ result: { newlyDeployed?: boolean; address: string },
+) {
+ if (result.newlyDeployed) {
+ env.showMessage(`✓ ${contract.name} deployed at ${result.address}`)
+ } else {
+ env.showMessage(`✓ ${contract.name} unchanged at ${result.address}`)
+ }
+}
+
+/**
+ * Show standard proxy deployment status messages
+ */
+export function showProxyDeploymentStatus(
+ env: Environment,
+ contract: RegistryEntry,
+ result: { newlyDeployed?: boolean; address: string },
+ implAddress?: string,
+ governor?: string,
+) {
+ if (result.newlyDeployed) {
+ env.showMessage(`✓ ${contract.name} proxy deployed at ${result.address}`)
+ if (implAddress) {
+ env.showMessage(`✓ ${contract.name} implementation at ${implAddress}`)
+ }
+ if (governor) {
+ env.showMessage(`✓ Governor role assigned to: ${governor}`)
+ }
+ } else {
+ env.showMessage(`✓ ${contract.name} deployed at ${result.address}`)
+ }
+}
+
+/**
+ * Update address book with proxy deployment information.
+ * Routes to the correct address book based on contract.addressBook.
+ */
+export async function updateProxyAddressBook(
+ env: Environment,
+ graphUtils: typeof graph,
+ contract: RegistryEntry,
+ proxyAddress: string,
+ implAddress?: string,
+ proxyAdminAddress?: string,
+ implementationDeployment?: DeploymentMetadata,
+ proxyDeployment?: DeploymentMetadata,
+) {
+ await graphUtils.updateAddressBookForContract(env, contract, {
+ name: contract.name,
+ address: proxyAddress,
+ proxy: 'transparent',
+ proxyAdmin: proxyAdminAddress,
+ implementation: implAddress,
+ proxyDeployment,
+ implementationDeployment,
+ })
+}
+
+/**
+ * Check if proxy has pending upgrade and display warning if needed
+ *
+ * Compares on-chain implementation with newly deployed implementation.
+ * If they differ, displays upgrade warning for governance action.
+ *
+ * @param env - Deployment environment
+ * @param client - Viem public client
+ * @param contract - Contract registry entry
+ * @param proxyAddress - Address of the proxy contract
+ * @param proxyType - 'transparent' for OZ TransparentProxy, 'graph' for Graph legacy proxy
+ * @param proxyAdminAddress - Address of proxy admin (required for 'graph' type)
+ */
+export async function checkPendingUpgrade(
+ env: Environment,
+ client: PublicClient,
+ contract: RegistryEntry,
+ proxyAddress: string,
+ proxyType: 'transparent' | 'graph' = 'transparent',
+ proxyAdminAddress?: string,
+) {
+ // Get implementation deployment if it exists
+ const implDeployment = env.getOrNull(`${contract.name}_Implementation`)
+ if (!implDeployment) {
+ return
+ }
+
+ // Get on-chain implementation
+ const onChainImpl = await getOnChainImplementation(client, proxyAddress, proxyType, proxyAdminAddress)
+
+ // Check if upgrade is pending
+ if (onChainImpl.toLowerCase() !== implDeployment.address.toLowerCase()) {
+ env.showMessage(``)
+ env.showMessage(`⚠️ UPGRADE REQUIRED`)
+ env.showMessage(` Proxy: ${proxyAddress}`)
+ env.showMessage(` Current (on-chain): ${onChainImpl}`)
+ env.showMessage(` New implementation: ${implDeployment.address}`)
+ env.showMessage(``)
+ env.showMessage(` Governance must upgrade the proxy.`)
+ env.showMessage(``)
+ } else {
+ env.showMessage(`✓ Current implementation: ${onChainImpl}`)
+ }
+}
+
+/**
+ * Configuration for deploying a proxy contract
+ */
+export interface ProxyDeployConfig {
+ /** Contract registry entry (provides addressBook and artifact config) */
+ contract: RegistryEntry
+ /** Constructor arguments for implementation (not used when sharedImplementation provided) */
+ constructorArgs?: unknown[]
+ /** Initialize function arguments (defaults to [governor] if not provided) */
+ initializeArgs?: unknown[]
+ /**
+ * Shared implementation contract (optional)
+ * When provided, deploys proxy pointing to this existing implementation
+ * instead of deploying a new implementation from contract.artifact
+ */
+ sharedImplementation?: RegistryEntry
+}
+
+/**
+ * Deploy or upgrade a proxy contract using OZ v5 TransparentUpgradeableProxy
+ *
+ * Uses OpenZeppelin v5's per-proxy ProxyAdmin pattern:
+ * - Each proxy creates its own ProxyAdmin in the constructor
+ * - Deployer is the initial ProxyAdmin owner (for post-deployment configuration)
+ * - Ownership is transferred to governor in the transfer-governance step
+ * - No shared ProxyAdmin required
+ *
+ * Deployment scenarios:
+ * - Fresh deployment: Deploy implementation + OZ v5 proxy (creates per-proxy ProxyAdmin)
+ * - Existing proxy: Deploy new implementation, store as pending for governance upgrade
+ *
+ * For shared implementations (sharedImplementation provided):
+ * - Fresh deployment: Deploy OZ v5 proxy pointing to shared implementation
+ * - Existing proxy: Reports status only (shared impl managed separately)
+ *
+ * @param env - Deployment environment
+ * @param config - Deployment configuration
+ * @returns Deployment result with address and status
+ */
+export async function deployProxyContract(
+ env: Environment,
+ config: ProxyDeployConfig,
+): Promise<{ address: string; newlyDeployed: boolean; upgraded: boolean }> {
+ const { contract, constructorArgs = [], initializeArgs, sharedImplementation } = config
+
+ // Validate contract has required metadata
+ if (!sharedImplementation && !contract.artifact) {
+ throw new Error(`No artifact configured for ${contract.name} in registry (and no sharedImplementation provided)`)
+ }
+
+ // Derive values from environment
+ const deployer = requireDeployer(env)
+ const governor = await getGovernor(env)
+ const actualInitializeArgs = initializeArgs ?? [governor]
+
+ // Check if proxy already exists (synced from address book)
+ const existingProxy = env.getOrNull(`${contract.name}_Proxy`)
+
+ if (existingProxy) {
+ if (sharedImplementation) {
+ // Shared implementation — detect if redeployed and set pendingImplementation
+ env.showMessage(`✓ ${contract.name} proxy already deployed at ${existingProxy.address}`)
+ env.showMessage(` Uses shared implementation: ${sharedImplementation.name}`)
+
+ const implDep = env.getOrNull(sharedImplementation.name)
+ if (!implDep) {
+ // Missing impl record means the impl's deploy script didn't run, or sync
+ // skipped seeding because the artifact couldn't be verified against the
+ // address book. Either way, silently treating this as "no change" would
+ // mask a drift between artifact and on-chain bytecode (the shared impl
+ // bug fixed alongside this guard). Fail loud instead.
+ throw new Error(
+ `${contract.name}: shared implementation ${sharedImplementation.name} not in env. ` +
+ `Ensure ${sharedImplementation.name} is listed in dependencies and its deploy script ran successfully.`,
+ )
+ }
+
+ const client = graph.getPublicClient(env)
+ const onChainImpl = await getOnChainImplementation(client, existingProxy.address, 'transparent')
+
+ if (onChainImpl.toLowerCase() !== implDep.address.toLowerCase()) {
+ // Shared implementation changed — store as pending for governance upgrade
+ const targetChainId = await getTargetChainIdFromEnv(env)
+ const addressBook = getAddressBookForType(contract.addressBook, targetChainId)
+
+ // Get deployment metadata from the shared implementation's address book entry
+ const implMetadata = addressBook.getDeploymentMetadata(sharedImplementation.name)
+ if (!implMetadata) {
+ throw new Error(
+ `${contract.name}: deployment metadata missing for ${sharedImplementation.name}. ` +
+ `Run ${sharedImplementation.name}'s deploy script (or sync) before re-running.`,
+ )
+ }
+ addressBook.setPendingImplementationWithMetadata(contract.name, implDep.address, implMetadata)
+
+ env.showMessage(``)
+ env.showMessage(`⚠️ UPGRADE REQUIRED`)
+ env.showMessage(` Proxy: ${existingProxy.address}`)
+ env.showMessage(` Current (on-chain): ${onChainImpl}`)
+ env.showMessage(` New implementation: ${implDep.address}`)
+ env.showMessage(``)
+ env.showMessage(` Stored as pending — run upgrade task to generate governance TX.`)
+
+ return {
+ address: existingProxy.address,
+ newlyDeployed: false,
+ upgraded: true,
+ }
+ }
+
+ // No change — check existing pending status
+ await checkPendingUpgrade(env, client, contract, existingProxy.address, 'transparent')
+
+ return {
+ address: existingProxy.address,
+ newlyDeployed: false,
+ upgraded: false,
+ }
+ }
+
+ // Own implementation - use deployImplementation for upgrade pattern
+ env.showMessage(` Existing proxy found at ${existingProxy.address}, using upgrade pattern`)
+
+ const implResult = await deployImplementation(
+ env,
+ getImplementationConfig(contract.addressBook, contract.name, {
+ constructorArgs,
+ }),
+ )
+
+ if (implResult.deployed) {
+ env.showMessage(`✓ New implementation deployed at ${implResult.address}`)
+ env.showMessage(` Upgrade TX required via governance`)
+ } else {
+ env.showMessage(`✓ Implementation unchanged at ${implResult.address}`)
+ }
+
+ // Check pending upgrade status
+ const client = graph.getPublicClient(env)
+ await checkPendingUpgrade(env, client, contract, existingProxy.address, 'transparent')
+
+ return {
+ address: existingProxy.address,
+ newlyDeployed: false,
+ upgraded: implResult.deployed,
+ }
+ }
+
+ // Fresh deployment - deploy implementation first, then OZ v5 proxy
+ if (sharedImplementation) {
+ return deployProxyWithSharedImpl(env, contract, sharedImplementation, actualInitializeArgs, deployer)
+ }
+
+ return deployProxyWithOwnImpl(env, contract, constructorArgs, actualInitializeArgs, deployer)
+}
+
+/**
+ * Deploy proxy with its own implementation (OZ v5 pattern)
+ */
+async function deployProxyWithOwnImpl(
+ env: Environment,
+ contract: RegistryEntry,
+ constructorArgs: unknown[],
+ initializeArgs: unknown[],
+ deployer: string,
+): Promise<{ address: string; newlyDeployed: boolean; upgraded: boolean }> {
+ const deployFn = deploy(env)
+
+ // Deploy implementation
+ const implArtifact = loadArtifactFromSource(contract.artifact!)
+ const implResult = await deployFn(
+ `${contract.name}_Implementation`,
+ {
+ account: deployer,
+ artifact: implArtifact,
+ args: constructorArgs,
+ },
+ { alwaysOverride: true },
+ )
+
+ env.showMessage(` Implementation deployed at ${implResult.address}`)
+
+ // Encode initialize call using the contract's own ABI
+ const initCalldata = encodeFunctionData({
+ abi: implArtifact.abi,
+ functionName: 'initialize',
+ args: initializeArgs as [`0x${string}`],
+ })
+
+ // Deploy OZ v5 TransparentUpgradeableProxy
+ // Constructor: (address _logic, address initialOwner, bytes memory _data)
+ // Deployer is the initial ProxyAdmin owner to allow post-deployment configuration.
+ // Ownership is transferred to the protocol governor in the transfer-governance step.
+ // Use issuance-compiled proxy artifact (0.8.34) for consistent verification
+ const proxyArtifact = loadTransparentProxyArtifact()
+ const proxyResult = await deployFn(
+ `${contract.name}_Proxy`,
+ {
+ account: deployer,
+ artifact: proxyArtifact,
+ args: [implResult.address, deployer, initCalldata],
+ },
+ { skipIfAlreadyDeployed: true },
+ )
+
+ // Read per-proxy ProxyAdmin address from ERC1967 slot
+ const client = graph.getPublicClient(env)
+ const proxyAdminAddress = await getProxyAdminAddress(client, proxyResult.address)
+
+ // Save main contract deployment (proxy address with implementation ABI)
+ await env.save(contract.name, {
+ ...proxyResult,
+ abi: implArtifact.abi,
+ })
+
+ // Build implementation deployment metadata. Hash from the artifact (with library resolution)
+ // so the stored value stays in lockstep with sync's artifact-side comparison.
+ const implementationDeployment = buildDeploymentMetadata(implResult, computeArtifactBytecodeHash(contract.artifact!))
+
+ // Build proxy deployment metadata from the proxy artifact bytecode
+ const proxyDeployment = buildDeploymentMetadata(
+ proxyResult,
+ computeBytecodeHash(proxyArtifact.deployedBytecode ?? '0x'),
+ )
+
+ // Update address book with per-proxy ProxyAdmin and deployment metadata
+ await updateProxyAddressBook(
+ env,
+ graph,
+ contract,
+ proxyResult.address,
+ implResult.address,
+ proxyAdminAddress,
+ implementationDeployment,
+ proxyDeployment,
+ )
+
+ if (proxyResult.newlyDeployed) {
+ env.showMessage(`✓ ${contract.name} proxy deployed at ${proxyResult.address}`)
+ env.showMessage(` Implementation: ${implResult.address}`)
+ env.showMessage(` ProxyAdmin (per-proxy, deployer-owned): ${proxyAdminAddress}`)
+ } else {
+ env.showMessage(`✓ ${contract.name} already deployed at ${proxyResult.address}`)
+ }
+
+ return {
+ address: proxyResult.address,
+ newlyDeployed: !!proxyResult.newlyDeployed,
+ upgraded: false,
+ }
+}
+
+/**
+ * Deploy proxy pointing to a shared implementation (OZ v5 pattern)
+ */
+async function deployProxyWithSharedImpl(
+ env: Environment,
+ contract: RegistryEntry,
+ sharedImplementation: RegistryEntry,
+ initializeArgs: unknown[],
+ deployer: string,
+): Promise<{ address: string; newlyDeployed: boolean; upgraded: boolean }> {
+ const deployFn = deploy(env)
+
+ // Get shared implementation deployment
+ const implDep = env.getOrNull(sharedImplementation.name)
+ if (!implDep) {
+ throw new Error(`Shared implementation ${sharedImplementation.name} not deployed. Deploy it first.`)
+ }
+
+ env.showMessage(` Deploying ${contract.name} proxy with shared implementation: ${sharedImplementation.name}`)
+
+ // Encode initialize call
+ const initCalldata = encodeFunctionData({
+ abi: INITIALIZE_GOVERNOR_ABI,
+ functionName: 'initialize',
+ args: initializeArgs as [`0x${string}`],
+ })
+
+ // Deploy OZ v5 TransparentUpgradeableProxy
+ // Constructor: (address _logic, address initialOwner, bytes memory _data)
+ // Deployer is the initial ProxyAdmin owner to allow post-deployment configuration.
+ // Ownership is transferred to the protocol governor in the transfer-governance step.
+ // Use issuance-compiled proxy artifact (0.8.34) for consistent verification
+ const proxyArtifact = loadTransparentProxyArtifact()
+ const proxyResult = await deployFn(
+ `${contract.name}_Proxy`,
+ {
+ account: deployer,
+ artifact: proxyArtifact,
+ args: [implDep.address, deployer, initCalldata],
+ },
+ { skipIfAlreadyDeployed: true },
+ )
+
+ // Read per-proxy ProxyAdmin address from ERC1967 slot
+ const client = graph.getPublicClient(env)
+ const proxyAdminAddress = await getProxyAdminAddress(client, proxyResult.address)
+
+ // Save main contract deployment (proxy address with implementation ABI)
+ await env.save(contract.name, {
+ ...proxyResult,
+ abi: implDep.abi,
+ })
+
+ // Build proxy deployment metadata from the proxy artifact bytecode
+ const proxyDeployment = buildDeploymentMetadata(
+ proxyResult,
+ computeBytecodeHash(proxyArtifact.deployedBytecode ?? '0x'),
+ )
+
+ // Update address book with per-proxy ProxyAdmin and proxy deployment metadata
+ await updateProxyAddressBook(
+ env,
+ graph,
+ contract,
+ proxyResult.address,
+ implDep.address,
+ proxyAdminAddress,
+ undefined,
+ proxyDeployment,
+ )
+
+ if (proxyResult.newlyDeployed) {
+ env.showMessage(`✓ ${contract.name} proxy deployed at ${proxyResult.address}`)
+ env.showMessage(` Implementation: ${implDep.address}`)
+ env.showMessage(` ProxyAdmin (per-proxy, deployer-owned): ${proxyAdminAddress}`)
+ } else {
+ env.showMessage(`✓ ${contract.name} already deployed at ${proxyResult.address}`)
+ }
+
+ return {
+ address: proxyResult.address,
+ newlyDeployed: !!proxyResult.newlyDeployed,
+ upgraded: false,
+ }
+}
+
+/**
+ * Transfer ProxyAdmin ownership for an issuance contract from deployer to governor.
+ *
+ * Reads the per-proxy ProxyAdmin address from the address book entry's proxyAdmin field,
+ * checks current ownership, and transfers if needed. Idempotent: skips if already owned
+ * by the target governor.
+ *
+ * @param env - Deployment environment
+ * @param contract - Registry entry for the contract whose ProxyAdmin to transfer
+ * @returns Whether a transfer was executed
+ *
+ * @example
+ * ```typescript
+ * await transferProxyAdminOwnership(env, Contracts.issuance.IssuanceAllocator)
+ * ```
+ */
+export async function transferProxyAdminOwnership(env: Environment, contract: RegistryEntry): Promise {
+ const deployer = requireDeployer(env)
+ const governor = await getGovernor(env)
+ const client = graph.getPublicClient(env) as PublicClient
+
+ // Get ProxyAdmin address from address book
+ const targetChainId = await getTargetChainIdFromEnv(env)
+ const ab = graph.getIssuanceAddressBook(targetChainId)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const entry = ab.getEntry(contract.name as any)
+ const proxyAdminAddress = entry?.proxyAdmin
+
+ if (!proxyAdminAddress) {
+ throw new Error(`No proxyAdmin found in address book for ${contract.name}`)
+ }
+
+ // Check current owner
+ const currentOwner = (await client.readContract({
+ address: proxyAdminAddress as `0x${string}`,
+ abi: OZ_PROXY_ADMIN_ABI,
+ functionName: 'owner',
+ })) as string
+
+ if (currentOwner.toLowerCase() === governor.toLowerCase()) {
+ env.showMessage(` ProxyAdmin ownership already transferred to governor: ${proxyAdminAddress}`)
+ return false
+ }
+
+ if (currentOwner.toLowerCase() !== deployer.toLowerCase()) {
+ throw new Error(
+ `ProxyAdmin ${proxyAdminAddress} owned by ${currentOwner}, expected deployer ${deployer}. ` +
+ `Cannot transfer ownership.`,
+ )
+ }
+
+ // Transfer ownership to governor
+ env.showMessage(` Transferring ProxyAdmin ownership to governor...`)
+ env.showMessage(` ProxyAdmin: ${proxyAdminAddress}`)
+ env.showMessage(` From: ${deployer}`)
+ env.showMessage(` To: ${governor}`)
+
+ const executeFn = execute(env)
+ await executeFn(
+ { address: proxyAdminAddress as `0x${string}`, abi: OZ_PROXY_ADMIN_ABI },
+ {
+ account: deployer,
+ functionName: 'transferOwnership',
+ args: [governor as `0x${string}`],
+ },
+ )
+
+ env.showMessage(` ✓ ProxyAdmin ownership transferred to governor`)
+ return true
+}
diff --git a/packages/deployment/lib/keystore-utils.ts b/packages/deployment/lib/keystore-utils.ts
new file mode 100644
index 000000000..516175b34
--- /dev/null
+++ b/packages/deployment/lib/keystore-utils.ts
@@ -0,0 +1,49 @@
+import { configVariable } from 'hardhat/config'
+
+/**
+ * Convert network name to env var prefix: arbitrumSepolia → ARBITRUM_SEPOLIA
+ */
+export function networkToEnvPrefix(networkName: string): string {
+ return networkName.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase()
+}
+
+/**
+ * Resolve a configuration variable from environment.
+ *
+ * For deploy scripts that need config values at runtime (like API keys),
+ * keystore values must be exported to environment first:
+ *
+ * export ARBISCAN_API_KEY=$(npx hardhat keystore get ARBISCAN_API_KEY)
+ *
+ * Note: Deployer/governor keys in network config use configVariable() which
+ * Hardhat resolves automatically via the keystore plugin. This function is
+ * for runtime values that aren't part of network config.
+ *
+ * @param name - Configuration variable name (e.g., 'ARBISCAN_API_KEY')
+ * @returns The resolved value or undefined if not set
+ */
+export async function resolveConfigVar(name: string): Promise {
+ const envValue = process.env[name]
+ if (envValue) {
+ return envValue
+ }
+ return undefined
+}
+
+/**
+ * Get deployer key name for a network.
+ * Always uses network-specific key (e.g., ARBITRUM_SEPOLIA_DEPLOYER_KEY).
+ */
+export function getDeployerKeyName(networkName: string): string {
+ const prefix = networkToEnvPrefix(networkName)
+ return `${prefix}_DEPLOYER_KEY`
+}
+
+/**
+ * Get governor key name for a network.
+ * Always uses network-specific key (e.g., ARBITRUM_SEPOLIA_GOVERNOR_KEY).
+ */
+export function getGovernorKeyName(networkName: string): string {
+ const prefix = networkToEnvPrefix(networkName)
+ return `${prefix}_GOVERNOR_KEY`
+}
diff --git a/packages/deployment/lib/oz-proxy-verify.ts b/packages/deployment/lib/oz-proxy-verify.ts
new file mode 100644
index 000000000..2e3b0f305
--- /dev/null
+++ b/packages/deployment/lib/oz-proxy-verify.ts
@@ -0,0 +1,247 @@
+import { readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import path from 'node:path'
+
+/**
+ * OpenZeppelin TransparentUpgradeableProxy verification utilities.
+ *
+ * OZ proxies are pre-compiled at a fixed Solidity version (0.8.27) that may not match
+ * the project config. This module provides direct Etherscan API verification using
+ * Standard JSON Input built from the installed OZ package sources.
+ *
+ * Uses Etherscan API V2 unified endpoint for all chains.
+ */
+
+const require = createRequire(import.meta.url)
+
+/** Etherscan API V2 unified endpoint (for all chains) */
+const ETHERSCAN_API_V2_URL = 'https://api.etherscan.io/v2/api'
+
+/** Browser URLs for verified contract links */
+const ETHERSCAN_BROWSER_URLS: Record = {
+ 1: 'https://etherscan.io',
+ 42161: 'https://arbiscan.io',
+ 421614: 'https://sepolia.arbiscan.io',
+}
+
+/**
+ * OZ TransparentUpgradeableProxy compiler settings (from OZ v5.4.0)
+ */
+const OZ_COMPILER_VERSION = 'v0.8.27+commit.40a35a09'
+const OZ_COMPILER_SETTINGS = {
+ optimizer: {
+ enabled: true,
+ runs: 200,
+ },
+ evmVersion: 'cancun', // Use cancun for broader compatibility (prague may not be supported)
+ outputSelection: {
+ '*': {
+ '*': ['abi', 'evm.bytecode', 'evm.deployedBytecode', 'evm.methodIdentifiers', 'metadata'],
+ '': ['ast'],
+ },
+ },
+}
+
+/**
+ * Source files required for TransparentUpgradeableProxy verification.
+ * Paths are relative to @openzeppelin/contracts package.
+ */
+const OZ_PROXY_SOURCE_FILES = [
+ 'proxy/transparent/TransparentUpgradeableProxy.sol',
+ 'proxy/transparent/ProxyAdmin.sol',
+ 'proxy/ERC1967/ERC1967Proxy.sol',
+ 'proxy/ERC1967/ERC1967Utils.sol',
+ 'proxy/Proxy.sol',
+ 'proxy/beacon/IBeacon.sol',
+ 'interfaces/IERC1967.sol',
+ 'utils/Address.sol',
+ 'utils/Errors.sol',
+ 'utils/StorageSlot.sol',
+ 'access/Ownable.sol',
+ 'utils/Context.sol',
+]
+
+/**
+ * Read an OZ contract source file from node_modules
+ */
+function readOZSource(relativePath: string): string {
+ const ozPackagePath = path.dirname(require.resolve('@openzeppelin/contracts/package.json'))
+ const fullPath = path.join(ozPackagePath, relativePath)
+ return readFileSync(fullPath, 'utf-8')
+}
+
+/**
+ * Build Standard JSON Input for OZ TransparentUpgradeableProxy verification
+ */
+export function buildOZProxyStandardJsonInput(): string {
+ const sources: Record = {}
+
+ for (const relativePath of OZ_PROXY_SOURCE_FILES) {
+ const sourcePath = `@openzeppelin/contracts/${relativePath}`
+ sources[sourcePath] = {
+ content: readOZSource(relativePath),
+ }
+ }
+
+ const standardJson = {
+ language: 'Solidity',
+ sources,
+ settings: OZ_COMPILER_SETTINGS,
+ }
+
+ return JSON.stringify(standardJson)
+}
+
+/**
+ * Get Etherscan API V2 URL (unified endpoint for all chains)
+ */
+export function getApiUrl(): string {
+ return ETHERSCAN_API_V2_URL
+}
+
+/**
+ * Get Etherscan browser URL for a chain
+ */
+export function getEtherscanBrowserUrl(chainId: number): string {
+ const url = ETHERSCAN_BROWSER_URLS[chainId]
+ if (!url) {
+ throw new Error(`No Etherscan browser URL configured for chainId ${chainId}`)
+ }
+ return url
+}
+
+/**
+ * Check if a contract is already verified on Etherscan.
+ *
+ * Queries the getsourcecode API — a verified contract has a non-empty
+ * SourceCode field. Returns the explorer URL if verified, undefined otherwise.
+ */
+export async function checkEtherscanVerified(
+ address: string,
+ apiKey: string,
+ chainId: number,
+): Promise {
+ const apiUrl = getApiUrl()
+ const browserUrl = getEtherscanBrowserUrl(chainId)
+
+ const params = new URLSearchParams({
+ module: 'contract',
+ action: 'getsourcecode',
+ address,
+ apikey: apiKey,
+ })
+
+ try {
+ const response = await fetch(`${apiUrl}?chainid=${chainId}&${params.toString()}`)
+ const data = (await response.json()) as { status: string; result: Array<{ SourceCode?: string }> }
+ if (data.status === '1' && data.result?.[0]?.SourceCode) {
+ return `${browserUrl}/address/${address}#code`
+ }
+ } catch {
+ // Network error — assume not verified, let the caller proceed
+ }
+ return undefined
+}
+
+/**
+ * Verify OZ TransparentUpgradeableProxy via Etherscan API
+ *
+ * @param address - Proxy contract address
+ * @param constructorArgs - ABI-encoded constructor arguments (without 0x prefix is fine)
+ * @param apiKey - Etherscan API key
+ * @param chainId - Chain ID
+ * @returns Verification result with URL if successful
+ */
+export async function verifyOZProxy(
+ address: string,
+ constructorArgs: string,
+ apiKey: string,
+ chainId: number,
+): Promise<{ success: boolean; url?: string; message?: string }> {
+ const apiUrl = getApiUrl()
+ const browserUrl = getEtherscanBrowserUrl(chainId)
+
+ // Build standard JSON input from OZ sources
+ const sourceCode = buildOZProxyStandardJsonInput()
+
+ // Strip 0x prefix from constructor args if present
+ const args = constructorArgs.startsWith('0x') ? constructorArgs.slice(2) : constructorArgs
+
+ // Build params - V2 API requires chainid in URL query string, not POST body
+ const params = new URLSearchParams({
+ apikey: apiKey,
+ module: 'contract',
+ action: 'verifysourcecode',
+ contractaddress: address,
+ sourceCode,
+ codeformat: 'solidity-standard-json-input',
+ contractname:
+ '@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol:TransparentUpgradeableProxy',
+ compilerversion: OZ_COMPILER_VERSION,
+ constructorArguements: args, // Note: Etherscan API has this typo
+ })
+
+ console.log(` 📤 Submitting verification to Etherscan API V2 (chainId: ${chainId})`)
+
+ // V2 API: chainid must be in URL query string
+ const submitUrl = `${apiUrl}?chainid=${chainId}`
+ const submitResponse = await fetch(submitUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: params.toString(),
+ })
+
+ const submitResult = (await submitResponse.json()) as { status: string; result: string; message?: string }
+
+ if (submitResult.status !== '1') {
+ // Check if already verified (case-insensitive, handles various API response formats)
+ if (submitResult.result?.toLowerCase().includes('already verified')) {
+ const url = `${browserUrl}/address/${address}#code`
+ return { success: true, url, message: 'Already verified' }
+ }
+ return { success: false, message: submitResult.result || submitResult.message || 'Unknown error' }
+ }
+
+ const guid = submitResult.result
+ console.log(` ⏳ Verification submitted, GUID: ${guid}`)
+
+ // Poll for verification result
+ const maxAttempts = 10
+ const pollInterval = 3000 // 3 seconds
+
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ await new Promise((resolve) => setTimeout(resolve, pollInterval))
+
+ const checkParams = new URLSearchParams({
+ apikey: apiKey,
+ module: 'contract',
+ action: 'checkverifystatus',
+ guid,
+ })
+
+ // V2 API: chainid must be in URL query string
+ const checkResponse = await fetch(`${apiUrl}?chainid=${chainId}&${checkParams.toString()}`)
+ const checkResult = (await checkResponse.json()) as { status: string; result: string }
+
+ if (checkResult.result === 'Pending in queue') {
+ console.log(` ⏳ Verification pending (attempt ${attempt + 1}/${maxAttempts})...`)
+ continue
+ }
+
+ if (checkResult.status === '1' || checkResult.result === 'Pass - Verified') {
+ const url = `${browserUrl}/address/${address}#code`
+ return { success: true, url }
+ }
+
+ // "Already Verified" can appear during polling (not just at submission)
+ if (checkResult.result?.toLowerCase().includes('already verified')) {
+ const url = `${browserUrl}/address/${address}#code`
+ return { success: true, url, message: 'Already verified' }
+ }
+
+ // Verification failed
+ return { success: false, message: checkResult.result }
+ }
+
+ return { success: false, message: 'Verification timed out' }
+}
diff --git a/packages/deployment/lib/preconditions.ts b/packages/deployment/lib/preconditions.ts
new file mode 100644
index 000000000..8f000597a
--- /dev/null
+++ b/packages/deployment/lib/preconditions.ts
@@ -0,0 +1,380 @@
+/**
+ * Shared Precondition Checks
+ *
+ * Each function answers "is this action step done?" for a specific component.
+ * Used by BOTH action scripts (to skip if done) and status scripts (for next-step hints).
+ *
+ * This is the SINGLE SOURCE OF TRUTH for precondition logic.
+ * Action scripts and status scripts must call the same functions — no copies.
+ *
+ * Configure checks: params, integration references, and role GRANTS (PAUSE_ROLE, GOVERNOR_ROLE)
+ * Transfer checks: deployer GOVERNOR_ROLE REVOKE + ProxyAdmin ownership
+ */
+
+import type { PublicClient } from 'viem'
+import { keccak256, toHex } from 'viem'
+
+import {
+ ACCESS_CONTROL_ENUMERABLE_ABI,
+ ISSUANCE_ALLOCATOR_ABI,
+ ISSUANCE_TARGET_ABI,
+ OZ_PROXY_ADMIN_ABI,
+ REWARDS_MANAGER_ABI,
+ REWARDS_MANAGER_DEPRECATED_ABI,
+} from './abis.js'
+
+// ============================================================================
+// Result type
+// ============================================================================
+
+/**
+ * Result of a precondition check
+ *
+ * @property done - true if the action step is complete (on-chain state matches target)
+ * @property reason - why not done (human-readable, for status display)
+ */
+export interface PreconditionResult {
+ done: boolean
+ reason?: string
+}
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+// Precomputed role hashes (matches BaseUpgradeable constants)
+const GOVERNOR_ROLE = keccak256(toHex('GOVERNOR_ROLE'))
+const PAUSE_ROLE = keccak256(toHex('PAUSE_ROLE'))
+
+/** Check if account has a role on a contract */
+async function hasRole(
+ client: PublicClient,
+ contractAddress: string,
+ role: `0x${string}`,
+ account: string,
+): Promise {
+ return (await client.readContract({
+ address: contractAddress as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [role, account as `0x${string}`],
+ })) as boolean
+}
+
+/**
+ * Check role grants common to all deployer-initialized contracts
+ *
+ * Configure must grant:
+ * - GOVERNOR_ROLE to protocol governor
+ * - PAUSE_ROLE to pause guardian
+ */
+async function checkRoleGrants(
+ client: PublicClient,
+ contractAddress: string,
+ governor: string,
+ pauseGuardian: string,
+): Promise<{ governorOk: boolean; pauseOk: boolean; reasons: string[] }> {
+ const governorOk = await hasRole(client, contractAddress, GOVERNOR_ROLE, governor)
+ const pauseOk = await hasRole(client, contractAddress, PAUSE_ROLE, pauseGuardian)
+
+ const reasons: string[] = []
+ if (!governorOk) reasons.push('governor missing GOVERNOR_ROLE')
+ if (!pauseOk) reasons.push('pauseGuardian missing PAUSE_ROLE')
+
+ return { governorOk, pauseOk, reasons }
+}
+
+// ============================================================================
+// Configure checks
+// ============================================================================
+
+/**
+ * Check if IssuanceAllocator is configured
+ *
+ * Matches the skip logic in allocate/allocator/04_configure.ts:
+ * - RM.issuancePerBlock must be > 0 (RM initialized)
+ * - IA.getIssuancePerBlock() must equal RM rate
+ * - governor has GOVERNOR_ROLE
+ * - pauseGuardian has PAUSE_ROLE
+ *
+ * Note: RM target allocation (setTargetAllocation) is an activation step
+ * in issuance-connect, not a configure step.
+ */
+export async function checkIAConfigured(
+ client: PublicClient,
+ iaAddress: string,
+ rmAddress: string,
+ governor: string,
+ pauseGuardian: string,
+): Promise {
+ // Check RM issuance rate
+ const rmIssuanceRate = (await client.readContract({
+ address: rmAddress as `0x${string}`,
+ abi: REWARDS_MANAGER_DEPRECATED_ABI,
+ functionName: 'issuancePerBlock',
+ })) as bigint
+
+ if (rmIssuanceRate === 0n) {
+ return { done: false, reason: 'RM.issuancePerBlock is 0' }
+ }
+
+ // Check IA rate matches RM
+ const iaIssuanceRate = (await client.readContract({
+ address: iaAddress as `0x${string}`,
+ abi: ISSUANCE_ALLOCATOR_ABI,
+ functionName: 'getIssuancePerBlock',
+ })) as bigint
+
+ const rateOk = iaIssuanceRate === rmIssuanceRate && iaIssuanceRate > 0n
+
+ // Check role grants
+ const roles = await checkRoleGrants(client, iaAddress, governor, pauseGuardian)
+
+ if (rateOk && roles.governorOk && roles.pauseOk) {
+ return { done: true }
+ }
+
+ const reasons: string[] = []
+ if (!rateOk) reasons.push('rate mismatch')
+ reasons.push(...roles.reasons)
+ return { done: false, reason: reasons.join(', ') }
+}
+
+/**
+ * Check if RecurringAgreementManager is configured
+ *
+ * Matches the skip logic in agreement/manager/04_configure.ts:
+ * - RC has COLLECTOR_ROLE
+ * - SS has DATA_SERVICE_ROLE
+ * - RAM.getIssuanceAllocator() == IA
+ * - governor has GOVERNOR_ROLE
+ * - pauseGuardian has PAUSE_ROLE
+ */
+export async function checkRAMConfigured(
+ client: PublicClient,
+ ramAddress: string,
+ rcAddress: string,
+ ssAddress: string,
+ iaAddress: string,
+ governor: string,
+ pauseGuardian: string,
+): Promise {
+ const COLLECTOR_ROLE = keccak256(toHex('COLLECTOR_ROLE'))
+ const DATA_SERVICE_ROLE = keccak256(toHex('DATA_SERVICE_ROLE'))
+
+ const rcHasCollectorRole = (await client.readContract({
+ address: ramAddress as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [COLLECTOR_ROLE, rcAddress as `0x${string}`],
+ })) as boolean
+
+ const ssHasDataServiceRole = (await client.readContract({
+ address: ramAddress as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [DATA_SERVICE_ROLE, ssAddress as `0x${string}`],
+ })) as boolean
+
+ let iaConfigured = false
+ try {
+ const currentIA = (await client.readContract({
+ address: ramAddress as `0x${string}`,
+ abi: ISSUANCE_TARGET_ABI,
+ functionName: 'getIssuanceAllocator',
+ })) as string
+ iaConfigured = currentIA.toLowerCase() === iaAddress.toLowerCase()
+ } catch {
+ // Not set
+ }
+
+ // Check role grants
+ const roles = await checkRoleGrants(client, ramAddress, governor, pauseGuardian)
+
+ if (rcHasCollectorRole && ssHasDataServiceRole && iaConfigured && roles.governorOk && roles.pauseOk) {
+ return { done: true }
+ }
+
+ const reasons: string[] = []
+ if (!rcHasCollectorRole) reasons.push('RC missing COLLECTOR_ROLE')
+ if (!ssHasDataServiceRole) reasons.push('SS missing DATA_SERVICE_ROLE')
+ if (!iaConfigured) reasons.push('IssuanceAllocator not set')
+ reasons.push(...roles.reasons)
+ return { done: false, reason: reasons.join(', ') }
+}
+
+/**
+ * Check Reclaim role grants only (governor has GOVERNOR_ROLE, pauseGuardian has PAUSE_ROLE)
+ *
+ * Use this when you need to know whether the deployer (with Reclaim GOVERNOR_ROLE) can
+ * fix the issue. The RM integration is governance-only and should be checked separately
+ * via checkReclaimRMIntegration.
+ */
+export async function checkReclaimRoles(
+ client: PublicClient,
+ reclaimAddress: string,
+ governor: string,
+ pauseGuardian: string,
+): Promise {
+ const roles = await checkRoleGrants(client, reclaimAddress, governor, pauseGuardian)
+ if (roles.governorOk && roles.pauseOk) {
+ return { done: true }
+ }
+ return { done: false, reason: roles.reasons.join(', ') }
+}
+
+/**
+ * Check RM integration with Reclaim: RM.getDefaultReclaimAddress() == reclaim address
+ *
+ * This is governance-only — only an account with GOVERNOR_ROLE on RM can fix it,
+ * which the deployer never has. Status logic should always treat a failure here
+ * as deferred (governance TX), not blocking on configure.
+ */
+export async function checkReclaimRMIntegration(
+ client: PublicClient,
+ rmAddress: string,
+ reclaimAddress: string,
+): Promise {
+ try {
+ const currentDefault = (await client.readContract({
+ address: rmAddress as `0x${string}`,
+ abi: REWARDS_MANAGER_ABI,
+ functionName: 'getDefaultReclaimAddress',
+ })) as string
+
+ if (currentDefault.toLowerCase() === reclaimAddress.toLowerCase()) {
+ return { done: true }
+ }
+ return { done: false, reason: 'default reclaim address not set' }
+ } catch {
+ // Function not available — RM not upgraded
+ return { done: false, reason: 'RM not upgraded' }
+ }
+}
+
+/**
+ * Check whether RM.getRevertOnIneligible() matches the desired value from config.
+ *
+ * Governance-only setter on RM — failure is deferred to the upgrade governance batch
+ * unless the deployer holds GOVERNOR_ROLE on RM (true on fresh networks where RM is
+ * deployed from scratch with the deployer as initial governor; false on networks
+ * where RM was deployed by separate horizon-Ignition infrastructure).
+ */
+export async function checkRMRevertOnIneligible(
+ client: PublicClient,
+ rmAddress: string,
+ desired: boolean,
+): Promise {
+ try {
+ const onChain = (await client.readContract({
+ address: rmAddress as `0x${string}`,
+ abi: REWARDS_MANAGER_ABI,
+ functionName: 'getRevertOnIneligible',
+ })) as boolean
+ if (onChain === desired) return { done: true }
+ return { done: false, reason: `revertOnIneligible=${onChain}, expected ${desired}` }
+ } catch {
+ return { done: false, reason: 'RM not upgraded' }
+ }
+}
+
+/**
+ * Check if ReclaimedRewards is fully configured (roles + RM integration)
+ *
+ * Convenience wrapper that combines checkReclaimRoles and checkReclaimRMIntegration.
+ * Use the split functions when callers need to distinguish deployer-fixable role
+ * issues from governance-only RM integration issues.
+ */
+export async function checkReclaimConfigured(
+ client: PublicClient,
+ rmAddress: string,
+ reclaimAddress: string,
+ governor: string,
+ pauseGuardian: string,
+): Promise {
+ const roles = await checkReclaimRoles(client, reclaimAddress, governor, pauseGuardian)
+ const rmIntegration = await checkReclaimRMIntegration(client, rmAddress, reclaimAddress)
+
+ if (roles.done && rmIntegration.done) {
+ return { done: true }
+ }
+
+ // If roles are done but RM not upgraded, report that specifically
+ if (roles.done && rmIntegration.reason === 'RM not upgraded') {
+ return { done: false, reason: 'RM not upgraded' }
+ }
+
+ const reasons: string[] = []
+ if (!roles.done && roles.reason) reasons.push(roles.reason)
+ if (!rmIntegration.done && rmIntegration.reason) reasons.push(rmIntegration.reason)
+ return { done: false, reason: reasons.join(', ') }
+}
+
+/**
+ * Check if DefaultAllocation is configured
+ *
+ * - governor has GOVERNOR_ROLE on DefaultAllocation
+ * - pauseGuardian has PAUSE_ROLE on DefaultAllocation
+ *
+ * Note: IA.setDefaultTarget(DA) is an activation step in issuance-connect.
+ */
+export async function checkDefaultAllocationConfigured(
+ client: PublicClient,
+ daAddress: string,
+ governor: string,
+ pauseGuardian: string,
+): Promise {
+ const roles = await checkRoleGrants(client, daAddress, governor, pauseGuardian)
+
+ if (roles.governorOk && roles.pauseOk) {
+ return { done: true }
+ }
+
+ return { done: false, reason: roles.reasons.join(', ') }
+}
+
+// ============================================================================
+// Transfer checks
+// ============================================================================
+
+/**
+ * Check if deployer GOVERNOR_ROLE is revoked on a contract
+ *
+ * Transfer = revoke deployer access. Role grants happen in configure.
+ * Generic check used for IA, RAM, Reclaim.
+ */
+export async function checkDeployerRevoked(
+ client: PublicClient,
+ contractAddress: string,
+ deployer: string,
+): Promise {
+ const deployerHasRole = await hasRole(client, contractAddress, GOVERNOR_ROLE, deployer)
+
+ if (!deployerHasRole) {
+ return { done: true }
+ }
+ return { done: false, reason: 'deployer GOVERNOR_ROLE not revoked' }
+}
+
+/**
+ * Check if ProxyAdmin ownership is transferred to governor
+ *
+ * Generic check used for any contract with an OZ v5 per-proxy ProxyAdmin.
+ * Used by transfer scripts for IA, RAM, Reclaim, REO.
+ */
+export async function checkProxyAdminTransferred(
+ client: PublicClient,
+ proxyAdminAddress: string,
+ governor: string,
+): Promise {
+ const currentOwner = (await client.readContract({
+ address: proxyAdminAddress as `0x${string}`,
+ abi: OZ_PROXY_ADMIN_ABI,
+ functionName: 'owner',
+ })) as string
+
+ if (currentOwner.toLowerCase() === governor.toLowerCase()) {
+ return { done: true }
+ }
+ return { done: false, reason: `ProxyAdmin owned by ${currentOwner}, not governor` }
+}
diff --git a/packages/deployment/lib/script-factories.ts b/packages/deployment/lib/script-factories.ts
new file mode 100644
index 000000000..6c1bb1de5
--- /dev/null
+++ b/packages/deployment/lib/script-factories.ts
@@ -0,0 +1,384 @@
+/**
+ * Deploy Script Factories - Create deployment modules with standard framework plumbing
+ *
+ * Two flavors:
+ *
+ * **Contract-based** (component lifecycle):
+ * Derive tags from registry componentTag. Action-verb skip gating.
+ * Post-action sync. Use for standard deploy/upgrade/configure/transfer steps.
+ *
+ * **Tag-based** (goals, multi-contract status, standalone actions):
+ * Accept a tag string directly. Skip when no --tags specified.
+ * Custom execute callback handles all logic.
+ *
+ * Skip gating uses func.skip (checked by rocketh's executor via patch)
+ * with early returns as a safety net.
+ */
+
+import type { DeployScriptModule, Environment } from '@rocketh/core/types'
+
+import type { RegistryEntry } from './contract-registry.js'
+import { deployImplementation, getImplementationConfig } from './deploy-implementation.js'
+import { DeploymentActions, noTagsRequested, shouldSkipAction } from './deployment-tags.js'
+import { requireUpgradeExecuted } from './execute-governance.js'
+import { deployProxyContract } from './issuance-deploy-utils.js'
+import { showDetailedComponentStatus } from './status-detail.js'
+import { syncComponentFromRegistry, syncComponentsFromRegistry } from './sync-utils.js'
+import type { ImplementationUpgradeOverrides } from './upgrade-implementation.js'
+import { upgradeImplementation } from './upgrade-implementation.js'
+
+/**
+ * Require that the registry entry has a componentTag, throwing a clear error if not.
+ */
+function requireComponentTag(contract: RegistryEntry): string {
+ if (!contract.componentTag) {
+ throw new Error(
+ `Contract '${contract.name}' has no componentTag in the registry. ` +
+ `Add a componentTag to use script factories.`,
+ )
+ }
+ return contract.componentTag
+}
+
+/**
+ * Create a standard upgrade deploy script module.
+ *
+ * Generates a governance TX to upgrade the contract's proxy to its pending implementation.
+ * Tags and dependencies are derived from the contract's componentTag.
+ *
+ * @example Standard single-contract upgrade:
+ * ```typescript
+ * import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
+ * import { createUpgradeModule } from '@graphprotocol/deployment/lib/script-factories.js'
+ *
+ * export default createUpgradeModule(Contracts.horizon.PaymentsEscrow)
+ * ```
+ *
+ * @example Upgrade with implementation name override:
+ * ```typescript
+ * export default createUpgradeModule(Contracts.issuance.SomeProxy, {
+ * overrides: { implementationName: 'DifferentImpl' },
+ * })
+ * ```
+ */
+export function createUpgradeModule(
+ contract: RegistryEntry,
+ options?: {
+ overrides?: ImplementationUpgradeOverrides
+ extraDependencies?: string[]
+ /** Additional contracts to sync alongside `contract` before the upgrade runs. */
+ prerequisites?: RegistryEntry[]
+ },
+): DeployScriptModule {
+ const tag = requireComponentTag(contract)
+
+ const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.UPGRADE)) return
+ await syncComponentsFromRegistry(env, [contract, ...(options?.prerequisites ?? [])])
+ await upgradeImplementation(env, contract, options?.overrides)
+ await syncComponentFromRegistry(env, contract)
+ }
+
+ func.tags = [tag]
+ func.dependencies = options?.extraDependencies ?? []
+ func.skip = async () => shouldSkipAction(DeploymentActions.UPGRADE)
+
+ return func
+}
+
+/**
+ * Create a standard end/complete deploy script module.
+ *
+ * Gates on `--tags ...,all`. Verifies the upgrade governance TX has been
+ * executed and shows a ready message. The actual lifecycle actions a component
+ * needs are encoded in its dependency chain via the component tag, not in this
+ * factory.
+ *
+ * @example
+ * ```typescript
+ * export default createEndModule(Contracts.horizon.PaymentsEscrow)
+ * ```
+ */
+export function createEndModule(contract: RegistryEntry): DeployScriptModule {
+ const tag = requireComponentTag(contract)
+
+ const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.ALL)) return
+ requireUpgradeExecuted(env, contract.name)
+ env.showMessage(`\n✓ ${contract.name} ready`)
+ }
+
+ func.tags = [tag]
+ func.dependencies = []
+ func.skip = async () => shouldSkipAction(DeploymentActions.ALL)
+
+ return func
+}
+
+/**
+ * Create a status deploy script module.
+ *
+ * Syncs the component with on-chain state and shows its current status.
+ * Tagged with the bare component name so `--tags IssuanceAllocator` is a
+ * safe, read-only operation.
+ *
+ * @example Single contract (default status display):
+ * ```typescript
+ * export default createStatusModule(Contracts.horizon.PaymentsEscrow)
+ * ```
+ *
+ * @example Custom status with tag (multi-contract or cross-component):
+ * ```typescript
+ * export default createStatusModule(GoalTags.GIP_0088, async (env) => {
+ * // custom multi-phase status display
+ * })
+ * ```
+ */
+export function createStatusModule(contract: RegistryEntry): DeployScriptModule
+export function createStatusModule(tag: string, execute: (env: Environment) => Promise): DeployScriptModule
+export function createStatusModule(
+ contractOrTag: RegistryEntry | string,
+ execute?: (env: Environment) => Promise,
+): DeployScriptModule {
+ const tag = typeof contractOrTag === 'string' ? contractOrTag : requireComponentTag(contractOrTag)
+
+ const func: DeployScriptModule = async (env) => {
+ if (noTagsRequested()) return
+ if (execute) {
+ await execute(env)
+ } else {
+ await showDetailedComponentStatus(env, contractOrTag as RegistryEntry)
+ }
+ }
+
+ func.tags = [tag]
+ func.dependencies = []
+ func.skip = async () => noTagsRequested()
+
+ return func
+}
+
+// ============================================================================
+// Action Factories (custom logic with standard framework plumbing)
+// ============================================================================
+
+/**
+ * Create a deploy script module for a custom action.
+ *
+ * Two forms:
+ *
+ * **Contract-based** (component lifecycle steps):
+ * Uses action verb gating (`shouldSkipAction`) and post-action sync.
+ * Requires both component tag AND action verb in `--tags`.
+ *
+ * **Tag-based** (goal scripts, standalone actions):
+ * Uses tag gating (`noTagsRequested`). The tag in `--tags` is sufficient.
+ * No post-action sync — the execute callback handles everything.
+ *
+ * @example Contract-based configure:
+ * ```typescript
+ * export default createActionModule(
+ * Contracts.horizon.RecurringCollector,
+ * DeploymentActions.CONFIGURE,
+ * async (env) => { ... },
+ * )
+ * ```
+ *
+ * @example Tag-based goal action:
+ * ```typescript
+ * export default createActionModule(
+ * GoalTags.GIP_0088_ISSUANCE_CONNECT,
+ * async (env) => { ... },
+ * { dependencies: [ComponentTags.ISSUANCE_ALLOCATOR] },
+ * )
+ * ```
+ */
+export function createActionModule(
+ contract: RegistryEntry,
+ action: (typeof DeploymentActions)[keyof typeof DeploymentActions],
+ execute: (env: Environment) => Promise,
+ options?: { extraDependencies?: string[]; prerequisites?: RegistryEntry[] },
+): DeployScriptModule
+export function createActionModule(
+ tag: string,
+ execute: (env: Environment) => Promise,
+ options?: { dependencies?: string[] },
+): DeployScriptModule
+export function createActionModule(
+ contractOrTag: RegistryEntry | string,
+ actionOrExecute: (typeof DeploymentActions)[keyof typeof DeploymentActions] | ((env: Environment) => Promise),
+ executeOrOptions?: ((env: Environment) => Promise) | { dependencies?: string[] },
+ maybeOptions?: { extraDependencies?: string[]; prerequisites?: RegistryEntry[] },
+): DeployScriptModule {
+ if (typeof contractOrTag === 'string') {
+ // Tag-based: (tag, execute, options?)
+ const tag = contractOrTag
+ const execute = actionOrExecute as (env: Environment) => Promise
+ const options = executeOrOptions as { dependencies?: string[] } | undefined
+
+ const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(tag)) return
+ await execute(env)
+ }
+
+ func.tags = [tag]
+ func.dependencies = options?.dependencies ?? []
+ func.skip = async () => shouldSkipAction(tag)
+
+ return func
+ }
+
+ // Contract-based: (contract, action, execute, options?)
+ const tag = requireComponentTag(contractOrTag)
+ const action = actionOrExecute as string
+ const execute = executeOrOptions as (env: Environment) => Promise
+
+ const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(action)) return
+ await syncComponentsFromRegistry(env, [contractOrTag, ...(maybeOptions?.prerequisites ?? [])])
+ await execute(env)
+ await syncComponentFromRegistry(env, contractOrTag)
+ }
+
+ func.tags = [tag]
+ func.dependencies = maybeOptions?.extraDependencies ?? []
+ func.skip = async () => shouldSkipAction(action)
+
+ return func
+}
+
+// ============================================================================
+// Deploy Factories
+// ============================================================================
+
+/**
+ * Options shared by deploy factories
+ */
+interface DeployModuleOptions {
+ /** Additional tags beyond the derived deploy action tag */
+ extraTags?: string[]
+ /** Additional rocketh dependency tags */
+ extraDependencies?: string[]
+ /**
+ * Additional registry entries to sync immediately before the action runs.
+ * Use for contracts read via `env.getOrNull(...)` inside `resolveArgs` /
+ * `resolveConstructorArgs` (e.g. Controller, shared implementations).
+ */
+ prerequisites?: RegistryEntry[]
+}
+
+/**
+ * Create a deploy module for prerequisite contracts (existing proxy, new implementation).
+ *
+ * Uses `deployImplementation` + `getImplementationConfig` to deploy a new implementation
+ * and store it as pendingImplementation for governance upgrade.
+ *
+ * @param contract - Registry entry (must have prerequisite: true, artifact, proxyType)
+ * @param resolveConstructorArgs - Optional callback to resolve constructor args from env.
+ * Called with the deployment environment. Return the args array.
+ * Omit for contracts with no constructor args (e.g., RewardsManager).
+ *
+ * @example No constructor args:
+ * ```typescript
+ * export default createImplementationDeployModule(Contracts.horizon.RewardsManager)
+ * ```
+ *
+ * @example With synced dependency args:
+ * ```typescript
+ * export default createImplementationDeployModule(
+ * Contracts['subgraph-service'].DisputeManager,
+ * (env) => {
+ * const controller = env.getOrNull('Controller')
+ * if (!controller) throw new Error('Missing Controller')
+ * return [controller.address]
+ * },
+ * )
+ * ```
+ */
+export function createImplementationDeployModule(
+ contract: RegistryEntry,
+ resolveConstructorArgs?: (env: Environment) => Promise | unknown[],
+ options?: DeployModuleOptions,
+): DeployScriptModule {
+ const tag = requireComponentTag(contract)
+
+ const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.DEPLOY)) return
+ await syncComponentsFromRegistry(env, [contract, ...(options?.prerequisites ?? [])])
+ const constructorArgs = resolveConstructorArgs ? await resolveConstructorArgs(env) : undefined
+ await deployImplementation(
+ env,
+ getImplementationConfig(contract.addressBook, contract.name, constructorArgs ? { constructorArgs } : undefined),
+ )
+ await syncComponentFromRegistry(env, contract)
+ }
+
+ func.tags = [tag, ...(options?.extraTags ?? [])]
+ func.dependencies = options?.extraDependencies ?? []
+ func.skip = async () => shouldSkipAction(DeploymentActions.DEPLOY)
+
+ return func
+}
+
+/**
+ * Create a deploy module for new contracts (fresh proxy + implementation).
+ *
+ * Uses `deployProxyContract` to deploy an OZ v5 TransparentUpgradeableProxy with
+ * atomic initialization. On subsequent runs, deploys new implementation and stores
+ * as pendingImplementation.
+ *
+ * @param contract - Registry entry (must have deployable: true, artifact, proxyType)
+ * @param resolveArgs - Optional callback to resolve constructor and initialize args.
+ * Omit initializeArgs to use default [governor].
+ *
+ * @example With graphToken constructor and deployer init:
+ * ```typescript
+ * export default createProxyDeployModule(
+ * Contracts.issuance.RewardsEligibilityOracleA,
+ * (env) => ({
+ * constructorArgs: [requireGraphToken(env).address],
+ * initializeArgs: [requireDeployer(env)],
+ * }),
+ * )
+ * ```
+ *
+ * @example With default initialize args [governor]:
+ * ```typescript
+ * export default createProxyDeployModule(
+ * Contracts.issuance.RecurringAgreementManager,
+ * (env) => ({
+ * constructorArgs: [requireGraphToken(env).address, paymentsEscrow.address],
+ * }),
+ * )
+ * ```
+ */
+export function createProxyDeployModule(
+ contract: RegistryEntry,
+ resolveArgs?: (env: Environment) => Promise | ProxyDeployArgs,
+ options?: DeployModuleOptions,
+): DeployScriptModule {
+ const tag = requireComponentTag(contract)
+
+ const func: DeployScriptModule = async (env) => {
+ if (shouldSkipAction(DeploymentActions.DEPLOY)) return
+ await syncComponentsFromRegistry(env, [contract, ...(options?.prerequisites ?? [])])
+ const args = resolveArgs ? await resolveArgs(env) : {}
+ await deployProxyContract(env, {
+ contract,
+ constructorArgs: args.constructorArgs,
+ initializeArgs: args.initializeArgs,
+ })
+ await syncComponentFromRegistry(env, contract)
+ }
+
+ func.tags = [tag, ...(options?.extraTags ?? [])]
+ func.dependencies = options?.extraDependencies ?? []
+ func.skip = async () => shouldSkipAction(DeploymentActions.DEPLOY)
+
+ return func
+}
+
+interface ProxyDeployArgs {
+ constructorArgs?: unknown[]
+ initializeArgs?: unknown[]
+}
diff --git a/packages/deployment/lib/status-detail.ts b/packages/deployment/lib/status-detail.ts
new file mode 100644
index 000000000..a9a4cede8
--- /dev/null
+++ b/packages/deployment/lib/status-detail.ts
@@ -0,0 +1,1135 @@
+/**
+ * Status Detail - Detailed contract status with integration checks
+ *
+ * Extracted from deployment-status task so deploy scripts (10_status.ts)
+ * can show the same detail view. The task delegates to these functions.
+ */
+
+import type { Environment } from '@rocketh/core/types'
+import type { PublicClient } from 'viem'
+
+import {
+ ACCESS_CONTROL_ENUMERABLE_ABI,
+ CONTROLLER_ABI,
+ IISSUANCE_TARGET_INTERFACE_ID,
+ IREWARDS_MANAGER_INTERFACE_ID,
+ ISSUANCE_ALLOCATOR_ABI,
+ ISSUANCE_TARGET_ABI,
+ PROVIDER_ELIGIBILITY_MANAGEMENT_ABI,
+ REWARDS_ELIGIBILITY_ORACLE_ABI,
+ REWARDS_MANAGER_ABI,
+} from './abis.js'
+import type { AddressBookOps } from './address-book-ops.js'
+import { getAddressBookForType, getTargetChainIdFromEnv } from './address-book-utils.js'
+import {
+ checkIssuanceAllocatorActivation,
+ checkOperatorRole,
+ formatAddress,
+ supportsInterface,
+} from './contract-checks.js'
+import type { RegistryEntry } from './contract-registry.js'
+import { getResolvedSettings } from './deployment-config.js'
+import { countPendingGovernanceTxs } from './execute-governance.js'
+import { formatGRT } from './format.js'
+import { getContractStatusLine, type ContractStatusResult, type ProxyAdminOwnershipContext } from './sync-utils.js'
+import { graph } from '../rocketh/deploy.js'
+
+// ============================================================================
+// Integration Check Types & Helpers
+// ============================================================================
+
+/** Integration check result */
+export interface IntegrationCheck {
+ ok: boolean | null // null = not applicable / not deployed
+ label: string
+}
+
+function formatCheck(check: IntegrationCheck): string {
+ const icon = check.ok === null ? '○' : check.ok ? '✓' : '✗'
+ return ` ${icon} ${check.label}`
+}
+
+function formatWarnings(warnings: string[] | undefined): string[] {
+ if (!warnings) return []
+ return warnings.map((w) => ` ⚠ ${w}`)
+}
+
+/** Format proxy admin detail lines */
+function formatProxyAdminDetail(result: ContractStatusResult): string[] {
+ if (!result.proxyAdminAddress) return []
+ const lines: string[] = []
+ const ownerIcon = result.proxyAdminOwner === 'governor' ? '✓' : result.proxyAdminOwner === 'unknown' ? '○' : '⚠'
+ const ownerRole =
+ result.proxyAdminOwner === 'governor'
+ ? 'governor'
+ : result.proxyAdminOwner === 'deployer'
+ ? 'deployer'
+ : result.proxyAdminOwner === 'other'
+ ? 'not governor'
+ : 'unknown'
+ const ownerAddr = result.proxyAdminOwnerAddress ? ` ${result.proxyAdminOwnerAddress}` : ''
+ lines.push(` ProxyAdmin: ${result.proxyAdminAddress}`)
+ lines.push(` ${ownerIcon} ProxyAdmin owner:${ownerAddr} (${ownerRole})`)
+ return lines
+}
+
+// ============================================================================
+// Ownership Context Resolution
+// ============================================================================
+
+/**
+ * Resolve governor/deployer context for proxy admin ownership checks
+ */
+export async function resolveOwnershipContext(
+ client: PublicClient,
+ env: Environment,
+ chainId: number,
+): Promise {
+ const horizonAddressBook = graph.getHorizonAddressBook(chainId)
+ try {
+ const controllerAddress = horizonAddressBook.entryExists('Controller')
+ ? horizonAddressBook.getEntry('Controller')?.address
+ : null
+ if (!controllerAddress) return undefined
+
+ const governor = (await client.readContract({
+ address: controllerAddress as `0x${string}`,
+ abi: CONTROLLER_ABI,
+ functionName: 'getGovernor',
+ })) as string
+
+ if (!governor) return undefined
+
+ // Deployer is best-effort: available when provider has accounts (fork/local)
+ let deployer: string | undefined
+ try {
+ const accounts = (await env.network.provider.request({ method: 'eth_accounts' })) as string[] | undefined
+ if (accounts && accounts.length > 0) {
+ deployer = accounts[0]
+ }
+ } catch {
+ // No accounts available (read-only provider)
+ }
+
+ return { governor, deployer }
+ } catch {
+ return undefined
+ }
+}
+
+// ============================================================================
+// Integration Check Functions
+// ============================================================================
+
+const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
+
+export async function getRewardsManagerChecks(
+ client: PublicClient,
+ horizonBook: AddressBookOps,
+ chainId: number,
+ issuanceBook?: AddressBookOps,
+ ssBook?: AddressBookOps,
+): Promise {
+ const checks: IntegrationCheck[] = []
+ const rmAddress = horizonBook.entryExists('RewardsManager') ? horizonBook.getEntry('RewardsManager')?.address : null
+
+ if (!rmAddress) return checks
+
+ // Interface support
+ const supportsRewardsManager = await supportsInterface(client, rmAddress, IREWARDS_MANAGER_INTERFACE_ID)
+ checks.push({ ok: supportsRewardsManager, label: `implements IRewardsManager (${IREWARDS_MANAGER_INTERFACE_ID})` })
+
+ const supportsIssuanceTarget = await supportsInterface(client, rmAddress, IISSUANCE_TARGET_INTERFACE_ID)
+ checks.push({ ok: supportsIssuanceTarget, label: `implements IIssuanceTarget (${IISSUANCE_TARGET_INTERFACE_ID})` })
+
+ if (!supportsRewardsManager) return checks
+
+ // Helper: read a contract value, returning null on failure
+ async function rmRead(functionName: string, abi: readonly unknown[] = REWARDS_MANAGER_ABI): Promise {
+ try {
+ return (await client.readContract({
+ address: rmAddress as `0x${string}`,
+ abi,
+ functionName,
+ })) as T
+ } catch {
+ return null
+ }
+ }
+
+ // Issuance rates
+ const rawRate = await rmRead('getRawIssuancePerBlock')
+ const allocatedRate = await rmRead('getAllocatedIssuancePerBlock')
+ if (rawRate !== null) {
+ checks.push({ ok: rawRate > 0n, label: `issuancePerBlock: ${formatGRT(rawRate)} (raw)` })
+ }
+ if (allocatedRate !== null) {
+ checks.push({
+ ok: allocatedRate > 0n,
+ label: `issuancePerBlock: ${formatGRT(allocatedRate)} (after IA allocation)`,
+ })
+ }
+
+ // SubgraphService
+ const ss = await rmRead('subgraphService')
+ if (ss !== null) {
+ const expected = ssBook?.entryExists('SubgraphService')
+ ? (ssBook.getEntry('SubgraphService')?.address ?? null)
+ : null
+ const matches = expected ? ss.toLowerCase() === expected.toLowerCase() : null
+ checks.push({
+ ok: ss !== ZERO_ADDRESS ? matches : false,
+ label: `subgraphService: ${ss}${matches === false && expected ? ` (expected ${expected})` : ''}`,
+ })
+ }
+
+ // IssuanceAllocator
+ const ia = await rmRead('getIssuanceAllocator', ISSUANCE_TARGET_ABI)
+ if (ia !== null) {
+ const iaBook = issuanceBook?.entryExists('IssuanceAllocator')
+ ? issuanceBook.getEntry('IssuanceAllocator')?.address
+ : null
+ const isSet = ia !== ZERO_ADDRESS
+ const matches = iaBook ? ia.toLowerCase() === iaBook.toLowerCase() : null
+ checks.push({
+ ok: isSet ? matches : null,
+ label: isSet
+ ? `issuanceAllocator: ${ia}${matches === false ? ` (expected ${iaBook!})` : ''}`
+ : 'issuanceAllocator: not set',
+ })
+ }
+
+ // Provider eligibility oracle
+ const reo = await rmRead('getProviderEligibilityOracle', PROVIDER_ELIGIBILITY_MANAGEMENT_ABI)
+ if (reo !== null) {
+ const reoA = issuanceBook?.entryExists('RewardsEligibilityOracleA')
+ ? issuanceBook.getEntry('RewardsEligibilityOracleA')?.address
+ : null
+ const isSet = reo !== ZERO_ADDRESS
+ const matchesA = reoA ? reo.toLowerCase() === reoA.toLowerCase() : null
+ checks.push({
+ ok: isSet ? matchesA : null,
+ label: isSet
+ ? `providerEligibilityOracle: ${reo}${matchesA === false ? ' (not REO-A)' : matchesA ? ' (REO-A)' : ''}`
+ : 'providerEligibilityOracle: not set',
+ })
+ } else {
+ checks.push({ ok: null, label: 'providerEligibilityOracle: not set' })
+ }
+
+ // Revert on ineligible — compare against resolved settings
+ const revertOnIneligible = await rmRead('getRevertOnIneligible')
+ if (revertOnIneligible !== null) {
+ const desired = getResolvedSettings(chainId).rewardsManager.revertOnIneligible
+ const matches = revertOnIneligible === desired
+ checks.push({
+ ok: matches,
+ label: `revertOnIneligible: ${revertOnIneligible}${matches ? '' : ` (expected ${desired})`}`,
+ })
+ }
+
+ // Default reclaim address
+ const defaultReclaim = await rmRead('getDefaultReclaimAddress')
+ if (defaultReclaim !== null) {
+ const expectedAddr = issuanceBook?.entryExists('ReclaimedRewards')
+ ? issuanceBook.getEntry('ReclaimedRewards')?.address
+ : null
+ const isSet = defaultReclaim !== ZERO_ADDRESS
+ const matches = isSet && expectedAddr ? defaultReclaim.toLowerCase() === expectedAddr.toLowerCase() : null
+ checks.push({
+ ok: isSet ? (matches ?? true) : null,
+ label: isSet
+ ? `defaultReclaimAddress: ${defaultReclaim}${matches === false ? ` (expected ${expectedAddr!})` : ''}`
+ : 'defaultReclaimAddress: not set',
+ })
+ }
+
+ return checks
+}
+
+export async function getIssuanceAllocatorChecks(
+ client: PublicClient,
+ horizonBook: AddressBookOps,
+ issuanceBook: AddressBookOps,
+): Promise {
+ const checks: IntegrationCheck[] = []
+
+ const iaAddress = issuanceBook.entryExists('IssuanceAllocator')
+ ? issuanceBook.getEntry('IssuanceAllocator')?.address
+ : null
+ const rmAddress = horizonBook.entryExists('RewardsManager') ? horizonBook.getEntry('RewardsManager')?.address : null
+ const gtAddress = horizonBook.entryExists('L2GraphToken') ? horizonBook.getEntry('L2GraphToken')?.address : null
+
+ if (!iaAddress || !rmAddress || !gtAddress) return checks
+
+ const rmSupportsTarget = await supportsInterface(client, rmAddress, IISSUANCE_TARGET_INTERFACE_ID)
+ checks.push({ ok: rmSupportsTarget, label: `RM implements IIssuanceTarget (${IISSUANCE_TARGET_INTERFACE_ID})` })
+
+ if (rmSupportsTarget) {
+ const activation = await checkIssuanceAllocatorActivation(client, iaAddress, rmAddress, gtAddress)
+ checks.push({ ok: activation.iaIntegrated, label: 'RM.issuanceAllocator == this' })
+ checks.push({ ok: activation.iaMinter, label: 'GraphToken.MINTER_ROLE granted' })
+ } else {
+ checks.push({ ok: null, label: 'RM.issuanceAllocator == this (RM not upgraded)' })
+ checks.push({ ok: null, label: 'GraphToken.MINTER_ROLE granted (RM not upgraded)' })
+ }
+
+ try {
+ const targetCount = (await client.readContract({
+ address: iaAddress as `0x${string}`,
+ abi: ISSUANCE_ALLOCATOR_ABI,
+ functionName: 'getTargetCount',
+ })) as bigint
+ const hasDefaultTarget = targetCount > 0n
+ checks.push({ ok: hasDefaultTarget, label: 'defaultTarget configured' })
+ } catch {
+ // Function not available
+ }
+
+ // Confirm 100% allocation: getTotalAllocation().totalAllocationRate == issuancePerBlock.
+ // Once a real defaultTarget is set (issuance-connect), the contract reports
+ // exactly issuancePerBlock; if it doesn't, the default is still address(0)
+ // and some issuance is unallocated (not minted). Skipped (○) when
+ // issuancePerBlock is 0 — the IA hasn't been configured with a rate yet,
+ // so the question is not yet meaningful.
+ try {
+ const issuancePerBlock = (await client.readContract({
+ address: iaAddress as `0x${string}`,
+ abi: ISSUANCE_ALLOCATOR_ABI,
+ functionName: 'getIssuancePerBlock',
+ })) as bigint
+ const totalAllocation = (await client.readContract({
+ address: iaAddress as `0x${string}`,
+ abi: ISSUANCE_ALLOCATOR_ABI,
+ functionName: 'getTotalAllocation',
+ })) as { totalAllocationRate: bigint; allocatorMintingRate: bigint; selfMintingRate: bigint }
+ if (issuancePerBlock === 0n) {
+ checks.push({ ok: null, label: '100% allocated (issuancePerBlock not set)' })
+ } else {
+ const fullyAllocated = totalAllocation.totalAllocationRate === issuancePerBlock
+ checks.push({
+ ok: fullyAllocated,
+ label: `100% allocated (${formatGRT(totalAllocation.totalAllocationRate)} of ${formatGRT(issuancePerBlock)})`,
+ })
+ }
+ } catch {
+ // Function not available
+ }
+
+ return checks
+}
+
+export async function getRewardsEligibilityOracleChecks(
+ client: PublicClient,
+ horizonBook: AddressBookOps,
+ issuanceBook: AddressBookOps,
+ entryName: string,
+): Promise {
+ const checks: IntegrationCheck[] = []
+
+ const reoAddress = issuanceBook.entryExists(entryName) ? issuanceBook.getEntry(entryName)?.address : null
+ const rmAddress = horizonBook.entryExists('RewardsManager') ? horizonBook.getEntry('RewardsManager')?.address : null
+ const controllerAddress = horizonBook.entryExists('Controller') ? horizonBook.getEntry('Controller')?.address : null
+
+ if (!reoAddress || !rmAddress) return checks
+
+ let governor: string | null = null
+ let pauseGuardian: string | null = null
+ if (controllerAddress) {
+ try {
+ governor = (await client.readContract({
+ address: controllerAddress as `0x${string}`,
+ abi: [
+ {
+ inputs: [],
+ name: 'getGovernor',
+ outputs: [{ type: 'address' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ ],
+ functionName: 'getGovernor',
+ })) as string
+ } catch {
+ // Controller doesn't have getGovernor
+ }
+ try {
+ pauseGuardian = (await client.readContract({
+ address: controllerAddress as `0x${string}`,
+ abi: [
+ {
+ inputs: [],
+ name: 'pauseGuardian',
+ outputs: [{ type: 'address' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ ],
+ functionName: 'pauseGuardian',
+ })) as string
+ } catch {
+ // Controller doesn't have pauseGuardian
+ }
+ }
+
+ try {
+ const governorRole = (await client.readContract({
+ address: reoAddress as `0x${string}`,
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ functionName: 'GOVERNOR_ROLE',
+ })) as `0x${string}`
+
+ if (governor) {
+ const governorHasRole = (await client.readContract({
+ address: reoAddress as `0x${string}`,
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ functionName: 'hasRole',
+ args: [governorRole, governor as `0x${string}`],
+ })) as boolean
+ checks.push({ ok: governorHasRole, label: 'governor has GOVERNOR_ROLE' })
+ }
+ } catch {
+ // Role check not available
+ }
+
+ try {
+ const pauseRole = (await client.readContract({
+ address: reoAddress as `0x${string}`,
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ functionName: 'PAUSE_ROLE',
+ })) as `0x${string}`
+
+ if (pauseGuardian) {
+ const pauseGuardianHasRole = (await client.readContract({
+ address: reoAddress as `0x${string}`,
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ functionName: 'hasRole',
+ args: [pauseRole, pauseGuardian as `0x${string}`],
+ })) as boolean
+ checks.push({ ok: pauseGuardianHasRole, label: 'pause guardian has PAUSE_ROLE' })
+ }
+ } catch {
+ // Role check not available
+ }
+
+ const networkOperator = issuanceBook.entryExists('NetworkOperator')
+ ? (issuanceBook.getEntry('NetworkOperator')?.address ?? null)
+ : null
+
+ try {
+ const operatorCheck = await checkOperatorRole(client, reoAddress, networkOperator)
+ const statusOk = networkOperator === null ? false : operatorCheck.ok
+ checks.push({ ok: statusOk, label: operatorCheck.message })
+ } catch {
+ checks.push({ ok: null, label: 'OPERATOR_ROLE (check failed)' })
+ }
+
+ try {
+ const currentREO = (await client.readContract({
+ address: rmAddress as `0x${string}`,
+ abi: PROVIDER_ELIGIBILITY_MANAGEMENT_ABI,
+ functionName: 'getProviderEligibilityOracle',
+ })) as string
+ const configured = currentREO.toLowerCase() === reoAddress.toLowerCase()
+ checks.push({ ok: configured, label: 'RM.providerEligibilityOracle == this' })
+ } catch {
+ // Function not available on old RM
+ }
+
+ try {
+ const enabled = (await client.readContract({
+ address: reoAddress as `0x${string}`,
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ functionName: 'getEligibilityValidation',
+ })) as boolean
+ checks.push({ ok: enabled, label: 'eligibility validation enabled' })
+ } catch {
+ // Function not available
+ }
+
+ try {
+ const lastUpdate = (await client.readContract({
+ address: reoAddress as `0x${string}`,
+ abi: REWARDS_ELIGIBILITY_ORACLE_ABI,
+ functionName: 'getLastOracleUpdateTime',
+ })) as bigint
+ const hasUpdates = lastUpdate > 0n
+ checks.push({ ok: hasUpdates, label: 'oracle has processed updates' })
+ } catch {
+ // Function not available
+ }
+
+ return checks
+}
+
+export async function getReclaimAddressChecks(
+ client: PublicClient,
+ horizonBook: AddressBookOps,
+ issuanceBook: AddressBookOps,
+): Promise {
+ const checks: IntegrationCheck[] = []
+
+ const rmAddress = horizonBook.entryExists('RewardsManager') ? horizonBook.getEntry('RewardsManager')?.address : null
+ const reclaimAddress = issuanceBook.entryExists('ReclaimedRewards')
+ ? issuanceBook.getEntry('ReclaimedRewards')?.address
+ : null
+
+ if (!rmAddress || !reclaimAddress) return checks
+
+ try {
+ const defaultReclaim = (await client.readContract({
+ address: rmAddress as `0x${string}`,
+ abi: REWARDS_MANAGER_ABI,
+ functionName: 'getDefaultReclaimAddress',
+ })) as string
+ const configured = defaultReclaim.toLowerCase() === reclaimAddress.toLowerCase()
+ checks.push({ ok: configured, label: 'configured as RM.defaultReclaimAddress' })
+ } catch {
+ checks.push({ ok: false, label: 'configured as RM.defaultReclaimAddress' })
+ }
+
+ return checks
+}
+
+// Minimal ABI for RecurringAgreementManager-specific view functions
+const RECURRING_AGREEMENT_MANAGER_ABI = [
+ {
+ inputs: [],
+ name: 'COLLECTOR_ROLE',
+ outputs: [{ type: 'bytes32' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'DATA_SERVICE_ROLE',
+ outputs: [{ type: 'bytes32' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'getCollectorCount',
+ outputs: [{ type: 'uint256' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'paused',
+ outputs: [{ type: 'bool' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+] as const
+
+export async function getRecurringAgreementManagerChecks(
+ client: PublicClient,
+ horizonBook: AddressBookOps,
+ issuanceBook: AddressBookOps,
+ ssBook: AddressBookOps,
+): Promise {
+ const checks: IntegrationCheck[] = []
+
+ const ramAddress = issuanceBook.entryExists('RecurringAgreementManager')
+ ? issuanceBook.getEntry('RecurringAgreementManager')?.address
+ : null
+ if (!ramAddress) return checks
+
+ // COLLECTOR_ROLE → RecurringCollector
+ const rcAddress = horizonBook.entryExists('RecurringCollector')
+ ? horizonBook.getEntry('RecurringCollector')?.address
+ : null
+ if (rcAddress) {
+ try {
+ const collectorRole = (await client.readContract({
+ address: ramAddress as `0x${string}`,
+ abi: RECURRING_AGREEMENT_MANAGER_ABI,
+ functionName: 'COLLECTOR_ROLE',
+ })) as `0x${string}`
+ const hasRole = (await client.readContract({
+ address: ramAddress as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [collectorRole, rcAddress as `0x${string}`],
+ })) as boolean
+ checks.push({ ok: hasRole, label: 'RecurringCollector has COLLECTOR_ROLE' })
+ } catch {
+ // Role check not available
+ }
+ }
+
+ // DATA_SERVICE_ROLE → SubgraphService
+ const ssAddress = ssBook?.entryExists('SubgraphService') ? ssBook.getEntry('SubgraphService')?.address : null
+ if (ssAddress) {
+ try {
+ const dataServiceRole = (await client.readContract({
+ address: ramAddress as `0x${string}`,
+ abi: RECURRING_AGREEMENT_MANAGER_ABI,
+ functionName: 'DATA_SERVICE_ROLE',
+ })) as `0x${string}`
+ const hasRole = (await client.readContract({
+ address: ramAddress as `0x${string}`,
+ abi: ACCESS_CONTROL_ENUMERABLE_ABI,
+ functionName: 'hasRole',
+ args: [dataServiceRole, ssAddress as `0x${string}`],
+ })) as boolean
+ checks.push({ ok: hasRole, label: 'SubgraphService has DATA_SERVICE_ROLE' })
+ } catch {
+ // Role check not available
+ }
+ }
+
+ // IssuanceAllocator
+ const iaAddress = issuanceBook.entryExists('IssuanceAllocator')
+ ? issuanceBook.getEntry('IssuanceAllocator')?.address
+ : null
+ try {
+ const currentIA = (await client.readContract({
+ address: ramAddress as `0x${string}`,
+ abi: ISSUANCE_TARGET_ABI,
+ functionName: 'getIssuanceAllocator',
+ })) as string
+ const isSet = currentIA !== ZERO_ADDRESS
+ const matches = iaAddress ? currentIA.toLowerCase() === iaAddress.toLowerCase() : null
+ checks.push({
+ ok: isSet ? matches : false,
+ label: isSet
+ ? `issuanceAllocator: ${formatAddress(currentIA)}${matches === false ? ` (expected ${formatAddress(iaAddress!)})` : ''}`
+ : 'issuanceAllocator: not set',
+ })
+ } catch {
+ // Function not available
+ }
+
+ // Provider eligibility oracle
+ try {
+ const reo = (await client.readContract({
+ address: ramAddress as `0x${string}`,
+ abi: PROVIDER_ELIGIBILITY_MANAGEMENT_ABI,
+ functionName: 'getProviderEligibilityOracle',
+ })) as string
+ const reoA = issuanceBook.entryExists('RewardsEligibilityOracleA')
+ ? issuanceBook.getEntry('RewardsEligibilityOracleA')?.address
+ : null
+ const isSet = reo !== ZERO_ADDRESS
+ const matchesA = reoA ? reo.toLowerCase() === reoA.toLowerCase() : null
+ checks.push({
+ ok: isSet ? matchesA : null,
+ label: isSet
+ ? `providerEligibilityOracle: ${reo}${matchesA === false ? ' (not REO-A)' : matchesA ? ' (REO-A)' : ''}`
+ : 'providerEligibilityOracle: not set',
+ })
+ } catch {
+ // Function not available
+ }
+
+ // Paused state
+ try {
+ const paused = (await client.readContract({
+ address: ramAddress as `0x${string}`,
+ abi: RECURRING_AGREEMENT_MANAGER_ABI,
+ functionName: 'paused',
+ })) as boolean
+ checks.push({ ok: !paused, label: paused ? 'PAUSED' : 'not paused' })
+ } catch {
+ // Function not available
+ }
+
+ // Collector count
+ try {
+ const count = (await client.readContract({
+ address: ramAddress as `0x${string}`,
+ abi: RECURRING_AGREEMENT_MANAGER_ABI,
+ functionName: 'getCollectorCount',
+ })) as bigint
+ checks.push({ ok: null, label: `collectors: ${count}` })
+ } catch {
+ // Function not available
+ }
+
+ return checks
+}
+
+// ============================================================================
+// Horizon / SubgraphService Contract Checks
+// ============================================================================
+
+// Minimal ABIs for contracts not in the abis.ts module
+const PAUSABLE_ABI = [
+ { inputs: [], name: 'paused', outputs: [{ type: 'bool' }], stateMutability: 'view', type: 'function' },
+] as const
+
+const PAUSE_GUARDIAN_ABI = [
+ {
+ inputs: [{ name: '_pauseGuardian', type: 'address' }],
+ name: 'pauseGuardians',
+ outputs: [{ type: 'bool' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+] as const
+
+const DISPUTE_MANAGER_ABI = [
+ { inputs: [], name: 'arbitrator', outputs: [{ type: 'address' }], stateMutability: 'view', type: 'function' },
+ { inputs: [], name: 'getDisputePeriod', outputs: [{ type: 'uint64' }], stateMutability: 'view', type: 'function' },
+ { inputs: [], name: 'disputeDeposit', outputs: [{ type: 'uint256' }], stateMutability: 'view', type: 'function' },
+ {
+ inputs: [],
+ name: 'getFishermanRewardCut',
+ outputs: [{ type: 'uint32' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ { inputs: [], name: 'maxSlashingCut', outputs: [{ type: 'uint32' }], stateMutability: 'view', type: 'function' },
+ { inputs: [], name: 'subgraphService', outputs: [{ type: 'address' }], stateMutability: 'view', type: 'function' },
+] as const
+
+const SUBGRAPH_SERVICE_ABI = [
+ {
+ inputs: [],
+ name: 'getProvisionTokensRange',
+ outputs: [{ type: 'uint256' }, { type: 'uint256' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'getDelegationRatio',
+ outputs: [{ type: 'uint32' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'stakeToFeesRatio',
+ outputs: [{ type: 'uint256' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'curationFeesCut',
+ outputs: [{ type: 'uint256' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'getDisputeManager',
+ outputs: [{ type: 'address' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'getGraphTallyCollector',
+ outputs: [{ type: 'address' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ { inputs: [], name: 'getCuration', outputs: [{ type: 'address' }], stateMutability: 'view', type: 'function' },
+] as const
+
+/** PPM denominator (1,000,000) for percentage display */
+const PPM = 1_000_000
+
+export async function getRecurringCollectorChecks(
+ client: PublicClient,
+ address: string,
+ horizonBook: AddressBookOps,
+): Promise {
+ const checks: IntegrationCheck[] = []
+
+ // Pause guardian
+ try {
+ const controllerAddress = horizonBook.entryExists('Controller') ? horizonBook.getEntry('Controller')?.address : null
+ if (controllerAddress) {
+ // pauseGuardian is a public storage variable auto-getter, not in IControllerToolshed
+ const pauseGuardian = (await client.readContract({
+ address: controllerAddress as `0x${string}`,
+ abi: [
+ {
+ inputs: [],
+ name: 'pauseGuardian',
+ outputs: [{ internalType: 'address', name: '', type: 'address' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ ] as const,
+ functionName: 'pauseGuardian',
+ })) as string
+ const isGuardian = (await client.readContract({
+ address: address as `0x${string}`,
+ abi: PAUSE_GUARDIAN_ABI,
+ functionName: 'pauseGuardians',
+ args: [pauseGuardian as `0x${string}`],
+ })) as boolean
+ checks.push({ ok: isGuardian, label: `pauseGuardian: ${pauseGuardian} ${isGuardian ? '' : '(not set)'}` })
+ }
+ } catch {
+ // Not available
+ }
+
+ // Paused state
+ try {
+ const paused = (await client.readContract({
+ address: address as `0x${string}`,
+ abi: PAUSABLE_ABI,
+ functionName: 'paused',
+ })) as boolean
+ checks.push({ ok: !paused, label: paused ? 'PAUSED' : 'not paused' })
+ } catch {
+ // paused() not available
+ }
+
+ // Thawing period
+ try {
+ const thawing = (await client.readContract({
+ address: address as `0x${string}`,
+ abi: [
+ {
+ inputs: [],
+ name: 'REVOKE_AUTHORIZATION_THAWING_PERIOD',
+ outputs: [{ type: 'uint256' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ ],
+ functionName: 'REVOKE_AUTHORIZATION_THAWING_PERIOD',
+ })) as bigint
+ checks.push({ ok: null, label: `REVOKE_AUTHORIZATION_THAWING_PERIOD: ${thawing}` })
+ } catch {
+ // Not available
+ }
+
+ return checks
+}
+
+export async function getDisputeManagerChecks(
+ client: PublicClient,
+ address: string,
+ horizonBook: AddressBookOps,
+ ssBook: AddressBookOps,
+): Promise {
+ const checks: IntegrationCheck[] = []
+
+ async function dmRead(functionName: (typeof DISPUTE_MANAGER_ABI)[number]['name']): Promise {
+ try {
+ return (await client.readContract({
+ address: address as `0x${string}`,
+ abi: DISPUTE_MANAGER_ABI,
+ functionName,
+ })) as T
+ } catch {
+ return null
+ }
+ }
+
+ // Arbitrator
+ const arbitrator = await dmRead('arbitrator')
+ if (arbitrator !== null) {
+ checks.push({ ok: arbitrator !== ZERO_ADDRESS, label: `arbitrator: ${arbitrator}` })
+ }
+
+ // SubgraphService reference
+ const ss = await dmRead('subgraphService')
+ if (ss !== null) {
+ const expected = ssBook?.entryExists('SubgraphService')
+ ? (ssBook.getEntry('SubgraphService')?.address ?? null)
+ : null
+ const matches = expected ? ss.toLowerCase() === expected.toLowerCase() : null
+ checks.push({
+ ok: ss !== ZERO_ADDRESS ? matches : false,
+ label: `subgraphService: ${ss}${matches === false && expected ? ` (expected ${expected})` : ''}`,
+ })
+ }
+
+ // Dispute period
+ const disputePeriod = await dmRead('getDisputePeriod')
+ if (disputePeriod !== null) {
+ checks.push({ ok: disputePeriod > 0n, label: `disputePeriod: ${disputePeriod}s` })
+ }
+
+ // Dispute deposit
+ const disputeDeposit = await dmRead('disputeDeposit')
+ if (disputeDeposit !== null) {
+ checks.push({ ok: disputeDeposit > 0n, label: `disputeDeposit: ${formatGRT(disputeDeposit)}` })
+ }
+
+ // Fisherman reward cut (PPM)
+ const fishermanCut = await dmRead('getFishermanRewardCut')
+ if (fishermanCut !== null) {
+ checks.push({
+ ok: null,
+ label: `fishermanRewardCut: ${fishermanCut} (${((fishermanCut / PPM) * 100).toFixed(2)}%)`,
+ })
+ }
+
+ // Max slashing cut (PPM)
+ const maxSlashing = await dmRead