diff --git a/.github/workflows/addressChecks.yml b/.github/workflows/addressChecks.yml index 16ccdd985e..c616425039 100644 --- a/.github/workflows/addressChecks.yml +++ b/.github/workflows/addressChecks.yml @@ -3,13 +3,13 @@ name: Address Checks on: workflow_dispatch: schedule: - - cron: '0 0 * * *' # Runs daily at midnight + - cron: '0 0 * * *' # Runs daily at midnight UTC jobs: update-data: runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -19,21 +19,36 @@ jobs: curl -fsSL https://bun.sh/install | bash -s "bun-v1.4.4" echo "$HOME/.bun/bin" >> $GITHUB_PATH - - name: Install dependencies and build - run: | - bun install --frozen-lockfile - bun run build + - name: Install dependencies + run: bun install --frozen-lockfile - - name: Run checks and update data + - name: Run address checks + id: checks + continue-on-error: true run: | + rm -f scripts/fetchedAddressData.json all_checks_passed.txt issue_body.md bun run runAddressCheck - echo "ALL_CHECKS_PASSED=$(cat all_checks_passed.txt)" >> $GITHUB_ENV - - name: Create an issue if checks failed - if: env.ALL_CHECKS_PASSED != 'true' + - name: Save address-check report + if: ${{ !cancelled() && (steps.checks.outcome == 'success' || steps.checks.outcome == 'failure') }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: address-check-report + path: | + scripts/fetchedAddressData.json + all_checks_passed.txt + issue_body.md + retention-days: 30 + if-no-files-found: warn + + - name: Create an issue if completed checks found mismatches + if: ${{ !cancelled() && steps.checks.outcome == 'failure' && hashFiles('issue_body.md') != '' }} run: | - curl -H "Authorization: Bearer ${{ secrets.ADDRESS_CHECKS }}" \ + curl --fail-with-body -H "Authorization: Bearer ${{ secrets.ADDRESS_CHECKS }}" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg title ':robot: Address Checks Failed' --arg body "$(cat issue_body.md)" '{title: $title, body: $body}')" \ https://api.github.com/repos/yearn/yearn-devdocs/issues - echo "Issue created" + + - name: Fail the job if checks failed or could not complete + if: ${{ !cancelled() && steps.checks.outcome == 'failure' }} + run: exit 1 diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index fb4ea92133..8f07bc2497 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -6,40 +6,42 @@ jobs: build-and-release: runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install Bun run: | curl -fsSL https://bun.sh/install | bash -s "bun-v1.4.4" echo "$HOME/.bun/bin" >> $GITHUB_PATH - - name: Install dependencies and build + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Test address-check pipeline + run: bun run test:addresses + + - name: Check addresses and build + id: build run: | - bun install --frozen-lockfile + rm -f scripts/fetchedAddressData.json all_checks_passed.txt issue_body.md bun run build - - name: Check if PR is from a fork - id: check_fork + - name: Report address-check result + if: steps.build.outcome == 'success' run: | - if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then - echo "PR is from a fork" - echo "is_fork=true" >> $GITHUB_ENV - else - echo "PR is not from a fork" - echo "is_fork=false" >> $GITHUB_ENV + if [ "$(cat all_checks_passed.txt)" != 'true' ]; then + cat issue_body.md + echo '::error::Address validation found mismatches. The site contains the fresh warning report.' + exit 1 fi - - name: Run address checks - if: env.is_fork != 'true' - run: | - bun run runAddressCheck - echo "ALL_CHECKS_PASSED=$(cat all_checks_passed.txt)" >> $GITHUB_ENV - - - name: Create an issue if address checks fail - if: env.is_fork != 'true' && env.ALL_CHECKS_PASSED != 'true' - run: | - curl -H "Authorization: Bearer ${{ secrets.ADDRESS_CHECKS }}" \ - -H "Content-Type: application/json" \ - -d "$(jq -n --arg title ':robot: Address Checks Failed' --arg body "$(cat issue_body.md)" '{title: $title, body: $body}')" \ - https://api.github.com/repos/yearn/yearn-devdocs/issues - echo "Issue created" + - name: Save address-check report + if: ${{ !cancelled() && (steps.build.outcome == 'success' || steps.build.outcome == 'failure') }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: address-check-report + path: | + scripts/fetchedAddressData.json + all_checks_passed.txt + issue_body.md + retention-days: 30 + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 8d2b804bad..7dda6bfc7d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ bun.lockb .branch-name .github/copilot-instructions.md all_checks_passed.txt +issue_body.md # Lockfiles managed by package managers (we use Bun) package-lock.json diff --git a/README.md b/README.md index 6888374e30..22bb73c900 100644 --- a/README.md +++ b/README.md @@ -107,16 +107,60 @@ This command starts a local development server and opens up a browser window. Mo bun run start ``` -Running `bun run start` will also run a script that checks the validity of smart contract addresses in the docs against a local constants file. This runs on every dev run as a way to keep the checks updated and the information about them on the site current (all PRs will include an update to last check time). If you want to start without the `runAddressChecks` script you can run `bun run start-no-check`. +`bun run start` collects a fresh mainnet address-check report before starting the +server. Completed checks with mismatches still start the site with warning banners. +An incomplete check, such as an RPC failure, stops startup. Use `bun run start-no-check` +only when you intentionally want to use the existing report without refreshing it. ## Build -This command generates static content into the `build` directory and can be served using any static content hosting service. +This command collects a fresh mainnet address-check report, then generates static +content in `build`. Vercel runs the same command, so the report included in a +deployment comes from that deployment's build. ```bash bun run build ``` +A completed check with mismatched addresses builds successfully and publishes the +fresh warning banner and check time. The independent address-check CI step fails +for those mismatches. RPC errors, missing reports, and incomplete checks stop the +build; the script removes the previous report before fetching, so an older green +snapshot cannot be reused after a failed check. + +`bun run build:site` only renders the existing snapshot and generates AI exports. +Use it for an intentional offline build; it does not run address checks. Normal +builds and deployments use `bun run build`. + +### Address-check results + +Run the standalone checker with `bun run runAddressCheck`. Its exit codes are: + +| Exit code | Meaning | Report | +| --- | --- | --- | +| 0 | All checks passed | Fresh complete report | +| 1 | Checks completed and found mismatches | Fresh complete report and failure details | +| 2 | Checks could not complete | No report available for deployment | + +Results are written to `scripts/fetchedAddressData.json`, with the summary in +`all_checks_passed.txt` and mismatch details in `issue_body.md`. The banner imports +the JSON snapshot; it is not a live browser check. A V3 mismatch includes a latest +release disagreement, a documented release absent from the mainnet registry, or +an address disagreement. Gauge-count discrepancies also fail their group's result. + +PR CI and the daily Address Checks workflow retain the JSON, summary, and available +failure details in the `address-check-report` artifact for 30 days. The daily job +checks addresses without rebuilding the website, keeps its existing mismatch issue +reporting, and fails on both mismatches and incomplete checks. It does not commit +generated data or deploy the website. Each later deployment collects its own fresh +report before rendering. + +Address checks use the public mainnet RPC by default and also run for fork PRs; +there is no dependency on privileged credentials for validation. Configure +`RPC_URI_FOR_1` to use another mainnet RPC. + +Run the focused regression checks with `bun run test:addresses`. + ## AI / agent exports The build also generates AI-friendly artifacts that will be served by the hosted site: diff --git a/docs/developers/wallet-integration.md b/docs/developers/wallet-integration.md index 4375df61ae..e1697d6a68 100644 --- a/docs/developers/wallet-integration.md +++ b/docs/developers/wallet-integration.md @@ -82,6 +82,7 @@ Below are the relevant factories and registries for V3 contracts. The most recen | Registry / ENS | Contract Address | | ---------------------- | ---------------------- | +| **VaultFactory 3.1.1** | | | **VaultFactory 3.1.0** | | | **VaultFactory 3.0.4** | | | **VaultFactory 3.0.3** | | diff --git a/package.json b/package.json index fafe2474fc..4667d1626e 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,10 @@ "scripts": { "docusaurus": "docusaurus", "get-branch-name": "git rev-parse --abbrev-ref HEAD > .branch-name", - "start": "bun run runAddressCheck && bun run get-branch-name && BRANCH_NAME=$(cat .branch-name) IS_DEV=true docusaurus start", + "start": "bun scripts/withAddressChecks.ts start-no-check", "start-no-check": "bun run get-branch-name && BRANCH_NAME=$(cat .branch-name) IS_DEV=true docusaurus start", - "build": "docusaurus build && bun scripts/generateAiArtifacts.ts", + "build": "bun scripts/withAddressChecks.ts build:site", + "build:site": "docusaurus build && bun scripts/generateAiArtifacts.ts", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", @@ -20,6 +21,7 @@ "vydoc": "bunx vydoc -i ../yearn-vaults-v3/contracts/ -o ./templates/temp -t ./templates/contract.ejs -c $(which vyper)", "v3-docs": "node natspec/scripts/natspec-generate.mjs", "test:natspec": "node natspec/scripts/helpers/copyDocs.test.mjs", + "test:addresses": "bun test scripts/addressChecks.test.ts", "runAddressCheck": "bun scripts/runAddressChecks.ts" }, "packageManager": "bun@1.4.4", diff --git a/scripts/addressCheckReport.ts b/scripts/addressCheckReport.ts new file mode 100644 index 0000000000..140a596dd6 --- /dev/null +++ b/scripts/addressCheckReport.ts @@ -0,0 +1,50 @@ +import fs from 'node:fs' +import path from 'node:path' +import type { AddressChecks, ContractAddresses } from '../src/ethereum/types' + +export type AddressCheckReport = { + status: 'complete' + timeLastChecked: number + addressesData: ContractAddresses + addressChecks: AddressChecks +} + +export function didAddressChecksPass(checks: AddressChecks): boolean { + return checks.allV3ChecksPassed === true && + checks.allVeYfiChecksPassed === true && + checks.allMultisigChecksPassed === true && + checks.failedChecks.length === 0 +} + +export function isCompleteAddressCheckReport(value: unknown): value is AddressCheckReport { + if (!value || typeof value !== 'object') return false + const report = value as AddressCheckReport + const checks = report.addressChecks + return report.status === 'complete' && + Number.isInteger(report.timeLastChecked) && report.timeLastChecked > 0 && + !!report.addressesData?.v3ContractAddresses && + typeof checks?.allV3ChecksPassed === 'boolean' && + typeof checks.allVeYfiChecksPassed === 'boolean' && + typeof checks.allMultisigChecksPassed === 'boolean' && + Array.isArray(checks.failedChecks) && checks.failedChecks.every(item => typeof item === 'string') +} + +export function writeAddressCheckReport(report: AddressCheckReport, directory: string): boolean { + if (!isCompleteAddressCheckReport(report)) throw new Error('Address checks returned an incomplete report') + const passed = didAddressChecksPass(report.addressChecks) + const reportPath = path.join(directory, 'scripts/fetchedAddressData.json') + const temporaryPath = `${reportPath}.${process.pid}.tmp` + fs.writeFileSync(temporaryPath, JSON.stringify(report, null, 2) + '\n') + fs.renameSync(temporaryPath, reportPath) + fs.writeFileSync(path.join(directory, 'all_checks_passed.txt'), String(passed)) + if (!passed) { + const failures = report.addressChecks.failedChecks.length + ? report.addressChecks.failedChecks + : ['One or more address-check groups did not pass.'] + fs.writeFileSync(path.join(directory, 'issue_body.md'), + ':robot::warning: **Automatic Address Checks have failed!**\n\n' + + failures.map(failure => `- ${failure}`).join('\n') + + '\n\nReview the on-chain observations against `src/ethereum/constants.ts` and the relevant documentation.\n') + } + return passed +} diff --git a/scripts/addressChecks.test.ts b/scripts/addressChecks.test.ts new file mode 100644 index 0000000000..78fb58be98 --- /dev/null +++ b/scripts/addressChecks.test.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { PublicClient } from 'viem' +import { BaseError, HttpRequestError, ContractFunctionZeroDataError } from 'viem' +import type { AddressChecks, ContractAddresses, V3ReleaseData } from '../src/ethereum/types' +import { releaseRegistry, veYfiGauges } from '../src/ethereum/constants' +import { fetchAndCheckFromReleaseRegistry } from '../src/ethereum/v3Checks' +import { + getProtocolContractAddresses, readYearnRoleManager, readReleaseRegistryAll, + getAuctionFactoryFromRegistry, getFeeRecipientFromAccountant, +} from '../src/ethereum/v3Calls' +import { didAddressChecksPass, writeAddressCheckReport } from './addressCheckReport' +import { runAddressCheck } from './runAddressChecks' +import { runWithAddressChecks } from './withAddressChecks' +import { veYfiChecks } from '../src/ethereum/veYfiChecks' +import snapshot from './fetchedAddressData.json' + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true }) +}) +function directory() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'yearn-address-checks-')) + fs.mkdirSync(path.join(root, 'scripts')) + directories.push(root) + return root +} +function result(failedChecks: string[] = []) { + return { + addressesData: snapshot.addressesData as ContractAddresses, + addressChecks: { + ...snapshot.addressChecks, + allV3ChecksPassed: true, + allVeYfiChecksPassed: true, + allMultisigChecksPassed: true, + failedChecks, + } as AddressChecks, + } +} +function report(root: string, passed: boolean, timestamp = Math.floor(Date.now() / 1000)) { + return writeAddressCheckReport({ status: 'complete', timeLastChecked: timestamp, + ...result(passed ? [] : ['latest release mismatch']), + }, root) +} +function registryClient(entries: [string, V3ReleaseData][], latestRelease: string = releaseRegistry.latestRelease) { + // Exercise viem's contract wrappers and the real registry reader without RPC. + return { + readContract: async ({ address, functionName, args }: { address: string; functionName: string; args?: bigint[] }) => { + if (functionName === 'latestRelease') return latestRelease + if (functionName === 'numReleases') return BigInt(entries.length) + if (functionName === 'factories') return entries[Number(args![0])][1].factory + if (functionName === 'tokenizedStrategies') return entries[Number(args![0])][1].tokenizedStrategy + const entry = entries.find(([, data]) => data.factory.toLowerCase() === address.toLowerCase()) + if (functionName === 'apiVersion') return entry![0] + if (functionName === 'vault_original') return entry![1].vaultOriginal + throw new Error(`Unexpected read: ${functionName}`) + }, + } as unknown as PublicClient +} +const registryEntries = () => Object.entries(releaseRegistry) + .filter(([key]) => key !== 'latestRelease') as [string, V3ReleaseData][] +async function checkRegistry(entries = registryEntries(), latest: string = releaseRegistry.latestRelease, incoming = true) { + const failures: string[] = [] + const checked = await fetchAndCheckFromReleaseRegistry( + '0x0000000000000000000000000000000000000001', registryClient(entries, latest), incoming, failures + ) + return { ...checked!, failures } +} + +describe('release-registry validation', () => { + test('matching releases pass', async () => { + const checked = await checkRegistry() + expect(checked.checkFlag).toBe(true) + expect(checked.failures).toEqual([]) + }) + test('latest-release mismatch fails even when every address matches', async () => { + const checked = await checkRegistry(registryEntries(), '3.1.0') + expect(checked.checkFlag).toBe(false) + expect(checked.checks.hasLatestRelease).toBe(false) + expect(checked.failures[0]).toContain('expected 3.1.1') + }) + test('a documented release absent from the registry is not silently skipped', async () => { + const checked = await checkRegistry(registryEntries().filter(([name]) => name !== '3.1.1')) + expect(checked.checkFlag).toBe(false) + expect(checked.checks.has311Release).toBe(false) + expect(checked.failures).toContain('V3 Release 3.1.1 is missing from the release registry') + }) + test('an on-chain release absent from constants fails', async () => { + const checked = await checkRegistry([...registryEntries(), ['3.2.0', { + ...releaseRegistry['3.1.1'], factory: '0x0000000000000000000000000000000000000002', + }]]) + expect(checked.checkFlag).toBe(false) + expect(checked.failures).toContain('V3 Release 3.2.0 is missing') + }) + test('address mismatches still fail', async () => { + const entries = registryEntries().map(([name, value]) => [name, name === '3.1.1' + ? { ...value, tokenizedStrategy: '0x0000000000000000000000000000000000000003' } + : value] as [string, V3ReleaseData]) + expect((await checkRegistry(entries)).checkFlag).toBe(false) + }) + test('matching registry data never clears an earlier failure', async () => { + expect((await checkRegistry(registryEntries(), releaseRegistry.latestRelease, false)).checkFlag).toBe(false) + }) +}) + +describe('address-check reports and exit codes', () => { + test('failure records cannot be hidden by true group flags', () => { + expect(didAddressChecksPass(result(['unpropagated failure']).addressChecks)).toBe(false) + }) + test('successful collection returns 0 and removes an old issue body', async () => { + const root = directory() + fs.writeFileSync(path.join(root, 'issue_body.md'), 'old failure') + expect(await runAddressCheck(async () => result(), root)).toBe(0) + expect(fs.readFileSync(path.join(root, 'all_checks_passed.txt'), 'utf8')).toBe('true') + expect(fs.existsSync(path.join(root, 'issue_body.md'))).toBe(false) + expect(JSON.parse(fs.readFileSync(path.join(root, 'scripts/fetchedAddressData.json'), 'utf8')).status).toBe('complete') + }) + test('completed mismatches return 1 and retain their fresh report', async () => { + const root = directory() + expect(await runAddressCheck(async () => result(['latest release mismatch']), root)).toBe(1) + expect(fs.readFileSync(path.join(root, 'all_checks_passed.txt'), 'utf8')).toBe('false') + const saved = JSON.parse(fs.readFileSync(path.join(root, 'scripts/fetchedAddressData.json'), 'utf8')) + expect(saved.addressChecks.failedChecks).toEqual(['latest release mismatch']) + expect(fs.readFileSync(path.join(root, 'issue_body.md'), 'utf8')).toContain('latest release mismatch') + }) + test('unavailable RPC returns 2 and removes an old success report', async () => { + const root = directory() + report(root, true) + expect(await runAddressCheck(async () => { throw new Error('RPC unavailable') }, root)).toBe(2) + expect(fs.existsSync(path.join(root, 'scripts/fetchedAddressData.json'))).toBe(false) + expect(fs.readFileSync(path.join(root, 'all_checks_passed.txt'), 'utf8')).toBe('false') + }) + test('undefined group results are incomplete, not a completed check', async () => { + const root = directory() + const collected = result() + collected.addressChecks.allV3ChecksPassed = undefined + expect(await runAddressCheck(async () => collected, root)).toBe(2) + expect(fs.existsSync(path.join(root, 'scripts/fetchedAddressData.json'))).toBe(false) + }) + test('CLI uses the unavailable exit code for a real connection failure', async () => { + const root = directory() + report(root, true) + const process = Bun.spawn(['bun', path.resolve('scripts/runAddressChecks.ts')], { + cwd: root, env: { ...Bun.env, RPC_URI_FOR_1: 'http://127.0.0.1:1' }, stdout: 'ignore', stderr: 'ignore', + }) + expect(await process.exited).toBe(2) + expect(fs.existsSync(path.join(root, 'scripts/fetchedAddressData.json'))).toBe(false) + }, 15000) +}) + +describe('deployment build ordering', () => { + const runDocumentationBuild = (run: Parameters[1], root: string) => + runWithAddressChecks('build:site', run, root) + for (const passed of [true, false]) { + test(`${passed ? 'passing' : 'mismatched'} checks are freshly collected before site compilation`, () => { + const root = directory() + report(root, true, 1) + const calls: string[] = [] + const exit = runDocumentationBuild(script => { + calls.push(script) + if (script === 'runAddressCheck') { + expect(fs.existsSync(path.join(root, 'scripts/fetchedAddressData.json'))).toBe(false) + report(root, passed) + return passed ? 0 : 1 + } + expect(JSON.parse(fs.readFileSync(path.join(root, 'scripts/fetchedAddressData.json'), 'utf8')).status).toBe('complete') + return 0 + }, root) + expect(exit).toBe(0) + expect(calls).toEqual(['runAddressCheck', 'build:site']) + }) + } + test('RPC failure stops compilation', () => { + const root = directory() + const calls: string[] = [] + expect(runDocumentationBuild(script => { calls.push(script); return 2 }, root)).toBe(2) + expect(calls).toEqual(['runAddressCheck']) + }) + test('a checker crash with exit 1 cannot reuse an old green report', () => { + const root = directory() + report(root, true) + const calls: string[] = [] + expect(runDocumentationBuild(script => { calls.push(script); return 1 }, root)).toBe(2) + expect(calls).toEqual(['runAddressCheck']) + expect(fs.existsSync(path.join(root, 'scripts/fetchedAddressData.json'))).toBe(false) + }) + test('a stale report stops compilation', () => { + const root = directory() + expect(runDocumentationBuild(() => { report(root, true, 1); return 0 }, root)).toBe(2) + }) + test('a contradictory success exit code stops compilation', () => { + const root = directory() + expect(runDocumentationBuild(() => { report(root, false); return 0 }, root)).toBe(2) + }) + test('site compilation errors remain failures', () => { + const root = directory() + expect(runDocumentationBuild(script => { + if (script === 'runAddressCheck') { report(root, true); return 0 } + return 7 + }, root)).toBe(7) + }) + test('local development proceeds with completed warning data', () => { + const root = directory() + const calls: string[] = [] + expect(runWithAddressChecks('start-no-check', script => { + calls.push(script) + if (script === 'runAddressCheck') { report(root, false); return 1 } + return 0 + }, root)).toBe(0) + expect(calls).toEqual(['runAddressCheck', 'start-no-check']) + }) +}) + +describe('gauge-count failures', () => { + for (const count of [0, veYfiGauges.length - 1, veYfiGauges.length + 1]) { + test(`${count} gauges cannot produce a passing page flag`, async () => { + const failures: string[] = [] + const client = { readContract: async ({ functionName, args }: { functionName: string; args?: bigint[] }) => { + if (functionName === 'vault_count') return BigInt(count) + if (functionName === 'gauges') return veYfiGauges[Number(args![0])]?.address || '0x0000000000000000000000000000000000000002' + throw new Error(`Unexpected read: ${functionName}`) + } } as unknown as PublicClient + const checked = await veYfiChecks(client, true, failures) + expect(checked.checkFlag).toBe(false) + expect(failures.length).toBeGreaterThan(0) + }) + } +}) + +describe('RPC failures in optional contract reads', () => { + const address = '0x0000000000000000000000000000000000000001' + const unavailable = () => new BaseError('Contract read unavailable', { + cause: new HttpRequestError({ url: 'http://rpc.invalid', details: 'Connection failed' }), + }) + for (const [name, read] of [ + ['getAprOracle', getProtocolContractAddresses], + ['getAccountant', readYearnRoleManager], + ['getLatestFactory', getAuctionFactoryFromRegistry], + ['feeRecipient', getFeeRecipientFromAccountant], + ] as const) { + test(`${name} transport errors are not converted to missing addresses`, async () => { + const error = unavailable() + const client = { readContract: async ({ functionName }: { functionName: string }) => { + if (functionName === name) throw error + return address + } } as unknown as PublicClient + await expect(read(address, client)).rejects.toBe(error) + }) + } + test('an observed missing getter remains a completed negative observation', async () => { + const client = { readContract: async ({ functionName }: { functionName: string }) => { + if (functionName === 'getRouter') throw new ContractFunctionZeroDataError({ functionName }) + return address + } } as unknown as PublicClient + expect((await getProtocolContractAddresses(address, client)).router).toBeUndefined() + }) + for (const legacy of [false, true]) { + test(`blueprint fallback ${legacy ? 'works for a missing getter' : 'does not swallow transport errors'}`, async () => { + const entries: [string, V3ReleaseData][] = [['3.0.1', releaseRegistry['3.0.1']]] + const original = registryClient(entries, '3.0.1') + const error = legacy ? new ContractFunctionZeroDataError({ functionName: 'vault_original' }) : unavailable() + let fallbacks = 0 + const client = { ...original, readContract: async (args: { functionName: string }) => { + if (args.functionName === 'vault_original') throw error + if (args.functionName === 'vault_blueprint') { fallbacks++; return entries[0][1].vaultOriginal } + return original.readContract(args as never) + } } as unknown as PublicClient + if (legacy) { + const data = await readReleaseRegistryAll(address, client) + expect(data['3.0.1']).toEqual(entries[0][1]) + expect(fallbacks).toBe(1) + } else { + await expect(readReleaseRegistryAll(address, client)).rejects.toBe(error) + expect(fallbacks).toBe(0) + } + }) + } +}) diff --git a/scripts/fetchedAddressData.json b/scripts/fetchedAddressData.json index c7f980ada0..627783ea5c 100644 --- a/scripts/fetchedAddressData.json +++ b/scripts/fetchedAddressData.json @@ -1,5 +1,6 @@ { - "timeLastChecked": 1784652070, + "status": "complete", + "timeLastChecked": 1791835859, "addressesData": { "v3ContractAddresses": { "topLevel": { @@ -105,10 +106,13 @@ } }, "addressChecks": { - "allV3ChecksPassed": true, + "allV3ChecksPassed": false, "allVeYfiChecksPassed": true, "allMultisigChecksPassed": true, - "failedChecks": [], + "failedChecks": [ + "latest V3 Release: 3.1.0 (expected 3.1.1)", + "V3 Release 3.1.1 is missing from the release registry" + ], "v3Checks": { "topLevel": { "v3ProtocolAddressProviderCheck": true, @@ -126,7 +130,13 @@ "auctionFactoryCheck": true }, "releaseRegistry": { - "hasLatestRelease": true, + "hasLatestRelease": false, + "has311Release": false, + "has310Release": true, + "has304Release": true, + "has303Release": true, + "has302Release": true, + "has301Release": true, "is301VaultOriginalMatch": true, "is301FactoryMatch": true, "is301TokenizedStrategyMatch": true, @@ -176,4 +186,4 @@ "exactMembersMatch": true } } -} \ No newline at end of file +} diff --git a/scripts/runAddressChecks.ts b/scripts/runAddressChecks.ts index 52b24892a3..4648198d99 100644 --- a/scripts/runAddressChecks.ts +++ b/scripts/runAddressChecks.ts @@ -1,4 +1,5 @@ import fs from 'fs' +import path from 'node:path' import dotenv from 'dotenv' import { fetchTopLevelAddressesFromENS, @@ -19,6 +20,7 @@ import { createYearnPublicClient, getRpcUriOverridesFromEnv, } from '../src/ethereum/publicRpc' +import { didAddressChecksPass, writeAddressCheckReport } from './addressCheckReport' dotenv.config() @@ -122,14 +124,7 @@ const fetchAddresses = async () => { veYfiChecks: veYfiData.veYfiGaugeChecks, multisigChecks: multisigData.checks, } - if ( - v3CheckFlag === false || - v3CheckFlag === undefined || - veYfiCheckFlag === false || - veYfiCheckFlag === undefined || - multisigCheckFlag === false || - multisigCheckFlag === undefined - ) { + if (!didAddressChecksPass(addressChecks)) { console.log('Addresses:', addressesData) console.log('Checks:', addressChecks) } else { @@ -141,49 +136,25 @@ const fetchAddresses = async () => { } } -async function runAddressCheck() { - let addressesData: ContractAddresses = {} as ContractAddresses // initialize with default value - let addressChecks: AddressChecks = {} as AddressChecks // initialize with default value - if (publicClient) { - const result = await fetchAddresses() - if (result) { - // added null check for result - addressesData = result.addressesData - addressChecks = result.addressChecks - } else { - throw new Error('Failed to fetch addresses') - } - } - - const allChecksPassed = - addressChecks.allV3ChecksPassed && - addressChecks.allVeYfiChecksPassed && - addressChecks.allMultisigChecksPassed - process.env.ALL_CHECKS_PASSED = allChecksPassed ? 'true' : 'false' - console.log('allChecksPassed: ', process.env.ALL_CHECKS_PASSED) - - fs.writeFileSync('all_checks_passed.txt', process.env.ALL_CHECKS_PASSED) - - if (addressChecks.failedChecks.length > 0) { - console.log('Generating issue content...') - let issueContent = - ':robot::warning: **Automatic Address Checks have failed!** :warning::robot:\n' - issueContent += 'The following contracts have changed:\n' - addressChecks.failedChecks.forEach((check: string) => { - issueContent += `- ${check}\n` - }) - issueContent += - '\nThe addresses shown above should be the updated, correct addresses. Please review and update the relevant source (`src/ethereum/constants.ts` and/or `docs/developers/security/multisig.md`).\n' - - fs.writeFileSync('issue_body.md', issueContent) - console.log('Issue content generated.') +export async function runAddressCheck(collect = fetchAddresses, directory = process.cwd()): Promise { + fs.rmSync(path.join(directory, 'scripts/fetchedAddressData.json'), { force: true }) + fs.rmSync(path.join(directory, 'issue_body.md'), { force: true }) + fs.writeFileSync(path.join(directory, 'all_checks_passed.txt'), 'false') + try { + const result = await collect() + if (!result) throw new Error('Failed to fetch addresses') + const passed = writeAddressCheckReport({ + status: 'complete', + timeLastChecked: Math.floor(Date.now() / 1000), + ...result, + }, directory) + console.log('allChecksPassed:', passed) + console.log('Wrote fresh report to scripts/fetchedAddressData.json') + return passed ? 0 : 1 + } catch (error) { + console.error('Address checks could not complete:', error instanceof Error ? error.message : error) + return 2 } - const timeLastChecked = Math.floor(Date.now() / 1000) // get current time in Unix format - console.log('writing report to scripts/fetchedAddressData.json') - fs.writeFileSync( - 'scripts/fetchedAddressData.json', - JSON.stringify({ timeLastChecked, addressesData, addressChecks }, null, 2) - ) } -runAddressCheck() +if (import.meta.main) process.exitCode = await runAddressCheck() diff --git a/scripts/withAddressChecks.ts b/scripts/withAddressChecks.ts new file mode 100644 index 0000000000..a7dd926da9 --- /dev/null +++ b/scripts/withAddressChecks.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { didAddressChecksPass, isCompleteAddressCheckReport } from './addressCheckReport' + +type RunCommand = (script: string, directory: string) => number +const runCommand: RunCommand = (script, directory) => { + const result = spawnSync('bun', ['run', script], { cwd: directory, stdio: 'inherit' }) + if (result.error) console.error(result.error.message) + return result.status ?? 2 +} + +export function runWithAddressChecks(script: 'build:site' | 'start-no-check', run: RunCommand = runCommand, directory = process.cwd()): number { + const reportPath = path.join(directory, 'scripts/fetchedAddressData.json') + // Even an early crash in the checker must not leave an old green snapshot. + fs.rmSync(reportPath, { force: true }) + const startedAt = Math.floor(Date.now() / 1000) + const result = run('runAddressCheck', directory) + if (result !== 0 && result !== 1) { + console.error(`Address checks could not complete. ${script} stopped.`) + return 2 + } + try { + const report: unknown = JSON.parse(fs.readFileSync(reportPath, 'utf8')) + if (!isCompleteAddressCheckReport(report) || report.timeLastChecked < startedAt) { + throw new Error('Missing, stale, or incomplete address-check report') + } + if ((result === 0) !== didAddressChecksPass(report.addressChecks)) { + throw new Error('Address-check exit status disagrees with its report') + } + } catch (error) { + console.error(error instanceof Error ? error.message : error) + return 2 + } + if (result === 1) console.warn('Address mismatches found. Proceeding with the fresh warning report.') + return run(script, directory) +} + +if (import.meta.main) { + const script = process.argv[2] + if (script !== 'build:site' && script !== 'start-no-check') { + console.error('Choose build:site or start-no-check') + process.exitCode = 2 + } else { + process.exitCode = runWithAddressChecks(script) + } +} diff --git a/src/components/AddressCheck.tsx b/src/components/AddressCheck.tsx index 2bf1485f97..e359afdea5 100644 --- a/src/components/AddressCheck.tsx +++ b/src/components/AddressCheck.tsx @@ -57,8 +57,8 @@ function AddressCheck({ contractType }: AddressCheckProps) {
⚠️ - Some Addresses on this page don't match on-chain data. Check the - console for details. + Address validation on this page found mismatches. Last checked on:{' '} + {lastTimeCheckedUTC} UTC. Check the console for details.
)} diff --git a/src/css/AddressCheck.module.css b/src/css/AddressCheck.module.css index d479373924..ee1675fa43 100644 --- a/src/css/AddressCheck.module.css +++ b/src/css/AddressCheck.module.css @@ -1,7 +1,6 @@ .addressCheckWrapper { width: 100%; padding-bottom: 1rem; - height: 6rem; } .loading { diff --git a/src/ethereum/v3Calls.ts b/src/ethereum/v3Calls.ts index 5cfcef6770..32c1455a39 100644 --- a/src/ethereum/v3Calls.ts +++ b/src/ethereum/v3Calls.ts @@ -1,4 +1,7 @@ -import { getContract, PublicClient, Address, getAddress } from 'viem' +import { + getContract, PublicClient, Address, getAddress, BaseError, + ContractFunctionRevertedError, ContractFunctionZeroDataError, +} from 'viem' import { v3ProtocolAddressProviderABI, v3ReleaseRegistryABI, @@ -10,6 +13,15 @@ import { } from './ABIs' import { V3ReleaseDataMap } from './types' +function allowMissingContractFunction(error: unknown) { + // A missing/reverting legacy getter is a completed on-chain observation. + // Transport errors and other unavailable reads must abort validation. + const missingFunction = error instanceof BaseError && error.walk(cause => + cause instanceof ContractFunctionRevertedError || cause instanceof ContractFunctionZeroDataError + ) + if (!missingFunction) throw error +} + const useFallback = (contractName: string, fallback: string): Address => { console.warn( `RPC call failed for ${contractName}. Using fallback address: ${fallback}` @@ -35,19 +47,23 @@ export const getProtocolContractAddresses = async ( v3ReportTrigger, v3RoleManagerFactory, ] = await Promise.all([ - contract.read.getRouter().catch(() => { + contract.read.getRouter().catch(error => { + allowMissingContractFunction(error) console.warn('v3Router not found') return undefined }), - contract.read.getAprOracle().catch(() => { + contract.read.getAprOracle().catch(error => { + allowMissingContractFunction(error) console.warn('v3AprOracle not found') return undefined }), - contract.read.getCommonReportTrigger().catch(() => { + contract.read.getCommonReportTrigger().catch(error => { + allowMissingContractFunction(error) console.warn('v3ReportTrigger not found') return undefined }), - contract.read.getRoleManagerFactory().catch(() => { + contract.read.getRoleManagerFactory().catch(error => { + allowMissingContractFunction(error) console.warn('v3RoleManagerFactory not found') return undefined }), @@ -99,7 +115,8 @@ export const readReleaseRegistryAll = async ( const releaseNumber = await factoryContract.read.apiVersion() const vaultAddress = await factoryContract.read .vault_original() - .catch(async () => { + .catch(async error => { + allowMissingContractFunction(error) // Re-initialize factoryContract with v3VaultFactoryBlueprintABI if fallback occurs const factoryContractWithBlueprint = getContract({ address: factoryAddress, @@ -138,15 +155,18 @@ export const readYearnRoleManager = async ( ] = await Promise.all([ contract.read.getBrain(), contract.read.getDaddy(), - contract.read.getAccountant().catch(() => { + contract.read.getAccountant().catch(error => { + allowMissingContractFunction(error) console.warn('yearnAccountant not found') return undefined }), - contract.read.getDebtAllocator().catch(() => { + contract.read.getDebtAllocator().catch(error => { + allowMissingContractFunction(error) console.warn('yearnDebtAllocator not found') return undefined }), - contract.read.getRegistry().catch(() => { + contract.read.getRegistry().catch(error => { + allowMissingContractFunction(error) console.warn('yearnRegistry not found') return undefined }), @@ -172,7 +192,8 @@ export const getAuctionFactoryFromRegistry = async ( client: publicClient, }) console.log('Fetching auction factory from auction registry...') - const auctionFactory = await contract.read.getLatestFactory().catch(() => { + const auctionFactory = await contract.read.getLatestFactory().catch(error => { + allowMissingContractFunction(error) console.warn('auctionFactory not found in auction registry') return undefined }) @@ -190,7 +211,8 @@ export const getFeeRecipientFromAccountant = async ( client: publicClient, }) console.log('Fetching fee recipient from accountant...') - const feeRecipient = await contract.read.feeRecipient().catch(() => { + const feeRecipient = await contract.read.feeRecipient().catch(error => { + allowMissingContractFunction(error) console.warn('feeRecipient not found in accountant') return undefined }) diff --git a/src/ethereum/v3Checks.ts b/src/ethereum/v3Checks.ts index 7f0bfc1d76..83088cc15e 100644 --- a/src/ethereum/v3Checks.ts +++ b/src/ethereum/v3Checks.ts @@ -249,10 +249,23 @@ export const fetchAndCheckFromReleaseRegistry = async ( 'Latest Release in Constants file does not match Release Registry Contract.' ) hasLatestRelease = false - const latestRelease = `latest V3 Release: ${addresses.latestRelease}` + checkFlag = false + const latestRelease = `latest V3 Release: ${addresses.latestRelease} (expected ${constants.releaseRegistry.latestRelease})` failedChecks.push(latestRelease) } - const checks = { hasLatestRelease } + const checks: Record = { hasLatestRelease } + + // Every documented release needs on-chain evidence, including a release + // which is present in constants but not yet registered on this chain. + for (const releaseNumber of Object.keys(constants.releaseRegistry)) { + if (releaseNumber === 'latestRelease') continue + const hasRelease = typeof addresses[releaseNumber] === 'object' + checks[`has${releaseNumber.replace(/\./g, '')}Release`] = hasRelease + if (!hasRelease) { + checkFlag = false + failedChecks.push(`V3 Release ${releaseNumber} is missing from the release registry`) + } + } for (const releaseNumber in addresses) { if (releaseNumber === 'latestRelease') continue // Skip the latestRelease key @@ -260,6 +273,7 @@ export const fetchAndCheckFromReleaseRegistry = async ( const constantRelease = constants.releaseRegistry[releaseNumber] if (!constantRelease) { console.warn(`Release ${releaseNumber} is missing in constants.`) + checkFlag = false failedChecks.push(`V3 Release ${releaseNumber} is missing`) continue } diff --git a/src/ethereum/veYfiChecks.ts b/src/ethereum/veYfiChecks.ts index de57c6cd31..e6e830599c 100644 --- a/src/ethereum/veYfiChecks.ts +++ b/src/ethereum/veYfiChecks.ts @@ -17,10 +17,12 @@ export const veYfiChecks = async ( console.log('validating veYFI Gauge addresses...') if (numberOfGauges < 1) { + checkFlag = false console.warn('No gauges found') failedChecks.push('No gauges found') } if (numberOfGauges !== constants.veYfiGauges.length) { + checkFlag = false console.warn('Number of gauges does not match number of fallback addresses') failedChecks.push( 'Number of gauges does not match number of fallback addresses' @@ -31,8 +33,17 @@ export const veYfiChecks = async ( // Validate each entry in gaugeAddresses against the corresponding address in constants.veYfiGauges for (const [index, address] of gaugeAddresses.entries()) { - const expectedAddress = constants.veYfiGauges[index].address - const expectedName = constants.veYfiGauges[index].name + const expectedGauge = constants.veYfiGauges[index] + if (!expectedGauge) { + checkFlag = false + const name = `Gauge ${index}` + failedChecks.push(`veYFI ${name} (${address}) is missing in constants`) + veYfiGaugeChecks[name] = false + veYfiGaugeAddresses[name] = address + continue + } + const expectedAddress = expectedGauge.address + const expectedName = expectedGauge.name console.log( `Validating ${expectedName} (${expectedAddress}) against ${address}...` )