diff --git a/.github/workflows/manual-subgraph-deploy.yml b/.github/workflows/manual-subgraph-deploy.yml new file mode 100644 index 0000000..44636f8 --- /dev/null +++ b/.github/workflows/manual-subgraph-deploy.yml @@ -0,0 +1,154 @@ +name: Subgraph manual deploy +on: + workflow_dispatch: + inputs: + metadata-ref: + description: >- + rain.metadata ref to take the subgraph SOURCE from. The manifest, schema and mappings are not in this repo; only networks.json is. + required: false + default: main +# Read-only, stated rather than left to the repo default. This job fetches and +# then RUNS another repo's `package.json` install scripts, so the ambient +# `GITHUB_TOKEN` is reachable from code this repo did not review; nothing here +# needs to write anything back. +permissions: + contents: read +# One deploy at a time. `GOLDSKY_SUBGRAPH_NAME` is a single name, so two +# dispatches publish over each other. Never cancelled: a half-finished +# seven-network loop leaves Goldsky holding some networks from one run and some +# from the other, which is worse than waiting. +concurrency: + group: subgraph-manual-deploy + cancel-in-progress: false +jobs: + deploy: + runs-on: ubuntu-latest + env: + GOLDSKY_TOKEN: ${{ secrets.CI_GOLDSKY_TOKEN }} + GOLDSKY_SUBGRAPH_NAME: metaboard + steps: + # `persist-credentials: false` on both checkouts: the fetched subgraph + # source's install scripts run in this job, and a git config holding the + # token is one `git config --get` away from them. Nothing here pushes. + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + # The subgraph is split on whether a file carries a deployment fact + # (rainlanguage/rain.metadata#149). This repo holds `networks.json` and + # nothing else under `subgraph/`; the manifest, schema, mappings and + # package lock are SOURCE and live in the library half. They are fetched + # rather than duplicated, so there is one copy of each and it is the one + # matchstick runs against. + - name: Checkout the subgraph source + uses: actions/checkout@v6 + with: + repository: rainlanguage/rain.metadata + ref: ${{ inputs.metadata-ref }} + path: .subgraph-source + persist-credentials: false + - uses: nixbuild/nix-quick-install-action@v30 + with: + nix_conf: | + keep-env-derivations = true + keep-outputs = true + - name: Restore and save Nix store + uses: nix-community/cache-nix-action@v7 + with: + primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock') }} + restore-prefixes-first-match: nix-${{ runner.os }}- + gc-max-store-size-linux: 1G + # Merged INTO `subgraph/` rather than over it: this repo's own + # `networks.json` has to survive, and the library must not be shipping one + # of its own to shadow it. Both are asserted, because a silent shadow + # would deploy whatever table the library happened to carry. + - name: Assemble the subgraph + run: | + set -euo pipefail + if [ -e .subgraph-source/subgraph/networks.json ]; then + echo "::error::rain.metadata ships a networks.json; it would shadow this repo's deploy record" + exit 1 + fi + cp -R .subgraph-source/subgraph/. subgraph/ + test -f subgraph/networks.json + test -f subgraph/subgraph.yaml + source_sha="$(git -C .subgraph-source rev-parse HEAD)" + echo "subgraph source: rainlanguage/rain.metadata@${source_sha}" + echo "Subgraph source: [rain.metadata@\`${source_sha}\`](https://github.com/rainlanguage/rain.metadata/commit/${source_sha})" >> "$GITHUB_STEP_SUMMARY" + # The manifest reads its ABI out of `out/`, which is a forge build of the + # interface it arrives with as a soldeer dependency, so the dependencies + # have to be on disk and compiled before `graph build` can read anything. + - name: Build the ABI the manifest reads + run: | + nix develop github:rainlanguage/rainix#sol-shell -c forge soldeer install + nix develop github:rainlanguage/rainix#sol-shell -c forge build + # Fail here rather than part way through a seven-network Goldsky loop: the + # manifest's ABI paths are a cross-repo coupling and this is the first + # place they can be resolved. Read out of the manifest with `yq` rather + # than grepped for: `file:` also names the schema and the mapping, and a + # grep that matched one of those would report success having checked a + # path that was never in question. + - name: Check the manifest ABIs resolve + run: | + nix develop --command bash -c ' + set -euo pipefail + abis="$(yq -r ".dataSources[].mapping.abis[].file" subgraph/subgraph.yaml)" + test -n "$abis" + while read -r abi; do + echo "manifest ABI: $abi" + test -f "subgraph/$abi" + done <<< "$abis" + ' + # `subgraph-deploy` runs `npm ci` and then `graph build --network`, and + # `graph build` does NOT codegen. It compiles `src/metaBoard.ts`, which + # imports `../generated/metaboard0/MetaBoard` and `../generated/schema` — + # generated files, gitignored in the library half, so they never arrive + # with the fetched source. Without this step the AssemblyScript compile + # fails on two unresolved imports for every network in the loop. + # + # `npm ci` here is the same install `subgraph-deploy` repeats. It removes + # `node_modules` and not `generated/`, so the types survive into it. + - name: Generate the subgraph types + run: nix develop --command bash -c 'cd subgraph && npm ci && graph codegen' + - name: Deploy and capture URLs + id: deploy + run: | + set -o pipefail + nix develop --command subgraph-deploy 2>&1 | tee deploy.log + echo + # `subgraph-deploy` skips a network whose version Goldsky already + # holds, and prints no URL for it. A re-dispatch that skips every + # network is a SUCCESSFUL no-op matching nothing, and `grep` answers + # no-match with exit 1 — which `pipefail` plus the runner's `-e` would + # read as a failed deploy. So exit 1 is folded into an empty list, and + # only exit 1: anything above it is `grep` failing rather than not + # matching, and still fails the step. + matched=0 + grep -oE 'https://api\.goldsky\.com/api/public/[^[:space:]]+/gn' deploy.log > matched_urls.txt || matched=$? + if [ "$matched" -gt 1 ]; then + exit "$matched" + fi + sort -u matched_urls.txt > deployed_urls.txt + echo "::group::Deployed Goldsky URLs" + cat deployed_urls.txt + echo "::endgroup::" + # Into the run summary as well as the log: `deployed_urls.txt` lives + # in the runner workspace and is gone with it, and the log is where + # nobody looks for the one fact a deploy produces. The summary already + # names the source commit these URLs were built from, so the two sit + # together and the pair survives the runner. + # + # The empty case is spelled rather than left as an empty list: a + # summary reading "Deployed:" and then nothing looks like a broken + # step rather than the no-op it is. + { + echo + if [ -s deployed_urls.txt ]; then + echo "Deployed:" + echo + sed 's|^|- |' deployed_urls.txt + else + echo "Deployed: nothing. Every network already held this version." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 95badd4..e21902e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,15 @@ result dependencies remappings.txt .pre-commit-config.yaml +<<<<<<< HEAD + +# `subgraph/networks.json` is the only file this repo owns under `subgraph/`. +# The `Subgraph manual deploy` workflow fetches the SOURCE from rain.metadata +# and merges it in here, then `graph build` writes `build/`, `generated/` and +# `node_modules/` beside it and rewrites the manifest in place. All of that is +# transient and none of it is this repo's to commit. +/subgraph/* +!/subgraph/networks.json +======= target +>>>>>>> origin/main diff --git a/.soldeerignore b/.soldeerignore index a903b1f..4ac1984 100644 --- a/.soldeerignore +++ b/.soldeerignore @@ -19,6 +19,7 @@ CLAUDE.md /remappings.txt /slither.config.json /soldeer.lock +/subgraph /REUSE.toml /script /test diff --git a/CLAUDE.md b/CLAUDE.md index c7e6fca..84ff9b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,10 @@ rain.metadata.deploy is the **deploy half** of `rain.metadata`: the concrete `MetaBoard` (an `IMetaBoardV1_2` that is nothing but one delegation per entry point into `LibIMetaBoardV1_2`) plus its deployed address + codehash pins. The `IMeta*` **interfaces and the metaboard logic are NOT here** — they live in -`rain.metadata` and arrive as the `rain-metadata` Soldeer dependency -(`dependencies/rain-metadata-/src/`). The metaboard subgraph is not -here either; it stays in `rain.metadata`, as do the metadata rust crates. The -one crate here reports on Goldsky deploys, and is not metadata logic. +`rain.metadata` and arrive as the `rain-metadata` Soldeer dependency. The +subgraph SOURCE and the metadata rust crates stay in `rain.metadata`. Here: the +subgraph's deployment record — see below — and one crate reporting on Goldsky +deploys, which is not metadata logic. ## Conventions an agent would get wrong @@ -42,6 +42,29 @@ one crate here reports on Goldsky deploys, and is not metadata logic. `src/lib/LibMetaBoardReleased.sol`, `src/lib/LibReleasedSuites.sol`) — do not hand-edit; `script/Build.sol` regenerates them. +## The subgraph: one file here (#2, recut by rain.metadata#149) + +- `subgraph/networks.json` (per-network address + start block) is a deploy + record and the WHOLE of this repo's share. Manifest, schema, mappings and + matchstick suite are SOURCE and stay in `rain.metadata`, which pins the + manifest to the interface it indexes. `Subgraph manual deploy` fetches that + source (`metadata-ref`) and merges it in beside the table, and `graph build` + rewrites the manifest in place — hence `.gitignore` ignores all of `subgraph/` + except the table. Nothing else here runs a subgraph command. +- The table names the **0.1.0** `MetaBoard` (`0x8fD50fF9...`) — this repo's own + frozen release — on all seven deploy networks, each `startBlock` the chain's + verified deploy block (#4). The v1 board (`0xfb8437Ae...`) survives here only + in git history. +- `SubgraphDeployRecord.t.sol`'s release-coverage assertion armed at + `sol-v0.1.0`: every frozen release must be indexed on every indexed network, + or the suite is red. +- The Graph and `LibRainDeploy` spell chains differently (`matic`/`polygon`, + `arbitrum-one`/`arbitrum`). Adding a network to `networks.json` means adding + its mapping in that test in the same change, or it fails closed. +- The Goldsky version is `
-`, not of the + source, so two dispatches from one commit against different `metadata-ref`s + collide and the second is skipped as already deployed (rainix#354). + ## Release / deploy shape - The on-chain deploy is a human-dispatched `Manual sol artifacts` run diff --git a/README.md b/README.md index 7f71990..6d6cff0 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,36 @@ Consumers that need only the interfaces or the libraries depend on `rain-metadata`; consumers that need the deployed address/codehash pins depend on `rain-metadata-deploy`. +## Subgraph + +`subgraph/networks.json` is a deployment record in JSON — a per-network table of +the deployed MetaBoard address and start blocks — which is the same class of +fact as `src/generated//`, so it belongs with the deploy records rather +than with the interfaces +([#2](https://github.com/rainlanguage/rain.metadata.deploy/issues/2)). + +It is the only file this repo holds under `subgraph/`. The manifest, schema, +mappings and matchstick suite are subgraph SOURCE and stay in `rain.metadata` +([rain.metadata#149](https://github.com/rainlanguage/rain.metadata/issues/149)), +whose `subgraph.yaml` is a template carrying no address, start block or real +network name. `graph build --network ` fills all three from the table beside +it. + +Because the table and the deploy records are in one tree, they are checked +against each other: `test/src/subgraph/SubgraphDeployRecord.t.sol` holds the +network table to `LibMetaBoardReleased` and to the networks this repo broadcasts +to. It is a Solidity test in the ordinary `rainix-sol` lane, so it runs without +docker, node or matchstick. + +Deploys are manual. The `Subgraph manual deploy` workflow (`workflow_dispatch`, +with a `metadata-ref` input naming the subgraph source revision) checks out that +source, merges it in beside `networks.json`, builds the ABI the manifest reads, +and publishes to Goldsky under the subgraph name `metaboard`. + +The Cynic GraphQL client that _consumes_ this subgraph (`crates/metaboard`, +published as `rain-metaboard-subgraph`) stays in `rain.metadata`: it is keyed by +endpoint URL and has no address or Goldsky coupling. + ## Releases This is a deploy repo: releases are **manual `sol-v*` tags**, not merges. diff --git a/REUSE.toml b/REUSE.toml index 03e4d67..0ef1788 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -16,6 +16,7 @@ path = [ "flake.nix", "foundry.toml", "slither.config.json", + "subgraph/**/", "REUSE.toml", "soldeer.lock", ".soldeerignore", diff --git a/foundry.toml b/foundry.toml index 9941aa3..c905db3 100644 --- a/foundry.toml +++ b/foundry.toml @@ -31,13 +31,21 @@ evm_version = "cancun" bytecode_hash = "none" cbor_metadata = false -# `script/Build.sol` writes the generated candidate snapshot and the generated -# libs under `src/`, and reads the release version from this file when -# `cutRelease()` runs. `MetaBoardDeploySnapshotTest`'s inherited frozen-record -# walk reads `src/generated/` too, so tests need the same read access. Nothing -# else in this repo touches the filesystem. +# The filesystem access in this repo. `script/Build.sol` writes the generated +# candidate snapshot and the generated libs under `src/`, and reads the release +# version from this file when `cutRelease()` runs; +# `MetaBoardDeploySnapshotTest`'s inherited frozen-record walk reads +# `src/generated/` too, so tests need the same read access. +# `test/src/subgraph/SubgraphDeployRecord.t.sol` reads the deploy record under +# `./subgraph`. That grant is the directory rather than the one file because +# `.gitignore` already states the boundary — this repo owns `networks.json` +# there and nothing else under it is committed — and read-only, so no Forge +# cheatcode can write into it. That is the whole of what `fs_permissions` +# governs: the deploy workflow drops the fetched subgraph source beside the +# table with its own `cp -R`, which this file has no say over. fs_permissions = [ { access = "read", path = "./foundry.toml" }, + { access = "read", path = "./subgraph" }, { access = "read-write", path = "./src" }, ] libs = ["dependencies"] diff --git a/subgraph/networks.json b/subgraph/networks.json new file mode 100644 index 0000000..a35e08b --- /dev/null +++ b/subgraph/networks.json @@ -0,0 +1,44 @@ +{ + "matic": { + "metaboard0": { + "address": "0x8fD50fF9Db9835ba1B61394752A26F53D721D2a1", + "startBlock": 92426181 + } + }, + "arbitrum-one": { + "metaboard0": { + "address": "0x8fD50fF9Db9835ba1B61394752A26F53D721D2a1", + "startBlock": 496974800 + } + }, + "base": { + "metaboard0": { + "address": "0x8fD50fF9Db9835ba1B61394752A26F53D721D2a1", + "startBlock": 50277465 + } + }, + "base-sepolia": { + "metaboard0": { + "address": "0x8fD50fF9Db9835ba1B61394752A26F53D721D2a1", + "startBlock": 45787995 + } + }, + "flare": { + "metaboard0": { + "address": "0x8fD50fF9Db9835ba1B61394752A26F53D721D2a1", + "startBlock": 67943188 + } + }, + "hyperevm": { + "metaboard0": { + "address": "0x8fD50fF9Db9835ba1B61394752A26F53D721D2a1", + "startBlock": 43806327 + } + }, + "mainnet": { + "metaboard0": { + "address": "0x8fD50fF9Db9835ba1B61394752A26F53D721D2a1", + "startBlock": 25805918 + } + } +} diff --git a/test/src/subgraph/SubgraphDeployRecord.t.sol b/test/src/subgraph/SubgraphDeployRecord.t.sol new file mode 100644 index 0000000..8a7fb65 --- /dev/null +++ b/test/src/subgraph/SubgraphDeployRecord.t.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibRainDeploy} from "rain-deploy-0.1.7/src/lib/LibRainDeploy.sol"; +import {DeploySuite} from "src/abstract/RainDeploySuitesBase.sol"; +import {LibMetaBoardReleased} from "src/lib/LibMetaBoardReleased.sol"; +import {SubgraphRecordReader, SubgraphDataSource} from "./SubgraphRecordReader.sol"; + +/// @title SubgraphDeployRecordTest +/// @notice `subgraph/networks.json` checked against this repo's deploy records +/// — the check rainlanguage/rain.metadata#134 lost and #2 moved the table here +/// to get back. +/// +/// Before the split, `networks.json`'s address sat in the same tree as +/// `METABOARD_DEPLOYED_ADDRESS` and could be compared to it. The split took the +/// constant away and left `networks.json` as the only place in the org naming a +/// live `MetaBoard`, with nothing to check it against. Both halves are here +/// again, so the comparison is a test rather than a convention. +/// +/// `networks.json` is ALL this repo holds under `subgraph/` +/// (rainlanguage/rain.metadata#149): the manifest, schema, mappings and +/// matchstick suite are subgraph SOURCE and stay in `rain.metadata`, which is +/// also where the manifest is pinned to the interface it indexes. So every +/// assertion below reads the network table and this repo's own records, and +/// nothing here reads a manifest — there is not one in this tree to read, and +/// one fetched at deploy time is not a thing a per-push test can hold. +/// +/// This is a Solidity test, in the existing `rainix-sol` lane, deliberately. +/// The deploy record IS Solidity — `LibMetaBoardReleased` and +/// `LibRainDeploy.supportedNetworks()` are the things being compared against — +/// so a check written anywhere else would have to re-spell the record in +/// another language and could then disagree with it. It also means the check +/// needs no docker, no node and no matchstick: the lane that already gates +/// every push runs it. +/// +/// What each assertion is worth TODAY: +/// +/// - Released deploys are indexed (`testEveryReleasedDeployIsIndexedOnEveryNetwork`). +/// This is the assertion #2 is about, and it is LIVE: `sol-v0.1.0` froze the +/// `MetaBoard` this repo broadcast to all seven supported networks, so +/// `LibMetaBoardReleased.releasedSuites()` holds its address and the table +/// must name it on every network it indexes or this test is red. It was +/// EMPTY-TRUE from the suite's landing until that first release armed it. +/// +/// - Everything else here is live too and does real work: the table parses to +/// something, one address per datasource across networks, every indexed +/// network a network this repo broadcasts to, no datasource starting at +/// genesis. +/// +/// What is deliberately NOT asserted: that every address in `networks.json` is +/// one this repo has a record of. Today the table names only the released +/// `0.1.0` address, but the deploy is dispatched BEFORE the release is tagged, +/// so there is a legitimate window in which `networks.json` names a freshly +/// broadcast candidate that no frozen snapshot covers yet. An assertion that +/// failed during that window would be an assertion the release process has to +/// be worked around. (The v1 `MetaBoard` the table named before the flip is +/// gone from this repo outside git history; rainlanguage/rain.metadata.deploy#4 +/// records the flip.) +contract SubgraphDeployRecordTest is SubgraphRecordReader { + /// `networks.json` MUST describe at least one datasource. + /// + /// Every other assertion here loops over what this parses. A file that + /// parsed to nothing would turn all of them green at once, which is the + /// failure a consistency suite can least afford to have. + function testNetworksJsonDescribesAtLeastOneDataSource() external view { + string[] memory networks = graphNetworks(); + assertTrue(networks.length > 0, "networks.json names no networks"); + + SubgraphDataSource[] memory sources = dataSources(); + assertTrue(sources.length > 0, "networks.json names no datasources"); + assertTrue(sources.length >= networks.length, "a network in networks.json declares no datasource"); + } + + /// A datasource name MUST index the same address on every network. + /// + /// This repo deploys through the Zoltu factory, which is CREATE2 over the + /// creation code under a zero salt. The address is therefore a pure + /// function of the bytes and is IDENTICAL on every chain they are + /// broadcast to — `DeploySuite` records one address, not one per network, + /// for exactly that reason. A per-network address is the shape of a + /// hand-edit typo, and it is unfalsifiable by inspection because every row + /// looks equally plausible. + /// + /// Per NAME rather than per file: a second deployment is added as a second + /// datasource (`metaboard1`) across the same networks, and the file is then + /// correctly holding two addresses. + /// + /// EVERYWHERE is asserted as well as SAME, because agreement between the + /// rows that happen to exist says nothing about a row that does not. A name + /// present on four networks and misspelled on the fifth leaves four + /// agreeing entries and one singleton group that agrees with itself, so the + /// address comparison alone passes on exactly the hand-edit it is for — + /// while the fifth chain silently indexes nothing under that name. + function testEachDataSourceIndexesOneAddressEverywhere() external view { + SubgraphDataSource[] memory sources = dataSources(); + string[] memory networks = graphNetworks(); + + for (uint256 i = 0; i < sources.length; i++) { + for (uint256 j = i + 1; j < sources.length; j++) { + if (keccak256(bytes(sources[i].name)) == keccak256(bytes(sources[j].name))) { + assertEq( + sources[i].deployedAddress, + sources[j].deployedAddress, + string.concat( + "datasource ", + sources[i].name, + " indexes a different address on ", + sources[i].graphNetwork, + " and ", + sources[j].graphNetwork + ) + ); + } + } + + for (uint256 n = 0; n < networks.length; n++) { + bool present = false; + for (uint256 j = 0; j < sources.length; j++) { + if ( + keccak256(bytes(sources[j].name)) == keccak256(bytes(sources[i].name)) + && keccak256(bytes(sources[j].graphNetwork)) == keccak256(bytes(networks[n])) + ) { + present = true; + break; + } + } + assertTrue(present, string.concat("datasource ", sources[i].name, " is not indexed on ", networks[n])); + } + } + } + + /// Every FROZEN release MUST be indexed, on every network the subgraph + /// indexes at all. + /// + /// This is the assertion the move exists for. A `sol-v*` tag freezes a + /// snapshot of something that has ALREADY been broadcast — the deploy is + /// dispatched first — so a release in the record is a live `MetaBoard` on + /// every supported network, and a subgraph that does not name it is a + /// subgraph silently missing the contract this repo deployed. + /// + /// On EVERY indexed network, not merely somewhere: the broadcast reaches + /// all of them, so a release added to one network's table and forgotten on + /// the other four is the exact hand-edit that has nothing checking it, and + /// "indexed somewhere" would pass on it. + /// + /// Read from `LibMetaBoardReleased` rather than the `LibReleasedSuites` + /// aggregate: the aggregate is every contract this repo releases, and a + /// second contract added later would not be a `MetaBoard` the metaboard + /// subgraph should be indexing. + /// + /// EMPTY-TRUE until the first release. See the contract natspec. + function testEveryReleasedDeployIsIndexedOnEveryNetwork() external view { + DeploySuite[] memory released = LibMetaBoardReleased.releasedSuites(); + SubgraphDataSource[] memory sources = dataSources(); + string[] memory networks = graphNetworks(); + + for (uint256 i = 0; i < released.length; i++) { + for (uint256 n = 0; n < networks.length; n++) { + bool isIndexed = false; + for (uint256 j = 0; j < sources.length; j++) { + if ( + sources[j].deployedAddress == released[i].storedDeployedAddress + && keccak256(bytes(sources[j].graphNetwork)) == keccak256(bytes(networks[n])) + ) { + isIndexed = true; + break; + } + } + assertTrue( + isIndexed, + string.concat( + "released suite ", + released[i].suite, + " deployed at ", + vm.toString(released[i].storedDeployedAddress), + " is not indexed on ", + networks[n] + ) + ); + } + } + } + + /// Every indexed network MUST be one this repo broadcasts to. + /// + /// A datasource on a chain `script/Deploy.sol` never reaches is a claim + /// this repo cannot back: whatever is at that address there, this repo did + /// not put it there and holds no record that it is a `MetaBoard` at all. + function testEveryIndexedNetworkIsADeployTarget() external view { + string[] memory networks = graphNetworks(); + string[] memory supported = LibRainDeploy.supportedNetworks(); + + for (uint256 i = 0; i < networks.length; i++) { + bytes32 target = keccak256(bytes(deployNetworkFor(networks[i]))); + bool isSupported = false; + for (uint256 j = 0; j < supported.length; j++) { + if (keccak256(bytes(supported[j])) == target) { + isSupported = true; + break; + } + } + assertTrue( + isSupported, + string.concat("networks.json indexes ", networks[i], ", which this repo does not deploy to") + ); + } + } + + /// Every datasource MUST start indexing after genesis. + /// + /// A `MetaBoard` cannot emit before it is deployed, so a zero start block + /// indexes an empty range of chain at real cost. Zero is also what a + /// missing or mistyped `startBlock` parses to, which is how it gets there. + function testEveryDataSourceStartsAfterGenesis() external view { + SubgraphDataSource[] memory sources = dataSources(); + for (uint256 i = 0; i < sources.length; i++) { + assertTrue( + sources[i].startBlock > 0, + string.concat("datasource ", sources[i].name, " on ", sources[i].graphNetwork, " starts at genesis") + ); + } + } +} diff --git a/test/src/subgraph/SubgraphRecordReader.sol b/test/src/subgraph/SubgraphRecordReader.sol new file mode 100644 index 0000000..71ee816 --- /dev/null +++ b/test/src/subgraph/SubgraphRecordReader.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.2/src/Test.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.7/src/lib/LibRainDeploy.sol"; + +/// Thrown when `networks.json` names a network with no declared correspondence +/// to a network this repo broadcasts to. +/// +/// A revert rather than a silent skip: an unmapped network is a network whose +/// datasource claims an address on a chain this repo cannot say it deployed to, +/// which is precisely the claim these tests exist to refuse. Adding a network +/// to `networks.json` means adding its mapping here in the same change. +/// @param graphNetwork The unmapped network name, as `networks.json` spells it. +error UnmappedSubgraphNetwork(string graphNetwork); + +/// One `dataSources` entry of `networks.json`, flattened: the file nests +/// datasource name under network, and every assertion here wants both keys +/// alongside the values. +struct SubgraphDataSource { + /// The network name as The Graph spells it — the outer key. + string graphNetwork; + /// The datasource name — the inner key, and the name `subgraph.yaml` + /// declares. + string name; + /// The address this datasource indexes. + address deployedAddress; + /// The block the datasource starts indexing from. + uint256 startBlock; +} + +/// @title SubgraphRecordReader +/// @notice Reads `subgraph/networks.json`: the network keys, the flattened +/// datasources, and the `LibRainDeploy` network each Graph network name +/// corresponds to. Shared between the record suite in +/// `SubgraphDeployRecord.t.sol` and the fork suite in `SubgraphStartBlock.t.sol`, +/// which must agree on what the file says while staying SEPARATE contracts: a +/// contract boundary is what `forge test --match-contract` selects at, so an +/// unreachable RPC endpoint can red the fork suite without touching an assertion +/// that reads only this repo. +abstract contract SubgraphRecordReader is Test { + /// The subgraph's per-network deployment table, and the whole of this + /// repo's share of the subgraph. + string constant NETWORKS_JSON = "subgraph/networks.json"; + + /// The network names, in file order. + /// @return The outer keys of `networks.json`. + function graphNetworks() internal view returns (string[] memory) { + return vm.parseJsonKeys(vm.readFile(NETWORKS_JSON), "$"); + } + + /// Every datasource in `networks.json`, flattened across networks. + /// + /// Keys are read out of the file rather than declared here, so a network or + /// a datasource ADDED to the file is covered by every assertion below + /// without anyone remembering to extend a list. A check that only looked at + /// names it already knew would be silent on exactly the hand-edit that + /// motivates it. + /// @return Every datasource, network-major in file order. + function dataSources() internal view returns (SubgraphDataSource[] memory) { + string memory json = vm.readFile(NETWORKS_JSON); + string[] memory networks = vm.parseJsonKeys(json, "$"); + + uint256 total = 0; + for (uint256 i = 0; i < networks.length; i++) { + total += vm.parseJsonKeys(json, string.concat("$[\"", networks[i], "\"]")).length; + } + + SubgraphDataSource[] memory sources = new SubgraphDataSource[](total); + uint256 offset = 0; + for (uint256 i = 0; i < networks.length; i++) { + string memory networkPath = string.concat("$[\"", networks[i], "\"]"); + string[] memory names = vm.parseJsonKeys(json, networkPath); + for (uint256 j = 0; j < names.length; j++) { + string memory sourcePath = string.concat(networkPath, "[\"", names[j], "\"]"); + sources[offset] = SubgraphDataSource({ + graphNetwork: networks[i], + name: names[j], + deployedAddress: vm.parseJsonAddress(json, string.concat(sourcePath, ".address")), + startBlock: vm.parseJsonUint(json, string.concat(sourcePath, ".startBlock")) + }); + offset++; + } + } + return sources; + } + + /// The network this repo broadcasts to, for a network name as + /// `networks.json` spells it. + /// + /// Declared rather than derived because the two names disagree and there is + /// no rule that recovers one from the other: The Graph calls Polygon + /// "matic" and Arbitrum One "arbitrum-one", while `LibRainDeploy` — and + /// `foundry.toml`'s `[rpc_endpoints]` with it — calls them "polygon" and + /// "arbitrum". + /// + /// Only the networks actually indexed are mapped. The remaining + /// `supportedNetworks()` entries are left out rather than guessed at: this + /// table's job is to resolve what the file says, and inventing a Graph + /// spelling for a chain the subgraph does not index would be a name nobody + /// has checked, sitting in the one place that is supposed to be checking. + /// @param graphNetwork The network name from `networks.json`. + /// @return The matching `LibRainDeploy` network name. + function deployNetworkFor(string memory graphNetwork) internal pure returns (string memory) { + bytes32 key = keccak256(bytes(graphNetwork)); + if (key == keccak256(bytes("matic"))) { + return LibRainDeploy.POLYGON; + } + if (key == keccak256(bytes("arbitrum-one"))) { + return LibRainDeploy.ARBITRUM_ONE; + } + if (key == keccak256(bytes("base"))) { + return LibRainDeploy.BASE; + } + if (key == keccak256(bytes("base-sepolia"))) { + return LibRainDeploy.BASE_SEPOLIA; + } + if (key == keccak256(bytes("flare"))) { + return LibRainDeploy.FLARE; + } + if (key == keccak256(bytes("hyperevm"))) { + return LibRainDeploy.HYPEREVM; + } + if (key == keccak256(bytes("mainnet"))) { + return LibRainDeploy.ETHEREUM; + } + revert UnmappedSubgraphNetwork(graphNetwork); + } +} diff --git a/test/src/subgraph/SubgraphStartBlock.t.sol b/test/src/subgraph/SubgraphStartBlock.t.sol new file mode 100644 index 0000000..2f052b8 --- /dev/null +++ b/test/src/subgraph/SubgraphStartBlock.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {SubgraphRecordReader, SubgraphDataSource} from "./SubgraphRecordReader.sol"; + +/// @title SubgraphStartBlockTest +/// @notice `startBlock` checked against the chain itself: for every datasource +/// in `subgraph/networks.json`, `eth_getCode` at `startBlock` finds code and at +/// `startBlock - 1` finds none. Together the two reads pin `startBlock` as THE +/// deployment block — the one field of the table `SubgraphDeployRecordTest` +/// cannot see past, because that suite compares the file to this repo's +/// records and no record here says when a released `MetaBoard` reached each chain. +/// All it can demand of `startBlock` is that it is not genesis. +/// +/// The error that matters is the too-late direction. The Graph indexes forward +/// from `startBlock` and never revisits earlier blocks, so a `startBlock` past +/// the deploy block drops every event in the gap from the subgraph forever, +/// and no query shows the absence as anything but "there was no event". The +/// too-early direction merely indexes empty chain at real cost. Pinning the +/// exact block refuses both. +/// +/// Two forks per datasource, on the datasource's own network, resolved through +/// the same declared mapping the record suite uses. The historical reads need +/// archive state — a start block only recedes as the chain grows — which is CI's +/// rpc-preflight's job: it binds each `[rpc_endpoints]` alias to an endpoint +/// that answered archive probes at or below the org's deepest pins for that +/// network, and every block this table names is at or above those probes. +/// +/// A separate contract from the record suite for the same reason +/// `RainDeployVerifyChain` is separate from the snapshot checks: a contract +/// boundary is what `forge test --match-contract` and a CI job select at, so +/// an RPC outage reds this suite alone and legibly — a fork that cannot be +/// created is an outage, while an assertion failing on a fork that was created +/// is a wrong `startBlock`. Nothing reachable from the record contract forks +/// anything. +contract SubgraphStartBlockTest is SubgraphRecordReader { + /// Every datasource's `startBlock` MUST be its address's deployment block: + /// code there at `startBlock`, no code one block earlier. + /// + /// The two reads bite one direction each: + /// + /// - `startBlock` too LATE (deploy block + n): the code was already there + /// a block earlier, so the empty-before read fails. This is the + /// silent-gap direction. + /// - `startBlock` too EARLY (deploy block - n): no code at `startBlock` + /// yet, so the code-at-start read fails first. + /// + /// `startBlock - 1` cannot underflow into a bogus fork: the record suite + /// refuses a genesis start, and here a zero `startBlock` panics the + /// subtraction — an arithmetic red rather than a semantic one, but red. + function testEveryStartBlockIsTheDeployBlock() external { + SubgraphDataSource[] memory sources = dataSources(); + for (uint256 i = 0; i < sources.length; i++) { + string memory network = deployNetworkFor(sources[i].graphNetwork); + + // createSelectFork returns a fork id that is not needed here; bind + // and reference it so the unused-return lint stays satisfied. + uint256 forkId = vm.createSelectFork(network, sources[i].startBlock); + (forkId); + assertTrue( + sources[i].deployedAddress.code.length > 0, + string.concat( + "datasource ", + sources[i].name, + " on ", + sources[i].graphNetwork, + " has no code at startBlock ", + vm.toString(sources[i].startBlock), + "; startBlock is before the deploy block" + ) + ); + + forkId = vm.createSelectFork(network, sources[i].startBlock - 1); + (forkId); + assertEq( + sources[i].deployedAddress.code.length, + 0, + string.concat( + "datasource ", + sources[i].name, + " on ", + sources[i].graphNetwork, + " already has code one block before startBlock ", + vm.toString(sources[i].startBlock), + "; startBlock is past the deploy block and every earlier event is silently dropped" + ) + ); + } + } +}