ci(contracts): gate the published ABI and run the storage-layout check - #50
Conversation
Adds an ABI compatibility gate and wires both contract-surface checks into CI, where neither ran before. The ABI fingerprint compares selector and topic identity rather than document text, so reordering or reformatting by the toolchain cannot produce a false failure. Events additionally carry an indexed bitmask, because topic0 is keccak of the canonical signature and excludes indexedness: flipping a field between topic and data leaves topic0 untouched while silently breaking every historical log decoder. The storage-layout check was already present, complete with a committed snapshot and make targets, but no workflow ever invoked it -- and it could not have passed if one had. Its snapshot embeds solc AST node ids, which shift whenever any storage-neutral declaration is added, so a clean rebuild of unmodified main reported a fake collision. It is therefore both wired up and repaired: AST ids are normalized away while array lengths and integer widths are preserved, entries are sorted by slot, struct members behind mappings are compared, and tool failure is distinguished from a real layout change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@penumbra23 the abi should be an artifact of the release, not committed in the repository. If anything, the check should just check that the semver is updated correctly if the ABI changes. If it's a new thing, it should be a minor. If it breaks the old ABI, it should be a major. External services (e.g. the dapp) should not depend on the commit hash, but instead on released artifacts, which is the github release and the npm package. |
…tted snapshot The published ABI is a release artifact: shell/create_artifacts.sh generates it into a gitignored artifacts/, and release.yml publishes it to npm and a GitHub release on every tag. Committing a second, differently shaped ABI fingerprint under .abi/ duplicated that artifact inside a repository that deliberately excludes generated ABI. The gate now computes both sides instead. It checks the base revision out into a temporary worktree, builds it, fingerprints it, and compares that against the working tree, so nothing is stored in the repository. The base revision is resolved the same way as the proto gate in credible-sdk: the pull request base sha, or the commit before a push, never origin/main on a push -- on main that is the commit under test, and the comparison would be vacuous. An unresolvable base, or one that leaves nothing to compare, refuses to report a pass rather than passing silently. The fingerprint itself is unchanged: selectors, event topic0s, the indexed bitmask, return types, error selectors, and the classification rules all behave exactly as before, as does the exit-code contract. The storage-layout half is untouched. It guards proxy upgrade safety against already-deployed state rather than a published package, so its committed snapshot is the right baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@odyslam you're right, and the committed snapshot is gone as of 9985381. Concretely why: What changed. The gate no longer stores anything. It checks the base revision out into a temporary worktree, builds it, fingerprints it, and compares that against the working tree — both sides computed, nothing committed. The base is resolved the way our proto gate in credible-sdk does it: the PR base sha, or the commit before a push, and never The detection logic is unchanged — selectors, event topic0s, the On the semver check. I agree that's the right end state, and it subsumes what this does: ABI changed additively → minor, breaks the old ABI → major, enforced against the last released artifact rather than a git ref. I've deliberately not built it here, for two reasons:
Comparing against the base branch is the useful subset that doesn't need either decision: it catches the break at review time, on the PR that introduces it. The released-artifact-plus-semver version can replace it once the policy call is made — happy to take that as a follow-up issue and put it behind whoever owns the versioning decision. Worth flagging separately: Storage layout is unaffected by all of this and keeps its committed |
- check_storage_layout.sh compares the metadata of every previous type, not only the ones with members. A user defined value type keeps its normalized key when its underlying type changes, so its storage entry still matched and the change was reported as benign. Widths may grow for a type with members - appending to a struct behind a mapping leaves existing records in place - and any other change to width, encoding, key, value or base is a collision. - A missing .storage-layout no longer bootstraps itself. Writing one into the CI runner and exiting 0 meant a pull request could delete the snapshot and every later run would re-baseline against itself and pass. - check_abi.sh reads its scope out of create_artifacts.sh instead of repeating it. The two lists had already drifted: #48 publishes StateOracleV2, TriggerManifestValidatorV1 and ITriggerManifestValidator, none of which this check would have fingerprinted. - Functions and errors are keyed by a 4-byte selector, so a shared key does not mean a shared signature. Differing signatures under one selector are now a break; previously the entry looked present on both sides and old calldata would decode under a different input layout. Also drops the HEAD^ fallback when resolving a push base, for the same reason it was dropped from the proto gate: after a force-push HEAD^ belongs to the rewritten history, not the published ABI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Adds an ABI compatibility gate alongside the existing storage-layout one, and wires both into CI. Day to day this means two new make targets next to the storage pair you already have:
.abi/make check-abimake update-abi.storage-layoutmake check-storage-layoutmake update-storage-layoutCI runs both on every pull request, plus a third step that regenerates
.abi/and fails if the committed snapshot is stale. Every fingerprint change has to be committed, additive ones included.The ABI snapshot covers the seven contracts
shell/create_artifacts.shpublishes to npm, plusAdminVerifierWhitelist—credible-layer-dapp/scripts/seed-onchain-test-data.shcallsaddToWhitelist(address,address)as a literal string throughcast send, so a signature change there breaks a cross-repo caller with no compile-time error. One snapshot file per contract, so a diff is attributable and a scope change is visible as a file add or remove.The storage check was worse than unwired
grep -rn "storage-layout\|check_storage" .github/returned nothing — it had never run. But it also could not have passed. On a clean rebuild of unmodifiedmainit exits 2 with a fake collision:Those trailing digits are solc AST node ids. They shift whenever anything earlier in the compilation unit changes — adding a single storage-neutral function is enough.
.storage-layoutandsrc/were last touched by the same commit, so this was never staleness. Turning the check on as written would have failed essentially every PR that touchedsrc/, which is worse than no gate at all.Repaired without changing what it means:
t_array(t_uint256)5_storagevs4_storage,dyn, andt_uint256vst_uint128all still compare.typestable is compared, not just.storage. A struct behind a mapping keeps its top-level slot, offset and type id while its members move underneath it — reorderingAssertionAdopterrelocates every deployed adopter record, and comparing.storagealone reports "unchanged".The committed
.storage-layoutneeded no edit; the fix is entirely in the comparison.What the ABI gate catches
Compared on selector and topic0 identity, never on document text.
Breaking (exit 2): function or event removed; parameter retyped or reordered; function renamed; state mutability tightened; return type or arity changed; event
indexedlayout changed; custom error removed.Additive (exit 1, passes CI with a warning): new function, new event, mutability relaxed.
Two of these need explaining:
indexedgets its own bitmask. topic0 is keccak of the canonical signature and does not encode indexedness, so flipping a field between topic and data leaves topic0 byte-identical. A topic0-only diff misses it entirely, while every historical log decoder breaks. Verified on a zero-input event too, where a field-splitting bug had previously been eating the flag.returns (uint256)→returns (bytes32)keeps the same key while breaking everyeth_calldecoder.Known limitation
Swapping two parameters of the same type — the two
addressarguments ofaddToWhitelist(address,address)— leaves the canonical signature and therefore the selector untouched. No selector-based check can detect it. Documented in the README and in the script; argument order of same-typed parameters still needs human review.Test Plan
Every probe applied to a real Solidity source edit, then reverted; the tree is byte-clean afterwards.
ABI gate
indexedAdminVerifierWhitelistStorage gate
uint16→uint32Failure injection — 7 malformed
.abi/states (empty,{}, merge-conflict markers, truncated, missing section, wrong root type, unreadable) and 8 malformed.storage-layoutstates all exit 3 rather than reporting a false pass. A failingcast keccakexits 3, emits zero bogus findings, and writes no snapshot.Other —
.abi/byte-identical across three regenerations and from a fresh clone with noout/;forge buildand 275 tests pass;shellcheck -xclean on both scripts;actionlintclean; clean-room simulation of all three CI steps green.Pre-existing
forge fmtdifferences intest/andscript/are untouched — no file in this diff is affected.Related
Note: this repository has no branch protection, so both checks are advisory until required checks are configured.
🤖 Generated with Claude Code