diff --git a/.github/scripts/extract-mainnet-aa-verification-inputs.mjs b/.github/scripts/extract-mainnet-aa-verification-inputs.mjs new file mode 100644 index 000000000000..65f07a29b017 --- /dev/null +++ b/.github/scripts/extract-mainnet-aa-verification-inputs.mjs @@ -0,0 +1,373 @@ +#!/usr/bin/env node + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path'; + +const CHAIN_ID = 7979; +const ENTRY_POINT_ADDRESS = '0x0000000071727De22E5E9d8BAf0edAc6f37da032'; +const KERNEL_ADDRESS = '0xd6CEDDe84be40893d153Be9d467CD6aD37875b28'; +const KERNEL_FACTORY_ADDRESS = '0x2577507b78c2008Ff367261CB6285d44ba5eF2E9'; +const ECDSA_VALIDATOR_ADDRESS = '0x845ADb2C711129d4f3966735eD98a9F09fC4cE57'; +const FACTORY_STAKER_ADDRESS = '0xd703aaE79538628d27099B8c4f621bE4CCd142d5'; + +const OUTPUT_SELECTION = [ + 'abi', + 'evm.deployedBytecode.object', + 'evm.deployedBytecode.immutableReferences', + 'evm.deployedBytecode.linkReferences', + 'evm.methodIdentifiers', +]; + +const targets = [ + { + key: 'entry-point', + address: ENTRY_POINT_ADDRESS, + contractName: 'EntryPoint', + sourcePath: 'contracts/core/EntryPoint.sol', + diskSourcePath: 'contracts/core/EntryPoint.sol', + standardInputFile: 'entry-point.standard-input.json', + compilerOutputFile: 'entry-point.compiler-output.json', + compilerPackage: 'solc-0.8.23', + compilerVersion: 'v0.8.23+commit.f704f362', + evmVersion: 'paris', + optimizer: { enabled: true, runs: 1_000_000 }, + viaIR: true, + metadata: { bytecodeHash: 'ipfs' }, + licenseType: 'gnu_gpl_v3', + spdxLicense: 'GPL-3.0', + constructorArgs: '', + expectedCodeSha256: '4dcad467095cd9af58006b270475ac7591c6946bca08552f6789727097b51eae', + rpcChecks: [], + verificationMatch: 'full', + sourceFamily: 'account-abstraction', + }, + { + key: 'kernel', + address: KERNEL_ADDRESS, + contractName: 'Kernel', + sourcePath: 'src/Kernel.sol', + diskSourcePath: 'src/Kernel.sol', + standardInputFile: 'kernel.standard-input.json', + compilerOutputFile: 'kernel.compiler-output.json', + compilerPackage: 'solc-0.8.28', + compilerVersion: 'v0.8.28+commit.7893614a', + evmVersion: 'prague', + optimizer: { enabled: true, runs: 200 }, + viaIR: true, + metadata: { appendCBOR: false, bytecodeHash: 'none' }, + licenseType: 'mit', + spdxLicense: 'MIT', + constructorArgs: '0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da032', + expectedCodeSha256: 'd13e7ff2bc90271659100c83f49ee6250555bbf26ed35c2315f243c6849a2127', + rpcChecks: [{ signature: 'entrypoint()', expectedAddress: ENTRY_POINT_ADDRESS }], + verificationMatch: 'partial', + sourceFamily: 'kernel', + soladySourcePrefix: 'lib/solady/src', + }, + { + key: 'kernel-factory', + address: KERNEL_FACTORY_ADDRESS, + contractName: 'KernelFactory', + sourcePath: 'dependencies/kernel-v3.3/src/factory/KernelFactory.sol', + diskSourcePath: 'src/factory/KernelFactory.sol', + standardInputFile: 'kernel-factory.standard-input.json', + compilerOutputFile: 'kernel-factory.compiler-output.json', + compilerPackage: 'solc-0.8.28', + compilerVersion: 'v0.8.28+commit.7893614a', + evmVersion: 'prague', + optimizer: { enabled: true, runs: 200 }, + viaIR: true, + metadata: { appendCBOR: false, bytecodeHash: 'none' }, + licenseType: 'mit', + spdxLicense: 'MIT', + constructorArgs: '000000000000000000000000d6cedde84be40893d153be9d467cd6ad37875b28', + expectedCodeSha256: '56443d7d18bfd62d5d69b04fc8207e439bf904166335dd7159e0eeef1cba2367', + rpcChecks: [{ signature: 'implementation()', expectedAddress: KERNEL_ADDRESS }], + verificationMatch: 'partial', + sourceFamily: 'kernel', + soladySourcePrefix: 'dependencies/solady-0.1.26/src', + }, + { + key: 'ecdsa-validator', + address: ECDSA_VALIDATOR_ADDRESS, + contractName: 'ECDSAValidator', + sourcePath: 'src/validator/ECDSAValidator.sol', + diskSourcePath: 'src/validator/ECDSAValidator.sol', + standardInputFile: 'ecdsa-validator.standard-input.json', + compilerOutputFile: 'ecdsa-validator.compiler-output.json', + compilerPackage: 'solc-0.8.25', + compilerVersion: 'v0.8.25+commit.b61c2a91', + evmVersion: 'paris', + optimizer: { enabled: true, runs: 200 }, + viaIR: true, + metadata: { appendCBOR: false, bytecodeHash: 'none' }, + licenseType: 'mit', + spdxLicense: 'MIT', + constructorArgs: '', + expectedCodeSha256: 'be711f07f49e57bf56c512b6f32f7c77d9ec1881c4051ed33a45cfad8c7a8b8e', + rpcChecks: [], + verificationMatch: 'partial', + sourceFamily: 'kernel-ecdsa', + soladySourcePrefix: 'lib/solady/src', + }, + { + key: 'factory-staker', + address: FACTORY_STAKER_ADDRESS, + contractName: 'FactoryStaker', + sourcePath: 'src/factory/FactoryStaker.sol', + diskSourcePath: 'src/factory/FactoryStaker.sol', + standardInputFile: 'factory-staker.standard-input.json', + compilerOutputFile: 'factory-staker.compiler-output.json', + compilerPackage: 'solc-0.8.24', + compilerVersion: 'v0.8.24+commit.e11b9ed9', + evmVersion: 'paris', + optimizer: { enabled: true, runs: 200 }, + viaIR: false, + metadata: { appendCBOR: false, bytecodeHash: 'none' }, + licenseType: 'mit', + spdxLicense: 'MIT', + constructorArgs: '', + expectedCodeSha256: 'f91091bf1260892a4d0b834494489fea55be2f2f968ad6b1abc1410531f2a2a1', + rpcChecks: [], + verificationMatch: 'partial', + sourceFamily: 'kernel-ecdsa', + soladySourcePrefix: 'lib/solady/src', + }, +]; + +function fail(message) { + throw new Error(message); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +function isInside(root, candidate) { + const child = relative(root, candidate); + return child === '' || (!child.startsWith('..') && !isAbsolute(child)); +} + +function validatePrimarySource(content, target) { + if (!content.includes(`SPDX-License-Identifier: ${target.spdxLicense}`)) { + fail(`${target.contractName} source must declare SPDX license ${target.spdxLicense}`); + } + if (!new RegExp(`\\b(?:abstract\\s+)?contract\\s+${target.contractName}\\b`).test(content)) { + fail(`${target.contractName} is missing from ${target.sourcePath}`); + } +} + +function validateEntryPointBuildInfo(buildInfo, target) { + const expectedLongVersion = target.compilerVersion.slice(1); + if (buildInfo.solcLongVersion !== expectedLongVersion) { + fail(`${target.contractName} compiler must be ${expectedLongVersion}`); + } + const input = buildInfo.input; + const settings = input?.settings; + if (input?.language !== 'Solidity') { + fail(`${target.contractName} build language must be Solidity`); + } + if (settings?.evmVersion !== target.evmVersion) { + fail(`${target.contractName} EVM version must be ${target.evmVersion}`); + } + if ( + settings?.optimizer?.enabled !== target.optimizer.enabled || + settings?.optimizer?.runs !== target.optimizer.runs + ) { + fail(`${target.contractName} optimizer settings are not canonical`); + } + if (settings?.viaIR !== true) { + fail(`${target.contractName} build must use viaIR`); + } + if ((settings?.metadata?.bytecodeHash ?? 'ipfs') !== 'ipfs') { + fail(`${target.contractName} metadata bytecode hash must be ipfs`); + } + const primarySource = input?.sources?.[target.sourcePath]?.content; + if (typeof primarySource !== 'string') { + fail(`${target.contractName} standard input is missing ${target.sourcePath}`); + } + validatePrimarySource(primarySource, target); + if (buildInfo.output?.contracts?.[target.sourcePath]?.[target.contractName] == null) { + fail(`${target.contractName} is missing from compiler output`); + } + return structuredClone(input); +} + +function importedPaths(content) { + const paths = []; + const expressions = [ + /import\s*["']([^"']+)["']\s*;/g, + /import\s+[^;]*?\s+from\s*["']([^"']+)["']\s*;/g, + ]; + for (const expression of expressions) { + for (const match of content.matchAll(expression)) { + paths.push(match[1]); + } + } + return paths; +} + +function resolveImport({ importerUnit, importerDiskPath, importPath, kernelCheckout, target }) { + if (importPath.startsWith('.')) { + const sourceUnit = posix.normalize(posix.join(posix.dirname(importerUnit), importPath)); + if (sourceUnit === '..' || sourceUnit.startsWith('../')) { + fail(`Import escapes source-unit root: ${importPath}`); + } + return { + sourceUnit, + diskPath: resolve(dirname(importerDiskPath), importPath), + }; + } + + if (importPath.startsWith('solady/')) { + const suffix = importPath.slice('solady/'.length); + return { + sourceUnit: posix.join(target.soladySourcePrefix, suffix), + diskPath: resolve(kernelCheckout, 'lib', 'solady', 'src', ...suffix.split('/')), + }; + } + + if (importPath.startsWith('ExcessivelySafeCall/')) { + const suffix = importPath.slice('ExcessivelySafeCall/'.length); + return { + sourceUnit: posix.join('lib/ExcessivelySafeCall/src', suffix), + diskPath: resolve(kernelCheckout, 'lib', 'ExcessivelySafeCall', 'src', ...suffix.split('/')), + }; + } + + fail(`Unsupported Kernel import: ${importPath}`); +} + +async function collectKernelSources({ kernelCheckout, target }) { + const root = resolve(kernelCheckout); + const pending = [{ + sourceUnit: target.sourcePath, + diskPath: resolve(root, ...target.diskSourcePath.split('/')), + }]; + const sources = {}; + + while (pending.length > 0) { + const current = pending.pop(); + if (sources[current.sourceUnit] != null) { + continue; + } + if (!isInside(root, current.diskPath)) { + fail(`Kernel source escapes checkout: ${current.diskPath}`); + } + + let content; + try { + content = await readFile(current.diskPath, 'utf8'); + } catch { + fail(`Missing Kernel source ${current.sourceUnit} at ${current.diskPath}`); + } + sources[current.sourceUnit] = { content }; + + for (const importPath of importedPaths(content)) { + pending.push(resolveImport({ + importerUnit: current.sourceUnit, + importerDiskPath: current.diskPath, + importPath, + kernelCheckout: root, + target, + })); + } + } + + return sources; +} + +function canonicalOutputSelection() { + return { '*': { '*': OUTPUT_SELECTION } }; +} + +async function entryPointInput(aaCheckout, target) { + const debugPath = resolve( + aaCheckout, + 'artifacts', + 'contracts', + 'core', + 'EntryPoint.sol', + 'EntryPoint.dbg.json', + ); + const debugArtifact = await readJson(debugPath); + if (typeof debugArtifact.buildInfo !== 'string' || debugArtifact.buildInfo.length === 0) { + fail('EntryPoint debug artifact does not reference build-info'); + } + const buildInfo = await readJson(resolve(dirname(debugPath), debugArtifact.buildInfo)); + return validateEntryPointBuildInfo(buildInfo, target); +} + +async function kernelInput(kernelCheckout, target) { + const sources = await collectKernelSources({ kernelCheckout, target }); + const primarySource = sources[target.sourcePath]?.content; + if (typeof primarySource !== 'string') { + fail(`${target.contractName} standard input is missing ${target.sourcePath}`); + } + validatePrimarySource(primarySource, target); + + const settings = { + evmVersion: target.evmVersion, + optimizer: target.optimizer, + metadata: target.metadata, + remappings: [ + `solady/=${target.soladySourcePrefix}/`, + 'ExcessivelySafeCall/=lib/ExcessivelySafeCall/src/', + ], + outputSelection: canonicalOutputSelection(), + }; + if (target.viaIR) { + settings.viaIR = true; + } + return { language: 'Solidity', sources, settings }; +} + +function publicTarget(target) { + const { diskSourcePath: _diskSourcePath, sourceFamily: _sourceFamily, soladySourcePrefix: _soladySourcePrefix, ...manifestTarget } = target; + return manifestTarget; +} + +async function main() { + const [, , aaArgument, kernelArgument, ecdsaKernelArgument, outputArgument] = process.argv; + if (!aaArgument || !kernelArgument || !ecdsaKernelArgument || !outputArgument) { + fail( + 'usage: extract-mainnet-aa-verification-inputs.mjs ', + ); + } + + const aaCheckout = resolve(aaArgument); + const kernelCheckout = resolve(kernelArgument); + const ecdsaKernelCheckout = resolve(ecdsaKernelArgument); + const outputDirectory = resolve(outputArgument); + await mkdir(outputDirectory, { recursive: true }); + + for (const target of targets) { + const input = target.sourceFamily === 'account-abstraction' + ? await entryPointInput(aaCheckout, target) + : await kernelInput( + target.sourceFamily === 'kernel-ecdsa' ? ecdsaKernelCheckout : kernelCheckout, + target, + ); + await writeFile( + join(outputDirectory, target.standardInputFile), + `${JSON.stringify(input, null, 2)}\n`, + 'utf8', + ); + } + + const manifest = { + version: 2, + chainId: CHAIN_ID, + contracts: targets.map(publicTarget), + }; + await writeFile( + join(outputDirectory, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + 'utf8', + ); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/.github/scripts/mainnet-aa-solc/package-lock.json b/.github/scripts/mainnet-aa-solc/package-lock.json new file mode 100644 index 000000000000..5d3a92ae6c73 --- /dev/null +++ b/.github/scripts/mainnet-aa-solc/package-lock.json @@ -0,0 +1,185 @@ +{ + "name": "doscan-mainnet-aa-solc", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "doscan-mainnet-aa-solc", + "version": "1.0.0", + "dependencies": { + "solc-0.8.23": "npm:solc@0.8.23-fixed", + "solc-0.8.24": "npm:solc@0.8.24", + "solc-0.8.25": "npm:solc@0.8.25", + "solc-0.8.28": "npm:solc@0.8.28" + } + }, + "node_modules/command-exists": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", + "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", + "license": "MIT" + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/solc-0.8.23": { + "name": "solc", + "version": "0.8.23-fixed", + "resolved": "https://registry.npmjs.org/solc/-/solc-0.8.23-fixed.tgz", + "integrity": "sha512-XMp8jbXl29nlD0losEG+9nAdH5bibQPELI0jqOpyqCT7DKo7MbIdWPMwiCtK/QKe0CCvCvKbHswBflZmcmXIYA==", + "license": "MIT", + "dependencies": { + "command-exists": "^1.2.8", + "commander": "^8.1.0", + "follow-redirects": "^1.12.1", + "js-sha3": "0.8.0", + "memorystream": "^0.3.1", + "semver": "^5.5.0", + "tmp": "0.0.33" + }, + "bin": { + "solcjs": "solc.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/solc-0.8.24": { + "name": "solc", + "version": "0.8.24", + "resolved": "https://registry.npmjs.org/solc/-/solc-0.8.24.tgz", + "integrity": "sha512-G5yUqjTUPc8Np74sCFwfsevhBPlUifUOfhYrgyu6CmYlC6feSw0YS6eZW47XDT23k3JYdKx5nJ+Q7whCEmNcoA==", + "license": "MIT", + "dependencies": { + "command-exists": "^1.2.8", + "commander": "^8.1.0", + "follow-redirects": "^1.12.1", + "js-sha3": "0.8.0", + "memorystream": "^0.3.1", + "semver": "^5.5.0", + "tmp": "0.0.33" + }, + "bin": { + "solcjs": "solc.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/solc-0.8.25": { + "name": "solc", + "version": "0.8.25", + "resolved": "https://registry.npmjs.org/solc/-/solc-0.8.25.tgz", + "integrity": "sha512-7P0TF8gPeudl1Ko3RGkyY6XVCxe2SdD/qQhtns1vl3yAbK/PDifKDLHGtx1t7mX3LgR7ojV7Fg/Kc6Q9D2T8UQ==", + "license": "MIT", + "dependencies": { + "command-exists": "^1.2.8", + "commander": "^8.1.0", + "follow-redirects": "^1.12.1", + "js-sha3": "0.8.0", + "memorystream": "^0.3.1", + "semver": "^5.5.0", + "tmp": "0.0.33" + }, + "bin": { + "solcjs": "solc.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/solc-0.8.28": { + "name": "solc", + "version": "0.8.28", + "resolved": "https://registry.npmjs.org/solc/-/solc-0.8.28.tgz", + "integrity": "sha512-AFCiJ+b4RosyyNhnfdVH4ZR1+TxiL91iluPjw0EJslIu4LXGM9NYqi2z5y8TqochC4tcH9QsHfwWhOIC9jPDKA==", + "license": "MIT", + "dependencies": { + "command-exists": "^1.2.8", + "commander": "^8.1.0", + "follow-redirects": "^1.12.1", + "js-sha3": "0.8.0", + "memorystream": "^0.3.1", + "semver": "^5.5.0", + "tmp": "0.0.33" + }, + "bin": { + "solcjs": "solc.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + } + } +} diff --git a/.github/scripts/mainnet-aa-solc/package.json b/.github/scripts/mainnet-aa-solc/package.json new file mode 100644 index 000000000000..0012d2576fb5 --- /dev/null +++ b/.github/scripts/mainnet-aa-solc/package.json @@ -0,0 +1,11 @@ +{ + "name": "doscan-mainnet-aa-solc", + "private": true, + "version": "1.0.0", + "dependencies": { + "solc-0.8.23": "npm:solc@0.8.23-fixed", + "solc-0.8.24": "npm:solc@0.8.24", + "solc-0.8.25": "npm:solc@0.8.25", + "solc-0.8.28": "npm:solc@0.8.28" + } +} diff --git a/.github/scripts/mainnet-aa-source-ui.spec.mjs b/.github/scripts/mainnet-aa-source-ui.spec.mjs new file mode 100644 index 000000000000..a2fec710874d --- /dev/null +++ b/.github/scripts/mainnet-aa-source-ui.spec.mjs @@ -0,0 +1,190 @@ +import { expect, test } from "@playwright/test"; + +const baseUrl = process.env.DOSCAN_MAINNET_URL; +const entryPointAddress = "0x0000000071727De22E5E9d8BAf0edAc6f37da032"; + +if (!baseUrl) { + throw new Error("Missing DOSCAN_MAINNET_URL"); +} + +const targets = [ + { + address: entryPointAddress, + name: "EntryPoint", + compiler: "v0.8.23+commit.f704f362", + sourcePath: "contracts/core/EntryPoint.sol", + optimizerRuns: 1_000_000, + evmVersion: "paris", + license: "gnu_gpl_v3", + constructorArgs: "", + verificationMatch: "full", + }, + { + address: "0xd6CEDDe84be40893d153Be9d467CD6aD37875b28", + name: "Kernel", + compiler: "v0.8.28+commit.7893614a", + sourcePath: "src/Kernel.sol", + optimizerRuns: 200, + evmVersion: "prague", + license: "mit", + constructorArgs: "0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da032", + verificationMatch: "partial", + }, + { + address: "0x2577507b78c2008Ff367261CB6285d44ba5eF2E9", + name: "KernelFactory", + compiler: "v0.8.28+commit.7893614a", + sourcePath: "dependencies/kernel-v3.3/src/factory/KernelFactory.sol", + optimizerRuns: 200, + evmVersion: "prague", + license: "mit", + constructorArgs: "000000000000000000000000d6cedde84be40893d153be9d467cd6ad37875b28", + verificationMatch: "partial", + }, + { + address: "0x845ADb2C711129d4f3966735eD98a9F09fC4cE57", + name: "ECDSAValidator", + compiler: "v0.8.25+commit.b61c2a91", + sourcePath: "src/validator/ECDSAValidator.sol", + optimizerRuns: 200, + evmVersion: "paris", + license: "mit", + constructorArgs: "", + verificationMatch: "partial", + }, + { + address: "0xd703aaE79538628d27099B8c4f621bE4CCd142d5", + name: "FactoryStaker", + compiler: "v0.8.24+commit.e11b9ed9", + sourcePath: "src/factory/FactoryStaker.sol", + optimizerRuns: 200, + evmVersion: "paris", + license: "mit", + constructorArgs: "", + verificationMatch: "partial", + }, +]; + +function normalizeCompiler(value) { + return value.startsWith("v") ? value : `v${value}`; +} + +function normalizeHex(value) { + return (value || "").toLowerCase().replace(/^0x/, ""); +} + +async function skipOnlyCloudflareChallenge(page) { + const title = await page.title(); + if ( + title === "Just a moment..." && + page.url().includes("__cf_chl_rt_tk=") + ) { + test.skip( + true, + "Cloudflare challenged the GitHub runner; deployment API gates remain authoritative", + ); + } +} + +async function openExplorer(page) { + await page.setViewportSize({ width: 1440, height: 900 }); + for (let attempt = 1; attempt <= 6; attempt += 1) { + let response; + try { + response = await page.goto(baseUrl, { waitUntil: "domcontentloaded" }); + } catch { + response = null; + } + await skipOnlyCloudflareChallenge(page); + const visibleContent = page.locator("main:visible, body:visible").first(); + if (response?.ok() && (await visibleContent.isVisible())) { + return; + } + if (attempt < 6) { + await page.waitForTimeout(5_000); + } + } + throw new Error(`Mainnet explorer did not become ready: ${page.url()}`); +} + +function expectExactMetadata(metadata, target) { + expect(metadata.is_verified).toBe(true); + expect(metadata.is_fully_verified).toBe(target.verificationMatch === "full"); + expect(metadata.is_partially_verified).toBe(target.verificationMatch === "partial"); + expect(metadata.verified_twin_address_hash).toBeNull(); + expect(metadata.name).toBe(target.name); + expect(normalizeCompiler(metadata.compiler_version)).toBe(target.compiler); + expect(metadata.file_path).toBe(target.sourcePath); + expect(metadata.optimization_enabled).toBe(true); + expect(metadata.optimization_runs).toBe(target.optimizerRuns); + expect(metadata.evm_version).toBe(target.evmVersion); + expect(metadata.license_type).toBe(target.license); + expect(normalizeHex(metadata.constructor_args)).toBe(normalizeHex(target.constructorArgs)); +} + +test("five Mainnet DOS ID Wallet contracts are exactly verified", async ({ page, request }) => { + test.setTimeout(180_000); + await openExplorer(page); + + for (const target of targets) { + const apiUrl = new URL(`/api/v2/smart-contracts/${target.address}`, baseUrl); + const apiResponse = await request.get(apiUrl.toString()); + expect(apiResponse.ok()).toBe(true); + expectExactMetadata(await apiResponse.json(), target); + + const contractUrl = new URL(`/address/${target.address}?tab=contract`, baseUrl); + const response = await page.goto(contractUrl.toString(), { waitUntil: "domcontentloaded" }); + await skipOnlyCloudflareChallenge(page); + expect(response?.ok()).toBe(true); + await expect(page.getByText(target.name, { exact: true }).first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(target.compiler, { exact: true }).first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(target.sourcePath, { exact: true }).first()).toBeVisible({ timeout: 30_000 }); + const verificationText = target.verificationMatch === "full" + ? /Contract source code verified \(exact match\)|Fully verified|Exact match/i + : /Contract source code verified \(partial match\)|Partial match/i; + await expect( + page.getByText(verificationText).first(), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("Oops! Something went wrong")).toHaveCount(0); + } +}); + +test("Mainnet operations page exposes EntryPoint v0.7 activity", async ({ page, request }) => { + test.setTimeout(90_000); + await openExplorer(page); + + const operationsApi = new URL("/api/v2/proxy/account-abstraction/operations", baseUrl); + const apiResponse = await request.get(operationsApi.toString()); + expect(apiResponse.ok()).toBe(true); + const payload = await apiResponse.json(); + expect(Array.isArray(payload.items)).toBe(true); + expect(payload.items.length).toBeGreaterThan(0); + const operation = payload.items.find( + (item) => + ( + item.entry_point?.hash || + item.entry_point_address || + item.entry_point || + "" + ).toLowerCase() === entryPointAddress.toLowerCase(), + ); + expect(operation?.hash).toMatch(/^0x[0-9a-f]{64}$/i); + + const opsUrl = new URL("/ops", baseUrl); + const response = await page.goto(opsUrl.toString(), { waitUntil: "domcontentloaded" }); + await skipOnlyCloudflareChallenge(page); + expect(response?.ok()).toBe(true); + const operationLink = page.locator(`a[href="/op/${operation.hash}"]:visible`).first(); + await expect(operationLink).toBeVisible({ timeout: 30_000 }); + const detailResponse = await page.goto( + new URL(`/op/${operation.hash}`, baseUrl).toString(), + { waitUntil: "domcontentloaded" }, + ); + await skipOnlyCloudflareChallenge(page); + expect(detailResponse?.ok()).toBe(true); + await expect(page.getByText("Entry point", { exact: true }).first()).toBeVisible({ timeout: 30_000 }); + await expect( + page.locator(`a[href="/address/${entryPointAddress}"]:visible`).first(), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("Oops! Something went wrong")).toHaveCount(0); +}); diff --git a/.github/scripts/prepare-mainnet-aa-verification.sh b/.github/scripts/prepare-mainnet-aa-verification.sh new file mode 100644 index 000000000000..55feea789ff1 --- /dev/null +++ b/.github/scripts/prepare-mainnet-aa-verification.sh @@ -0,0 +1,139 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 9 ]; then + echo "usage: prepare-mainnet-aa-verification.sh " >&2 + exit 2 +fi + +aa_repository="$1" +aa_ref="$2" +kernel_repository="$3" +kernel_ref="$4" +solady_ref="$5" +ecdsa_kernel_ref="$6" +ecdsa_solady_ref="$7" +excessively_safe_call_ref="81cd99ce3e69117d665d7601c330ea03b97acce0" +output_directory="$8" +rpc_url="$9" +workspace="${GITHUB_WORKSPACE:-$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)}" +checkout_root="$(mktemp -d "${RUNNER_TEMP:-/tmp}/doscan-mainnet-aa.XXXXXX")" +aa_checkout="${checkout_root}/account-abstraction" +kernel_checkout="${checkout_root}/kernel" +ecdsa_kernel_checkout="${checkout_root}/kernel-ecdsa" +cleanup() { + rm -rf "${checkout_root}" +} +trap cleanup EXIT HUP INT TERM + +require_sha() { + value="$1" + label="$2" + if ! printf '%s' "${value}" | grep -Eq '^[0-9a-f]{40}$'; then + echo "${label} must be a full lowercase Git SHA" >&2 + exit 2 + fi +} +require_sha "${aa_ref}" "Account Abstraction ref" +require_sha "${kernel_ref}" "Kernel ref" +require_sha "${solady_ref}" "Solady ref" +require_sha "${ecdsa_kernel_ref}" "ECDSA Kernel ref" +require_sha "${ecdsa_solady_ref}" "ECDSA Solady ref" + +checkout_exact() { + repository="$1" + ref="$2" + destination="$3" + git init -q "${destination}" + git -C "${destination}" config core.autocrlf false + git -C "${destination}" remote add origin "${repository}" + git -C "${destination}" fetch --depth 1 origin "${ref}" + git -C "${destination}" checkout --detach -q FETCH_HEAD + [ "$(git -C "${destination}" rev-parse HEAD)" = "${ref}" ] +} + +if [ -e "${output_directory}" ] && [ -n "$(find "${output_directory}" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + echo "Mainnet Account Abstraction output directory must be empty" >&2 + exit 2 +fi +mkdir -p "${output_directory}" + +checkout_exact "${aa_repository}" "${aa_ref}" "${aa_checkout}" +checkout_exact "${kernel_repository}" "${kernel_ref}" "${kernel_checkout}" +checkout_exact "${kernel_repository}" "${ecdsa_kernel_ref}" "${ecdsa_kernel_checkout}" +git -C "${kernel_checkout}" submodule update --init --depth 1 \ + lib/solady lib/ExcessivelySafeCall +[ "$(git -C "${kernel_checkout}/lib/solady" rev-parse HEAD)" = "${solady_ref}" ] +[ "$(git -C "${kernel_checkout}/lib/ExcessivelySafeCall" rev-parse HEAD)" = "${excessively_safe_call_ref}" ] +git -C "${ecdsa_kernel_checkout}" submodule update --init --depth 1 lib/solady +[ "$(git -C "${ecdsa_kernel_checkout}/lib/solady" rev-parse HEAD)" = "${ecdsa_solady_ref}" ] + +npm install --global yarn@1.22.22 +yarn --cwd "${aa_checkout}" install --frozen-lockfile --non-interactive +( + cd "${aa_checkout}" + PATH="${aa_checkout}/node_modules/.bin:${PATH}" + export PATH + ./scripts/hh-wrapper compile +) +npm ci --prefix "${workspace}/.github/scripts/mainnet-aa-solc" --ignore-scripts + +node "${workspace}/.github/scripts/extract-mainnet-aa-verification-inputs.mjs" \ + "${aa_checkout}" "${kernel_checkout}" "${ecdsa_kernel_checkout}" "${output_directory}" + +for compiler_package in solc-0.8.23 solc-0.8.24 solc-0.8.25 solc-0.8.28; do + test -s "${workspace}/.github/scripts/mainnet-aa-solc/node_modules/${compiler_package}/solc.js" +done + +compile_plan="${output_directory}/.compile-plan.tsv" +node -e ' + const fs = require("fs"); + const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (!Array.isArray(manifest.contracts) || manifest.contracts.length !== 5) process.exit(2); + for (const contract of manifest.contracts) { + const fields = [contract.compilerPackage, contract.standardInputFile, contract.compilerOutputFile]; + if (fields.some((field) => typeof field !== "string" || field.includes("\t") || field.includes("\n"))) process.exit(2); + console.log(fields.join("\t")); + } +' "${output_directory}/manifest.json" >"${compile_plan}" + +compiled_count=0 +while IFS="$(printf '\t')" read -r compiler_package standard_input_file compiler_output_file; do + case "${compiler_package}" in + solc-0.8.23 | solc-0.8.24 | solc-0.8.25 | solc-0.8.28) ;; + *) + echo "Unexpected Mainnet compiler package: ${compiler_package}" >&2 + exit 2 + ;; + esac + compiler_js="${workspace}/.github/scripts/mainnet-aa-solc/node_modules/${compiler_package}/solc.js" + raw_compiler_output="${output_directory}/.${compiler_output_file}.raw" + node "${compiler_js}" --standard-json \ + <"${output_directory}/${standard_input_file}" \ + >"${raw_compiler_output}" + node -e ' + const fs = require("fs"); + const raw = fs.readFileSync(process.argv[1], "utf8"); + const jsonStart = raw.indexOf("{"); + if (jsonStart < 0) process.exit(2); + const prefixLines = raw.slice(0, jsonStart).split(/\r?\n/).filter((line) => line.trim() !== ""); + if (prefixLines.some((line) => !line.startsWith(">>> "))) process.exit(2); + const parsed = JSON.parse(raw.slice(jsonStart)); + fs.writeFileSync(process.argv[2], JSON.stringify(parsed) + "\n"); + ' "${raw_compiler_output}" "${output_directory}/${compiler_output_file}" + rm -f "${raw_compiler_output}" + test -s "${output_directory}/${standard_input_file}" + test -s "${output_directory}/${compiler_output_file}" + compiled_count="$((compiled_count + 1))" +done <"${compile_plan}" +rm -f "${compile_plan}" +[ "${compiled_count}" -eq 5 ] + +test -s "${output_directory}/entry-point.compiler-output.json" +test -s "${output_directory}/kernel.compiler-output.json" +test -s "${output_directory}/kernel-factory.compiler-output.json" +test -s "${output_directory}/ecdsa-validator.compiler-output.json" +test -s "${output_directory}/factory-staker.compiler-output.json" +test -s "${output_directory}/manifest.json" +node "${workspace}/.github/scripts/verify-mainnet-aa-bytecode.mjs" \ + "${output_directory}" "${rpc_url}" diff --git a/.github/scripts/tests/test_extract_mainnet_aa_verification_inputs.py b/.github/scripts/tests/test_extract_mainnet_aa_verification_inputs.py new file mode 100644 index 000000000000..9be009f79704 --- /dev/null +++ b/.github/scripts/tests/test_extract_mainnet_aa_verification_inputs.py @@ -0,0 +1,395 @@ +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT_PATH = ROOT / ".github" / "scripts" / "extract-mainnet-aa-verification-inputs.mjs" + + +class ExtractMainnetAaVerificationInputsTests(unittest.TestCase): + def test_extracts_five_contract_specific_inputs_and_manifest(self): + result, output = self._run_extractor() + + self.assertEqual(0, result.returncode, result.stderr) + manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(2, manifest["version"]) + self.assertEqual(7979, manifest["chainId"]) + + contracts = {contract["key"]: contract for contract in manifest["contracts"]} + self.assertEqual( + [ + "entry-point", + "kernel", + "kernel-factory", + "ecdsa-validator", + "factory-staker", + ], + [contract["key"] for contract in manifest["contracts"]], + ) + self.assertEqual( + "dependencies/kernel-v3.3/src/factory/KernelFactory.sol", + contracts["kernel-factory"]["sourcePath"], + ) + self.assertEqual( + "FactoryStaker", contracts["factory-staker"]["contractName"] + ) + self.assertEqual( + "v0.8.23+commit.f704f362", + contracts["entry-point"]["compilerVersion"], + ) + self.assertEqual( + "v0.8.25+commit.b61c2a91", + contracts["ecdsa-validator"]["compilerVersion"], + ) + self.assertEqual("full", contracts["entry-point"]["verificationMatch"]) + self.assertEqual("partial", contracts["ecdsa-validator"]["verificationMatch"]) + self.assertEqual( + "d13e7ff2bc90271659100c83f49ee6250555bbf26ed35c2315f243c6849a2127", + contracts["kernel"]["expectedCodeSha256"], + ) + self.assertEqual( + [{ + "signature": "entrypoint()", + "expectedAddress": "0x0000000071727De22E5E9d8BAf0edAc6f37da032", + }], + contracts["kernel"]["rpcChecks"], + ) + + entry_point_input = self._read_input(output, contracts["entry-point"]) + self.assertEqual("paris", entry_point_input["settings"]["evmVersion"]) + self.assertEqual( + {"enabled": True, "runs": 1_000_000}, + entry_point_input["settings"]["optimizer"], + ) + self.assertTrue(entry_point_input["settings"]["viaIR"]) + self.assertEqual( + "ipfs", + entry_point_input["settings"].get("metadata", {}).get( + "bytecodeHash", "ipfs" + ), + ) + + kernel_input = self._read_input(output, contracts["kernel"]) + self.assertEqual("prague", kernel_input["settings"]["evmVersion"]) + self.assertEqual( + {"appendCBOR": False, "bytecodeHash": "none"}, + kernel_input["settings"]["metadata"], + ) + self.assertTrue(kernel_input["settings"]["viaIR"]) + self.assertIn("src/interfaces/I.sol", kernel_input["sources"]) + self.assertIn("lib/solady/src/utils/EIP712.sol", kernel_input["sources"]) + self.assertIn( + "lib/ExcessivelySafeCall/src/ExcessivelySafeCall.sol", + kernel_input["sources"], + ) + + factory_input = self._read_input(output, contracts["kernel-factory"]) + self.assertIn( + "dependencies/kernel-v3.3/src/factory/KernelFactory.sol", + factory_input["sources"], + ) + self.assertIn( + "dependencies/solady-0.1.26/src/utils/LibClone.sol", + factory_input["sources"], + ) + + ecdsa_input = self._read_input(output, contracts["ecdsa-validator"]) + self.assertIn( + "// pinned ECDSA deployment source", + ecdsa_input["sources"]["src/validator/ECDSAValidator.sol"]["content"], + ) + factory_staker_input = self._read_input( + output, contracts["factory-staker"] + ) + self.assertIn( + "// pinned FactoryStaker deployment dependency", + factory_staker_input["sources"]["src/factory/KernelFactory.sol"][ + "content" + ], + ) + + factory_staker_input = self._read_input( + output, contracts["factory-staker"] + ) + self.assertNotIn("viaIR", factory_staker_input["settings"]) + self.assertIn( + "src/factory/KernelFactory.sol", factory_staker_input["sources"] + ) + self.assertIn( + "lib/solady/src/auth/Ownable.sol", factory_staker_input["sources"] + ) + + entry_point_selection = entry_point_input["settings"]["outputSelection"]["*"]["*"] + self.assertIn("evm.deployedBytecode", entry_point_selection) + self.assertIn("evm.methodIdentifiers", entry_point_selection) + + for key, contract in contracts.items(): + if key == "entry-point": + continue + standard_input = self._read_input(output, contract) + selection = standard_input["settings"]["outputSelection"]["*"]["*"] + self.assertEqual( + [ + "abi", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "evm.deployedBytecode.linkReferences", + "evm.methodIdentifiers", + ], + selection, + ) + + def test_rejects_missing_kernel_import_without_manifest(self): + def mutate(_aa_checkout, kernel_checkout): + (kernel_checkout / "src" / "interfaces" / "I.sol").unlink() + + result, output = self._run_extractor(mutate) + + self.assertNotEqual(0, result.returncode) + self.assertIn("I.sol", result.stderr) + self.assertFalse((output / "manifest.json").exists()) + + def test_rejects_noncanonical_entrypoint_build_settings(self): + def mutate(aa_checkout, _kernel_checkout): + build_path = aa_checkout / "artifacts" / "build-info" / "build.json" + build = json.loads(build_path.read_text(encoding="utf-8")) + build["input"]["settings"]["optimizer"]["runs"] = 200 + build_path.write_text(json.dumps(build), encoding="utf-8") + + result, output = self._run_extractor(mutate) + + self.assertNotEqual(0, result.returncode) + self.assertIn("optimizer", result.stderr.lower()) + self.assertFalse((output / "manifest.json").exists()) + + def test_rejects_noncanonical_entrypoint_provenance_fields(self): + mutations = [ + ( + "compiler", + lambda build: build.update( + {"solcLongVersion": "0.8.22+commit.4fc1097e"} + ), + ), + ( + "evm version", + lambda build: build["input"]["settings"].update( + {"evmVersion": "shanghai"} + ), + ), + ( + "spdx", + lambda build: build["input"]["sources"][ + "contracts/core/EntryPoint.sol" + ].update( + {"content": "// SPDX-License-Identifier: MIT\ncontract EntryPoint {}"} + ), + ), + ( + "missing", + lambda build: build["input"]["sources"][ + "contracts/core/EntryPoint.sol" + ].update( + {"content": "// SPDX-License-Identifier: GPL-3.0\ncontract WrongEntryPoint {}"} + ), + ), + ] + for expected_error, mutate_build in mutations: + with self.subTest(expected_error=expected_error): + def mutate(aa_checkout, _kernel_checkout): + build_path = aa_checkout / "artifacts" / "build-info" / "build.json" + build = json.loads(build_path.read_text(encoding="utf-8")) + mutate_build(build) + build_path.write_text(json.dumps(build), encoding="utf-8") + + result, output = self._run_extractor(mutate) + self.assertNotEqual(0, result.returncode) + self.assertIn(expected_error, result.stderr.lower()) + self.assertFalse((output / "manifest.json").exists()) + + def test_rejects_import_outside_pinned_source_root(self): + def mutate(_aa_checkout, kernel_checkout): + kernel = kernel_checkout / "src" / "Kernel.sol" + kernel.write_text( + kernel.read_text(encoding="utf-8") + + '\nimport "../../../outside.sol";\n', + encoding="utf-8", + ) + + result, output = self._run_extractor(mutate) + self.assertNotEqual(0, result.returncode) + self.assertIn("escapes source-unit root", result.stderr) + self.assertFalse((output / "manifest.json").exists()) + + def _run_extractor(self, mutate=None): + temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(temporary_directory.cleanup) + root = Path(temporary_directory.name) + aa_checkout = root / "account-abstraction" + kernel_checkout = root / "kernel" + ecdsa_kernel_checkout = root / "kernel-ecdsa" + output = root / "output" + self._write_entrypoint_build(aa_checkout) + self._write_kernel_checkout(kernel_checkout) + self._write_kernel_checkout(ecdsa_kernel_checkout) + ecdsa_path = ecdsa_kernel_checkout / "src" / "validator" / "ECDSAValidator.sol" + ecdsa_path.write_text( + ecdsa_path.read_text(encoding="utf-8") + + "\n// pinned ECDSA deployment source\n", + encoding="utf-8", + ) + legacy_factory_path = ( + ecdsa_kernel_checkout / "src" / "factory" / "KernelFactory.sol" + ) + legacy_factory_path.write_text( + legacy_factory_path.read_text(encoding="utf-8") + + "\n// pinned FactoryStaker deployment dependency\n", + encoding="utf-8", + ) + if mutate: + mutate(aa_checkout, kernel_checkout) + + result = subprocess.run( + [ + "node", + str(SCRIPT_PATH), + str(aa_checkout), + str(kernel_checkout), + str(ecdsa_kernel_checkout), + str(output), + ], + capture_output=True, + text=True, + check=False, + ) + return result, output + + @staticmethod + def _read_input(output, contract): + return json.loads( + (output / contract["standardInputFile"]).read_text(encoding="utf-8") + ) + + @staticmethod + def _write_entrypoint_build(checkout): + build_path = checkout / "artifacts" / "build-info" / "build.json" + build_path.parent.mkdir(parents=True) + source_path = "contracts/core/EntryPoint.sol" + build = { + "solcLongVersion": "0.8.23+commit.f704f362", + "input": { + "language": "Solidity", + "sources": { + source_path: { + "content": "// SPDX-License-Identifier: GPL-3.0\ncontract EntryPoint {}" + } + }, + "settings": { + "evmVersion": "paris", + "optimizer": {"enabled": True, "runs": 1_000_000}, + "viaIR": True, + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode", + "evm.deployedBytecode", + "evm.methodIdentifiers", + "metadata", + ], + "": ["ast"], + } + }, + }, + }, + "output": { + "contracts": {source_path: {"EntryPoint": {"abi": []}}} + }, + } + build_path.write_text(json.dumps(build), encoding="utf-8") + debug_path = ( + checkout + / "artifacts" + / "contracts" + / "core" + / "EntryPoint.sol" + / "EntryPoint.dbg.json" + ) + debug_path.parent.mkdir(parents=True) + debug_path.write_text( + json.dumps({"buildInfo": "../../../build-info/build.json"}), + encoding="utf-8", + ) + + @staticmethod + def _write_kernel_checkout(checkout): + files = { + "src/Kernel.sol": ( + "// SPDX-License-Identifier: MIT\n" + "pragma solidity ^0.8.0;\n" + 'import {I} from "./interfaces/I.sol";\n' + 'import {EIP712} from "solady/utils/EIP712.sol";\n' + 'import {ExcessivelySafeCall} from "ExcessivelySafeCall/ExcessivelySafeCall.sol";\n' + "contract Kernel {}\n" + ), + "src/interfaces/I.sol": ( + "// SPDX-License-Identifier: MIT\n" + "pragma solidity ^0.8.0;\n" + "interface I {}\n" + ), + "src/factory/KernelFactory.sol": ( + "// SPDX-License-Identifier: MIT\n" + "pragma solidity ^0.8.0;\n" + 'import {LibClone} from "solady/utils/LibClone.sol";\n' + "contract KernelFactory {}\n" + ), + "src/validator/ECDSAValidator.sol": ( + "// SPDX-License-Identifier: MIT\n" + "pragma solidity ^0.8.0;\n" + 'import {I} from "../interfaces/I.sol";\n' + 'import {ECDSA} from "solady/utils/ECDSA.sol";\n' + "contract ECDSAValidator {}\n" + ), + "src/factory/FactoryStaker.sol": ( + "// SPDX-License-Identifier: MIT\n" + "pragma solidity ^0.8.0;\n" + 'import "./KernelFactory.sol";\n' + 'import {Ownable} from "solady/auth/Ownable.sol";\n' + "contract FactoryStaker {}\n" + ), + "lib/solady/src/utils/EIP712.sol": ( + "// SPDX-License-Identifier: MIT\n" + "pragma solidity ^0.8.0;\n" + "abstract contract EIP712 {}\n" + ), + "lib/solady/src/utils/LibClone.sol": ( + "// SPDX-License-Identifier: MIT\n" + "pragma solidity ^0.8.0;\n" + "library LibClone {}\n" + ), + "lib/solady/src/utils/ECDSA.sol": ( + "// SPDX-License-Identifier: MIT\n" + "pragma solidity ^0.8.0;\n" + "library ECDSA {}\n" + ), + "lib/solady/src/auth/Ownable.sol": ( + "// SPDX-License-Identifier: MIT\n" + "pragma solidity ^0.8.0;\n" + "abstract contract Ownable {}\n" + ), + "lib/ExcessivelySafeCall/src/ExcessivelySafeCall.sol": ( + "// SPDX-License-Identifier: MIT OR Apache-2.0\n" + "pragma solidity ^0.8.0;\n" + "library ExcessivelySafeCall {}\n" + ), + } + for relative_path, content in files.items(): + path = checkout / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/tests/test_validate_mainnet_bens.py b/.github/scripts/tests/test_validate_mainnet_bens.py index 37e3cc3da0e9..1177b0ba0961 100644 --- a/.github/scripts/tests/test_validate_mainnet_bens.py +++ b/.github/scripts/tests/test_validate_mainnet_bens.py @@ -19,6 +19,8 @@ CONFIG_TEMPLATE = ROOT / "docker-compose" / "bens" / "config.mainnet.template.json" RENDERER = ROOT / "scripts" / "render-mainnet-bens.py" RUNTIME = ROOT / ".github" / "scripts" / "mainnet-bens-runtime.sh" +AA_PREPARER = ROOT / ".github" / "scripts" / "prepare-mainnet-aa-verification.sh" +AA_UI_SPEC = ROOT / ".github" / "scripts" / "mainnet-aa-source-ui.spec.mjs" MAINNET_RPC = ( "http://host.docker.internal:9650/ext/bc/" "2ewKoUrSjnviEgGmeTiELHBmNjxVTVczBPowST471rYUZvA9bk/rpc" @@ -152,6 +154,70 @@ def test_mainnet_workflow_consumes_the_canonical_manifest(self): self.assertIn("/name-service/api/v1/7979/domains/${SMOKE_NAME}", mainnet_job) self.assertIn("Verify Mainnet DOS Name UI with Playwright", mainnet_job) + def test_mainnet_aa_inputs_are_built_before_cloud_auth_and_verified_after_runtime(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + mainnet_job = workflow.split(" deploy-mainnet:", 1)[1].split( + "\n deploy-testnet:", 1 + )[0] + testnet_job = workflow.split(" deploy-testnet:", 1)[1].split( + "\n deploy-beta:", 1 + )[0] + prepare_index = mainnet_job.index( + "Prepare immutable Mainnet Account Abstraction verification inputs" + ) + google_auth_index = mainnet_job.index("Authenticate to Google Cloud") + bytecode_gate_index = prepare_index + deployment_stopped_index = mainnet_job.rindex("DEPLOYMENT_STARTED=0") + source_verify_index = mainnet_job.rindex( + '/bin/sh "${SRC}/.github/scripts/verify-mainnet-aa-sources.sh"' + ) + + self.assertLess(prepare_index, google_auth_index) + self.assertLess(bytecode_gate_index, google_auth_index) + self.assertLess(deployment_stopped_index, source_verify_index) + self.assertNotIn("verify-mainnet-aa-sources.sh", testnet_job) + self.assertIn( + "7af70c8993a6f42973f520ae0752386a5032abe7", mainnet_job + ) + self.assertIn( + "cd697c7e21715d015e0643af22310a99aa17433b", mainnet_job + ) + self.assertIn( + "3f2f5345261904463f5429c9031c3d2185c0f4fe", mainnet_job + ) + self.assertIn( + "8f7fd9946b9d351bb5be0428bf34c87bad7ed6c9", mainnet_job + ) + self.assertIn( + "9deb9ed36a27261a8745db5b7cd7f4cdc3b1cd4e", mainnet_job + ) + self.assertIn('"https://main.doschain.com/"', mainnet_job) + package_step = mainnet_job.split( + "- name: Package deployment configuration", 1 + )[1].split("- name: Upload configuration", 1)[0] + self.assertIn("mainnet-aa-verification", package_step) + self.assertIn("verify-mainnet-aa-sources.sh", package_step) + + def test_mainnet_aa_preparer_pins_sources_compilers_and_outputs(self): + preparer = AA_PREPARER.read_text(encoding="utf-8") + self.assertIn("verify-mainnet-aa-bytecode.mjs", preparer) + self.assertIn("yarn@1.22.22", preparer) + self.assertIn("./scripts/hh-wrapper compile", preparer) + self.assertIn('PATH="${aa_checkout}/node_modules/.bin:${PATH}"', preparer) + self.assertIn("81cd99ce3e69117d665d7601c330ea03b97acce0", preparer) + self.assertIn('line.startsWith(">>> ")', preparer) + self.assertIn("config core.autocrlf false", preparer) + for compiler in ("solc-0.8.23", "solc-0.8.24", "solc-0.8.25", "solc-0.8.28"): + self.assertIn(compiler, preparer) + for output in ( + "entry-point.compiler-output.json", + "kernel.compiler-output.json", + "kernel-factory.compiler-output.json", + "ecdsa-validator.compiler-output.json", + "factory-staker.compiler-output.json", + ): + self.assertIn(output, preparer) + def test_existing_bens_service_check_consumes_the_complete_compose_output(self): runtime = RUNTIME.as_posix() result = run_bash( @@ -191,6 +257,53 @@ def test_mainnet_playwright_retries_a_transient_frontend_restart(self): ), ) + def test_mainnet_playwright_checks_five_aa_sources_and_ops(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + mainnet_job = workflow.split(" deploy-mainnet:", 1)[1].split( + "\n deploy-testnet:", 1 + )[0] + ui_test = AA_UI_SPEC.read_text(encoding="utf-8") + + self.assertIn("mainnet-aa-source-ui.spec.mjs", mainnet_job) + self.assertIn("npx playwright test", mainnet_job) + for address in ( + "0x0000000071727De22E5E9d8BAf0edAc6f37da032", + "0xd6CEDDe84be40893d153Be9d467CD6aD37875b28", + "0x2577507b78c2008Ff367261CB6285d44ba5eF2E9", + "0x845ADb2C711129d4f3966735eD98a9F09fC4cE57", + "0xd703aaE79538628d27099B8c4f621bE4CCd142d5", + ): + self.assertIn(address, ui_test) + self.assertIn("/ops", ui_test) + self.assertIn("response?.ok()", ui_test) + self.assertIn(":visible", ui_test) + self.assertIn('target.verificationMatch === "full"', ui_test) + self.assertIn("Contract source code verified \\(exact match\\)", ui_test) + self.assertIn("Contract source code verified \\(partial match\\)", ui_test) + self.assertIn("page.getByText(target.compiler", ui_test) + self.assertIn("page.getByText(target.sourcePath", ui_test) + self.assertIn("const operation = payload.items.find", ui_test) + self.assertIn('a[href="/op/${operation.hash}"]:visible', ui_test) + self.assertIn('page.getByText("Entry point", { exact: true })', ui_test) + self.assertIn('a[href="/address/${entryPointAddress}"]:visible', ui_test) + navigation_blocks = re.findall( + r"const (?:detailResponse|response) = await page\.goto\([\s\S]+?;\s*" + r"await skipOnlyCloudflareChallenge\(page\);\s*" + r"expect\((?:detailResponse|response)\?\.ok\(\)\)\.toBe\(true\);", + ui_test, + re.DOTALL, + ) + self.assertEqual(3, len(navigation_blocks)) + self.assertRegex( + ui_test, + re.compile( + r'if \(\s*title === "Just a moment\.\.\."\s*&&\s*' + r'page\.url\(\)\.includes\("__cf_chl_rt_tk="\)\s*\)\s*' + r'\{\s*test\.skip\(', + re.DOTALL, + ), + ) + def test_mainnet_apply_script_parses_as_bash(self): workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) apply_script = next( diff --git a/.github/scripts/tests/test_verify_mainnet_aa_bytecode.py b/.github/scripts/tests/test_verify_mainnet_aa_bytecode.py new file mode 100644 index 000000000000..22189e0e891e --- /dev/null +++ b/.github/scripts/tests/test_verify_mainnet_aa_bytecode.py @@ -0,0 +1,241 @@ +import hashlib +import json +import os +import subprocess +import tempfile +import threading +import time +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / ".github" / "scripts" / "verify-mainnet-aa-bytecode.mjs" +ADDRESS = "0xd6CEDDe84be40893d153Be9d467CD6aD37875b28" +EXPECTED_GETTER = "0x0000000071727De22E5E9d8BAf0edAc6f37da032" +COMPILED_CODE = "0x600160026003" +LIVE_CODE = "0x600160ff6003" + + +def code_hash(code): + return hashlib.sha256(code.lower().encode("utf-8")).hexdigest() + + +class RpcServer: + def __init__(self, code=LIVE_CODE, getter=EXPECTED_GETTER, failures=0, trickle_delay=None): + self.code = code + self.getter = getter + self.failures = failures + self.trickle_delay = trickle_delay + self.requests = [] + owner = self + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length)) + owner.requests.append(payload) + if owner.failures > 0: + owner.failures -= 1 + self.send_response(503) + self.end_headers() + return + if payload["method"] == "eth_getCode": + result = owner.code + elif payload["method"] == "eth_call": + result = "0x" + "0" * 24 + owner.getter[2:].lower() + else: + result = None + body = json.dumps({"jsonrpc": "2.0", "id": payload["id"], "result": result}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if owner.trickle_delay is None: + self.wfile.write(body) + else: + try: + for byte in body: + self.wfile.write(bytes([byte])) + self.wfile.flush() + time.sleep(owner.trickle_delay) + except OSError: + return + + def log_message(self, _format, *_args): + return + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + + def __enter__(self): + self.thread.start() + return f"http://127.0.0.1:{self.server.server_port}" + + def __exit__(self, *_args): + self.server.shutdown() + self.server.server_close() + self.thread.join() + + +class VerifyMainnetAaBytecodeTests(unittest.TestCase): + def write_artifacts(self, directory, *, live_code=LIVE_CODE, output_mutator=None): + target = { + "key": "kernel", + "address": ADDRESS, + "contractName": "Kernel", + "sourcePath": "src/Kernel.sol", + "compilerOutputFile": "kernel.compiler-output.json", + "expectedCodeSha256": code_hash(live_code), + "rpcChecks": [{"signature": "entrypoint()", "expectedAddress": EXPECTED_GETTER}], + } + manifest = {"version": 2, "chainId": 7979, "contracts": [target]} + output = { + "contracts": { + "src/Kernel.sol": { + "Kernel": { + "evm": { + "deployedBytecode": { + "object": COMPILED_CODE[2:], + "immutableReferences": {"1": [{"start": 3, "length": 1}]}, + "linkReferences": {}, + }, + "methodIdentifiers": {"entrypoint()": "b0d691fe"}, + } + } + } + }, + "errors": [], + } + if output_mutator: + output_mutator(output) + Path(directory, "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + Path(directory, target["compilerOutputFile"]).write_text(json.dumps(output), encoding="utf-8") + + def run_verifier(self, directory, rpc_url, extra_env=None): + environment = os.environ.copy() + environment.update(extra_env or {}) + return subprocess.run( + ["node", str(SCRIPT), str(directory), rpc_url], + capture_output=True, + text=True, + timeout=15, + check=False, + env=environment, + ) + + def test_accepts_exact_code_and_immutable_getters(self): + with tempfile.TemporaryDirectory() as directory: + self.write_artifacts(directory) + server = RpcServer() + with server as rpc_url: + result = self.run_verifier(directory, rpc_url) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(["eth_getCode", "eth_call"], [request["method"] for request in server.requests]) + + def test_rejects_live_code_hash_mismatch(self): + with tempfile.TemporaryDirectory() as directory: + self.write_artifacts(directory, live_code=LIVE_CODE) + with RpcServer(code="0x600160aa6003") as rpc_url: + result = self.run_verifier(directory, rpc_url) + self.assertNotEqual(0, result.returncode) + self.assertIn("hash", result.stderr.lower()) + + def test_rejects_non_immutable_byte_mismatch(self): + mismatched = "0x610160ff6003" + with tempfile.TemporaryDirectory() as directory: + self.write_artifacts(directory, live_code=mismatched) + with RpcServer(code=mismatched) as rpc_url: + result = self.run_verifier(directory, rpc_url) + self.assertNotEqual(0, result.returncode) + self.assertIn("outside immutable", result.stderr.lower()) + + def test_rejects_wrong_immutable_getter(self): + with tempfile.TemporaryDirectory() as directory: + self.write_artifacts(directory) + with RpcServer(getter="0x1111111111111111111111111111111111111111") as rpc_url: + result = self.run_verifier(directory, rpc_url) + self.assertNotEqual(0, result.returncode) + self.assertIn("entrypoint()", result.stderr) + + def test_rejects_unresolved_library_link(self): + def mutate(output): + deployed = output["contracts"]["src/Kernel.sol"]["Kernel"]["evm"]["deployedBytecode"] + deployed["object"] = "6001__$1234567890123456789012345678901234$__6003" + + with tempfile.TemporaryDirectory() as directory: + self.write_artifacts(directory, output_mutator=mutate) + with RpcServer() as rpc_url: + result = self.run_verifier(directory, rpc_url) + self.assertNotEqual(0, result.returncode) + self.assertIn("unresolved", result.stderr.lower()) + + def test_rejects_malformed_overlapping_or_out_of_bounds_immutable_ranges(self): + mutations = [ + lambda deployed: deployed.update({"immutableReferences": []}), + lambda deployed: deployed.update( + {"immutableReferences": {"1": [{"start": 1, "length": 2}], "2": [{"start": 2, "length": 1}]}} + ), + lambda deployed: deployed.update( + {"immutableReferences": {"1": [{"start": 6, "length": 1}]}} + ), + ] + for mutate_deployed in mutations: + with self.subTest(mutate_deployed=mutate_deployed), tempfile.TemporaryDirectory() as directory: + def mutate(output): + mutate_deployed( + output["contracts"]["src/Kernel.sol"]["Kernel"]["evm"]["deployedBytecode"] + ) + + self.write_artifacts(directory, output_mutator=mutate) + with RpcServer() as rpc_url: + result = self.run_verifier(directory, rpc_url) + self.assertNotEqual(0, result.returncode) + self.assertIn("immutable", result.stderr.lower()) + + def test_rejects_compiler_error_or_missing_contract(self): + mutations = [ + lambda output: output["errors"].append({"severity": "error", "formattedMessage": "compile failed"}), + lambda output: output.update({"contracts": {}}), + ] + for mutate in mutations: + with self.subTest(mutate=mutate), tempfile.TemporaryDirectory() as directory: + self.write_artifacts(directory, output_mutator=mutate) + with RpcServer() as rpc_url: + result = self.run_verifier(directory, rpc_url) + self.assertNotEqual(0, result.returncode) + + def test_retries_transient_rpc_failure_with_a_finite_limit(self): + with tempfile.TemporaryDirectory() as directory: + self.write_artifacts(directory) + server = RpcServer(failures=2) + with server as rpc_url: + result = self.run_verifier(directory, rpc_url) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(4, len(server.requests)) + + server = RpcServer(failures=99) + with server as rpc_url: + result = self.run_verifier(directory, rpc_url) + self.assertNotEqual(0, result.returncode) + self.assertEqual(3, len(server.requests)) + + def test_rpc_request_has_an_absolute_wall_clock_timeout(self): + with tempfile.TemporaryDirectory() as directory: + self.write_artifacts(directory) + started = time.monotonic() + with RpcServer(trickle_delay=0.03) as rpc_url: + result = self.run_verifier( + directory, + rpc_url, + {"DOSCAN_MAINNET_AA_RPC_REQUEST_TIMEOUT_MS": "100"}, + ) + elapsed = time.monotonic() - started + self.assertNotEqual(0, result.returncode) + self.assertIn("timeout", result.stderr.lower()) + self.assertLess(elapsed, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/tests/test_verify_mainnet_aa_sources.py b/.github/scripts/tests/test_verify_mainnet_aa_sources.py new file mode 100644 index 000000000000..4ac4eb67ef2e --- /dev/null +++ b/.github/scripts/tests/test_verify_mainnet_aa_sources.py @@ -0,0 +1,428 @@ +import http.server +import json +import os +import subprocess +import tempfile +import threading +import unittest +from copy import deepcopy +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT_PATH = ROOT / ".github" / "scripts" / "verify-mainnet-aa-sources.sh" + +TARGETS = [ + { + "key": "entry-point", + "address": "0x0000000071727De22E5E9d8BAf0edAc6f37da032", + "contractName": "EntryPoint", + "sourcePath": "contracts/core/EntryPoint.sol", + "standardInputFile": "entry-point.standard-input.json", + "compilerOutputFile": "entry-point.compiler-output.json", + "compilerPackage": "solc-0.8.23", + "compilerVersion": "v0.8.23+commit.f704f362", + "evmVersion": "paris", + "optimizer": {"enabled": True, "runs": 1_000_000}, + "viaIR": True, + "metadata": {"bytecodeHash": "ipfs"}, + "licenseType": "gnu_gpl_v3", + "spdxLicense": "GPL-3.0", + "constructorArgs": "", + "expectedCodeSha256": "4dcad467095cd9af58006b270475ac7591c6946bca08552f6789727097b51eae", + "rpcChecks": [], + "verificationMatch": "full", + }, + { + "key": "kernel", + "address": "0xd6CEDDe84be40893d153Be9d467CD6aD37875b28", + "contractName": "Kernel", + "sourcePath": "src/Kernel.sol", + "standardInputFile": "kernel.standard-input.json", + "compilerOutputFile": "kernel.compiler-output.json", + "compilerPackage": "solc-0.8.28", + "compilerVersion": "v0.8.28+commit.7893614a", + "evmVersion": "prague", + "optimizer": {"enabled": True, "runs": 200}, + "viaIR": True, + "metadata": {"appendCBOR": False, "bytecodeHash": "none"}, + "licenseType": "mit", + "spdxLicense": "MIT", + "constructorArgs": "0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da032", + "expectedCodeSha256": "d13e7ff2bc90271659100c83f49ee6250555bbf26ed35c2315f243c6849a2127", + "rpcChecks": [{"signature": "entrypoint()", "expectedAddress": "0x0000000071727De22E5E9d8BAf0edAc6f37da032"}], + "verificationMatch": "partial", + }, + { + "key": "kernel-factory", + "address": "0x2577507b78c2008Ff367261CB6285d44ba5eF2E9", + "contractName": "KernelFactory", + "sourcePath": "dependencies/kernel-v3.3/src/factory/KernelFactory.sol", + "standardInputFile": "kernel-factory.standard-input.json", + "compilerOutputFile": "kernel-factory.compiler-output.json", + "compilerPackage": "solc-0.8.28", + "compilerVersion": "v0.8.28+commit.7893614a", + "evmVersion": "prague", + "optimizer": {"enabled": True, "runs": 200}, + "viaIR": True, + "metadata": {"appendCBOR": False, "bytecodeHash": "none"}, + "licenseType": "mit", + "spdxLicense": "MIT", + "constructorArgs": "000000000000000000000000d6cedde84be40893d153be9d467cd6ad37875b28", + "expectedCodeSha256": "56443d7d18bfd62d5d69b04fc8207e439bf904166335dd7159e0eeef1cba2367", + "rpcChecks": [{"signature": "implementation()", "expectedAddress": "0xd6CEDDe84be40893d153Be9d467CD6aD37875b28"}], + "verificationMatch": "partial", + }, + { + "key": "ecdsa-validator", + "address": "0x845ADb2C711129d4f3966735eD98a9F09fC4cE57", + "contractName": "ECDSAValidator", + "sourcePath": "src/validator/ECDSAValidator.sol", + "standardInputFile": "ecdsa-validator.standard-input.json", + "compilerOutputFile": "ecdsa-validator.compiler-output.json", + "compilerPackage": "solc-0.8.25", + "compilerVersion": "v0.8.25+commit.b61c2a91", + "evmVersion": "paris", + "optimizer": {"enabled": True, "runs": 200}, + "viaIR": True, + "metadata": {"appendCBOR": False, "bytecodeHash": "none"}, + "licenseType": "mit", + "spdxLicense": "MIT", + "constructorArgs": "", + "expectedCodeSha256": "be711f07f49e57bf56c512b6f32f7c77d9ec1881c4051ed33a45cfad8c7a8b8e", + "rpcChecks": [], + "verificationMatch": "partial", + }, + { + "key": "factory-staker", + "address": "0xd703aaE79538628d27099B8c4f621bE4CCd142d5", + "contractName": "FactoryStaker", + "sourcePath": "src/factory/FactoryStaker.sol", + "standardInputFile": "factory-staker.standard-input.json", + "compilerOutputFile": "factory-staker.compiler-output.json", + "compilerPackage": "solc-0.8.24", + "compilerVersion": "v0.8.24+commit.e11b9ed9", + "evmVersion": "paris", + "optimizer": {"enabled": True, "runs": 200}, + "viaIR": False, + "metadata": {"appendCBOR": False, "bytecodeHash": "none"}, + "licenseType": "mit", + "spdxLicense": "MIT", + "constructorArgs": "", + "expectedCodeSha256": "f91091bf1260892a4d0b834494489fea55be2f2f968ad6b1abc1410531f2a2a1", + "rpcChecks": [], + "verificationMatch": "partial", + }, +] + +UNVERIFIED = {"creation_bytecode": "0x6000", "deployed_bytecode": "0x6001"} + + +def exact_contract(target): + return { + "is_verified": True, + "is_fully_verified": target["verificationMatch"] == "full", + "is_partially_verified": target["verificationMatch"] == "partial", + "verified_twin_address_hash": None, + "name": target["contractName"], + "compiler_version": target["compilerVersion"], + "optimization_enabled": True, + "optimization_runs": target["optimizer"]["runs"], + "evm_version": target["evmVersion"], + "file_path": target["sourcePath"], + "license_type": target["licenseType"], + "constructor_args": target["constructorArgs"], + "compiler_settings": {"viaIR": target["viaIR"], "metadata": target["metadata"]}, + } + + +class VerifyMainnetAaSourcesTests(unittest.TestCase): + def exact_responses(self): + return {target["address"].lower(): [exact_contract(target)] for target in TARGETS} + + def test_five_exact_contracts_are_not_submitted(self): + result, state = self.run_verifier(self.exact_responses()) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual([], state["posts"]) + self.assertEqual(5, sum(state["gets"].values())) + + def test_submits_each_unverified_contract_with_its_own_profile(self): + responses = { + target["address"].lower(): [UNVERIFIED, exact_contract(target)] for target in TARGETS + } + result, state = self.run_verifier(responses) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(5, len(state["posts"])) + for target, post in zip(TARGETS, state["posts"]): + self.assertIn(target["compilerVersion"], post["body"]) + self.assertIn(f'{target["sourcePath"]}:{target["contractName"]}', post["body"]) + self.assertIn(target["licenseType"], post["body"]) + self.assertIn("src/factory/FactoryStaker.sol:FactoryStaker", state["posts"][-1]["body"]) + self.assertNotIn("MetaFactory", state["posts"][-1]["body"]) + + def test_already_verified_race_requires_exact_get(self): + responses = self.exact_responses() + first = TARGETS[0] + responses[first["address"].lower()] = [UNVERIFIED, exact_contract(first)] + result, state = self.run_verifier( + responses, post_response=(200, {"message": "Already verified"}) + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(2, state["gets"][first["address"].lower()]) + + responses[first["address"].lower()] = [UNVERIFIED] + result, _ = self.run_verifier( + responses, attempts=2, post_response=(200, {"message": "Already verified"}) + ) + self.assertNotEqual(0, result.returncode) + + def test_rejects_wrong_metadata_for_each_profile_field(self): + mutations = [ + (0, "compiler_version", "v0.8.22+commit.4fc1097e"), + (1, "optimization_runs", 1_000_000), + (2, "evm_version", "paris"), + (3, "license_type", "gnu_gpl_v3"), + (4, "file_path", "MetaFactory.sol"), + (0, "name", "WrongEntryPoint"), + (1, "optimization_enabled", False), + (2, "constructor_args", "00"), + ] + for index, field, value in mutations: + with self.subTest(index=index, field=field): + responses = self.exact_responses() + target = TARGETS[index] + response = exact_contract(target) + response[field] = value + responses[target["address"].lower()] = [response] + result, state = self.run_verifier(responses) + self.assertNotEqual(0, result.returncode) + self.assertIn("metadata", result.stderr.lower()) + self.assertEqual([], state["posts"]) + + settings_mutations = [ + (0, {"viaIR": False, "metadata": {"bytecodeHash": "ipfs"}}), + (1, {"viaIR": True, "metadata": {"appendCBOR": True, "bytecodeHash": "none"}}), + (4, {"viaIR": True, "metadata": {"appendCBOR": False, "bytecodeHash": "none"}}), + ] + for index, compiler_settings in settings_mutations: + with self.subTest(index=index, compiler_settings=compiler_settings): + responses = self.exact_responses() + target = TARGETS[index] + response = exact_contract(target) + response["compiler_settings"] = compiler_settings + responses[target["address"].lower()] = [response] + result, state = self.run_verifier(responses) + self.assertNotEqual(0, result.returncode) + self.assertIn("metadata", result.stderr.lower()) + self.assertEqual([], state["posts"]) + + def test_rejects_unexpected_match_or_twin_verification(self): + for index, mutation in [ + (0, {"is_fully_verified": False, "is_partially_verified": True}), + (1, {"is_fully_verified": True, "is_partially_verified": False}), + (0, {"verified_twin_address_hash": TARGETS[1]["address"]}), + ]: + responses = self.exact_responses() + response = exact_contract(TARGETS[index]) + response.update(mutation) + responses[TARGETS[index]["address"].lower()] = [response] + result, _ = self.run_verifier(responses) + self.assertNotEqual(0, result.returncode) + + def test_one_global_deadline_covers_all_five_contracts(self): + responses = self.exact_responses() + responses[TARGETS[0]["address"].lower()] = [UNVERIFIED, exact_contract(TARGETS[0])] + responses[TARGETS[1]["address"].lower()] = [UNVERIFIED] + result, state = self.run_verifier( + responses, max_seconds=5, clock_values=[100, 101, 102, 103, 105] + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("timed out", result.stderr.lower()) + self.assertEqual(0, state["gets"][TARGETS[1]["address"].lower()]) + + def test_global_deadline_clamps_each_curl_request_budget(self): + responses = self.exact_responses() + responses[TARGETS[0]["address"].lower()] = [UNVERIFIED] + result, state = self.run_verifier( + responses, + attempts=1, + max_seconds=5, + clock_values=[100, 101, 103, 104, 105], + record_curl_args=True, + ) + self.assertNotEqual(0, result.returncode) + self.assertEqual(2, len(state["curl_args"])) + self.assertIn("--connect-timeout 2 --max-time 2", state["curl_args"][0]) + self.assertIn("--connect-timeout 1 --max-time 1", state["curl_args"][1]) + self.assertIn("--retry-max-time 1", state["curl_args"][1]) + + def test_rejects_malformed_or_http_failure_with_bounded_attempts(self): + first_address = TARGETS[0]["address"].lower() + for response in [ + (200, "not-json"), + (503, {"message": "temporarily unavailable"}), + ]: + with self.subTest(response=response): + responses = self.exact_responses() + responses[first_address] = [response] + result, state = self.run_verifier(responses, attempts=2) + self.assertNotEqual(0, result.returncode) + self.assertLessEqual(state["gets"][first_address], 2) + + def test_rejects_noncanonical_manifest_before_http(self): + mutations = [ + lambda manifest: manifest.update({"chainId": 3939}), + lambda manifest: manifest["contracts"][0].update({"address": TARGETS[1]["address"]}), + lambda manifest: manifest["contracts"].reverse(), + ] + for mutation in mutations: + with self.subTest(mutation=mutation): + result, state = self.run_verifier(self.exact_responses(), manifest_mutate=mutation) + self.assertNotEqual(0, result.returncode) + self.assertIn("manifest is invalid", result.stderr.lower()) + self.assertEqual(0, sum(state["gets"].values())) + + def test_rejects_missing_or_renamed_standard_input(self): + for mode in ["missing", "renamed"]: + result, state = self.run_verifier(self.exact_responses(), input_mode=mode) + self.assertNotEqual(0, result.returncode) + self.assertEqual(0, sum(state["gets"].values())) + + def run_verifier( + self, + responses, + attempts=5, + post_response=None, + manifest_mutate=None, + input_mode=None, + clock_values=None, + max_seconds=30, + record_curl_args=False, + ): + class Handler(http.server.BaseHTTPRequestHandler): + state = {"gets": {address: 0 for address in responses}, "posts": []} + + def do_GET(self): + address = self.path.split("/smart-contracts/", 1)[-1].split("?", 1)[0].lower() + queue = responses[address] + index = self.state["gets"][address] + self.state["gets"][address] += 1 + response = queue[min(index, len(queue) - 1)] + status, body = response if isinstance(response, tuple) else (200, response) + self.send_json(status, body) + + def do_POST(self): + length = int(self.headers.get("content-length", "0")) + body = self.rfile.read(length).decode("utf-8", errors="replace") + self.state["posts"].append({"path": self.path, "body": body}) + status, response = post_response or (200, {"message": "Smart-contract verification started"}) + self.send_json(status, response) + + def send_json(self, status, body): + payload = ( + body.encode() + if isinstance(body, str) + else json.dumps(body).encode() + ) + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, _format, *_args): + return + + api_host = self.wsl_gateway() if os.name == "nt" else "127.0.0.1" + server = http.server.ThreadingHTTPServer((api_host, 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with tempfile.TemporaryDirectory() as directory: + artifact_directory = Path(directory) + self.write_artifacts(artifact_directory, manifest_mutate, input_mode) + overrides = { + "DOSCAN_MAINNET_AA_API_BASE_URL": f"http://{api_host}:{server.server_port}", + "DOSCAN_MAINNET_AA_API_HOST_HEADER": "doscan.io", + "DOSCAN_MAINNET_AA_POLL_ATTEMPTS": str(attempts), + "DOSCAN_MAINNET_AA_POLL_INTERVAL_SECONDS": "0", + "DOSCAN_MAINNET_AA_MAX_SECONDS": str(max_seconds), + "DOSCAN_MAINNET_AA_CURL_RETRY_DELAY_SECONDS": "0", + "DOSCAN_MAINNET_AA_CURL_RETRY_MAX_SECONDS": "3", + } + if clock_values is not None: + clock = artifact_directory / "fake-date.sh" + counter = artifact_directory / "fake-date-counter" + clock.write_text( + "#!/bin/sh\n" + 'index="$(cat "${DOSCAN_FAKE_DATE_COUNTER}" 2>/dev/null || printf 0)"\n' + "position=0\nvalue=\nold_ifs=${IFS}\nIFS=,\n" + "set -- ${DOSCAN_FAKE_DATE_VALUES}\nIFS=${old_ifs}\n" + 'for candidate in "$@"; do value="${candidate}"; ' + 'if [ "${position}" -eq "${index}" ]; then break; fi; ' + "position=$((position + 1)); done\n" + 'printf "%s" "$((index + 1))" > "${DOSCAN_FAKE_DATE_COUNTER}"\n' + 'printf "%s\\n" "${value}"\n', + encoding="utf-8", + newline="\n", + ) + clock.chmod(0o755) + overrides["DOSCAN_MAINNET_AA_DATE_BIN"] = self.wsl_path(clock) if os.name == "nt" else clock.as_posix() + overrides["DOSCAN_FAKE_DATE_COUNTER"] = self.wsl_path(counter) if os.name == "nt" else counter.as_posix() + overrides["DOSCAN_FAKE_DATE_VALUES"] = ",".join(map(str, clock_values)) + curl_args = artifact_directory / "curl-args.log" + if record_curl_args: + curl_wrapper = artifact_directory / "fake-curl.sh" + curl_wrapper.write_text( + "#!/bin/sh\n" + 'printf "%s\\n" "$*" >> "${DOSCAN_FAKE_CURL_ARGS}"\n' + 'exec curl "$@"\n', + encoding="utf-8", + newline="\n", + ) + curl_wrapper.chmod(0o755) + overrides["DOSCAN_MAINNET_AA_CURL_BIN"] = self.wsl_path(curl_wrapper) if os.name == "nt" else curl_wrapper.as_posix() + overrides["DOSCAN_FAKE_CURL_ARGS"] = self.wsl_path(curl_args) if os.name == "nt" else curl_args.as_posix() + environment = os.environ.copy() + environment.update(overrides) + if os.name == "nt": + command = ["wsl.exe", "env", *[f"{key}={value}" for key, value in overrides.items()], "/bin/sh", self.wsl_path(SCRIPT_PATH), self.wsl_path(artifact_directory)] + else: + command = ["/bin/sh", SCRIPT_PATH.as_posix(), artifact_directory.as_posix()] + result = subprocess.run(command, capture_output=True, text=True, env=environment, timeout=20, check=False) + Handler.state["curl_args"] = curl_args.read_text(encoding="utf-8").splitlines() if curl_args.exists() else [] + return result, Handler.state + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + @staticmethod + def write_artifacts(directory, manifest_mutate=None, input_mode=None): + manifest = {"version": 2, "chainId": 7979, "contracts": deepcopy(TARGETS)} + if manifest_mutate: + manifest_mutate(manifest) + if input_mode == "renamed": + manifest["contracts"][0]["standardInputFile"] = "renamed.standard-input.json" + (directory / "manifest.json").write_text( + json.dumps(manifest), encoding="utf-8" + ) + for index, target in enumerate(manifest["contracts"]): + filename = target["standardInputFile"] + if index == 0 and input_mode == "missing": + continue + (directory / filename).write_text(json.dumps({"language": "Solidity"}), encoding="utf-8") + + @staticmethod + def wsl_path(path): + result = subprocess.run(["wsl.exe", "wslpath", "-a", str(path.resolve()).replace("\\", "/")], capture_output=True, text=True, check=True) + return result.stdout.strip() + + @staticmethod + def wsl_gateway(): + result = subprocess.run(["wsl.exe", "ip", "route", "show", "default"], capture_output=True, text=True, check=True) + return result.stdout.split()[2] + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/verify-mainnet-aa-bytecode.mjs b/.github/scripts/verify-mainnet-aa-bytecode.mjs new file mode 100644 index 000000000000..e399ffcb3bf5 --- /dev/null +++ b/.github/scripts/verify-mainnet-aa-bytecode.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import http from 'node:http'; +import https from 'node:https'; +import { join } from 'node:path'; + +function fail(message) { + throw new Error(message); +} + +function positiveIntegerEnvironment(name, fallback) { + const raw = process.env[name]; + if (raw == null) { + return fallback; + } + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + fail(`${name} must be a positive integer`); + } + return parsed; +} + +const RPC_ATTEMPTS = 3; +const CONNECT_TIMEOUT_MS = 10_000; +const REQUEST_TIMEOUT_MS = positiveIntegerEnvironment( + 'DOSCAN_MAINNET_AA_RPC_REQUEST_TIMEOUT_MS', + 20_000, +); + +async function readJson(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +function contractOutput(compilerOutput, target) { + const compilerErrors = (compilerOutput.errors || []).filter((error) => error.severity === 'error'); + if (compilerErrors.length > 0) { + fail(`${target.key}: compiler error: ${compilerErrors.map((error) => error.formattedMessage || error.message).join('\n')}`); + } + const output = compilerOutput.contracts?.[target.sourcePath]?.[target.contractName]; + if (!output) { + fail(`${target.key}: missing compiler output for ${target.sourcePath}:${target.contractName}`); + } + return output; +} + +function flattenImmutableRanges(immutableReferences) { + if (!immutableReferences || typeof immutableReferences !== 'object' || Array.isArray(immutableReferences)) { + fail('immutable references must be an object'); + } + const ranges = []; + for (const entries of Object.values(immutableReferences)) { + if (!Array.isArray(entries)) { + fail('immutable reference entries must be arrays'); + } + for (const range of entries) { + if (!Number.isSafeInteger(range?.start) || range.start < 0 || !Number.isSafeInteger(range?.length) || range.length <= 0) { + fail('immutable range must contain nonnegative integer start and positive integer length'); + } + ranges.push({ start: range.start, length: range.length }); + } + } + return ranges.sort((left, right) => left.start - right.start); +} + +function maskRanges(bytecode, ranges) { + const hex = bytecode.slice(2); + const byteLength = hex.length / 2; + const bytes = Buffer.from(hex, 'hex'); + let previousEnd = 0; + for (const range of ranges) { + const end = range.start + range.length; + if (range.start < previousEnd) { + fail('immutable ranges overlap'); + } + if (end > byteLength) { + fail('immutable range exceeds deployed bytecode'); + } + bytes.fill(0, range.start, end); + previousEnd = end; + } + return `0x${bytes.toString('hex')}`; +} + +function sha256LowercaseHexString(bytecode) { + return createHash('sha256').update(bytecode.toLowerCase(), 'utf8').digest('hex'); +} + +function rpcRequest(rpcUrl, payload) { + const url = new URL(rpcUrl); + const transport = url.protocol === 'https:' ? https : http; + const body = JSON.stringify(payload); + return new Promise((resolve, reject) => { + let connectTimer; + let absoluteTimer; + const clearTimers = () => { + clearTimeout(connectTimer); + clearTimeout(absoluteTimer); + }; + const request = transport.request( + url, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }, + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + clearTimers(); + const responseBody = Buffer.concat(chunks).toString('utf8'); + if (response.statusCode < 200 || response.statusCode >= 300) { + reject(new Error(`RPC HTTP ${response.statusCode}: ${responseBody}`)); + return; + } + try { + resolve(JSON.parse(responseBody)); + } catch (error) { + reject(new Error(`RPC returned invalid JSON: ${error.message}`)); + } + }); + response.on('error', (error) => { + clearTimers(); + reject(error); + }); + }, + ); + connectTimer = setTimeout(() => request.destroy(new Error('RPC connect timeout')), CONNECT_TIMEOUT_MS); + absoluteTimer = setTimeout( + () => request.destroy(new Error('RPC absolute request timeout')), + REQUEST_TIMEOUT_MS, + ); + request.on('socket', (socket) => { + const connected = () => clearTimeout(connectTimer); + if (socket.connecting) { + socket.once(url.protocol === 'https:' ? 'secureConnect' : 'connect', connected); + } else { + connected(); + } + }); + request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy(new Error('RPC request timeout'))); + request.on('error', (error) => { + clearTimers(); + reject(error); + }); + request.end(body); + }); +} + +let rpcId = 0; +async function rpcCall(rpcUrl, method, params) { + let lastError; + for (let attempt = 1; attempt <= RPC_ATTEMPTS; attempt += 1) { + try { + const response = await rpcRequest(rpcUrl, { jsonrpc: '2.0', id: ++rpcId, method, params }); + if (response.error) { + fail(`RPC ${method} failed: ${JSON.stringify(response.error)}`); + } + if (!Object.hasOwn(response, 'result')) { + fail(`RPC ${method} response is missing result`); + } + return response.result; + } catch (error) { + lastError = error; + if (attempt < RPC_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, 100 * attempt)); + } + } + } + fail(`RPC ${method} failed after ${RPC_ATTEMPTS} attempts: ${lastError.message}`); +} + +function normalizeBytecode(value, label) { + if (typeof value !== 'string') { + fail(`${label} must be a hex string`); + } + const normalized = value.toLowerCase().startsWith('0x') ? value.toLowerCase() : `0x${value.toLowerCase()}`; + if (!/^0x[0-9a-f]+$/.test(normalized) || normalized.length % 2 !== 0) { + fail(`${label} must be nonempty, even-length hex bytecode`); + } + return normalized; +} + +function hasLinkReferences(linkReferences) { + return Object.values(linkReferences || {}).some((libraries) => + Object.values(libraries || {}).some((references) => Array.isArray(references) && references.length > 0), + ); +} + +async function verifyTarget({ artifactDirectory, rpcUrl, target }) { + const compilerOutput = await readJson(join(artifactDirectory, target.compilerOutputFile)); + const output = contractOutput(compilerOutput, target); + const deployed = output.evm?.deployedBytecode; + if (!deployed) { + fail(`${target.key}: missing deployed bytecode output`); + } + if (hasLinkReferences(deployed.linkReferences)) { + fail(`${target.key}: compiler output contains unresolved library link references`); + } + if (typeof deployed.object === 'string' && deployed.object.includes('__$')) { + fail(`${target.key}: compiler output contains unresolved library placeholders`); + } + const compiledCode = normalizeBytecode(deployed.object, `${target.key} compiled bytecode`); + const immutableRanges = flattenImmutableRanges(deployed.immutableReferences || {}); + const liveCode = normalizeBytecode(await rpcCall(rpcUrl, 'eth_getCode', [target.address, 'latest']), `${target.key} live bytecode`); + if (liveCode.length !== compiledCode.length) { + fail(`${target.key}: live and compiled bytecode lengths differ`); + } + const liveHash = sha256LowercaseHexString(liveCode); + if (liveHash !== target.expectedCodeSha256) { + fail(`${target.key}: live bytecode hash ${liveHash} does not match ${target.expectedCodeSha256}`); + } + if (maskRanges(liveCode, immutableRanges) !== maskRanges(compiledCode, immutableRanges)) { + fail(`${target.key}: live bytecode differs from compiler output outside immutable ranges`); + } + + for (const check of target.rpcChecks || []) { + const selector = output.evm?.methodIdentifiers?.[check.signature]; + if (typeof selector !== 'string' || !/^[0-9a-fA-F]{8}$/.test(selector)) { + fail(`${target.key}: missing selector for ${check.signature}`); + } + const result = await rpcCall(rpcUrl, 'eth_call', [{ to: target.address, data: `0x${selector}` }, 'latest']); + if (typeof result !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(result)) { + fail(`${target.key}: ${check.signature} must return exactly one ABI word`); + } + const actualAddress = `0x${result.slice(-40)}`.toLowerCase(); + if (actualAddress !== check.expectedAddress.toLowerCase()) { + fail(`${target.key}: ${check.signature} returned ${actualAddress}, expected ${check.expectedAddress}`); + } + } + console.log(`${target.key}: compiler bytecode and Mainnet runtime match`); +} + +async function main() { + const [artifactDirectory, rpcUrl] = process.argv.slice(2); + if (!artifactDirectory || !rpcUrl) { + fail('usage: verify-mainnet-aa-bytecode.mjs '); + } + const manifest = await readJson(join(artifactDirectory, 'manifest.json')); + if (manifest.version !== 2 || manifest.chainId !== 7979 || !Array.isArray(manifest.contracts) || manifest.contracts.length === 0) { + fail('invalid Mainnet AA verification manifest'); + } + for (const target of manifest.contracts) { + await verifyTarget({ artifactDirectory, rpcUrl, target }); + } +} + +main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); + +export { contractOutput, flattenImmutableRanges, maskRanges, rpcCall, sha256LowercaseHexString, verifyTarget }; diff --git a/.github/scripts/verify-mainnet-aa-sources.sh b/.github/scripts/verify-mainnet-aa-sources.sh new file mode 100644 index 000000000000..8636fa9b3645 --- /dev/null +++ b/.github/scripts/verify-mainnet-aa-sources.sh @@ -0,0 +1,255 @@ +#!/bin/sh +set -eu + +verification_directory="${1:-}" +if [ -z "${verification_directory}" ]; then + echo "usage: verify-mainnet-aa-sources.sh " >&2 + exit 2 +fi + +manifest_path="${verification_directory}/manifest.json" +api_base_url="${DOSCAN_MAINNET_AA_API_BASE_URL:-http://127.0.0.1:13080}" +api_base_url="${api_base_url%/}" +api_host_header="${DOSCAN_MAINNET_AA_API_HOST_HEADER:-doscan.io}" +curl_bin="${DOSCAN_MAINNET_AA_CURL_BIN:-curl}" +jq_bin="${DOSCAN_MAINNET_AA_JQ_BIN:-jq}" +date_bin="${DOSCAN_MAINNET_AA_DATE_BIN:-date}" +poll_attempts="${DOSCAN_MAINNET_AA_POLL_ATTEMPTS:-60}" +poll_interval_seconds="${DOSCAN_MAINNET_AA_POLL_INTERVAL_SECONDS:-5}" +max_seconds="${DOSCAN_MAINNET_AA_MAX_SECONDS:-300}" +connect_timeout_seconds="${DOSCAN_MAINNET_AA_CONNECT_TIMEOUT_SECONDS:-10}" +request_timeout_seconds="${DOSCAN_MAINNET_AA_REQUEST_TIMEOUT_SECONDS:-20}" +curl_retry_delay_seconds="${DOSCAN_MAINNET_AA_CURL_RETRY_DELAY_SECONDS:-1}" +curl_retry_max_seconds="${DOSCAN_MAINNET_AA_CURL_RETRY_MAX_SECONDS:-30}" + +status_file="$(mktemp)" +submit_file="$(mktemp)" +trap 'rm -f "${status_file}" "${submit_file}"' EXIT HUP INT TERM + +case "${poll_attempts}:${poll_interval_seconds}:${max_seconds}:${connect_timeout_seconds}:${request_timeout_seconds}:${curl_retry_delay_seconds}:${curl_retry_max_seconds}" in + *[!0-9:]* | :* | *: | *::* ) + echo "Mainnet Account Abstraction polling settings must be non-negative integers" >&2 + exit 2 + ;; +esac +if [ "${poll_attempts}" -lt 1 ] || [ "${max_seconds}" -lt 1 ] || \ + [ "${connect_timeout_seconds}" -lt 1 ] || [ "${request_timeout_seconds}" -lt 1 ] || \ + [ "${curl_retry_max_seconds}" -lt 1 ]; then + echo "Mainnet Account Abstraction attempts, deadline, and request timeouts must be positive" >&2 + exit 2 +fi + +if ! "${jq_bin}" -e ' + .version == 2 and .chainId == 7979 and + .contracts == [ + { + "key":"entry-point","address":"0x0000000071727De22E5E9d8BAf0edAc6f37da032","contractName":"EntryPoint", + "sourcePath":"contracts/core/EntryPoint.sol","standardInputFile":"entry-point.standard-input.json", + "compilerOutputFile":"entry-point.compiler-output.json","compilerPackage":"solc-0.8.23", + "compilerVersion":"v0.8.23+commit.f704f362","evmVersion":"paris","optimizer":{"enabled":true,"runs":1000000}, + "viaIR":true,"metadata":{"bytecodeHash":"ipfs"},"licenseType":"gnu_gpl_v3","spdxLicense":"GPL-3.0", + "constructorArgs":"","expectedCodeSha256":"4dcad467095cd9af58006b270475ac7591c6946bca08552f6789727097b51eae","rpcChecks":[],"verificationMatch":"full" + }, + { + "key":"kernel","address":"0xd6CEDDe84be40893d153Be9d467CD6aD37875b28","contractName":"Kernel", + "sourcePath":"src/Kernel.sol","standardInputFile":"kernel.standard-input.json","compilerOutputFile":"kernel.compiler-output.json", + "compilerPackage":"solc-0.8.28","compilerVersion":"v0.8.28+commit.7893614a","evmVersion":"prague", + "optimizer":{"enabled":true,"runs":200},"viaIR":true,"metadata":{"appendCBOR":false,"bytecodeHash":"none"}, + "licenseType":"mit","spdxLicense":"MIT","constructorArgs":"0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da032", + "expectedCodeSha256":"d13e7ff2bc90271659100c83f49ee6250555bbf26ed35c2315f243c6849a2127", + "rpcChecks":[{"signature":"entrypoint()","expectedAddress":"0x0000000071727De22E5E9d8BAf0edAc6f37da032"}],"verificationMatch":"partial" + }, + { + "key":"kernel-factory","address":"0x2577507b78c2008Ff367261CB6285d44ba5eF2E9","contractName":"KernelFactory", + "sourcePath":"dependencies/kernel-v3.3/src/factory/KernelFactory.sol","standardInputFile":"kernel-factory.standard-input.json", + "compilerOutputFile":"kernel-factory.compiler-output.json","compilerPackage":"solc-0.8.28", + "compilerVersion":"v0.8.28+commit.7893614a","evmVersion":"prague","optimizer":{"enabled":true,"runs":200}, + "viaIR":true,"metadata":{"appendCBOR":false,"bytecodeHash":"none"},"licenseType":"mit","spdxLicense":"MIT", + "constructorArgs":"000000000000000000000000d6cedde84be40893d153be9d467cd6ad37875b28", + "expectedCodeSha256":"56443d7d18bfd62d5d69b04fc8207e439bf904166335dd7159e0eeef1cba2367", + "rpcChecks":[{"signature":"implementation()","expectedAddress":"0xd6CEDDe84be40893d153Be9d467CD6aD37875b28"}],"verificationMatch":"partial" + }, + { + "key":"ecdsa-validator","address":"0x845ADb2C711129d4f3966735eD98a9F09fC4cE57","contractName":"ECDSAValidator", + "sourcePath":"src/validator/ECDSAValidator.sol","standardInputFile":"ecdsa-validator.standard-input.json", + "compilerOutputFile":"ecdsa-validator.compiler-output.json","compilerPackage":"solc-0.8.25", + "compilerVersion":"v0.8.25+commit.b61c2a91","evmVersion":"paris","optimizer":{"enabled":true,"runs":200}, + "viaIR":true,"metadata":{"appendCBOR":false,"bytecodeHash":"none"},"licenseType":"mit","spdxLicense":"MIT", + "constructorArgs":"","expectedCodeSha256":"be711f07f49e57bf56c512b6f32f7c77d9ec1881c4051ed33a45cfad8c7a8b8e","rpcChecks":[],"verificationMatch":"partial" + }, + { + "key":"factory-staker","address":"0xd703aaE79538628d27099B8c4f621bE4CCd142d5","contractName":"FactoryStaker", + "sourcePath":"src/factory/FactoryStaker.sol","standardInputFile":"factory-staker.standard-input.json", + "compilerOutputFile":"factory-staker.compiler-output.json","compilerPackage":"solc-0.8.24", + "compilerVersion":"v0.8.24+commit.e11b9ed9","evmVersion":"paris","optimizer":{"enabled":true,"runs":200}, + "viaIR":false,"metadata":{"appendCBOR":false,"bytecodeHash":"none"},"licenseType":"mit","spdxLicense":"MIT", + "constructorArgs":"","expectedCodeSha256":"f91091bf1260892a4d0b834494489fea55be2f2f968ad6b1abc1410531f2a2a1","rpcChecks":[],"verificationMatch":"partial" + } + ] +' "${manifest_path}" >/dev/null; then + echo "Mainnet Account Abstraction verification manifest is invalid" >&2 + exit 2 +fi + +contract_index=0 +while [ "${contract_index}" -lt 5 ]; do + standard_input_file="$("${jq_bin}" -er ".contracts[${contract_index}].standardInputFile" "${manifest_path}")" + case "${standard_input_file}" in + *[!a-z0-9.-]* | .* | *..* | */*) + echo "Invalid Mainnet Account Abstraction input filename: ${standard_input_file}" >&2 + exit 2 + ;; + esac + if [ ! -s "${verification_directory}/${standard_input_file}" ]; then + echo "Missing Mainnet Account Abstraction standard input: ${standard_input_file}" >&2 + exit 2 + fi + contract_index="$((contract_index + 1))" +done + +remaining_budget() { + deadline="$1" + now="$("${date_bin}" +%s)" + remaining="$((deadline - now))" + if [ "${remaining}" -lt 1 ]; then + return 1 + fi + printf '%s\n' "${remaining}" +} + +clamp_timeout() { + configured="$1" + remaining="$2" + if [ "${configured}" -lt "${remaining}" ]; then + printf '%s\n' "${configured}" + else + printf '%s\n' "${remaining}" + fi +} + +get_contract_status() { + contract_address="$1" + deadline="$2" + remaining="$(remaining_budget "${deadline}")" || return 1 + bounded_connect_timeout="$(clamp_timeout "${connect_timeout_seconds}" "${remaining}")" + bounded_request_timeout="$(clamp_timeout "${request_timeout_seconds}" "${remaining}")" + : >"${status_file}" + "${curl_bin}" --connect-timeout "${bounded_connect_timeout}" --max-time "${bounded_request_timeout}" \ + --fail --silent --show-error --header "Host: ${api_host_header}" --header "Cache-Control: no-cache" \ + --output "${status_file}" "${api_base_url}/api/v2/smart-contracts/${contract_address}" +} + +classify_contract_status() { + contract_index="$1" + expected_name="$("${jq_bin}" -er ".contracts[${contract_index}].contractName" "${manifest_path}")" + + if "${jq_bin}" -e '.is_verified == false or ((has("is_verified") | not) and (.creation_bytecode | type == "string") and (.deployed_bytecode | type == "string"))' "${status_file}" >/dev/null; then + return 1 + fi + if ! "${jq_bin}" -e 'type == "object" and has("is_verified") and (.is_verified | type == "boolean")' "${status_file}" >/dev/null; then + echo "Blockscout returned malformed Mainnet verification metadata for ${expected_name}" >&2 + return 2 + fi + + if "${jq_bin}" -e --argjson target "$("${jq_bin}" -c ".contracts[${contract_index}]" "${manifest_path}")" ' + def normalize_hex: tostring | ascii_downcase | sub("^0x"; ""); + def normalize_compiler: tostring | if startswith("v") then . else "v" + . end; + .is_verified == true and + (if $target.verificationMatch == "full" then .is_fully_verified == true and .is_partially_verified == false + else .is_fully_verified == false and .is_partially_verified == true end) and + .verified_twin_address_hash == null and .name == $target.contractName and .file_path == $target.sourcePath and + (.compiler_version | normalize_compiler) == ($target.compilerVersion | normalize_compiler) and + .optimization_enabled == true and .optimization_runs == $target.optimizer.runs and + .evm_version == $target.evmVersion and .license_type == $target.licenseType and + ((.constructor_args // "") | normalize_hex) == ($target.constructorArgs | normalize_hex) and + (if $target.viaIR then .compiler_settings.viaIR == true else (.compiler_settings.viaIR // false) == false end) and + (if $target.key == "entry-point" then (.compiler_settings.metadata.bytecodeHash // "ipfs") == "ipfs" + else .compiler_settings.metadata.appendCBOR == false and .compiler_settings.metadata.bytecodeHash == "none" end) + ' "${status_file}" >/dev/null; then + return 0 + fi + + echo "Blockscout Mainnet verification metadata mismatch for ${expected_name}" >&2 + return 2 +} + +submit_contract() { + contract_index="$1" + deadline="$2" + contract_address="$("${jq_bin}" -er ".contracts[${contract_index}].address" "${manifest_path}")" + contract_name="$("${jq_bin}" -er ".contracts[${contract_index}].contractName" "${manifest_path}")" + source_path="$("${jq_bin}" -er ".contracts[${contract_index}].sourcePath" "${manifest_path}")" + compiler_version="$("${jq_bin}" -er ".contracts[${contract_index}].compilerVersion" "${manifest_path}")" + license_type="$("${jq_bin}" -er ".contracts[${contract_index}].licenseType" "${manifest_path}")" + constructor_args="$("${jq_bin}" -er ".contracts[${contract_index}].constructorArgs" "${manifest_path}")" + standard_input_file="$("${jq_bin}" -er ".contracts[${contract_index}].standardInputFile" "${manifest_path}")" + remaining="$(remaining_budget "${deadline}")" || return 1 + bounded_connect_timeout="$(clamp_timeout "${connect_timeout_seconds}" "${remaining}")" + bounded_request_timeout="$(clamp_timeout "${request_timeout_seconds}" "${remaining}")" + bounded_retry_timeout="$(clamp_timeout "${curl_retry_max_seconds}" "${remaining}")" + : >"${submit_file}" + if ! "${curl_bin}" --connect-timeout "${bounded_connect_timeout}" --max-time "${bounded_request_timeout}" \ + --retry 2 --retry-all-errors --retry-delay "${curl_retry_delay_seconds}" --retry-max-time "${bounded_retry_timeout}" \ + --fail --silent --show-error --header "Host: ${api_host_header}" --output "${submit_file}" \ + --form "compiler_version=${compiler_version}" --form "contract_name=${source_path}:${contract_name}" \ + --form "autodetect_constructor_args=false" --form "constructor_args=${constructor_args}" \ + --form "license_type=${license_type}" --form "files[0]=@${verification_directory}/${standard_input_file};type=application/json" \ + "${api_base_url}/api/v2/smart-contracts/${contract_address}/verification/via/standard-input"; then + echo "Blockscout Mainnet verification submission failed for ${contract_name}" >&2 + return 1 + fi + if "${jq_bin}" -e '.message == "Smart-contract verification started" or .message == "Already verified"' "${submit_file}" >/dev/null; then + return 0 + fi + echo "Blockscout Mainnet verification submission returned an unexpected response for ${contract_name}" >&2 + cat "${submit_file}" >&2 + return 1 +} + +verify_contract() { + contract_index="$1" + deadline="$2" + contract_address="$("${jq_bin}" -er ".contracts[${contract_index}].address" "${manifest_path}")" + contract_name="$("${jq_bin}" -er ".contracts[${contract_index}].contractName" "${manifest_path}")" + submitted=0 + attempt=1 + while [ "${attempt}" -le "${poll_attempts}" ]; do + now="$("${date_bin}" +%s)" + if [ "${now}" -ge "${deadline}" ]; then + echo "Blockscout Mainnet source verification timed out for ${contract_name}" >&2 + return 1 + fi + status_code=3 + if get_contract_status "${contract_address}" "${deadline}"; then + if classify_contract_status "${contract_index}"; then + echo "Blockscout exact Mainnet source verification confirmed for ${contract_name}" + return 0 + else + status_code="$?" + fi + if [ "${status_code}" -eq 2 ]; then + return 1 + fi + if [ "${status_code}" -eq 1 ] && [ "${submitted}" -eq 0 ]; then + submit_contract "${contract_index}" "${deadline}" + submitted=1 + fi + fi + now="$("${date_bin}" +%s)" + if [ "${attempt}" -ge "${poll_attempts}" ] || [ "${now}" -ge "${deadline}" ]; then + echo "Blockscout Mainnet source verification timed out for ${contract_name}" >&2 + return 1 + fi + remaining="$((deadline - now))" + bounded_poll_interval="$(clamp_timeout "${poll_interval_seconds}" "${remaining}")" + sleep "${bounded_poll_interval}" + attempt="$((attempt + 1))" + done +} + +verification_deadline="$(( $("${date_bin}" +%s) + max_seconds ))" +contract_index=0 +while [ "${contract_index}" -lt 5 ]; do + verify_contract "${contract_index}" "${verification_deadline}" + contract_index="$((contract_index + 1))" +done diff --git a/.github/workflows/deploy-config.yml b/.github/workflows/deploy-config.yml index f49d7a441bdb..0f7958a7fda1 100644 --- a/.github/workflows/deploy-config.yml +++ b/.github/workflows/deploy-config.yml @@ -26,8 +26,14 @@ on: - "scripts/render-mainnet-bens.py" - "scripts/validate-testnet-bens.py" - ".github/scripts/extract-aa-verification-inputs.mjs" + - ".github/scripts/extract-mainnet-aa-verification-inputs.mjs" + - ".github/scripts/mainnet-aa-solc/**" - ".github/scripts/mainnet-bens-runtime.sh" - ".github/scripts/mainnet-bens-ui.spec.mjs" + - ".github/scripts/mainnet-aa-source-ui.spec.mjs" + - ".github/scripts/prepare-mainnet-aa-verification.sh" + - ".github/scripts/verify-mainnet-aa-bytecode.mjs" + - ".github/scripts/verify-mainnet-aa-sources.sh" - ".github/scripts/verify-testnet-aa-sources.sh" - ".github/workflows/deploy-config.yml" workflow_dispatch: @@ -68,6 +74,14 @@ jobs: (github.event_name == 'workflow_dispatch' && inputs.environment == 'mainnet')) runs-on: ubuntu-latest environment: mainnet + env: + AA_V07_SOURCE_REPOSITORY: https://github.com/eth-infinitism/account-abstraction.git + AA_V07_SOURCE_REF: 7af70c8993a6f42973f520ae0752386a5032abe7 + KERNEL_SOURCE_REPOSITORY: https://github.com/zerodevapp/kernel.git + KERNEL_SOURCE_REF: cd697c7e21715d015e0643af22310a99aa17433b + KERNEL_SOLADY_REF: 3f2f5345261904463f5429c9031c3d2185c0f4fe + KERNEL_ECDSA_SOURCE_REF: 8f7fd9946b9d351bb5be0428bf34c87bad7ed6c9 + KERNEL_ECDSA_SOLADY_REF: 9deb9ed36a27261a8745db5b7cd7f4cdc3b1cd4e steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -105,6 +119,20 @@ jobs: "${CADDY_IMAGE}" \ caddy validate --config /etc/caddy/Caddyfile + - name: Prepare immutable Mainnet Account Abstraction verification inputs + run: | + set -euo pipefail + /bin/sh .github/scripts/prepare-mainnet-aa-verification.sh \ + "${AA_V07_SOURCE_REPOSITORY}" \ + "${AA_V07_SOURCE_REF}" \ + "${KERNEL_SOURCE_REPOSITORY}" \ + "${KERNEL_SOURCE_REF}" \ + "${KERNEL_SOLADY_REF}" \ + "${KERNEL_ECDSA_SOURCE_REF}" \ + "${KERNEL_ECDSA_SOLADY_REF}" \ + "${RUNNER_TEMP}/mainnet-aa-verification" \ + "https://main.doschain.com/" + - name: Authenticate to Google Cloud uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 with: @@ -166,6 +194,7 @@ jobs: tar -czf /tmp/doscan-config.tgz \ .github/scripts/mainnet-bens-runtime.sh \ + .github/scripts/verify-mainnet-aa-sources.sh \ docker-compose/docker-compose-mainnet.yml \ docker-compose/Caddyfile-gcp-mainnet \ docker-compose/bens \ @@ -180,6 +209,7 @@ jobs: docker-compose/envs/common-frontend-scan.env \ docker-compose/envs/common-smart-contract-verifier.env \ docker-compose/envs/common-visualizer.env \ + -C "${RUNNER_TEMP}" mainnet-aa-verification \ -C /tmp common-blockscout-nft-media.env rm -f /tmp/common-blockscout-nft-media.env @@ -671,6 +701,8 @@ jobs: <<<"${search_result}" >/dev/null DEPLOYMENT_STARTED=0 + /bin/sh "${SRC}/.github/scripts/verify-mainnet-aa-sources.sh" \ + "${SRC}/mainnet-aa-verification" for metadata_url in \ "https://doscan.io/metadata-api/api/v1/metadata" \ "https://doscan.io/api/v1/metadata"; do @@ -713,11 +745,15 @@ jobs: playwright_dir="$(mktemp -d "${RUNNER_TEMP}/doscan-mainnet-playwright.XXXXXX")" trap 'rm -rf "${playwright_dir}"' EXIT cp .github/scripts/mainnet-bens-ui.spec.mjs "${playwright_dir}/" + cp .github/scripts/mainnet-aa-source-ui.spec.mjs "${playwright_dir}/" cd "${playwright_dir}" npm init --yes >/dev/null npm install --no-save --package-lock=false @playwright/test@1.55.0 >/dev/null npx playwright install --with-deps chromium >/dev/null - npx playwright test mainnet-bens-ui.spec.mjs --reporter=line + npx playwright test \ + mainnet-bens-ui.spec.mjs \ + mainnet-aa-source-ui.spec.mjs \ + --reporter=line deploy-testnet: if: > diff --git a/.gitignore b/.gitignore index 6763b86d52e4..66474f1a31b6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ npm-debug.log # Static artifacts /apps/**/node_modules +/.github/scripts/mainnet-aa-solc/node_modules # Since we are building assets from assets/, # we ignore priv/static. You may want to comment diff --git a/docs/superpowers/plans/2026-08-14-mainnet-aa-source-verification.md b/docs/superpowers/plans/2026-08-14-mainnet-aa-source-verification.md new file mode 100644 index 000000000000..d071e534e56a --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-mainnet-aa-source-verification.md @@ -0,0 +1,689 @@ +# Mainnet Account Abstraction Source Verification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reproducibly compile, bytecode-gate, verify, and publicly validate the five DOS ID Wallet Account Abstraction contracts on DOScan Mainnet. + +**Architecture:** GitHub Actions checks out three immutable official source commits and builds five contract-specific standard JSON inputs before obtaining GCP credentials. A local bytecode gate proves the generated compiler outputs correspond to the immutable Mainnet code, then a standalone remote verifier runs after `DEPLOYMENT_STARTED=0` and requires the expected Blockscout match mode plus exact metadata under one global deadline. A separate Playwright spec validates all five public contract pages and `/ops` after deployment. + +**Tech Stack:** GitHub Actions YAML, Node.js ESM, npm lockfiles, Yarn 1, Hardhat build-info, Solidity standard JSON, Bash, Python `unittest`, Blockscout API v2, JSON-RPC, `curl`, `jq`, Playwright. + +## Global Constraints + +- Modify only the DOScan repository. DOS-Chain and DOS-Me remain read-only references. +- Do not redeploy, upgrade, transfer ownership of, or fund any contract. +- Verify exactly five Mainnet contracts and do not add `SimpleAccountFactory`. +- Pin `eth-infinitism/account-abstraction` to `7af70c8993a6f42973f520ae0752386a5032abe7`. +- Pin `zerodevapp/kernel` to `cd697c7e21715d015e0643af22310a99aa17433b` and its Solady submodule to `3f2f5345261904463f5429c9031c3d2185c0f4fe`. +- Pin the ECDSAValidator and FactoryStaker deployment source to Kernel commit `8f7fd9946b9d351bb5be0428bf34c87bad7ed6c9` and its Solady submodule to `9deb9ed36a27261a8745db5b7cd7f4cdc3b1cd4e`. +- Install dependencies, run upstream compile code, assemble inputs, compile standard JSON, and pass the bytecode gate before Google authentication. +- Use one global 300-second source-verification deadline for all five contracts. +- Start source verification only after runtime acceptance passes and `DEPLOYMENT_STARTED=0` is set. +- A source-verification failure must fail the workflow without triggering database, IPFS, or container rollback. +- Never write directly to the Blockscout database to repair verification metadata. +- Do not archive or log private keys, paymaster credentials, API tokens, or GCP credentials. +- Keep the existing Testnet v0.8 extractor and verifier behavior unchanged. +- Expected SHA-256 values are hashes of the lowercase `0x`-prefixed runtime bytecode string encoded as UTF-8: + - EntryPoint: `4dcad467095cd9af58006b270475ac7591c6946bca08552f6789727097b51eae` + - Kernel: `d13e7ff2bc90271659100c83f49ee6250555bbf26ed35c2315f243c6849a2127` + - KernelFactory: `56443d7d18bfd62d5d69b04fc8207e439bf904166335dd7159e0eeef1cba2367` + - ECDSAValidator: `be711f07f49e57bf56c512b6f32f7c77d9ec1881c4051ed33a45cfad8c7a8b8e` + - FactoryStaker: `f91091bf1260892a4d0b834494489fea55be2f2f968ad6b1abc1410531f2a2a1` + +## File Structure + +- `.github/scripts/mainnet-aa-solc/package.json`: exact npm aliases for Solidity 0.8.23, 0.8.24, 0.8.25, and 0.8.28. +- `.github/scripts/mainnet-aa-solc/package-lock.json`: integrity-pinned compiler dependency graph used by `npm ci --ignore-scripts`. +- `.github/scripts/extract-mainnet-aa-verification-inputs.mjs`: source-unit collection, per-contract standard-input generation, and manifest generation. +- `.github/scripts/verify-mainnet-aa-bytecode.mjs`: compiler-output validation, immutable-aware bytecode comparison, pinned live-code hashes, and immutable getter checks. +- `.github/scripts/prepare-mainnet-aa-verification.sh`: isolated pre-auth checkout, dependency, compile, extraction, and bytecode-gate orchestration shared by CI and local validation. +- `.github/scripts/verify-mainnet-aa-sources.sh`: idempotent exact Blockscout verification state machine for manifest version 2. +- `.github/scripts/mainnet-aa-source-ui.spec.mjs`: public Browser UAT for five contract pages and `/ops`. +- `.github/scripts/tests/test_extract_mainnet_aa_verification_inputs.py`: synthetic source-tree and manifest tests. +- `.github/scripts/tests/test_verify_mainnet_aa_bytecode.py`: fake JSON-RPC behavioral tests for code and immutable gates. +- `.github/scripts/tests/test_verify_mainnet_aa_sources.py`: fake Blockscout behavioral tests for exact verification and the shared deadline. +- `.github/scripts/tests/test_validate_mainnet_bens.py`: workflow ordering, archive, rollback boundary, and Playwright wiring regression tests. +- `.github/workflows/deploy-config.yml`: immutable source preparation, pre-auth bytecode gate, archive wiring, post-runtime verification, and public UAT. + +--- + +### Task 1: Generate the five deterministic standard inputs + +**Files:** + +- Create: `.github/scripts/mainnet-aa-solc/package.json` +- Create: `.github/scripts/mainnet-aa-solc/package-lock.json` +- Create: `.github/scripts/extract-mainnet-aa-verification-inputs.mjs` +- Create: `.github/scripts/tests/test_extract_mainnet_aa_verification_inputs.py` + +**Interfaces:** + +- Consumes: `node extract-mainnet-aa-verification-inputs.mjs `. +- Produces: `manifest.json`, five `*.standard-input.json` files, and manifest fields used by Tasks 2 and 3. +- Produces manifest version 2 with this per-contract shape: + +```json +{ + "key": "kernel", + "address": "0xd6CEDDe84be40893d153Be9d467CD6aD37875b28", + "contractName": "Kernel", + "sourcePath": "src/Kernel.sol", + "standardInputFile": "kernel.standard-input.json", + "compilerOutputFile": "kernel.compiler-output.json", + "compilerPackage": "solc-0.8.28", + "compilerVersion": "v0.8.28+commit.7893614a", + "evmVersion": "prague", + "optimizer": { "enabled": true, "runs": 200 }, + "viaIR": true, + "licenseType": "mit", + "spdxLicense": "MIT", + "constructorArgs": "0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da032", + "expectedCodeSha256": "d13e7ff2bc90271659100c83f49ee6250555bbf26ed35c2315f243c6849a2127", + "rpcChecks": [{ "signature": "entrypoint()", "expectedAddress": "0x0000000071727De22E5E9d8BAf0edAc6f37da032" }] +} +``` + +- The complete target catalog is: + +| Key | Address | Contract | Source path | Compiler | EVM | Runs | viaIR | Metadata | Constructor | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `entry-point` | `0x0000000071727De22E5E9d8BAf0edAc6f37da032` | `EntryPoint` | `contracts/core/EntryPoint.sol` | `v0.8.23+commit.f704f362` | `paris` | 1,000,000 | true | `bytecodeHash=ipfs` | empty | +| `kernel` | `0xd6CEDDe84be40893d153Be9d467CD6aD37875b28` | `Kernel` | `src/Kernel.sol` | `v0.8.28+commit.7893614a` | `prague` | 200 | true | `appendCBOR=false`, `bytecodeHash=none` | EntryPoint address | +| `kernel-factory` | `0x2577507b78c2008Ff367261CB6285d44ba5eF2E9` | `KernelFactory` | `dependencies/kernel-v3.3/src/factory/KernelFactory.sol` | `v0.8.28+commit.7893614a` | `prague` | 200 | true | `appendCBOR=false`, `bytecodeHash=none` | Kernel address | +| `ecdsa-validator` | `0x845ADb2C711129d4f3966735eD98a9F09fC4cE57` | `ECDSAValidator` | `src/validator/ECDSAValidator.sol` | `v0.8.25+commit.b61c2a91` | `paris` | 200 | true | `appendCBOR=false`, `bytecodeHash=none` | empty | +| `factory-staker` | `0xd703aaE79538628d27099B8c4f621bE4CCd142d5` | `FactoryStaker` | `src/factory/FactoryStaker.sol` | `v0.8.24+commit.e11b9ed9` | `paris` | 200 | omitted | `appendCBOR=false`, `bytecodeHash=none` | empty | + +- [ ] **Step 1: Write the compiler toolchain package manifest** + +```json +{ + "name": "doscan-mainnet-aa-solc", + "private": true, + "version": "1.0.0", + "dependencies": { + "solc-0.8.23": "npm:solc@0.8.23-fixed", + "solc-0.8.24": "npm:solc@0.8.24", + "solc-0.8.25": "npm:solc@0.8.25", + "solc-0.8.28": "npm:solc@0.8.28" + } +} +``` + +- [ ] **Step 2: Generate and inspect the lockfile without running lifecycle scripts** + +Run: + +```bash +npm install --prefix .github/scripts/mainnet-aa-solc --package-lock-only --ignore-scripts +npm ci --prefix .github/scripts/mainnet-aa-solc --ignore-scripts +``` + +Expected: both commands exit 0 and `package-lock.json` contains all four alias names with integrity hashes. + +- [ ] **Step 3: Write failing extractor tests** + +Create synthetic Account Abstraction Hardhat build-info and a synthetic Kernel checkout with local and `solady/` imports. Assert: + +```python +self.assertEqual(2, manifest["version"]) +self.assertEqual(7979, manifest["chainId"]) +self.assertEqual( + ["entry-point", "kernel", "kernel-factory", "ecdsa-validator", "factory-staker"], + [contract["key"] for contract in manifest["contracts"]], +) +self.assertEqual( + "dependencies/kernel-v3.3/src/factory/KernelFactory.sol", + contracts["kernel-factory"]["sourcePath"], +) +self.assertNotIn("viaIR", factory_staker_input["settings"]) +self.assertEqual( + {"appendCBOR": False, "bytecodeHash": "none"}, + kernel_input["settings"]["metadata"], +) +``` + +Also mutate compiler version, optimizer runs, EVM, SPDX, missing import, unexpected source root, and source content. Each mutation must exit nonzero without leaving a complete manifest. + +- [ ] **Step 4: Run the focused test and confirm the red state** + +Run: + +```bash +python -m unittest .github.scripts.tests.test_extract_mainnet_aa_verification_inputs -v +``` + +Expected: FAIL because the extractor does not exist. + +- [ ] **Step 5: Implement deterministic source collection** + +Use these exact interfaces: + +```js +async function collectSources({ sourceUnit, diskPath, remappings }) +function resolveImport({ importerUnit, importerDiskPath, importPath, remappings }) +function validateSettings(input, target) +async function writeTargetInput({ aaCheckout, kernelCheckout, outputDirectory, target }) +``` + +`collectSources` must parse both `import "path";` and `import {...} from "path";`, recurse with a visited source-unit set, reject paths outside the two checkouts and the pinned Solady checkout, and never perform network access. Relative imports preserve the parent source-unit directory. `solady/` resolves to `/lib/solady/src/` while the KernelFactory source-unit key uses `dependencies/solady-0.1.26/src/`. + +Read EntryPoint's exact standard input from its Hardhat debug artifact and build-info. Rebuild the four Kernel inputs from the pinned checkout with the catalog settings. Require the primary SPDX declaration and require every input's `outputSelection` to include: + +```json +{ + "*": { + "*": [ + "abi", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "evm.deployedBytecode.linkReferences", + "evm.methodIdentifiers" + ] + } +} +``` + +- [ ] **Step 6: Run extractor tests and compiler syntax checks** + +Run: + +```bash +node --check .github/scripts/extract-mainnet-aa-verification-inputs.mjs +python -m unittest .github.scripts.tests.test_extract_mainnet_aa_verification_inputs -v +``` + +Expected: PASS. + +- [ ] **Step 7: Commit the deterministic input generator** + +```bash +git add .github/scripts/mainnet-aa-solc .github/scripts/extract-mainnet-aa-verification-inputs.mjs .github/scripts/tests/test_extract_mainnet_aa_verification_inputs.py +git commit -m "Build immutable Mainnet AA verification inputs" +``` + +--- + +### Task 2: Fail closed on compiler output and Mainnet bytecode drift + +**Files:** + +- Create: `.github/scripts/verify-mainnet-aa-bytecode.mjs` +- Create: `.github/scripts/tests/test_verify_mainnet_aa_bytecode.py` + +**Interfaces:** + +- Consumes: `node verify-mainnet-aa-bytecode.mjs `. +- Consumes each target's `compilerOutputFile` generated by the corresponding `solc.js --standard-json` invocation. +- Produces exit 0 only when compiler output, runtime code hash, immutable-aware bytecode, and declared immutable getters all match. + +- [ ] **Step 1: Write a fake JSON-RPC behavioral test** + +The local `ThreadingHTTPServer` must record `eth_getCode` and `eth_call`. Cover: + +```python +def test_accepts_exact_code_and_immutable_getters(self): ... +def test_rejects_live_code_hash_mismatch(self): ... +def test_rejects_non_immutable_byte_mismatch(self): ... +def test_rejects_wrong_immutable_getter(self): ... +def test_rejects_unresolved_library_link(self): ... +def test_rejects_compiler_error_or_missing_contract(self): ... +def test_retries_transient_rpc_failure_with_a_finite_limit(self): ... +``` + +The synthetic compiler output must include one immutable range and `methodIdentifiers` for `entrypoint()` or `implementation()`. + +- [ ] **Step 2: Run the bytecode test and confirm the red state** + +Run: + +```bash +python -m unittest .github.scripts.tests.test_verify_mainnet_aa_bytecode -v +``` + +Expected: FAIL because the bytecode verifier does not exist. + +- [ ] **Step 3: Implement strict compiler-output validation** + +Use these interfaces: + +```js +function contractOutput(compilerOutput, target) +function flattenImmutableRanges(immutableReferences) +function maskRanges(bytecode, ranges) +function sha256LowercaseHexString(bytecode) +async function rpcCall(rpcUrl, method, params) +async function verifyTarget({ artifactDirectory, rpcUrl, target }) +``` + +Reject any compiler error with `severity === "error"`, missing contract output, empty deployed bytecode, nonempty link references, unresolved `__$...$__` placeholders, unexpected bytecode length, malformed immutable ranges, or overlapping/out-of-bounds ranges. + +Require the lowercase live `eth_getCode` string hash to equal `expectedCodeSha256`. Then mask only compiler-declared immutable ranges in both compiled and live bytecode and require exact equality outside those ranges. + +- [ ] **Step 4: Implement immutable getter checks** + +For every `rpcChecks` entry, read the selector from `evm.methodIdentifiers[signature]`, call the contract with that selector, require exactly one ABI word, and require its low 20 bytes to equal `expectedAddress`. + +The catalog must require: + +```json +{"signature":"entrypoint()","expectedAddress":"0x0000000071727De22E5E9d8BAf0edAc6f37da032"} +``` + +for Kernel and: + +```json +{"signature":"implementation()","expectedAddress":"0xd6CEDDe84be40893d153Be9d467CD6aD37875b28"} +``` + +for KernelFactory. Other targets use an empty `rpcChecks` list because their full pinned code hashes still apply. + +Use three RPC attempts, a 10-second connect timeout, a 20-second request timeout, and no unbounded retry loop. + +- [ ] **Step 5: Run focused bytecode tests** + +Run: + +```bash +node --check .github/scripts/verify-mainnet-aa-bytecode.mjs +python -m unittest .github.scripts.tests.test_verify_mainnet_aa_bytecode -v +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the bytecode gate** + +```bash +git add .github/scripts/verify-mainnet-aa-bytecode.mjs .github/scripts/tests/test_verify_mainnet_aa_bytecode.py +git commit -m "Gate Mainnet AA compiler bytecode" +``` + +--- + +### Task 3: Verify five contracts idempotently through Blockscout + +**Files:** + +- Create: `.github/scripts/verify-mainnet-aa-sources.sh` +- Create: `.github/scripts/tests/test_verify_mainnet_aa_sources.py` + +**Interfaces:** + +- Consumes: `/bin/sh verify-mainnet-aa-sources.sh `. +- Environment: `DOSCAN_MAINNET_AA_API_BASE_URL`, `DOSCAN_MAINNET_AA_API_HOST_HEADER`, `DOSCAN_MAINNET_AA_MAX_SECONDS`, `DOSCAN_MAINNET_AA_POLL_ATTEMPTS`, bounded curl settings, and test-only clock/curl overrides. +- Produces exit 0 only after all five manifest entries report their expected Blockscout match mode and exact metadata. + +- [ ] **Step 1: Build a five-contract fake Blockscout server** + +Copy the behavioral server pattern from `test_verify_testnet_aa_sources.py`, but define exact responses from each manifest contract instead of global compiler settings. Add tests for: + +```python +def test_five_exact_contracts_are_not_submitted(self): ... +def test_submits_each_unverified_contract_with_its_own_profile(self): ... +def test_already_verified_race_requires_exact_get(self): ... +def test_rejects_wrong_metadata_for_each_profile_field(self): ... +def test_rejects_unexpected_match_or_twin_verification(self): ... +def test_one_global_deadline_covers_all_five_contracts(self): ... +def test_rejects_noncanonical_manifest_before_http(self): ... +def test_rejects_missing_or_renamed_standard_input(self): ... +``` + +Assert FactoryStaker POST uses contract name `src/factory/FactoryStaker.sol:FactoryStaker`, not `MetaFactory`. + +- [ ] **Step 2: Run the verifier test and confirm the red state** + +Run: + +```bash +python -m unittest .github.scripts.tests.test_verify_mainnet_aa_sources -v +``` + +Expected: FAIL because the Mainnet verifier does not exist. + +- [ ] **Step 3: Implement manifest version 2 validation** + +Validate exact chain ID 7979, exactly five ordered targets, addresses, file names, compiler profiles, SPDX values, constructor arguments, source paths, code hashes, and getter expectations before the first HTTP request. Reject directory separators, `..`, hidden names, missing files, extra targets, and duplicate addresses. + +- [ ] **Step 4: Implement per-contract exact metadata predicates** + +The classifier must normalize the optional compiler `v` prefix and constructor `0x` prefix, then require: + +```jq +.is_verified == true and +(if $target.verificationMatch == "full" then .is_fully_verified == true and .is_partially_verified == false + else .is_fully_verified == false and .is_partially_verified == true end) and +.verified_twin_address_hash == null and +.name == $expected_name and +.file_path == $expected_source_path and +.optimization_enabled == true and +.optimization_runs == $expected_runs and +.evm_version == $expected_evm and +.license_type == $expected_license and +((.constructor_args // "") | normalize_hex) == ($expected_constructor | normalize_hex) +``` + +Require `compiler_settings.viaIR == true` for the four IR targets. For FactoryStaker require `(.compiler_settings.viaIR // false) == false`. Require Kernel-family `metadata.appendCBOR == false` and `metadata.bytecodeHash == "none"`; require EntryPoint `metadata.bytecodeHash == "ipfs"`. + +- [ ] **Step 5: Implement bounded idempotent submission and polling** + +Reuse the safe multipart fields from the Testnet verifier, but send each target's compiler, source path, name, constructor, license, and input file. Create `verification_deadline` once before the loop over five contracts and pass it into every `verify_contract` call. + +Treat `Already verified` as a race signal only. GET again and accept only exact metadata. A verified but inexact contract fails immediately and is never resubmitted. + +- [ ] **Step 6: Run shell and behavioral validation** + +Run: + +```bash +bash -n .github/scripts/verify-mainnet-aa-sources.sh +python -m unittest .github.scripts.tests.test_verify_mainnet_aa_sources -v +``` + +Expected: PASS. + +- [ ] **Step 7: Commit the Mainnet source verifier** + +```bash +git add .github/scripts/verify-mainnet-aa-sources.sh .github/scripts/tests/test_verify_mainnet_aa_sources.py +git commit -m "Verify Mainnet AA sources exactly" +``` + +--- + +### Task 4: Wire the pre-auth build and post-runtime verification into Mainnet deploy + +**Files:** + +- Create: `.github/scripts/prepare-mainnet-aa-verification.sh` +- Modify: `.github/workflows/deploy-config.yml:4-32` +- Modify: `.github/workflows/deploy-config.yml:64-145` +- Modify: `.github/workflows/deploy-config.yml:145-184` +- Modify: `.github/workflows/deploy-config.yml:620-705` +- Modify: `.github/scripts/tests/test_validate_mainnet_bens.py:140-190` + +**Interfaces:** + +- Consumes Task 1's source generator and Task 2's bytecode verifier. +- Consumes: `prepare-mainnet-aa-verification.sh `. +- Packages `${RUNNER_TEMP}/mainnet-aa-verification` as `mainnet-aa-verification` in `/tmp/doscan-config.tgz`. +- Invokes Task 3's verifier through the local Caddy origin after `DEPLOYMENT_STARTED=0`. + +- [ ] **Step 1: Write failing workflow regression assertions** + +Extend `test_mainnet_workflow_consumes_the_canonical_manifest` and add focused tests that parse the Mainnet job and preparation script. Assert: + +```python +self.assertLess(prepare_index, google_auth_index) +self.assertLess(bytecode_gate_index, google_auth_index) +self.assertLess(deployment_stopped_index, source_verify_index) +self.assertIn("mainnet-aa-verification", package_step) +self.assertIn("verify-mainnet-aa-sources.sh", package_step) +self.assertNotIn("verify-mainnet-aa-sources.sh", testnet_job) +``` + +Also assert the exact two repository SHAs, exact Solady SHA, all four compiler aliases, five expected output files, and a bounded public RPC URL. Assert the remote source verifier appears after the last rollback-sensitive runtime gate and before external metadata checks. + +- [ ] **Step 2: Run the Mainnet regression and confirm the red state** + +Run: + +```bash +python -m unittest .github.scripts.tests.test_validate_mainnet_bens -v +``` + +Expected: FAIL because Mainnet has no AA source preparation or verification wiring. + +- [ ] **Step 3: Add Mainnet-only immutable source constants** + +Add job-level environment variables: + +```yaml +env: + AA_V07_SOURCE_REPOSITORY: https://github.com/eth-infinitism/account-abstraction.git + AA_V07_SOURCE_REF: 7af70c8993a6f42973f520ae0752386a5032abe7 + KERNEL_SOURCE_REPOSITORY: https://github.com/zerodevapp/kernel.git + KERNEL_SOURCE_REF: cd697c7e21715d015e0643af22310a99aa17433b + KERNEL_SOLADY_REF: 3f2f5345261904463f5429c9031c3d2185c0f4fe +``` + +Do not move these into global workflow env because Testnet uses a different Account Abstraction release. + +- [ ] **Step 4: Add the pre-auth preparation step** + +Implement `prepare-mainnet-aa-verification.sh` with `set -eu`, exactly nine required arguments, private `mktemp -d` checkouts, and an `EXIT HUP INT TERM` cleanup trap. Place `Prepare immutable Mainnet Account Abstraction verification inputs` after Caddy validation and before `Authenticate to Google Cloud`; the workflow step calls the script with the seven pinned provenance variables, `${RUNNER_TEMP}/mainnet-aa-verification`, and `https://main.doschain.com/`. + +The preparation script must: + +1. Create clean runner-temp checkouts. +2. Fetch and detach exactly both full SHAs. +3. Initialize only `lib/solady` and require its HEAD to equal `KERNEL_SOLADY_REF`. +4. Install Yarn 1.22.22, run `yarn install --frozen-lockfile --non-interactive`, and compile Account Abstraction v0.7. +5. Run `npm ci --prefix .github/scripts/mainnet-aa-solc --ignore-scripts`. +6. Run the Task 1 extractor. +7. Compile each standard input with its manifest-selected `compilerPackage`: + +```bash +compiler_js="${GITHUB_WORKSPACE}/.github/scripts/mainnet-aa-solc/node_modules/${compiler_package}/solc.js" +node "${compiler_js}" --standard-json \ + < "${output}/${standard_input_file}" \ + > "${output}/${compiler_output_file}" +``` + +8. Run `verify-mainnet-aa-bytecode.mjs` against `https://main.doschain.com/`. +9. Require all five standard inputs, all five compiler outputs, and `manifest.json` to be nonempty. + +- [ ] **Step 5: Package only public verification artifacts** + +Add these archive members: + +```text +.github/scripts/verify-mainnet-aa-sources.sh +-C ${RUNNER_TEMP} mainnet-aa-verification +``` + +Keep the existing `umask 077` and NFT media credential cleanup. The compiler toolchain and upstream checkouts are not shipped to the VM. + +- [ ] **Step 6: Run source verification outside rollback scope** + +Immediately after the existing Mainnet line that sets `DEPLOYMENT_STARTED=0`, invoke: + +```sh +/bin/sh "${SRC}/.github/scripts/verify-mainnet-aa-sources.sh" \ + "${SRC}/mainnet-aa-verification" +``` + +Do not add the verifier to `TOUCHED_SERVICES`, `bens_rollback`, or database restore paths. + +- [ ] **Step 7: Run workflow regressions and syntax checks** + +Run: + +```bash +python -m unittest .github.scripts.tests.test_validate_mainnet_bens -v +python -c "import pathlib,yaml; yaml.safe_load(pathlib.Path('.github/workflows/deploy-config.yml').read_text())" +actionlint .github/workflows/deploy-config.yml +``` + +Extract both remote `gcloud --command` payloads with the existing test helpers and require `bash -n` exit 0. + +Also run: + +```bash +bash -n .github/scripts/prepare-mainnet-aa-verification.sh +``` + +- [ ] **Step 8: Commit Mainnet workflow wiring** + +```bash +git add .github/scripts/prepare-mainnet-aa-verification.sh .github/workflows/deploy-config.yml .github/scripts/tests/test_validate_mainnet_bens.py +git commit -m "Wire Mainnet AA source verification" +``` + +--- + +### Task 5: Add public Browser UAT for five sources and `/ops` + +**Files:** + +- Create: `.github/scripts/mainnet-aa-source-ui.spec.mjs` +- Modify: `.github/workflows/deploy-config.yml:708-720` +- Modify: `.github/scripts/tests/test_validate_mainnet_bens.py:180-215` + +**Interfaces:** + +- Consumes: `DOSCAN_MAINNET_URL=https://doscan.io/`. +- Produces: Playwright success only when the API and visible UI agree on all five exact contracts and `/ops` still exposes EntryPoint v0.7 operations. + +- [ ] **Step 1: Write failing static workflow and spec assertions** + +Require the workflow to copy and run both `mainnet-bens-ui.spec.mjs` and `mainnet-aa-source-ui.spec.mjs` in the same pinned Playwright installation. Assert the new spec contains all five addresses, the EntryPoint v0.7 address on `/ops`, `response?.ok()`, visible locators, and the exact two-marker Cloudflare skip predicate. + +- [ ] **Step 2: Run the Mainnet regression and confirm the red state** + +Run: + +```bash +python -m unittest .github.scripts.tests.test_validate_mainnet_bens -v +``` + +Expected: FAIL because the new Playwright spec is absent. + +- [ ] **Step 3: Implement the public contract-page acceptance helper** + +Use this target shape: + +```js +const targets = [ + { + address: "0x0000000071727De22E5E9d8BAf0edAc6f37da032", + name: "EntryPoint", + compiler: "v0.8.23+commit.f704f362", + sourcePath: "contracts/core/EntryPoint.sol", + } +]; +``` + +Include all five targets. For each target: + +1. Fetch `/api/v2/smart-contracts/${address}` and require HTTP 200. +2. Require the expected Blockscout match mode, exact name, compiler normalization, source path, optimizer settings, EVM, license, and constructor arguments. EntryPoint requires full match; the four no-CBOR Kernel contracts require partial match after the pre-auth bytecode gate proves exact runtime equality. +3. Navigate to `/address/${address}?tab=contract`. +4. Require the contract name and the visible expected verification signal. +5. Reject visible `Oops! Something went wrong` text. + +Retry only the initial landing readiness six times with 5-second delays. Skip only when both the exact title `Just a moment...` and URL token `__cf_chl_rt_tk=` are present. + +- [ ] **Step 4: Add the `/ops` regression** + +Navigate to `/ops`, require a successful Account Abstraction API response, require at least one visible operation row, and require the visible EntryPoint address or its shortened checksum text to resolve to `0x0000000071727De22E5E9d8BAf0edAc6f37da032`. Do not accept a static page title as evidence. + +- [ ] **Step 5: Run Node syntax and workflow regressions** + +Run: + +```bash +node --check .github/scripts/mainnet-aa-source-ui.spec.mjs +python -m unittest .github.scripts.tests.test_validate_mainnet_bens -v +``` + +Expected: PASS. + +- [ ] **Step 6: Commit public UAT** + +```bash +git add .github/scripts/mainnet-aa-source-ui.spec.mjs .github/workflows/deploy-config.yml .github/scripts/tests/test_validate_mainnet_bens.py +git commit -m "Test Mainnet AA source verification UI" +``` + +--- + +### Task 6: Full validation, independent review, merge, deployment, and live UAT + +**Files:** + +- Review: complete diff against `origin/main` +- Verify: all files created or modified in Tasks 1 through 5 + +**Interfaces:** + +- Consumes all previous task outputs. +- Produces a reviewed and merged PR, a terminally successful Mainnet workflow, and live Browser evidence. + +- [ ] **Step 1: Prove the new test suite passes** + +Run: + +```bash +python -m unittest .github.scripts.tests.test_extract_mainnet_aa_verification_inputs -v +python -m unittest .github.scripts.tests.test_verify_mainnet_aa_bytecode -v +python -m unittest .github.scripts.tests.test_verify_mainnet_aa_sources -v +python -m unittest .github.scripts.tests.test_validate_mainnet_bens -v +``` + +Expected: all tests pass. + +- [ ] **Step 2: Prove existing Testnet behavior did not regress** + +Run: + +```bash +python -m unittest .github.scripts.tests.test_extract_aa_verification_inputs -v +python -m unittest .github.scripts.tests.test_verify_testnet_aa_sources -v +python scripts/validate-testnet-bens.py +``` + +Expected: all tests and validator pass without changing Testnet target metadata. + +- [ ] **Step 3: Run repository quality gates** + +Run: + +```bash +node --check .github/scripts/extract-mainnet-aa-verification-inputs.mjs +node --check .github/scripts/verify-mainnet-aa-bytecode.mjs +node --check .github/scripts/mainnet-aa-source-ui.spec.mjs +bash -n .github/scripts/verify-mainnet-aa-sources.sh +bash -n .github/scripts/prepare-mainnet-aa-verification.sh +python -c "import pathlib,yaml; yaml.safe_load(pathlib.Path('.github/workflows/deploy-config.yml').read_text())" +actionlint .github/workflows/deploy-config.yml +git diff --check origin/main...HEAD +git status --short +``` + +Expected: every command exits 0 and the worktree contains only the intended task files. + +- [ ] **Step 4: Rebuild real upstream inputs from clean checkouts** + +Run the shared preparation script from a clean temporary output directory: + +```bash +output="$(mktemp -d)" +trap 'rm -rf "${output}"' EXIT +.github/scripts/prepare-mainnet-aa-verification.sh \ + https://github.com/eth-infinitism/account-abstraction.git \ + 7af70c8993a6f42973f520ae0752386a5032abe7 \ + https://github.com/zerodevapp/kernel.git \ + cd697c7e21715d015e0643af22310a99aa17433b \ + 3f2f5345261904463f5429c9031c3d2185c0f4fe \ + 8f7fd9946b9d351bb5be0428bf34c87bad7ed6c9 \ + 9deb9ed36a27261a8745db5b7cd7f4cdc3b1cd4e \ + "${output}" \ + https://main.doschain.com/ +``` + +Expected: exit 0, all five compilers report the exact long version, all five code hashes match the global constraints, Kernel returns the v0.7 EntryPoint from `entrypoint()`, and KernelFactory returns the pinned Kernel from `implementation()`. + +- [ ] **Step 5: Request independent review on the exact head** + +Use `superpowers:requesting-code-review`. The reviewer must inspect provenance, compiler settings, source-unit mapping, immutable masking, code hashes, deadline behavior, credential ordering, rollback boundary, archive cleanup, shell quoting, and Browser acceptance. Fix every Critical, Important, and Minor finding and request re-review on the new exact SHA. + +- [ ] **Step 6: Publish and merge** + +Push `codex/mainnet-aa-source-verification`, open a ready PR, wait for all required CI and CodeQL checks, and merge only after the independent reviewer approves the exact head. + +- [ ] **Step 7: Observe Mainnet deployment to terminal success** + +Verify the merged `Deploy Config` Mainnet job completes. Record evidence that input preparation and bytecode gating ran before GCP auth, runtime deployment passed, `DEPLOYMENT_STARTED=0` preceded source verification, and all five exact confirmations appeared. + +- [ ] **Step 8: Run live Browser UAT after deployment** + +Using Browser, open each public contract page and `/ops`. Confirm the exact contract name, compiler, source path, expected full or partial match state, and expected v0.7 EntryPoint operation data. Treat CI Playwright and live Browser inspection as separate evidence. diff --git a/docs/superpowers/specs/2026-08-14-mainnet-aa-source-verification-design.md b/docs/superpowers/specs/2026-08-14-mainnet-aa-source-verification-design.md new file mode 100644 index 000000000000..86eee410d36b --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-mainnet-aa-source-verification-design.md @@ -0,0 +1,173 @@ +# Mainnet Account Abstraction Source Verification Design + +**Date:** 2026-08-14 + +**Status:** Approved + +**Repository:** DOScan + +**Network:** DOS Chain Mainnet, chain ID 7979 + +## 1. Decision + +DOScan will deterministically reconstruct Solidity standard JSON inputs from pinned official upstream repositories and verify the five production contracts that make up the DOS ID wallet Account Abstraction stack. + +The verification gate will be part of the existing Mainnet deployment workflow. It will run only after runtime deployment and semantic health checks have succeeded and after `DEPLOYMENT_STARTED=0`, so a source-verification failure reports a failed workflow without rolling back a healthy runtime. + +This change does not redeploy contracts, change wallet behavior, modify Blockscout core, or depend on another explorer at deployment time. + +## 2. Scope + +### In scope + +| Role | Address | Expected contract name | +| --- | --- | --- | +| EntryPoint v0.7 | `0x0000000071727De22E5E9d8BAf0edAc6f37da032` | `EntryPoint` | +| Kernel v3.3 implementation | `0xd6CEDDe84be40893d153Be9d467CD6aD37875b28` | `Kernel` | +| KernelFactory v3.3 | `0x2577507b78c2008Ff367261CB6285d44ba5eF2E9` | `KernelFactory` | +| ECDSA validator | `0x845ADb2C711129d4f3966735eD98a9F09fC4cE57` | `ECDSAValidator` | +| MetaFactory deployment | `0xd703aaE79538628d27099B8c4f621bE4CCd142d5` | `FactoryStaker` | + +The last address is called MetaFactory by the wallet stack, but its Solidity contract name is `FactoryStaker`. Verification must use the Solidity name. + +### Out of scope + +- No contract deployment, upgrade, ownership change, or fund transfer. +- No verification of `SimpleAccountFactory`. +- No migration to EntryPoint v0.8 or later. +- No Blockscout Backend, Frontend, or smart-contract-verifier core changes. +- No DOS-Chain or DOS-Me repository changes. +- No direct database correction when Blockscout metadata is wrong. + +## 3. Considered approaches + +### Chosen: reconstruct from pinned official upstream repositories + +The workflow checks out immutable upstream commits, installs their locked dependencies, compiles with exact per-contract settings, and extracts deterministic standard JSON inputs. + +Benefits: + +- Official source provenance is explicit and reproducible. +- DOScan does not vendor a large third-party source snapshot. +- Deployment does not trust a live third-party explorer. +- A source or compiler drift fails before production credentials are issued. + +### Rejected: vendor generated standard inputs in DOScan + +This removes build work from CI but duplicates third-party source artifacts and makes provenance updates easier to miss. + +### Rejected: fetch verified inputs from Ethereum Blockscout during deployment + +This is compact, but Mainnet deployment would depend on an external explorer's availability and mutable response format. + +## 4. Immutable source provenance + +| Family | Official repository | Ref | Pinned commit | +| --- | --- | --- | --- | +| EntryPoint v0.7 | `eth-infinitism/account-abstraction` | `v0.7.0` | `7af70c8993a6f42973f520ae0752386a5032abe7` | +| Kernel v3.3 stack | `zerodevapp/kernel` | `v3.3` | `cd697c7e21715d015e0643af22310a99aa17433b` | +| Legacy deployment source | `zerodevapp/kernel` | deployment commit | `8f7fd9946b9d351bb5be0428bf34c87bad7ed6c9` | + +The Kernel v3.3 pin supplies `Kernel` and `KernelFactory`. ECDSAValidator and FactoryStaker were deployed from the older pinned Kernel commit and its pinned Solady submodule `9deb9ed36a27261a8745db5b7cd7f4cdc3b1cd4e`. Every checkout must resolve to the full expected SHA. Generated inputs are deployment artifacts only and must not contain credentials. + +## 5. Exact compilation profiles + +Each contract has its own canonical compilation profile. The implementation must not apply one repository-wide compiler configuration to all Kernel contracts. + +| Contract | Source path | Compiler | Optimizer | EVM | IR | Metadata | License | Constructor data | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| EntryPoint | `contracts/core/EntryPoint.sol` | `v0.8.23+commit.f704f362` | enabled, 1,000,000 runs | `paris` | `true` | IPFS bytecode hash | `gnu_gpl_v3` | none | +| Kernel | `src/Kernel.sol` | `v0.8.28+commit.7893614a` | enabled, 200 runs | `prague` | `true` | no CBOR, no bytecode hash | `mit` | EntryPoint v0.7 address | +| KernelFactory | `dependencies/kernel-v3.3/src/factory/KernelFactory.sol` | `v0.8.28+commit.7893614a` | enabled, 200 runs | `prague` | `true` | no CBOR, no bytecode hash | `mit` | Kernel implementation address | +| ECDSAValidator | `src/validator/ECDSAValidator.sol` | `v0.8.25+commit.b61c2a91` | enabled, 200 runs | `paris` | `true` | no CBOR, no bytecode hash | `mit` | none | +| FactoryStaker | `src/factory/FactoryStaker.sol` | `v0.8.24+commit.e11b9ed9` | enabled, 200 runs | `paris` | omitted, equivalent to `false` | no CBOR, no bytecode hash | `mit` | none | + +Compiler versions are normalized to a canonical `vX.Y.Z+commit...` form when comparing Blockscout metadata. + +Kernel contains immutable data tied to EntryPoint and chain-specific deployment behavior. The bytecode preflight must compile for DOS Chain Mainnet with the exact constructor arguments and compare `eth_getCode` against the compiler's deployed bytecode using the emitted immutable-reference ranges. It must not compare Kernel blindly against Ethereum bytecode. + +All other contracts must also pass an exact deployed-bytecode comparison before any production mutation. Any unresolved library link, immutable range, constructor mismatch, or metadata mismatch fails closed. + +## 6. Workflow architecture and ordering + +```text +checkout pinned upstream sources + -> install locked toolchains and dependencies + -> compile five exact profiles + -> extract standard JSON inputs and verification manifest + -> compare generated deployed bytecode with canonical Mainnet code + -> package immutable artifacts + -> authenticate to Google Cloud + -> run the existing Mainnet deployment and semantic gates + -> set DEPLOYMENT_STARTED=0 + -> verify five sources through the internal Blockscout API + -> run public Browser UAT +``` + +The source preparation, dependency installation, compilation, extraction, and local bytecode checks must run before Google authentication. Upstream lifecycle scripts therefore cannot access production cloud credentials. The generator preserves the canonical source-unit names expected by Blockscout, including the `dependencies/kernel-v3.3/` prefix used by KernelFactory. + +The remote verification script uses the internal canonical Blockscout API to avoid CDN caching while exercising the same deployed verifier service. The public explorer is checked afterward by Browser UAT. + +## 7. Verification state machine + +One global deadline of 300 seconds is created before processing the first contract. The same deadline applies to all five contracts and every poll. It must never reset per contract. + +For each contract: + +1. GET current smart-contract metadata. +2. If every required field is exact, skip POST. +3. If the contract is unverified, POST its standard JSON input and exact constructor arguments. +4. Poll until the contract is exact or the shared deadline expires. +5. If POST reports `Already verified`, GET again and accept only exact metadata. +6. If Blockscout reports a verified contract with wrong metadata, fail immediately. + +Required exact metadata includes the expected Blockscout match mode, contract name, source path, compiler, optimizer state and runs, EVM version, license, and constructor arguments. EntryPoint requires a full match because it embeds IPFS metadata. The four Kernel contracts intentionally omit CBOR metadata, so Blockscout reports a partial match even after the earlier bytecode gate proves exact compiler/runtime equality. + +The script must use bounded connection and transfer timeouts. Invalid JSON, missing required fields, HTTP failures that outlive the shared deadline, an unexpected match mode, or contradictory metadata fail closed. + +## 8. Failure and rollback semantics + +- Source preparation or bytecode mismatch occurs before Google authentication and before production mutation. +- Runtime deployment preserves its existing backup, rollback, and semantic verification behavior. +- Source verification begins only after runtime success and `DEPLOYMENT_STARTED=0`. +- A source-verification failure fails the workflow but does not trigger a database, IPFS, or container rollback. +- Temporary source artifacts are removed on success and failure. +- The verifier never writes directly to the Blockscout database and never tries to repair wrong verified metadata automatically. + +## 9. Test strategy + +Behavioral tests must cover real request parsing and state transitions, not only string assertions. + +Required cases: + +- Pinned commits resolve exactly and all five standard inputs are deterministic. +- All five compiled deployed bytecodes match Mainnet, including immutable-aware Kernel matching. +- A bytecode mismatch fails before Google authentication and before mutation. +- Five already-exact contracts produce no POST requests. +- An unverified contract submits once and polls to exact metadata. +- An `Already verified` race succeeds only after an exact GET. +- A verified contract with wrong compiler, optimizer, EVM, path, name, license, or constructor arguments fails immediately. +- Partial verification fails. +- Two or more pending contracts share one global 300-second deadline. +- Network and malformed-JSON failures are bounded. +- Source verification is ordered after `DEPLOYMENT_STARTED=0` and cannot invoke runtime rollback. + +Existing Mainnet deployment, shell syntax, workflow validation, actionlint, Compose rendering, and diff checks must continue to pass. + +## 10. Security properties + +- Upstream code is pinned by full commit SHA and built before cloud credentials exist. +- Package managers use lockfiles and pinned tool versions. +- Verification artifacts contain source inputs and public contract metadata only. +- No private key, API token, paymaster credential, wallet secret, or GCP credential is logged or archived. +- Deployment has no runtime dependency on Ethereum Blockscout or another third-party explorer. +- Every remote call and poll has a finite wall-clock bound. + +## 11. Acceptance criteria + +- An independent reviewer reports no Critical, Important, or Minor finding. +- The PR is merged by the agent after approval and all required CI checks pass. +- The Mainnet deployment workflow completes successfully. +- DOScan Mainnet shows the expected full match for EntryPoint and partial match for the four no-CBOR Kernel contracts, with exact compiler metadata on all five pages. +- Browser UAT confirms each contract page exposes the expected name, compiler, source, and verified state. +- Browser UAT confirms `/ops` remains healthy and still reports the production EntryPoint v0.7 operations. diff --git a/docs/superpowers/specs/2026-08-14-mainnet-aa-source-verification-design.vi.html b/docs/superpowers/specs/2026-08-14-mainnet-aa-source-verification-design.vi.html new file mode 100644 index 000000000000..3c62814df83c --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-mainnet-aa-source-verification-design.vi.html @@ -0,0 +1,241 @@ + + + + + + + Thiết kế verify source Account Abstraction trên DOScan Mainnet + + + +
+
+
DOScan Mainnet
+

Verify source cho toàn bộ DOS ID Wallet Account Abstraction

+

Workflow Mainnet sẽ tái tạo standard input từ source chính thức đã pin, đối chiếu bytecode trên DOS Chain 7979, rồi verify chính xác năm contract production qua API chuẩn của Blockscout mà không sửa lõi Blockscout.

+
+
5 contractEntryPoint, Kernel, Factory, Validator và MetaFactory
+
2 source pinaccount-abstraction v0.7.0 và Kernel v3.3
+
5 phút tổngmột deadline chung cho toàn bộ gate
+
+
Quyết định đã chốt: tái tạo input từ upstream chính thức đã pin. Lỗi source verification làm workflow fail nhưng không rollback runtime Mainnet đang healthy.
+
+ +

1. Phạm vi chính xác

+ + + + + + + + + +
Vai tròĐịa chỉTên contract khi verify
EntryPoint v0.70x0000000071727De22E5E9d8BAf0edAc6f37da032EntryPoint
Kernel v3.3 implementation0xd6CEDDe84be40893d153Be9d467CD6aD37875b28Kernel
KernelFactory v3.30x2577507b78c2008Ff367261CB6285d44ba5eF2E9KernelFactory
ECDSA validator0x845ADb2C711129d4f3966735eD98a9F09fC4cE57ECDSAValidator
MetaFactory0xd703aaE79538628d27099B8c4f621bE4CCd142d5FactoryStaker
+
Lưu ý: wallet gọi địa chỉ cuối là MetaFactory, nhưng tên Solidity thật là FactoryStaker. Metadata DOScan phải dùng tên Solidity.
+ +
+
+

Trong phạm vi

+
    +
  • Tự động verify đủ năm contract trên Mainnet 7979.
  • +
  • Kiểm tra đúng Blockscout match mode và exact metadata.
  • +
  • Đối chiếu bytecode đã compile với code live.
  • +
  • Browser UAT trên năm trang contract và /ops.
  • +
+
+
+

Ngoài phạm vi

+
    +
  • Không deploy lại hoặc nâng cấp contract.
  • +
  • Không verify SimpleAccountFactory.
  • +
  • Không chuyển sang EntryPoint v0.8.
  • +
  • Không sửa lõi Blockscout.
  • +
+
+
+

Ranh giới repo

+
    +
  • Chỉ sửa DOScan.
  • +
  • DOS-Chain và DOS-Me chỉ dùng làm nguồn tham chiếu read-only.
  • +
  • Không ghi key, token hoặc credential vào artifact.
  • +
+
+
+ +

2. Ba hướng đã cân nhắc

+ + + + + + + +
HướngĐánh giáKết luận
Tái tạo từ upstream đã pinProvenance rõ, reproducible, không phụ thuộc explorer ngoài lúc deploy.Chọn
Commit sẵn standard input vào DOScanCI đơn giản hơn nhưng duplicate source artifact và dễ drift.Không chọn
Tải input từ Ethereum Blockscout lúc deployÍt code nhưng biến deploy Mainnet thành phụ thuộc vào dịch vụ ngoài.Không chọn
+ +

3. Source pin bất biến

+ + + + + + + +
NhómRepo chính thứcTagCommit đã pin
EntryPoint v0.7eth-infinitism/account-abstractionv0.7.07af70c8993a6f42973f520ae0752386a5032abe7
Kernel v3.3 stackzerodevapp/kernelv3.3cd697c7e21715d015e0643af22310a99aa17433b
Source deploy legacyzerodevapp/kernelCommit deployment8f7fd9946b9d351bb5be0428bf34c87bad7ed6c9
+

Pin Kernel v3.3 cung cấp source cho KernelKernelFactory. ECDSAValidator cùng FactoryStaker dùng commit deployment cũ với Solady pin 9deb9ed36a27261a8745db5b7cd7f4cdc3b1cd4e. Mỗi checkout phải resolve đúng full SHA, sai một ký tự là dừng.

+ +

4. Profile compile riêng cho từng contract

+ + + + + + + + + +
ContractSourceCompilerOptimizerEVMviaIRMetadata
EntryPointcontracts/core/EntryPoint.solv0.8.23+commit.f704f3621.000.000 runsparistrueIPFS bytecode hash
Kernelsrc/Kernel.solv0.8.28+commit.7893614a200 runspraguetrueKhông CBOR, không bytecode hash
KernelFactorydependencies/kernel-v3.3/src/factory/KernelFactory.solv0.8.28+commit.7893614a200 runspraguetrueKhông CBOR, không bytecode hash
ECDSAValidatorsrc/validator/ECDSAValidator.solv0.8.25+commit.b61c2a91200 runsparistrueKhông CBOR, không bytecode hash
FactoryStakersrc/factory/FactoryStaker.solv0.8.24+commit.e11b9ed9200 runsparisBỏ field, tương đương falseKhông CBOR, không bytecode hash
+

EntryPoint dùng license gnu_gpl_v3. Bốn contract Kernel dùng mit. Compiler được normalize về dạng vX.Y.Z+commit... trước khi so metadata.

+
Kernel cần xử lý riêng: bytecode có immutable gắn với EntryPoint và deployment theo chain. Gate phải compile cho DOS Chain 7979 với constructor argument chính xác, rồi so deployed bytecode bằng immutable-reference ranges. Không được lấy raw bytecode Ethereum làm chuẩn cho Kernel.
+ +

5. Luồng workflow

+
checkout source pin -> cài dependency khóa phiên bản -> compile 5 profile -> extract standard input -> đối chiếu bytecode -> package artifact -> GCP auth -> deploy Mainnet -> runtime gates -> DEPLOYMENT_STARTED=0 -> verify 5 source -> Browser UAT
+
    +
  1. Checkout đúng ba upstream commit và xác nhận full SHA.
  2. +
  3. Cài dependency, compile, extract input và đối chiếu bytecode trước khi cấp GCP credential. Generator giữ canonical source-unit name, gồm prefix dependencies/kernel-v3.3/ của KernelFactory.
  4. +
  5. Package chỉ source input và metadata công khai cần cho verification.
  6. +
  7. Chạy nguyên luồng deploy, backup, rollback và semantic gate Mainnet hiện có.
  8. +
  9. Sau khi runtime thành công, workflow đặt DEPLOYMENT_STARTED=0.
  10. +
  11. Script gọi Blockscout API nội bộ để tránh CDN cache, submit contract chưa verified và poll.
  12. +
  13. Một deadline chung 300 giây áp dụng cho cả năm contract.
  14. +
  15. Sau CI thành công, Browser UAT kiểm tra public DOScan.
  16. +
+ +

6. State machine verification

+
    +
  • Nếu metadata đã exact: bỏ qua POST.
  • +
  • Nếu chưa verified: POST standard input và constructor argument chính xác, sau đó poll.
  • +
  • Nếu POST trả Already verified: GET lại và chỉ chấp nhận khi metadata exact.
  • +
  • Nếu đã verified nhưng sai metadata: fail ngay, không tự sửa database.
  • +
  • Nếu match mode khác profile, JSON lỗi, thiếu field, HTTP lỗi kéo dài hoặc hết deadline: fail-closed.
  • +
+

EntryPoint bắt buộc full match vì có IPFS metadata. Bốn contract Kernel chủ động bỏ CBOR metadata nên Blockscout báo partial match dù bytecode gate trước đó đã chứng minh compiler output khớp runtime. Cả năm vẫn phải khớp contract name, source path, compiler, optimizer, số runs, EVM, license và constructor arguments.

+ +

7. Rollback và bảo mật

+
    +
  • Lỗi source, compile hoặc bytecode xảy ra trước Google auth và trước mutation production.
  • +
  • Source verification chỉ chạy sau runtime success và DEPLOYMENT_STARTED=0.
  • +
  • Verification fail làm workflow đỏ nhưng không restore database, IPFS hoặc container đang healthy.
  • +
  • Không có direct database write để ép metadata thành verified.
  • +
  • Artifact tạm được xóa ở cả nhánh success và failure.
  • +
  • Không archive hoặc log private key, wallet secret, paymaster credential, API token hay GCP credential.
  • +
  • Mọi request và poll đều có wall-clock bound hữu hạn.
  • +
+ +

8. Test bắt buộc

+ + + + + + + + + + + + + + + +
CaseKết quả bắt buộc
Ba pin source và năm inputDeterministic, đúng full SHA
Bytecode đủ năm contractKhớp code Mainnet, Kernel xử lý immutable đúng
Bytecode mismatchFail trước Google auth và mutation
Năm contract đã exactKhông POST
Chưa verifiedPOST một lần, poll đến exact
Race Already verifiedGET lại, chỉ pass khi exact
Sai compiler, optimizer, EVM, source, name, license hoặc constructorFail ngay
Match mode sai profileFail
Nhiều contract pendingDùng chung deadline 300 giây, không reset
HTTP hoặc JSON lỗiRetry bounded rồi fail hữu hạn
Verification fail sau runtime successKhông gọi runtime rollback
+ +

9. Tiêu chí hoàn tất

+
    +
  • Regression mới fail trên base và pass sau implementation.
  • +
  • Toàn bộ validator Mainnet, actionlint, shell syntax, Compose render và CI hiện có vẫn pass.
  • +
  • Reviewer độc lập không còn finding Critical, Important hoặc Minor.
  • +
  • Agent merge PR sau approval và required CI xanh.
  • +
  • Workflow Mainnet hoàn tất.
  • +
  • Trang EntryPoint hiển thị full match; bốn trang Kernel hiển thị partial match do không có CBOR; cả năm có compiler và tên đúng.
  • +
  • /ops vẫn healthy và tiếp tục hiển thị operation EntryPoint v0.7 production.
  • +
+
+ +