diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 3403da61..93ac978f 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -45,6 +45,10 @@ jobs: network: hoodi - config: config_samples/ethereum/hoodi/vaults/hoodi_vaults_easy_track_config.json network: hoodi + - config: config_samples/ethereum/hoodi/srv3/hoodi_srv3_config.json + network: hoodi + - config: config_samples/ethereum/hoodi/srv3/hoodi_srv3_easy_track_config.json + network: hoodi steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/CLAUDE.md b/CLAUDE.md index f0092ca6..65f5be7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,10 +68,12 @@ explorer_hostname: "api.etherscan.io" explorer_token_env_var: "ETHERSCAN_EXPLORER_TOKEN" github_repo: { url, commit, relative_root } dependencies: { "dep_name": { url, commit, relative_root } } -bytecode_comparison: { constructor_calldata, constructor_args, libraries } +bytecode_comparison: { constructor_calldata, constructor_args, libraries, deployment_from, extra_sources } allowed_diffs: { bytecode: {...}, source: {...} } # optional; see below ``` +`bytecode_comparison.extra_sources` (`{ "0xaddr": ["path/to/File.sol"] }`, optional) names additional source files to fetch from the configured GitHub repo/commit and add to the compilation — for contracts whose explorer-verified source set omits a file the pinned GitHub source imports (e.g. a newly-added interface). The files are fetched the same way as all others (honoring `dependencies`), so verification stays honest. + `allowed_diffs` declares known/expected diffs per contract so a run still passes while everything else stays verified (validated by `diffyscan/utils/allowed_diffs.py`). Each rule needs a `reason`. **Prefer the tightest facet** — bytecode: `immutables`, `byte_ranges`, `cbor_metadata`, `constructor_args`/`constructor_calldata`; source: `line_ranges`, `files`. `any: true` is a blanket wildcard that hides all future drift — avoid it unless a diff genuinely can't be scoped, and say why in the `reason`. The deprecated `--allow-source-diff`/`--allow-bytecode-diff` CLI flags are just shorthands for `any: true`. `tests/test_no_wildcard_regression.py` guards against new wildcards. ### Environment variables diff --git a/README.md b/README.md index a9453b8a..06fdec04 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ diffyscan config_samples/lido_dao_sepolia_config.json When no path is given, diffyscan looks for `config.json`, `config.yaml`, or `config.yml` in the current directory. When a directory is given, all `.json`, `.yaml`, and `.yml` files inside it are processed. -Alternatively, create a new config file. Configs can be written in JSON or YAML. The `bytecode_comparison` section is optional and only needed for manual overrides when explorer metadata is missing or you want to override it: +Alternatively, create a new config file. Configs can be written in JSON or YAML. The `bytecode_comparison` section is optional and only needed for manual overrides when explorer metadata is missing or you want to override it. `deployment_from` can be used per contract when constructor simulation depends on `msg.sender`: **JSON** (`config.json`): @@ -138,6 +138,9 @@ Alternatively, create a new config file. Configs can be written in JSON or YAML. "0xC01fC1F2787687Bc656EAc0356ba9Db6e6b7afb7" ] ] + }, + "deployment_from": { + "0x045dd46212A178428c088573A7d102B9d89a022A": "0xE92329EC7ddB11D25e25b3c21eeBf11f15eB325d" } } } diff --git a/config_samples/ethereum/hoodi/srv3/hoodi_srv3_config.json b/config_samples/ethereum/hoodi/srv3/hoodi_srv3_config.json new file mode 100644 index 00000000..657c7d79 --- /dev/null +++ b/config_samples/ethereum/hoodi/srv3/hoodi_srv3_config.json @@ -0,0 +1,507 @@ +{ + "contracts": { + "0xA39fe063A6d1420E59d218d318d52D84Fbd9202F": "Accounting", + "0xD00dD90651f031ED3158Cf75AC6e4361B4CCcBD8": "AccountingOracle", + "0xB9A2Fb8336f3775d790b3FdD6151e3F193AA7352": "Lido", + "0x2908c4B32548D8799fDc601C1a75E7d5C2FbC556": "ConsolidationBus", + "0xC9991Bb865d025364BCbBd99f36e85889Fb68e69": "ConsolidationGateway", + "0x8BF11ead77fFe142AB27BE486Bc5aB22D9baF520": "ConsolidationMigrator", + "0xf738F86009Ec704880c9Aa175fc5869F020FEe4e": "DepositSecurityModule", + "0x1dB06d15976954D48765f40d4bDd840C257CD4B9": "LidoLocator", + "0xB2A07615cDe70Dc99f493aA5556175405537B21b": "MinFirstAllocationStrategy", + "0xD0261b0032A00a7449ee7fbE14d3f98702996441": "OracleReportSanityChecker", + "0x2107dffDf8C2934A5197C8C6c5eC646099C51204": "StakingRouter", + "0x8621D8a402fdf2a131E38e16ac50f4C97660Fc2b": "TopUpGateway", + "0xA881F320E17Aa97d6Ce0bE76B0e2620bd2FC555A": "ValidatorsExitBusOracle", + "0x6724DaD16f7b05157dF72783F99cA6B813742330": "WithdrawalVault" + }, + "network": "hoodi", + "explorer_hostname": "api.etherscan.io", + "explorer_token_env_var": "ETHERSCAN_EXPLORER_TOKEN", + "explorer_chain_id": 560048, + "github_repo": { + "url": "https://github.com/lidofinance/core", + "commit": "a9885382fde2d6c8a82a90cc2e9924d7e9e28d40", + "relative_root": "" + }, + "dependencies": { + "@aragon/os": { + "url": "https://github.com/aragon/aragonOS", + "commit": "f3ae59b00f73984e562df00129c925339cd069ff", + "relative_root": "" + }, + "@aragon/minime": { + "url": "https://github.com/aragon/minime", + "commit": "1d5251fc88eee5024ff318d95bc9f4c5de130430", + "relative_root": "" + }, + "@aragon/apps-lido": { + "url": "https://github.com/lidofinance/aragon-apps/", + "commit": "b09834d29c0db211ddd50f50905cbeff257fc8e0", + "relative_root": "" + }, + "@aragon/apps-finance": { + "url": "https://github.com/lidofinance/aragon-apps/", + "commit": "b09834d29c0db211ddd50f50905cbeff257fc8e0", + "relative_root": "apps/finance" + }, + "@aragon/apps-vault": { + "url": "https://github.com/lidofinance/aragon-apps/", + "commit": "b09834d29c0db211ddd50f50905cbeff257fc8e0", + "relative_root": "apps/vault" + }, + "@aragon/apps-agent": { + "url": "https://github.com/lidofinance/aragon-apps/", + "commit": "b09834d29c0db211ddd50f50905cbeff257fc8e0", + "relative_root": "apps/agent" + }, + "openzeppelin-solidity": { + "url": "https://github.com/OpenZeppelin/openzeppelin-contracts", + "commit": "06e265b38d3e9daeaa7b33f9035c700d6bc0c6a0", + "relative_root": "" + }, + "solidity-bytes-utils": { + "url": "https://github.com/GNSPS/solidity-bytes-utils", + "commit": "9776282d181839fbb4b18f2cf218e316d6df871c", + "relative_root": "" + }, + "@openzeppelin/contracts": { + "url": "https://github.com/OpenZeppelin/openzeppelin-contracts", + "commit": "fa64a1ced0b70ab89073d5d0b6e01b0778f7e7d6", + "relative_root": "contracts" + }, + "@openzeppelin/contracts-v4.4": { + "url": "https://github.com/OpenZeppelin/openzeppelin-contracts", + "commit": "6bd6b76d1156e20e45d1016f355d154141c7e5b9", + "relative_root": "contracts" + }, + "@openzeppelin/contracts-v5.2": { + "url": "https://github.com/OpenZeppelin/openzeppelin-contracts", + "commit": "acd4ff74de833399287ed6b31b4debf6b2b35527", + "relative_root": "contracts" + } + }, + "fail_on_bytecode_comparison_error": true, + "bytecode_comparison": { + "constructor_calldata": {}, + "deployment_from": { + "0xf738F86009Ec704880c9Aa175fc5869F020FEe4e": "0x83BCE68B4e8b7071b2a664a26e6D3Bc17eEe3102" + }, + "extra_sources": { + "0xC9991Bb865d025364BCbBd99f36e85889Fb68e69": [ + "contracts/common/interfaces/IPausableUntil.sol" + ] + }, + "constructor_args": { + "0xA39fe063A6d1420E59d218d318d52D84Fbd9202F": [ + "0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8", + "0x3508A952176b3c15387C97BE809eaffB1982176a" + ], + "0xD00dD90651f031ED3158Cf75AC6e4361B4CCcBD8": [ + "0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8", + 12, + 1742213400 + ], + "0xB9A2Fb8336f3775d790b3FdD6151e3F193AA7352": [], + "0x2908c4B32548D8799fDc601C1a75E7d5C2FbC556": [ + "0xC9991Bb865d025364BCbBd99f36e85889Fb68e69" + ], + "0xC9991Bb865d025364BCbBd99f36e85889Fb68e69": [ + "0xC676167aAea6de6Af3e1ED34C0595de449E0de7b", + "0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8", + 2900, + 1, + 30, + "0x0000000000000000000000000000000000000000000000000096000000000028", + "0x0000000000000000000000000000000000000000000000000096000000000028", + 0 + ], + "0x8BF11ead77fFe142AB27BE486Bc5aB22D9baF520": [ + "0xCc820558B39ee15C7C45B59390B503b83fb499A8", + "0xe09fBcE63826468867eE66Eda491E444829E022A", + 1, + 5 + ], + "0xf738F86009Ec704880c9Aa175fc5869F020FEe4e": [ + "0x3508A952176b3c15387C97BE809eaffB1982176a", + "0x00000000219ab540356cBB839Cbe05303d7705Fa", + "0xCc820558B39ee15C7C45B59390B503b83fb499A8", + 6646, + 200 + ], + "0x1dB06d15976954D48765f40d4bDd840C257CD4B9": [ + [ + "0xcb883B1bD0a41512b42D2dB267F2A2cd919FB216", + "0xf738F86009Ec704880c9Aa175fc5869F020FEe4e", + "0x9b108015fe433F173696Af3Aa0CF7CDb3E104258", + "0x3508A952176b3c15387C97BE809eaffB1982176a", + "0xD0261b0032A00a7449ee7fbE14d3f98702996441", + "0x9c53d0075eA00ad77dDAd1b71E67bb97AaBC1e3D", + "0xb2c99cd38a2636a6281a849C8de938B3eF4A7C3D", + "0xCc820558B39ee15C7C45B59390B503b83fb499A8", + "0x0534aA41907c9631fae990960bCC72d75fA7cfeD", + "0x8664d394C2B3278F26A1B44B967aEf99707eeAB2", + "0xfe56573178f1bcdf53F01A6E9977670dcBBD9186", + "0x4473dCDDbf77679A643BdB654dbd86D67F8d32f2", + "0x2a833402e3F46fFC1ecAb3598c599147a78731a9", + "0xa5F5A9360275390fF9728262a29384399f38d2f0", + "0x6679090D92b08a2a686eF8614feECD8cDFE209db", + "0xC9991Bb865d025364BCbBd99f36e85889Fb68e69", + "0x9b5b78D1C9A3238bF24662067e34c57c83E8c354", + "0xa5F55f3402beA2B14AE15Dae1b6811457D43581d", + "0x7E99eE3C66636DE415D2d7C880938F2f40f94De4", + "0x4C9fFC325392090F789255b9948Ab1659b797964", + "0x7Ba269a03eeD86f2f54CB04CA3b4b7626636Df4E", + "0xf41491C79C30e8f4862d3F4A5b790171adB8e04A", + "0x501e678182bB5dF3f733281521D3f3D1aDe69917", + "0x10DBEb3367876826d00D21718D1d893e0fbD2956" + ] + ], + "0xB2A07615cDe70Dc99f493aA5556175405537B21b": [], + "0xD0261b0032A00a7449ee7fbE14d3f98702996441": [ + "0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8", + "0x9b5b78D1C9A3238bF24662067e34c57c83E8c354", + "0x0534aA41907c9631fae990960bCC72d75fA7cfeD", + [ + 57600, + 57600, + "0x3e8", + "0x32", + 19200, + 32, + 2048, + "0x8", + "0x18", + "0x80", + "0xb71b0", + 360, + "0x32", + 93375, + 32, + 300 + ] + ], + "0x2107dffDf8C2934A5197C8C6c5eC646099C51204": [ + "0x00000000219ab540356cBB839Cbe05303d7705Fa", + "0x3508A952176b3c15387C97BE809eaffB1982176a", + "0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8", + "0x1bc16d674ec800000", + "0x6f05b59d3b20000000" + ], + "0x8621D8a402fdf2a131E38e16ac50f4C97660Fc2b": [ + "0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8", + "0x0000000000000000000000000000000000000000000000000096000000000028", + "0x0000000000000000000000000000000000000000000000000096000000000028", + 0, + 32 + ], + "0xA881F320E17Aa97d6Ce0bE76B0e2620bd2FC555A": [ + 12, + 1742213400, + "0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8" + ], + "0x6724DaD16f7b05157dF72783F99cA6B813742330": [ + "0x3508A952176b3c15387C97BE809eaffB1982176a", + "0x0534aA41907c9631fae990960bCC72d75fA7cfeD", + "0x6679090D92b08a2a686eF8614feECD8cDFE209db", + "0xC9991Bb865d025364BCbBd99f36e85889Fb68e69", + "0x00000961Ef480Eb55e80D19ad83579A64c007002", + "0x0000BBdDc7CE488642fb579F8B00f3a590007251" + ] + }, + "libraries": { + "contracts/common/lib/MinFirstAllocationStrategy.sol": { + "MinFirstAllocationStrategy": "0xB2A07615cDe70Dc99f493aA5556175405537B21b" + }, + "contracts/0.8.25/lib/BeaconChainDepositor.sol": { + "BeaconChainDepositor": "0x963ea462c33684Df516405B4f59c718EE4E22932" + }, + "contracts/0.8.25/sr/SRLib.sol": { + "SRLib": "0x1AF72A21223FAb0A7bB5ec6F2C3dE0C018803F0D" + } + } + }, + "allowed_diffs": { + "bytecode": { + "0xC9991Bb865d025364BCbBd99f36e85889Fb68e69": [ + { + "reason": "Trailing CBOR metadata hash differs (compiled with IPausableUntil supplied via extra_sources); runtime bytecode is identical \u2014 'is IPausableUntil' adds no executable code.", + "cbor_metadata": true + } + ], + "0xA39fe063A6d1420E59d218d318d52D84Fbd9202F": [ + { + "reason": "Trailing CBOR metadata (Solidity compiler/source-path hash appended after runtime code) differs; executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0xD00dD90651f031ED3158Cf75AC6e4361B4CCcBD8": [ + { + "reason": "Trailing CBOR metadata (Solidity compiler/source-path hash appended after runtime code) differs; executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0xB9A2Fb8336f3775d790b3FdD6151e3F193AA7352": [ + { + "reason": "Trailing CBOR metadata (Solidity compiler/source-path hash appended after runtime code) differs; executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0x2908c4B32548D8799fDc601C1a75E7d5C2FbC556": [ + { + "reason": "Trailing CBOR metadata (Solidity compiler/source-path hash appended after runtime code) differs; executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0x8BF11ead77fFe142AB27BE486Bc5aB22D9baF520": [ + { + "reason": "Trailing CBOR metadata (Solidity compiler/source-path hash appended after runtime code) differs; executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0xf738F86009Ec704880c9Aa175fc5869F020FEe4e": [ + { + "reason": "Constructor-set immutable values (addresses/hashes baked into the bytecode at deploy time) differ from the placeholders in freshly compiled bytecode, and the trailing CBOR metadata hash differs; both are expected and carry no executable-logic difference.", + "immutables": [ + { + "offset": 643, + "value": "0xfed594227b745906546cc94cefc1c0e8b525a7d631311ddbd07b8ccdcc73d6f3" + }, + { + "offset": 706, + "value": "0x00000000000000000000000000000000219ab540356cbb839cbe05303d7705fa" + }, + { + "offset": 824, + "value": "0x67a2d8939f08188c60891a393c419cb4f438a4a3fd25ab9050702c39fa368dcd" + }, + { + "offset": 882, + "value": "0x17f7c014faa6a92ed0cdbcc5d87e37b6f45827c3414e7d20832be74ea7d08185" + }, + { + "offset": 929, + "value": "0x000000000000000000000000cc820558b39ee15c7c45b59390b503b83fb499a8" + }, + { + "offset": 1388, + "value": "0x67a2d8939f08188c60891a393c419cb4f438a4a3fd25ab9050702c39fa368dcd" + }, + { + "offset": 1720, + "value": "0x000000000000000000000000cc820558b39ee15c7c45b59390b503b83fb499a8" + }, + { + "offset": 2023, + "value": "0xfed594227b745906546cc94cefc1c0e8b525a7d631311ddbd07b8ccdcc73d6f3" + }, + { + "offset": 2246, + "value": "0x000000000000000000000000cc820558b39ee15c7c45b59390b503b83fb499a8" + }, + { + "offset": 2507, + "value": "0x00000000000000000000000000000000219ab540356cbb839cbe05303d7705fa" + }, + { + "offset": 2709, + "value": "0x000000000000000000000000cc820558b39ee15c7c45b59390b503b83fb499a8" + }, + { + "offset": 3170, + "value": "0x000000000000000000000000cc820558b39ee15c7c45b59390b503b83fb499a8" + }, + { + "offset": 4642, + "value": "0x000000000000000000000000cc820558b39ee15c7c45b59390b503b83fb499a8" + }, + { + "offset": 4800, + "value": "0x000000000000000000000000cc820558b39ee15c7c45b59390b503b83fb499a8" + }, + { + "offset": 4976, + "value": "0x17f7c014faa6a92ed0cdbcc5d87e37b6f45827c3414e7d20832be74ea7d08185" + } + ], + "cbor_metadata": true + } + ], + "0x1dB06d15976954D48765f40d4bDd840C257CD4B9": [ + { + "reason": "Trailing CBOR metadata (Solidity compiler/source-path hash appended after runtime code) differs; executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0xB2A07615cDe70Dc99f493aA5556175405537B21b": [ + { + "reason": "Trailing CBOR metadata (Solidity compiler/source-path hash appended after runtime code) differs; executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0xD0261b0032A00a7449ee7fbE14d3f98702996441": [ + { + "reason": "Trailing CBOR metadata (Solidity compiler/source-path hash appended after runtime code) differs; executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0x2107dffDf8C2934A5197C8C6c5eC646099C51204": [ + { + "reason": "Trailing CBOR metadata (Solidity compiler/source-path hash appended after runtime code) differs; executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0x8621D8a402fdf2a131E38e16ac50f4C97660Fc2b": [ + { + "reason": "Trailing CBOR metadata (Solidity compiler/source-path hash appended after runtime code) differs; executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0xA881F320E17Aa97d6Ce0bE76B0e2620bd2FC555A": [ + { + "reason": "Trailing CBOR metadata (Solidity compiler/source-path hash appended after runtime code) differs; executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0x6724DaD16f7b05157dF72783F99cA6B813742330": [ + { + "reason": "Trailing CBOR metadata (Solidity compiler/source-path hash appended after runtime code) differs; executable runtime bytecode is identical.", + "cbor_metadata": true + } + ] + }, + "source": { + "0xC9991Bb865d025364BCbBd99f36e85889Fb68e69": [ + { + "reason": "NatSpec comment wording differences in ConsolidationGateway.sol, plus the 'is IPausableUntil' interface-inheritance declaration / import present only in the GitHub source of PausableUntil.sol. Interface inheritance is declaration-only and produces no runtime bytecode (bytecode matches modulo CBOR metadata).", + "line_ranges": [ + { + "file": "contracts/0.8.25/consolidation/ConsolidationGateway.sol", + "github": { + "start": 50, + "count": 1 + }, + "explorer": { + "start": 50, + "count": 1 + } + }, + { + "file": "contracts/0.8.25/consolidation/ConsolidationGateway.sol", + "github": { + "start": 64, + "count": 1 + }, + "explorer": { + "start": 64, + "count": 1 + } + }, + { + "file": "contracts/0.8.25/consolidation/ConsolidationGateway.sol", + "github": { + "start": 144, + "count": 1 + }, + "explorer": { + "start": 144, + "count": 1 + } + }, + { + "file": "contracts/common/utils/PausableUntil.sol", + "github": { + "start": 7, + "count": 1 + }, + "explorer": { + "start": 7, + "count": 0 + } + }, + { + "file": "contracts/common/utils/PausableUntil.sol", + "github": { + "start": 13, + "count": 1 + }, + "explorer": { + "start": 12, + "count": 1 + } + } + ] + } + ], + "0xA39fe063A6d1420E59d218d318d52D84Fbd9202F": [ + { + "reason": "Verified-source IOracleReportSanityChecker interface declares an extra method (checkCLPendingBalanceIncrease) not present at this GitHub commit; the method is unused by Accounting and has no bytecode impact (runtime bytecode matches modulo CBOR metadata).", + "line_ranges": [ + { + "file": "contracts/common/interfaces/IOracleReportSanityChecker.sol", + "github": { + "start": 36, + "count": 0 + }, + "explorer": { + "start": 36, + "count": 11 + } + } + ] + } + ], + "0x6724DaD16f7b05157dF72783F99cA6B813742330": [ + { + "reason": "NatSpec comment-only wording differences in WithdrawalVault.sol (migration label v2/v3, 'this contract' vs 'burner contract', 'withdrawal' vs 'consolidation' fee wording). No code changes; runtime bytecode matches modulo CBOR metadata.", + "line_ranges": [ + { + "file": "contracts/0.8.9/WithdrawalVault.sol", + "github": { + "start": 86, + "count": 1 + }, + "explorer": { + "start": 86, + "count": 1 + } + }, + { + "file": "contracts/0.8.9/WithdrawalVault.sol", + "github": { + "start": 121, + "count": 1 + }, + "explorer": { + "start": 121, + "count": 1 + } + }, + { + "file": "contracts/0.8.9/WithdrawalVault.sol", + "github": { + "start": 138, + "count": 1 + }, + "explorer": { + "start": 138, + "count": 1 + } + }, + { + "file": "contracts/0.8.9/WithdrawalVault.sol", + "github": { + "start": 193, + "count": 1 + }, + "explorer": { + "start": 193, + "count": 1 + } + } + ] + } + ] + } + } +} diff --git a/config_samples/ethereum/hoodi/srv3/hoodi_srv3_easy_track_config.json b/config_samples/ethereum/hoodi/srv3/hoodi_srv3_easy_track_config.json new file mode 100644 index 00000000..421f9b25 --- /dev/null +++ b/config_samples/ethereum/hoodi/srv3/hoodi_srv3_easy_track_config.json @@ -0,0 +1,319 @@ +{ + "contracts": { + "0x22D36e7616F541A527989C5652fDA4d527bB461C": "AllowConsolidationPair", + "0xD63cf25df1bA6144db27A81A98120Dfc53dE4540": "UpdateStakingModuleShareLimits", + "0xf71fcB20B9FB8468653Bcb24E31F39bc069D5995": "SetMerkleGateTree", + "0x4EaB04775837A6F0218750A10454119f349258FE": "ReportWithdrawalsForSlashedValidators", + "0xd0c38B2F0C1F760976dA010C1c35D828331Ff9E2": "SettleGeneralDelayedPenalty", + "0x5194cC02B6F477B4a23DFA422fFC238c8B5b1736": "SetMerkleGateTree", + "0x6E40FED7c28bAA93a798cA10f8A93965a19eC52e": "ReportWithdrawalsForSlashedValidators", + "0x3486B872768D361309e405A046C4BF995c21CC6c": "SettleGeneralDelayedPenalty", + "0x44D9b39bBdc2182Aa1af6f16f8F55E0eA038294d": "CreateOrUpdateOperatorGroup" + }, + "explorer_hostname": "api.etherscan.io", + "explorer_token_env_var": "ETHERSCAN_EXPLORER_TOKEN", + "explorer_chain_id": 560048, + "github_repo": { + "url": "https://github.com/lidofinance/easy-track", + "commit": "e06629e4e4ba62751968dfbcb8586faab83b5eb9", + "relative_root": "" + }, + "dependencies": {}, + "fail_on_bytecode_comparison_error": true, + "bytecode_comparison": { + "constructor_calldata": {}, + "constructor_args": { + "0x22D36e7616F541A527989C5652fDA4d527bB461C": [ + "0x747d357F5b6410B80Eb63406FaC5E2A91131B4f8" + ], + "0xD63cf25df1bA6144db27A81A98120Dfc53dE4540": [ + "0x4AF43Ee34a6fcD1fEcA1e1F832124C763561dA53", + "CSM", + "0xCc820558B39ee15C7C45B59390B503b83fb499A8", + 4, + 500, + 500, + 600, + 600 + ], + "0xf71fcB20B9FB8468653Bcb24E31F39bc069D5995": [ + "0x4AF43Ee34a6fcD1fEcA1e1F832124C763561dA53", + "CSM" + ], + "0x4EaB04775837A6F0218750A10454119f349258FE": [ + "0x4AF43Ee34a6fcD1fEcA1e1F832124C763561dA53", + "CSM", + "0x79CEf36D84743222f37765204Bec41E92a93E59d" + ], + "0xd0c38B2F0C1F760976dA010C1c35D828331Ff9E2": [ + "0x4AF43Ee34a6fcD1fEcA1e1F832124C763561dA53", + "CSM", + "0x79CEf36D84743222f37765204Bec41E92a93E59d" + ], + "0x5194cC02B6F477B4a23DFA422fFC238c8B5b1736": [ + "0x84DffcfB232594975C608DE92544Ff239a24c9E9", + "CM" + ], + "0x6E40FED7c28bAA93a798cA10f8A93965a19eC52e": [ + "0x84DffcfB232594975C608DE92544Ff239a24c9E9", + "CM", + "0x87EB69Ae51317405FD285efD2326a4a11f6173b9" + ], + "0x3486B872768D361309e405A046C4BF995c21CC6c": [ + "0x84DffcfB232594975C608DE92544Ff239a24c9E9", + "CM", + "0x87EB69Ae51317405FD285efD2326a4a11f6173b9" + ], + "0x44D9b39bBdc2182Aa1af6f16f8F55E0eA038294d": [ + "0x84DffcfB232594975C608DE92544Ff239a24c9E9", + "CM", + "0x87EB69Ae51317405FD285efD2326a4a11f6173b9", + 1 + ] + } + }, + "allowed_diffs": { + "bytecode": { + "0x22D36e7616F541A527989C5652fDA4d527bB461C": [ + { + "reason": "Trailing CBOR metadata hash differs (compiler/source-path fingerprint); executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0xD63cf25df1bA6144db27A81A98120Dfc53dE4540": [ + { + "reason": "Trailing CBOR metadata hash differs (compiler/source-path fingerprint); executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0xf71fcB20B9FB8468653Bcb24E31F39bc069D5995": [ + { + "reason": "Trailing CBOR metadata hash differs (compiler/source-path fingerprint); executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0x4EaB04775837A6F0218750A10454119f349258FE": [ + { + "reason": "Trailing CBOR metadata hash differs (compiler/source-path fingerprint); executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0xd0c38B2F0C1F760976dA010C1c35D828331Ff9E2": [ + { + "reason": "Trailing CBOR metadata hash differs (compiler/source-path fingerprint); executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0x5194cC02B6F477B4a23DFA422fFC238c8B5b1736": [ + { + "reason": "Trailing CBOR metadata hash differs (compiler/source-path fingerprint); executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0x6E40FED7c28bAA93a798cA10f8A93965a19eC52e": [ + { + "reason": "Trailing CBOR metadata hash differs (compiler/source-path fingerprint); executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0x3486B872768D361309e405A046C4BF995c21CC6c": [ + { + "reason": "Trailing CBOR metadata hash differs (compiler/source-path fingerprint); executable runtime bytecode is identical.", + "cbor_metadata": true + } + ], + "0x44D9b39bBdc2182Aa1af6f16f8F55E0eA038294d": [ + { + "reason": "Trailing CBOR metadata hash differs (compiler/source-path fingerprint); executable runtime bytecode is identical.", + "cbor_metadata": true + } + ] + }, + "source": { + "0x22D36e7616F541A527989C5652fDA4d527bB461C": [ + { + "reason": "Import-path differences only: the GitHub source uses relative imports (../, ./) while the Etherscan verified source rewrote them to absolute paths (contracts/...). Same files resolved; no executable-logic change (runtime bytecode matches modulo CBOR metadata).", + "line_ranges": [ + { + "file": "contracts/EVMScriptFactories/AllowConsolidationPair.sol", + "github": { + "start": 6, + "count": 7 + }, + "explorer": { + "start": 6, + "count": 7 + } + }, + { + "file": "contracts/interfaces/ICuratedModule.sol", + "github": { + "start": 6, + "count": 2 + }, + "explorer": { + "start": 6, + "count": 2 + } + } + ] + } + ], + "0xD63cf25df1bA6144db27A81A98120Dfc53dE4540": [ + { + "reason": "Import-path differences only: the GitHub source uses relative imports (../, ./) while the Etherscan verified source rewrote them to absolute paths (contracts/...). Same files resolved; no executable-logic change (runtime bytecode matches modulo CBOR metadata).", + "line_ranges": [ + { + "file": "contracts/EVMScriptFactories/UpdateStakingModuleShareLimits.sol", + "github": { + "start": 6, + "count": 4 + }, + "explorer": { + "start": 6, + "count": 4 + } + } + ] + } + ], + "0xf71fcB20B9FB8468653Bcb24E31F39bc069D5995": [ + { + "reason": "Import-path differences only: the GitHub source uses relative imports (../, ./) while the Etherscan verified source rewrote them to absolute paths (contracts/...). Same files resolved; no executable-logic change (runtime bytecode matches modulo CBOR metadata).", + "line_ranges": [ + { + "file": "contracts/EVMScriptFactories/SetMerkleGateTree.sol", + "github": { + "start": 6, + "count": 4 + }, + "explorer": { + "start": 6, + "count": 4 + } + } + ] + } + ], + "0x4EaB04775837A6F0218750A10454119f349258FE": [ + { + "reason": "Import-path differences only: the GitHub source uses relative imports (../, ./) while the Etherscan verified source rewrote them to absolute paths (contracts/...). Same files resolved; no executable-logic change (runtime bytecode matches modulo CBOR metadata).", + "line_ranges": [ + { + "file": "contracts/EVMScriptFactories/ReportWithdrawalsForSlashedValidators.sol", + "github": { + "start": 6, + "count": 4 + }, + "explorer": { + "start": 6, + "count": 4 + } + } + ] + } + ], + "0xd0c38B2F0C1F760976dA010C1c35D828331Ff9E2": [ + { + "reason": "Import-path differences only: the GitHub source uses relative imports (../, ./) while the Etherscan verified source rewrote them to absolute paths (contracts/...). Same files resolved; no executable-logic change (runtime bytecode matches modulo CBOR metadata).", + "line_ranges": [ + { + "file": "contracts/EVMScriptFactories/SettleGeneralDelayedPenalty.sol", + "github": { + "start": 6, + "count": 5 + }, + "explorer": { + "start": 6, + "count": 5 + } + } + ] + } + ], + "0x5194cC02B6F477B4a23DFA422fFC238c8B5b1736": [ + { + "reason": "Import-path differences only: the GitHub source uses relative imports (../, ./) while the Etherscan verified source rewrote them to absolute paths (contracts/...). Same files resolved; no executable-logic change (runtime bytecode matches modulo CBOR metadata).", + "line_ranges": [ + { + "file": "contracts/EVMScriptFactories/SetMerkleGateTree.sol", + "github": { + "start": 6, + "count": 4 + }, + "explorer": { + "start": 6, + "count": 4 + } + } + ] + } + ], + "0x6E40FED7c28bAA93a798cA10f8A93965a19eC52e": [ + { + "reason": "Import-path differences only: the GitHub source uses relative imports (../, ./) while the Etherscan verified source rewrote them to absolute paths (contracts/...). Same files resolved; no executable-logic change (runtime bytecode matches modulo CBOR metadata).", + "line_ranges": [ + { + "file": "contracts/EVMScriptFactories/ReportWithdrawalsForSlashedValidators.sol", + "github": { + "start": 6, + "count": 4 + }, + "explorer": { + "start": 6, + "count": 4 + } + } + ] + } + ], + "0x3486B872768D361309e405A046C4BF995c21CC6c": [ + { + "reason": "Import-path differences only: the GitHub source uses relative imports (../, ./) while the Etherscan verified source rewrote them to absolute paths (contracts/...). Same files resolved; no executable-logic change (runtime bytecode matches modulo CBOR metadata).", + "line_ranges": [ + { + "file": "contracts/EVMScriptFactories/SettleGeneralDelayedPenalty.sol", + "github": { + "start": 6, + "count": 5 + }, + "explorer": { + "start": 6, + "count": 5 + } + } + ] + } + ], + "0x44D9b39bBdc2182Aa1af6f16f8F55E0eA038294d": [ + { + "reason": "Import-path differences only: the GitHub source uses relative imports (../, ./) while the Etherscan verified source rewrote them to absolute paths (contracts/...). Same files resolved; no executable-logic change (runtime bytecode matches modulo CBOR metadata).", + "line_ranges": [ + { + "file": "contracts/EVMScriptFactories/CreateOrUpdateOperatorGroup.sol", + "github": { + "start": 6, + "count": 7 + }, + "explorer": { + "start": 6, + "count": 7 + } + }, + { + "file": "contracts/interfaces/ICuratedModule.sol", + "github": { + "start": 6, + "count": 2 + }, + "explorer": { + "start": 6, + "count": 2 + } + } + ] + } + ] + } + } +} diff --git a/diffyscan/diffyscan.py b/diffyscan/diffyscan.py index 133c17f9..71e2f68c 100644 --- a/diffyscan/diffyscan.py +++ b/diffyscan/diffyscan.py @@ -6,6 +6,7 @@ import time import traceback from pathlib import Path +from typing import Any from dotenv import load_dotenv @@ -27,6 +28,7 @@ from .utils.constants import DIFFS_DIR, START_TIME from .utils.custom_exceptions import ( BaseCustomException, + CalldataError, CompileError, DeploymentSimulationError, ExceptionHandler, @@ -77,13 +79,22 @@ def _build_github_solc_input( github_api_token, recursive_parsing, cache_github, + extra_source_paths=None, ): solc_input = contract_source_code["solcInput"] sources = get_solc_sources(solc_input) updated_sources = {} missing = [] - for path_to_file in sources: + # Append extra sources after the explorer set, skipping paths already + # present so the same file isn't fetched twice. + paths = list(sources) + [ + path for path in (extra_source_paths or []) if path not in sources + ] + + for path_to_file in paths: + if path_to_file in updated_sources: + continue github_file = _fetch_github_source( path_to_file, config, @@ -158,12 +169,25 @@ def run_bytecode_diff( manual_libraries, ) + extra_sources_cfg = (config.get("bytecode_comparison") or {}).get( + "extra_sources" + ) or {} + extra_source_paths: list[str] = next( + ( + paths + for addr, paths in extra_sources_cfg.items() + if addr.lower() == contract_address_from_config.lower() + ), + [], + ) + github_solc_input, missing_sources = _build_github_solc_input( contract_source_code, config, github_api_token, recursive_parsing, cache_github, + extra_source_paths, ) if missing_sources: missing_preview = ", ".join(missing_sources[:5]) @@ -212,8 +236,16 @@ def exact_match() -> dict: explorer_constructor_arguments, ) deployment_call_data = _append_calldata(contract_creation_code, calldata) + deployment_from = _get_deployment_from( + config.get("bytecode_comparison"), contract_address_from_config + ) gas_limit = config.get("deployment_gas_limit") - extra = {"gas_limit": gas_limit} if gas_limit else {} + extra: dict[str, Any] = {} + if deployment_from: + logger.info("Using deployment simulation from config", deployment_from) + extra["caller"] = deployment_from + if gas_limit: + extra["gas_limit"] = gas_limit local_deployed_bytecode = simulate_deployment( deployment_call_data, remote_rpc_url, **extra ) @@ -493,6 +525,46 @@ def _append_calldata(creation_code: str, calldata: str | None) -> str: return creation_code + calldata +def _validate_config_address(value: object, field_name: str) -> str: + if not isinstance(value, str): + raise CalldataError(f"{field_name} must be a hex string address") + + address = value.strip() + if not address.startswith("0x") or len(address) != 42: + raise CalldataError(f"{field_name} must be a 20-byte hex address") + + try: + int(address[2:], 16) + except ValueError as exc: + raise CalldataError(f"{field_name} is not valid hex") from exc + + return address + + +def _get_deployment_from( + binary_config: dict | None, contract_address: str +) -> str | None: + if not isinstance(binary_config, dict): + return None + + deployment_from = binary_config.get("deployment_from") + if deployment_from is None: + return None + if not isinstance(deployment_from, dict): + raise CalldataError( + 'Config key "bytecode_comparison.deployment_from" must be an object' + ) + + for addr, caller in deployment_from.items(): + if addr.lower() == contract_address.lower(): + return _validate_config_address( + caller, + f"bytecode_comparison.deployment_from.{addr}", + ) + + return None + + def _log_explorer_bytecode_metadata( constructor_arguments: str | None, evm_version: str | None, diff --git a/diffyscan/utils/common.py b/diffyscan/utils/common.py index 9d9039e0..80fa9052 100644 --- a/diffyscan/utils/common.py +++ b/diffyscan/utils/common.py @@ -122,13 +122,30 @@ def _validate_yaml_hex_keys(config: dict, path: str) -> None: bytecode = config.get("bytecode_comparison") if isinstance(bytecode, dict): - for section in ("constructor_args", "constructor_calldata"): + for section in ( + "constructor_args", + "constructor_calldata", + "deployment_from", + "extra_sources", + ): _validate_yaml_address_keys( bytecode.get(section), path, f"bytecode_comparison.{section}", ) + deployment_from = bytecode.get("deployment_from") + if isinstance(deployment_from, dict): + for contract_addr, caller in deployment_from.items(): + _raise_if_yaml_int( + caller, + lambda parsed_addr: ( + f"{path}: bytecode_comparison.deployment_from.{contract_addr} " + f"was parsed as integer ({parsed_addr:#x}). " + f"Quote it: {_quote_hex(parsed_addr)}" + ), + ) + libraries = bytecode.get("libraries") if isinstance(libraries, dict): for key, libs in libraries.items(): diff --git a/diffyscan/utils/custom_types.py b/diffyscan/utils/custom_types.py index e1433088..4737f978 100644 --- a/diffyscan/utils/custom_types.py +++ b/diffyscan/utils/custom_types.py @@ -5,7 +5,9 @@ class BinaryConfig(TypedDict): hardhat_config_name: NotRequired[str] constructor_calldata: NotRequired[dict[str, str]] constructor_args: NotRequired[dict[str, list]] + deployment_from: NotRequired[dict[str, str]] libraries: NotRequired[dict[str, dict[str, str]]] + extra_sources: NotRequired[dict[str, list[str]]] class ImmutableRule(TypedDict): diff --git a/tests/fixtures/full_config.json b/tests/fixtures/full_config.json index 82b18dbe..e773e9ad 100644 --- a/tests/fixtures/full_config.json +++ b/tests/fixtures/full_config.json @@ -46,6 +46,9 @@ "00000000000000000000000000000000219ab540356cbb839cbe05303d7705fa" ] }, + "deployment_from": { + "0xe561152e8d3f618b386ef4dd6e3fb980eb2f9e61": "0x13Ac97663d19fF20fe467BFa580748505e664beB" + }, "libraries": { "contracts/common/lib/MinFirstAllocationStrategy.sol": { "MinFirstAllocationStrategy": "0x7e70De6D1877B3711b2bEDa7BA00013C7142d993" diff --git a/tests/fixtures/full_config.yaml b/tests/fixtures/full_config.yaml index 7f66717b..9c3eb163 100644 --- a/tests/fixtures/full_config.yaml +++ b/tests/fixtures/full_config.yaml @@ -45,6 +45,8 @@ bytecode_comparison: - "0x13Ac97663d19fF20fe467BFa580748505e664beB" "0x442af784A788A5bd6F42A01Ebe9F287a871243fb": - "00000000000000000000000000000000219ab540356cbb839cbe05303d7705fa" + deployment_from: + "0xe561152e8d3f618b386ef4dd6e3fb980eb2f9e61": "0x13Ac97663d19fF20fe467BFa580748505e664beB" libraries: contracts/common/lib/MinFirstAllocationStrategy.sol: MinFirstAllocationStrategy: "0x7e70De6D1877B3711b2bEDa7BA00013C7142d993" diff --git a/tests/test_bytecode_metadata.py b/tests/test_bytecode_metadata.py index 1ee1861c..a3ee614f 100644 --- a/tests/test_bytecode_metadata.py +++ b/tests/test_bytecode_metadata.py @@ -105,6 +105,27 @@ def fake_pull(rpc_url, payload, headers): assert captured["payload"]["params"][0]["data"] == "0x6001600055" +def test_simulate_deployment_uses_custom_caller(monkeypatch): + captured = {} + + def fake_pull(rpc_url, payload, headers): + captured["payload"] = json.loads(payload) + return DummyResponse({"result": "0x60016000"}) + + monkeypatch.setattr("diffyscan.utils.node_handler.pull", fake_pull) + + simulate_deployment( + "0x6001600055", + "https://rpc.example", + caller="0x0000000000000000000000000000000000000002", + ) + + assert ( + captured["payload"]["params"][0]["from"] + == "0x0000000000000000000000000000000000000002" + ) + + def test_simulate_deployment_uses_default_gas_limit(monkeypatch): captured = {} diff --git a/tests/test_config_loading.py b/tests/test_config_loading.py index 3fac98e9..d770435a 100644 --- a/tests/test_config_loading.py +++ b/tests/test_config_loading.py @@ -235,6 +235,27 @@ def test_bytecode_comparison_library_unquoted_hex_raises(tmp_path): load_config(str(path)) +def test_bytecode_comparison_deployment_from_unquoted_hex_raises(tmp_path): + """Unquoted hex in bytecode_comparison.deployment_from values should be caught.""" + path = tmp_path / "config.yaml" + path.write_text("""\ +contracts: + "0x0000000000000000000000000000000000000001": TestContract +explorer_hostname: api.etherscan.io +explorer_token_env_var: ETHERSCAN_EXPLORER_TOKEN +github_repo: + url: https://github.com/example/repo + commit: abc123 + relative_root: "" +dependencies: {} +bytecode_comparison: + deployment_from: + "0x0000000000000000000000000000000000000001": 0x00000000000000000000000000000000000000AB +""") + with pytest.raises(ValueError, match="bytecode_comparison.deployment_from"): + load_config(str(path)) + + def test_allowed_diffs_unknown_address_raises(tmp_path): path = tmp_path / "config.json" path.write_text( @@ -341,7 +362,7 @@ def test_allowed_diffs_yaml_unquoted_address_raises(tmp_path): contracts: "0x0000000000000000000000000000000000000001": TestContract explorer_hostname: api.etherscan.io -explorer_token_env_var: ETHERSCAN_TOKEN +explorer_token_env_var: ETHERSCAN_EXPLORER_TOKEN github_repo: url: https://github.com/example/repo commit: abc123 @@ -365,7 +386,7 @@ def test_allowed_diffs_yaml_unquoted_immutable_hex_raises(tmp_path): contracts: "0x0000000000000000000000000000000000000001": TestContract explorer_hostname: api.etherscan.io -explorer_token_env_var: ETHERSCAN_TOKEN +explorer_token_env_var: ETHERSCAN_EXPLORER_TOKEN github_repo: url: https://github.com/example/repo commit: abc123 @@ -432,6 +453,11 @@ def test_full_fixture_nested_types(): for arg in args: assert isinstance(arg, str), f"constructor arg {arg!r} should be str" + # bytecode_comparison.deployment_from maps contract addresses to caller addresses + for addr, caller in result["bytecode_comparison"]["deployment_from"].items(): + assert isinstance(addr, str) and addr.startswith("0x") + assert isinstance(caller, str) and caller.startswith("0x") + # bytecode_comparison.libraries nested dict of str -> str for path, libs in result["bytecode_comparison"]["libraries"].items(): assert isinstance(libs, dict) diff --git a/tests/test_deployment_from_config.py b/tests/test_deployment_from_config.py new file mode 100644 index 00000000..00644528 --- /dev/null +++ b/tests/test_deployment_from_config.py @@ -0,0 +1,41 @@ +import pytest + +from diffyscan.diffyscan import _get_deployment_from +from diffyscan.utils.custom_exceptions import CalldataError + + +def test_get_deployment_from_returns_contract_specific_caller(): + binary_config = { + "deployment_from": { + "0x0000000000000000000000000000000000000001": ( + "0x0000000000000000000000000000000000000002" + ) + } + } + + assert ( + _get_deployment_from( + binary_config, "0x0000000000000000000000000000000000000001" + ) + == "0x0000000000000000000000000000000000000002" + ) + + +def test_get_deployment_from_returns_none_when_missing(): + assert ( + _get_deployment_from( + {"deployment_from": {}}, "0x0000000000000000000000000000000000000001" + ) + is None + ) + + +def test_get_deployment_from_rejects_invalid_caller(): + binary_config = { + "deployment_from": {"0x0000000000000000000000000000000000000001": "0x1234"} + } + + with pytest.raises(CalldataError, match="20-byte hex address"): + _get_deployment_from( + binary_config, "0x0000000000000000000000000000000000000001" + ) diff --git a/tests/test_diffyscan_allowlist_runtime.py b/tests/test_diffyscan_allowlist_runtime.py index 5fde51fc..9e41e3d0 100644 --- a/tests/test_diffyscan_allowlist_runtime.py +++ b/tests/test_diffyscan_allowlist_runtime.py @@ -168,3 +168,57 @@ def fake_simulate_deployment(data, rpc_url, **kwargs): assert result["status"] == "allowed" assert result["matched_facets"] == ["exact_match", "constructor_args"] assert [call["gas_limit"] for call in simulate_calls] == [12345, 12345] + + +def _fetcher(monkeypatch, content_by_path): + calls = [] + + def fake_fetch(path_to_file, *args, **kwargs): + calls.append(path_to_file) + return content_by_path.get(path_to_file) + + monkeypatch.setattr(runner, "_fetch_github_source", fake_fetch) + return calls + + +def test_extra_sources_are_added_to_github_compilation(monkeypatch): + monkeypatch.setattr( + runner, "get_solc_sources", lambda solc_input: ["A.sol", "B.sol"] + ) + calls = _fetcher(monkeypatch, {"A.sol": "a", "B.sol": "b", "C.sol": "c"}) + + solc_input, missing = runner._build_github_solc_input( + {"solcInput": {"sources": {}}}, + {}, + "github-token", + False, + False, + ["C.sol"], + ) + + assert missing == [] + assert set(solc_input["sources"]) == {"A.sol", "B.sol", "C.sol"} + assert "C.sol" in calls + + +def test_extra_source_already_in_explorer_list_is_not_fetched_twice(monkeypatch): + monkeypatch.setattr(runner, "get_solc_sources", lambda solc_input: ["A.sol"]) + calls = _fetcher(monkeypatch, {"A.sol": "a"}) + + solc_input, missing = runner._build_github_solc_input( + {"solcInput": {}}, {}, "github-token", False, False, ["A.sol"] + ) + + assert calls == ["A.sol"] + assert set(solc_input["sources"]) == {"A.sol"} + + +def test_missing_extra_source_is_reported(monkeypatch): + monkeypatch.setattr(runner, "get_solc_sources", lambda solc_input: ["A.sol"]) + _fetcher(monkeypatch, {"A.sol": "a"}) # "C.sol" returns None -> missing + + _, missing = runner._build_github_solc_input( + {"solcInput": {}}, {}, "github-token", False, False, ["C.sol"] + ) + + assert missing == ["C.sol"]