Skip to content

test: cover verify_prices, check contract artifacts against sources, keep secrets out of release.yml (V2-1750) - #23

Open
Nic-dorman wants to merge 6 commits into
mainfrom
nic/v2-1750-evmlib-ci-follow-ups
Open

Nic-dorman wants to merge 6 commits into
mainfrom
nic/v2-1750-evmlib-ci-follow-ups

Conversation

@Nic-dorman

@Nic-dorman Nic-dorman commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Round 5 follow-up (93bb12c)

Hermes approved 36f0ea1 with one non-blocking note: the Foundry version was unpinned. contracts.yml now pins foundry-toolchain to 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, so stable would already have moved. Review range: 36f0ea1..93bb12c (optional). merge.yml's Foundry (Anvil for the tests) is left on stable.

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's rawMetadata doesn'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:

Checkout Remappings in the metadata Built vs committed bytecode
submodules: true (non-recursive, what contracts.yml did) 4 (no erc4626-tests, halmos-cheatcodes: they come from OpenZeppelin's nested submodules) code identical, metadata hash differs
recursive 6, same as the committed artifact identical byte for byte, metadata included
  • contracts.yml checks out submodules recursively.
  • check_contract_artifacts.py compares the ABI and the full bytecode/deployedBytecode. The CBOR parser and test_check_contract_artifacts.py are 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.
  • Stricter than before: the metadata hashes every source file, so a comment-only source change now fails until the artifacts are regenerated (inferred from solc's metadata format; not re-run).
  • "Artifacts match sources" runs the real build and full comparison on this push.

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.

  1. verify_prices gets unit tests. MerklePaymentCandidatePool::verify_prices had none. Six tests in src/merkle_payments/merkle_payment.rs:
    • matching prices pass;
    • the winner is found by hash, not by position (a decoy commitment with wrong prices is listed first);
    • a mismatch at candidate 0 and at candidate 15 is rejected with the right index and prices;
    • with several mismatches, the first one is reported;
    • a winner hash missing from the commitments (or an empty list) is rejected.
  2. release.yml passes secrets through env:. cargo login "${{ secrets.CRATES_IO_TOKEN }}" and --git-token ${{ secrets.GITHUB_TOKEN }} were interpolated into the script. Now CARGO_REGISTRY_TOKEN and GIT_TOKEN are step env, with no cargo login; cargo reads CARGO_REGISTRY_TOKEN when release-plz publishes. Same pattern as self_encryption #449.
  3. The committed PaymentVaultV2 artifacts are checked against contracts/. The tests deploy artifacts/PaymentVaultV2.json and bind abi/IPaymentVault.json via sol!, so a source change without regenerated artifacts would leave the tests exercising the old contract.
    • foundry.toml pins solc_version = "0.8.28" and evm_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).
    • New contracts.yml (path-filtered: contracts, artifacts, abi, lib, Foundry config) runs forge build into a temp dir. .github/scripts/check_contract_artifacts.py then 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.json is 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

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

Compatibility

  • Wire: none
  • Storage: none
  • API: none (tests, CI and Foundry config only; no library code or committed artifact changes)

Semver impact

  • breaking
  • feature
  • fix

Test evidence

On dev2 (Linux), same commands as merge.yml and wasm.yml:

  • cargo fmt --all -- --check: clean
  • RUSTFLAGS="-D warnings" cargo clippy --all-targets --all-features: clean
  • cargo 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.
  • wasm.yml: both cargo check --target wasm32-unknown-unknown variants are clean; cargo test --no-default-features passes (15 + 1); cargo test --lib --test cryptography --no-default-features --features rpc,external-signer passes (28 + 4).
  • cargo publish --dry-run: OK.
  • The new tests catch the bugs they target. I broke verify_prices locally two ways and ran the 6 tests:
    • dropping the price comparison fails the 3 mismatch tests;
    • taking the first commitment instead of looking the winner up by hash fails the decoy test and the missing-hash test.

Artifact check, on dev2 with Foundry 1.7.1 (Round 4 design):

  • recursive checkout of this branch: built bytecode and deployedBytecode equal the committed artifact byte for byte, so the check passes;
  • non-recursive checkout: differs only in the metadata hash, so the check fails (the reason contracts.yml is recursive now);
  • earlier, against the metadata-stripping design: a new public function failed on the ABI (both files) and the bytecode, and a > → >= logic change failed on the bytecode. The full comparison is strictly stronger, so both still fail.

actionlint is 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.yml is path-filtered and not a required check, so at worst it reds PRs that touch the contracts until fixed.

🤖 Generated with Claude Code

…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 MaidSafe-Hermes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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__)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MaidSafe-Hermes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MaidSafe-Hermes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 != "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MaidSafe-Hermes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MaidSafe-Hermes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants