Repository navigation
test: cover verify_prices, check contract artifacts against sources, keep secrets out of release.yml (V2-1750) - #23
Nic-dorman wants to merge 6 commits into
Conversation
…keep secrets out of release.yml scripts Follow-ups from the faux-CI audit (V2-1750, part of V2-1746). - Unit tests for MerklePaymentCandidatePool::verify_prices: matching prices pass; the winner is looked up by hash, not position; mismatches at candidate 0 and 15 are rejected with the right index; the first of several mismatches is reported; a winner hash missing from the commitments is rejected. - release.yml: pass CRATES_IO_TOKEN and GITHUB_TOKEN through step env (CARGO_REGISTRY_TOKEN, GIT_TOKEN) instead of interpolating them into the script; drop `cargo login`. - foundry.toml: pin solc 0.8.28 and EVM cancun, the settings recorded in the committed PaymentVaultV2 artifact. - contracts.yml (path-filtered) + .github/scripts/check_contract_artifacts.py: rebuild the contracts and fail if artifacts/PaymentVaultV2.json or abi/IPaymentVault.json no longer match them (ABI exactly, bytecode without the CBOR metadata tail). Closes V2-1750 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES. Coverage: 2/2 seats reviewed (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). The seats disagreed: sol-1 raised the finding below; glm-1 approved but noted the same metadata-free robustness concern. I reproduced the false-negative behavior against the checker, so this blocks approval.
Other review evidence: all PR checks, including Artifacts match sources, are green. I ran cargo test --lib verify_prices -- --nocapture: 6 passed. The release-token change and test additions look sound. No local Foundry run (forge is not installed).
|
|
||
| def main(): | ||
| if len(sys.argv) != 2: | ||
| sys.exit(__doc__) |
There was a problem hiding this comment.
P2: This assumes the final two bytes always encode a valid CBOR metadata length. With metadata absent (for example, if a later change sets cbor_metadata = false in foundry.toml, which triggers this workflow), an oversized footer length can strip all executable code: I reproduced code("0x600160005500") == code("0x600260005500") == "" although the bytecode differs. The drift check could then pass despite a code change. Please validate the footer length and CBOR structure; if no valid metadata tail exists, compare the full bytecode or fail explicitly. Add tests for metadata-free, truncated, and oversized-footer input.
…(V2-1750) Hermes round 1 (REQUEST_CHANGES on d5fe55a): code() trusted the last two bytes as a CBOR length. With no metadata (e.g. cbor_metadata = false in foundry.toml, which triggers this workflow) the "length" is just code, and an oversized one stripped all executable code: code("0x600160005500") == code("0x600260005500") == "". The drift check could then pass despite a code change. metadata_length() now accepts the tail only if the bytes before the footer are exactly one well-formed CBOR map of the stated length whose keys are all solc metadata keys (ipfs, bzzr0, bzzr1, solc, experimental). Otherwise the full bytecode is compared. test_check_contract_artifacts.py (11 tests, run by contracts.yml before the comparison): a solc-shaped tail is stripped; builds differing only in metadata compare equal; different code with the same tail differs; metadata-free, oversized-footer, truncated, non-CBOR, short-map and unknown-key inputs are compared in full; the committed artifact's tail is recognised. Three of them fail against the old parser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES. Coverage: 2/2 seats reviewed (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). The seats disagreed: sol-1 identified the issue below; glm-1 approved but noted the parser can strip valid-looking but non-solc CBOR tails. I reproduced a false match with an invalid solc value, so the concern is confirmed.
The prior metadata-free false match is fixed: the new 11 artifact-checker tests pass locally, and the updated Artifacts match sources check and all other reported PR checks are green.
| parsed = _cbor_item(blob, 0) | ||
| if parsed is None or parsed[1] != len(blob): | ||
| return 0 # malformed, or doesn't fill exactly the stated length | ||
| keys = {key.decode("utf-8", "replace") for key in parsed[0]} |
There was a problem hiding this comment.
P2: Whitelisting only map keys does not establish that this is solc metadata. _cbor_item also conflates CBOR text and byte-string keys and silently overwrites duplicate map keys; arbitrary value types are accepted. Reproduction: code("0x600060006000a164736f6c63010007") returns 600060006000, the same as code("0x600060006000"): the suffix is the CBOR map {"solc": 1} plus its length, not valid solc metadata. Its bytes also execute as LOG1/PUSH5/STOP, so changed executable bytecode can be stripped and compare equal. Validate CBOR text-key types, uniqueness, required metadata keys and their value types/lengths; otherwise compare the full bytecode. Add this counterexample and duplicate/byte-string-key regression tests.
Hermes round 2 (REQUEST_CHANGES on e1d4c44): whitelisting map keys doesn't establish solc metadata. "...a164736f6c63010007" ({"solc": 1} plus its length) was stripped although those bytes also execute (LOG1/PUSH5/STOP), so changed code could compare equal. The parser also accepted byte-string keys and silently kept the last duplicate. _is_solc_metadata now requires: unique CBOR text-string keys; `solc` present, as a 3-byte release version or a non-empty prerelease text; at most one hash, ipfs as 34 bytes or bzzr0/bzzr1 as 32 bytes; `experimental` only as true; no other keys. Anything else is compared as full bytecode. Tests (17, up from 11): the reviewer's counterexample, duplicate keys, a byte-string key, wrong value types/lengths, missing solc, two hashes, and the shapes solc does emit (ipfs + release, bytecodeHash none, prerelease, bzzr1 + experimental). 5 of the new tests fail against the round-1 parser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES. Coverage: 2/2 seats reviewed (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). The seats disagreed on severity: sol-1 treated the invalid compiler-version suffix below as a blocker; glm-1 observed the same false normalization but considered executable impact unverified for this contract. I reproduced the false match in the checker.
The prior round's recognized-key/value-shape cases are now covered; all 17 checker tests pass locally, git diff --check passes, and all current PR checks are green.
| if key in HASH_LENGTHS: | ||
| ok = kind == "bytes" and len(value) == HASH_LENGTHS[key] | ||
| elif key == "solc": | ||
| ok = (kind == "bytes" and len(value) == 3) or (kind == "text" and value != "") |
There was a problem hiding this comment.
P2: The parser validates only the solc value's type and length, not that it matches the pinned compiler (foundry.toml sets 0.8.28). It therefore strips {"solc": h'ffffff'} as metadata, even though this compiler cannot emit version 255.255.255. Reproduction: code("0x600060006000a164736f6c6343ffffff000a") and code("0x600060006000") both return 600060006000; the suffix is valid EVM instructions (LOG1/PUSH5/SELFDESTRUCT/STOP), not metadata from the pinned build. The test gate can thus normalize different bytecode to the same value. Require the solc field to match the pinned version (00 08 1c for 0.8.28; reject prerelease text for this release build), and add a regression test that keeps this executable suffix in the full comparison.
…750) Review of 2a2722d: the checker accepted any 3-byte `solc` value, so {"solc": h'ffffff'} (LOG1/PUSH5/SELFDESTRUCT/STOP, valid code) was stripped as metadata and different bytecode could compare equal. The `solc` value must now equal foundry.toml's [profile.default] solc_version (0.8.28 -> 00 08 1c), read from foundry.toml so the two can't drift. Prerelease (text) versions are rejected too, since the pin is a release. Tests: the reviewer's counterexample, neighbouring versions, prerelease, and the version parser; the fixtures now derive the version from the pin. The new tests fail against 2a2722d's checker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES. Coverage: 2/2 seats reviewed (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). The seats disagreed: sol-1 treated the exact-pin false match below as blocking; glm-1 considered it non-blocking because the suffix resembles metadata. I independently reproduced it and confirmed the suffix also executes as EVM instructions, so the checker can still accept a bytecode mismatch.
The new version-pin tests pass locally (21 tests), and current PR checks are green.
| return "solc" in seen and len(seen & HASH_LENGTHS.keys()) <= 1 | ||
|
|
||
|
|
||
| def metadata_length(raw): |
There was a problem hiding this comment.
P2: Matching the pinned compiler version still does not prove these bytes are metadata rather than executable code. The accepted solc-only shape is explicitly tested as valid metadata, but the same bytes can execute. Reproduction: code("0x600060006000a164736f6c634300081c000a") and code("0x600060006000") both return 600060006000; the longer program executes LOG1 (empty data, zero topic), unlike the shorter program. This suffix names the pinned 0.8.28 compiler, so the new version check does not reject it. The checker can therefore normalize distinct bytecode as equal. Authenticate the tail against the artifact/compiler metadata (for example, verify its hash against rawMetadata and compare the relevant metadata), or fail closed when the boundary cannot be established. Add this exact-pin executable-suffix regression test.
…ely (V2-1750) Review of 7290274: no shape or version check can prove a tail is solc metadata rather than code. {"solc": 0.8.28} (a164736f6c634300081c000a) also executes (LOG1), so stripping it let different bytecode compare equal. forge's rawMetadata does not hash to the tail either, so the tail can't be authenticated against it. The metadata only ever differed because contracts.yml checked out submodules non-recursively: Foundry then misses the remappings it auto-detects from OpenZeppelin's nested submodules (erc4626-tests, halmos-cheatcodes), and the remappings are hashed into the metadata. With a recursive checkout, a fresh forge 1.7.1 build reproduces artifacts/PaymentVaultV2.json byte for byte, metadata included (checked on Linux; the flat checkout differs only in the metadata hash). So contracts.yml now checks out recursively and the checker compares the full bytecode, stripping nothing. The CBOR parser and its tests are gone; the failure message points at recursive submodules when only the metadata differs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
APPROVE. Coverage: 2/2 seats reviewed (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). The seats disagreed: glm-1 flagged solc_version as unrecognized. I checked the official Foundry configuration docs, which use solc_version = "0.8.28" in the minimal foundry.toml example, and the PR's Artifacts match sources job passes on this head; that objection is refuted. The latest change compares full bytecode and recursively checks out submodules, removing the previous metadata-stripping false-match path. All PR checks are green. The Foundry version itself remains unpinned; I consider that a future reproducibility concern, not a current blocker.
Follow-up to the approval of 36f0ea1: the byte-for-byte check ran on whatever `stable` Foundry was that day (1.8.7; 1.9.0 shipped since). Pin the version the check passed on, so a Foundry release can't change the build under it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 5 follow-up (93bb12c)
Hermes approved
36f0ea1with one non-blocking note: the Foundry version was unpinned.contracts.ymlnow pinsfoundry-toolchainto v1.8.7, the version the byte-for-byte check passed on in CI (dev2's 1.7.1 also produced identical bytecode). Foundry 1.9.0 shipped on 2026-11-02, sostablewould already have moved. Review range:36f0ea1..93bb12c(optional).merge.yml's Foundry (Anvil for the tests) is left onstable.Round 4 fix (36f0ea1): compare the full bytecode, strip nothing
Hermes requested changes on
7290274, valid again:{"solc": 0.8.28}(…a164736f6c634300081c000a) names the pinned compiler and also executes (LOG1), so it was stripped. No shape or version check can prove a tail is metadata rather than code. forge'srawMetadatadoesn't hash to the tail either, so the tail can't be authenticated against it. Review range:7290274..36f0ea1.So the stripping is gone. On dev2 (forge 1.7.1, solc 0.8.28) I found why the metadata ever differed:
submodules: true(non-recursive, what contracts.yml did)erc4626-tests,halmos-cheatcodes: they come from OpenZeppelin's nested submodules)contracts.ymlchecks out submodules recursively.check_contract_artifacts.pycompares the ABI and the fullbytecode/deployedBytecode. The CBOR parser andtest_check_contract_artifacts.pyare removed; none of the four counterexamples can occur when no bytes are dropped. On failure, it also says to check out submodules recursively if only the metadata hash differs.Superseded review rounds: R1 (
e1d4c44, footer trusted as a length), R2 (2a2722d, key whitelist), R3 (7290274, version pin) each tightened the metadata parser against a Hermes counterexample. Round 4 replaces that approach.Follow-ups from the 2026-10-27 faux-CI audit (V2-1746). evmlib's CI was found honest; these close the small gaps it listed.
verify_pricesgets unit tests.MerklePaymentCandidatePool::verify_priceshad none. Six tests insrc/merkle_payments/merkle_payment.rs:env:.cargo login "${{ secrets.CRATES_IO_TOKEN }}"and--git-token ${{ secrets.GITHUB_TOKEN }}were interpolated into the script. NowCARGO_REGISTRY_TOKENandGIT_TOKENare step env, with nocargo login; cargo readsCARGO_REGISTRY_TOKENwhen release-plz publishes. Same pattern as self_encryption #449.contracts/. The tests deployartifacts/PaymentVaultV2.jsonand bindabi/IPaymentVault.jsonviasol!, so a source change without regenerated artifacts would leave the tests exercising the old contract.foundry.tomlpinssolc_version = "0.8.28"andevm_version = "cancun", the values recorded in the committed artifact's metadata. Unpinned, Foundry picks the newest matching solc and its own default EVM version (prague on Foundry 1.7.1).contracts.yml(path-filtered: contracts, artifacts, abi, lib, Foundry config) runsforge buildinto a temp dir..github/scripts/check_contract_artifacts.pythen compares the ABI and the full bytecode/deployedBytecode exactly. The job checks out submodules recursively, because the metadata at the end of the bytecode hashes the remappings Foundry auto-detects, including those from OpenZeppelin's nested submodules (see Round 4).artifacts/AutonomiNetworkToken.jsonis not checked: it's a Hardhat artifact whose source isn't in this repo.Not changed: the release test gate (V2-1746 item 2) and ADR Governance (V2-1746 item 4).
Linear issue
Closes V2-1750
Risk tier
Compatibility
Semver impact
Test evidence
On dev2 (Linux), same commands as merge.yml and wasm.yml:
cargo fmt --all -- --check: cleanRUSTFLAGS="-D warnings" cargo clippy --all-targets --all-features: cleancargo test --release(Anvil 1.7.1): all suites pass. Lib 36 (including the 6 new), cryptography 4, gas_fee_limit 1, network_token 3, payment_vault 5, wallet 8.cargo check --target wasm32-unknown-unknownvariants are clean;cargo test --no-default-featurespasses (15 + 1);cargo test --lib --test cryptography --no-default-features --features rpc,external-signerpasses (28 + 4).cargo publish --dry-run: OK.verify_priceslocally two ways and ran the 6 tests:Artifact check, on dev2 with Foundry 1.7.1 (Round 4 design):
bytecodeanddeployedBytecodeequal the committed artifact byte for byte, so the check passes;>→>=logic change failed on the bytecode. The full comparison is strictly stronger, so both still fail.actionlintis clean on all workflows.New dependency
none (Foundry is already installed by merge.yml's test job; the script is stdlib Python)
ADR
n/a
Mitigation / rollback
Revert the PR.
contracts.ymlis path-filtered and not a required check, so at worst it reds PRs that touch the contracts until fixed.🤖 Generated with Claude Code