Repository navigation
feat(gateway): the production D1 (operator) EIP-712 verifier for the FinalMilestonePackageV2 mint guard (stacked on #358) - #555
Merged
Conversation
…ifier for the FinalMilestonePackageV2 mint guard createEip712OperatorVerifier builds the OperatorSignatureVerifier the mint guard's injected seam has needed since #358 round 2 made D1 fail closed (no verifier existed). Implements the ratified struct/domain (d1-eip712-struct-proposal.md, RATIFIED by oracle bus #5773 and escrow bus #5785, 2026-10-03) via viem's hashTypedData/recoverAddress: - exactly 65-byte r||s||v, lowercase hex; v in {27,28}; low-s enforced BEFORE recovery (never delegated to a library); - digest over FinalMilestonePackageV2(uint256 chainId,address escrow, bytes32 settlementUnitId,bytes32 jobIdHash,uint256 milestoneIndex, bytes32 stepId,bytes32 compositionRoot,bytes32 acceptedEnvelopeHash, bytes32 packageBodyHash) against domain "PCC FinalMilestonePackage" v2, chainId/verifyingContract tied to unitBinding's own fields; - recovered address non-zero, equal to the claimed D1 signer label, equal to the injected operatorForUnit's answer, and operatorPrincipalId byte-exact eip155:<chainId>:<recovered lowercase>; - fails closed, never throws: operatorForUnit null/throw/rejection all collapse to false, caught by one outer try/catch. ERC-1271 (contract) operators cannot mint under v1 (documented in the module doc, per the ratified contract) -- they have no ECDSA key, so no signature they produce ever reaches acceptance. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
… the D1 operator verifier operator-signature-verifier.test.ts covers createEip712OperatorVerifier against #270 @59f6c45f's golden vectors (1 positive + all 15 negatives), copied byte-identical into fixtures/finalmilestonepackage-v2-d1-eip712 .vectors.json (sha256 asserted in-test; sibling .source.md names the exact source + hash so drift is caught, not silently tested against). 13 of the 15 negatives map 1:1 onto the vector's own message/signature/ claimedOperatorPrincipalId. The remaining 2 ("wrong-domain-chainId", "wrong-verifyingContract") pose a mismatched EIP-712 domain against an unchanged struct -- a knob the #270 mirror's generic verifyD1(digest, sig, expectedOperator) exposes but this module's real API does not (domain.chainId/verifyingContract are always unitBinding.chainId/escrow, by construction, per the ratified contract). Those two are adapted, not byte-replayed: same unitBinding, with exactly the one field the vector's domain diverges on overridden, reusing the vector's own stale signature -- the faithful way to pose the same cross-domain-replay property through this module's actual input shape; documented inline. Also covers operatorForUnit's full contract (null/wrong-address/throw/ rejection all refuse; async resolution to the real operator accepts) and the claimed-signer-label-vs-recovered-address check independently of operatorForUnit, neither of which the golden vectors alone exercise. End to end: a fresh, self-consistent mintable body is signed for real with viem's local-account signer (reusing the golden operator's private key), run through assertMintablePackage with createEip712OperatorVerifier wired in as the D1 verifier alongside the existing real D2/registry/ challenge-reader pattern -- it mints, and flipping one byte of the D1 signature refuses (PackageNotMintableError). 27/27 passing. Full gateway suite: 191 files, 3164 passed, 0 failed (6 pre-existing skips, 3 pre-existing todos, unrelated to this change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
… 6, so only the digest's chain id binding can refuse it With the vector's own eip155:8453 principal id, rule 6 refused the replay to chain 8454 by itself. So a verifier whose digest ignored unitBinding.chainId still passed this test: mutant M8 (the chain id in the domain and the struct a constant) SURVIVED at 12ddd34. The test now claims eip155:8454:<operator>. mutate-d1-verifier.py gains four digest-binding mutants (chain id, escrow, milestoneIndex, packageBodyHash). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
… the production verifier The guard said no production D1 verifier exists and the struct is not pinned. Both stopped being true with the ratification (oracle #5773, escrow #5785) and createEip712OperatorVerifier. - The STOP note becomes a D1 STATUS note: the struct, the domain, the vectors (#270 @59f6c45f), the verifier, and where the authoritative operator comes from (the bound clone's operator() through the injected operatorForUnit). - The refusal message for a caller that injects no verifier now says that: "no operator signature verifier is injected, so D1 cannot be verified and the guard fails closed". - The one test that matched the old message is updated. No behaviour change: a caller with no verifier is still refused, and only an exact true mints. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…gs and bounds its operator lookup (E13 F1-F3) E13 (astra on aa5c638, SHIP, three MEDIUMs). All three reproduced at aa5c638 before any change. F1: the standalone verifier accepted spellings the ratified body refuses. - Reproduced: each of these verified TRUE, though it encodes the same bytes: - chain id "0x2105", with principal id eip155:0x2105:<operator>; - milestone index "0x3"; - the EIP-55 checksummed escrow 0x...e5C0f. - Fix: before anything is encoded, every field must be in the ratified body's own spelling, the same three rules the guard applies: - chainId and milestoneIndex are canonical decimal; - escrow is a lowercase address; - the six bytes32 fields are lowercase hex. F2: an operatorForUnit that never settles held the verifier, and so the mint guard, open forever. - Reproduced: verification was still pending after 2 s. - Fix: operatorForUnit now gets an AbortSignal and at most operatorLookupTimeoutMs (default 10 000). - On timeout, the signal is aborted and the answer is false. - The timer is cleared when the lookup settles. - A bound that is not a positive safe integer is refused at construction with a RangeError. F3: the adapted vectors changed the domain and the struct occurrence of the chain id or escrow together. - Reproduced: with the old tests, mutants M12-M15 SURVIVE. Each drops only the domain's or only the struct's chain id or escrow. - New tests: the operator really signs typed data whose domain and message disagree, in four combinations (chain id or escrow, OLD domain with NEW message or the reverse), then submits the unified NEW unit. All four are refused, and a NEW/NEW positive control is accepted. Also asked for by the review: - r = n and r > n are refused; - an operatorForUnit answer that is not an address is refused; - the end-to-end test title says "one hex digit", which is what it changes. Tests: operator-signature-verifier 41/41. gateway 191 files: 3177 passed, plus one known load flake, completion-real-tier (a 5 s timeout), which passes alone 3 of 3. tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
… abort, and a null bound is refused (E13b F2) E13b (astra on b933fd5, NOT CONFIRMED): F1 and F3 CLOSED; F2 OPEN on two points. Both were checked at b933fd5. 1. The abort ordering: REPRODUCED, by a thenable. - The timer called controller.abort() before it resolved TIMED_OUT, and abort() runs its listeners synchronously. - The review's own native-promise repro verifies false at b933fd5: the Promise.resolve().then wrapper settles the lookup one microtask after TIMED_OUT. An async variant is false too. - But a thenable whose `then` hands its resolver to the abort listener settles the lookup inside abort(), one tick ahead. Verification then returned TRUE after the bound. - Fix: the timer sets `expired` first, then resolves TIMED_OUT, then aborts, and an answer that arrives once `expired` is set is TIMED_OUT. A lookup that answers before the bound settles the race in microtasks, which all run before the timer fires. 2. The null bound: REPRODUCED. `operatorLookupTimeoutMs: null` took the default through `??`. Now only undefined selects the default; null is a RangeError like any non-number. An explicit undefined is still accepted (new test). Tests: - operator-signature-verifier 43/43, with three abort-handler variants (native promise, async, thenable) each refused, null among the invalid bounds, and an undefined bound accepted. - gateway 191 files: 3179 passed, plus the known completion-real-tier load flake. - tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
… monotonic clock, so a lookup that blocks past it is refused (E13c F2) E13c (astra on 21b4c54, NOT CONFIRMED): F2(b) CLOSED, and the abort-listener race CLOSED. One route remained OPEN. operatorForUnit may answer synchronously, or through a thenable that blocks in `then`. While it blocks the event loop, the timer cannot run and `expired` stays false. So an answer that arrived after the bound won the race and verified true. REPRODUCED at 21b4c54: with a 5 ms bound and a lookup that busy-waits 50 ms and then answers the operator, verification returned TRUE. Fix: - lookupOperator reads performance.now() (node:perf_hooks) when it starts the lookup and again when it observes the answer. An answer observed at or after timeoutMs is TIMED_OUT, timer or no timer. - The latch stays: a timer can fire marginally early against the clock, and the latch still decides the abort-listener race. - A timely synchronous answer is still accepted (test). Tests: - operator-signature-verifier 44/44, stable over 3 repeat runs. The new test blocks synchronously and in a thenable's then, and checks that a timely synchronous answer is accepted. - gateway 191 files, 3181 passed. tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…est timer delay, 2^31 - 1 ms (E13d) E13d (astra on df42a86): SHIP, with F2 CLOSED and one new MEDIUM. Validation admitted any positive safe integer, but Node clamps a setTimeout delay above 2^31 - 1 ms to 1 ms. So a huge bound refused every lookup almost at once. That fails closed, an availability defect, not a late true. REPRODUCED at df42a86 with the review's repro: - bound 2^31, a lookup answering after 10 ms: Node warns "TimeoutOverflowWarning: 2147483648 does not fit into a 32-bit signed integer", and verification answered FALSE in 3 ms; - bound 2^31 - 1: TRUE in 29 ms. Fix: MAX_OPERATOR_LOOKUP_TIMEOUT_MS = 2^31 - 1. A larger bound is a RangeError at construction. Tests: - 2^31 is refused, and 2^31 - 1 with a 10 ms lookup verifies true. operator-signature-verifier 45/45. - gateway 191 files, 3182 passed. tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…ster, for a fresh full CI run (steward #6167) A plain merge: its tree equals git merge-tree of 2a3be66 (E13e SHIP) and master. #555 was stacked on #358 and now targets master. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
LamaSu
had a problem deploying
to
trusted-checks
October 4, 2026 03:36 — with
GitHub Actions
Failure
…rent master (steward wake-up, 23:2x) A plain merge, no edits: its tree equals git merge-tree of b62ccdd and master. CI that ran on an older master isn't evidence: many PRs merged since. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
LamaSu
had a problem deploying
to
trusted-checks
October 4, 2026 07:02 — with
GitHub Actions
Failure
LamaSu
marked this pull request as ready for review
October 4, 2026 07:30
LamaSu
had a problem deploying
to
trusted-checks
October 4, 2026 07:30 — with
GitHub Actions
Failure
This branch had an error being deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #358 (
feat/g2-package-digest-v2-wired@5b2be327, E9c SHIP), which is stacked on #341. DRAFT. The operator makes every merge.What
The production D1 (operator) verifier for the FinalMilestonePackageV2 mint guard:
createEip712OperatorVerifier({ operatorForUnit })inpackages/gateway/src/settlement/operator-signature-verifier.ts.#358 left D1 fail-closed behind an injected
OperatorSignatureVerifier, because no byte-exact struct existed. The struct is now RATIFIED by the oracle (#5773) and escrow (#5785):The domain is {"PCC FinalMilestonePackage", "2", chainId, verifyingContract = escrow}.
The verifier answers
trueonly when every rule holds:operatorForUnit: the bound clone'soperator()at one pinned block, which the caller reads;operatorPrincipalIdequalseip155:<chainId>:<recovered, lowercase>byte for byte.Anything else, including a throw or a rejection, is
false. ERC-1271 operators cannot mint in v1.Tests
signTypedData) and a real ed25519 D2 mint throughassertMintablePackage. One flipped byte refuses.Commits: f9ef00e, 12ddd34 (implementer-golf), d435856 (a test gap found in the lane's review: mutant M8 survived), aa5c638 (the guard's D1 notes updated; no behaviour change).
Cross-family review: pack E13 (first round, full).
🤖 Generated with Claude Code
https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst