Skip to content

feat(gateway): the production D1 (operator) EIP-712 verifier for the FinalMilestonePackageV2 mint guard (stacked on #358) - #555

Merged
LamaSu merged 10 commits into
masterfrom
feat/g2-d1-operator-verifier
Oct 6, 2026
Merged

LamaSu merged 10 commits into
masterfrom
feat/g2-d1-operator-verifier

Conversation

@LamaSu

@LamaSu LamaSu commented Oct 3, 2026

Copy link
Copy Markdown
Owner

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 }) in packages/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):

FinalMilestonePackageV2(uint256 chainId,address escrow,bytes32 settlementUnitId,bytes32 jobIdHash,uint256 milestoneIndex,bytes32 stepId,bytes32 compositionRoot,bytes32 acceptedEnvelopeHash,bytes32 packageBodyHash)

The domain is {"PCC FinalMilestonePackage", "2", chainId, verifyingContract = escrow}.

The verifier answers true only when every rule holds:

  • a 65-byte signature, checked BEFORE recovery: v in {27, 28}, 0 < r < n, 0 < s ≤ n/2;
  • the EIP-712 digest over the validated unitBinding and packageBodyHash;
  • the recovered address is non-zero;
  • it equals the D1 signer label;
  • it equals the AUTHORITATIVE operator from the injected operatorForUnit: the bound clone's operator() at one pinned block, which the caller reads;
  • operatorPrincipalId equals eip155:<chainId>:<recovered, lowercase> byte for byte.

Anything else, including a throw or a rejection, is false. ERC-1271 operators cannot mint in v1.

Tests

  • The golden vectors from feat(gateway): v3 evidence-signing Step-6 — async split + final-package (audited; gate CLOSED; HOLD for auth-P0) #270 @59f6c45f are copied byte-identical, with their sha256 pinned. The positive is accepted, and all 15 negatives are refused: 13 replay 1:1 and 2 are adapted, with the reason in the test.
  • operatorForUnit answering null, another address, throwing, or rejecting is refused. So is a wrong signer label.
  • End to end: a real D1 signature (viem signTypedData) and a real ed25519 D2 mint through assertMintablePackage. One flipped byte refuses.
  • Mutants: 9 of 11 killed. The zero-address check and the length check survive, and both are argued equivalent.
  • gateway: 191 files, 3164 passed. tsc clean.

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

LamaSu and others added 8 commits October 3, 2026 14:05
…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
@LamaSu
LamaSu changed the base branch from feat/g2-package-digest-v2-wired to master October 4, 2026 02:24
…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
…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
LamaSu marked this pull request as ready for review October 4, 2026 07:30
@LamaSu
LamaSu merged commit 1f32094 into master Oct 6, 2026
16 of 20 checks passed

This branch had an error being deployed

1 failed deployment
trusted-checks — 684a11b4 Deployed Oct 4, 2026 by LamaSu via post-verdicts #65
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.

1 participant