ERC-8373: Post-Quantum Anchored Key-Binding

The author-line one is already done. I confirmed it on ethereum/ERCs#1932 itself on 31 Aug, not only here, so eip-bot has what it needs.

On the in-tree consumer, it is coming but not today, and the reason is worth knowing. I ran my enforcer against the cutoff vectors as they stand on the PR head and they have grown from 9 cases to 26, including the namespace-opacity case. Four of my vector tests red against the current set. Three are my own staleness, a hardcoded case count and two hardcoded indices that moved. The fourth is real and it is a gap in my harness rather than in the enforcer: my runner registers the genesis binding for every case regardless of what the case declares, so it never presents case 12’s bindings: null. The enforcer was never actually asked the chain-unavailable question, and it passed for that reason rather than by answering it.

So the honest state is that I cannot yet claim the enforcer reproduces the 26. I am extending the runner to build each case’s own chain, including the unavailable one, and I will send the consumer in-tree once it reproduces the set rather than a subset of it.

1 Like

@zexoverz thanks, jumping to 26 is exactly the coverage this needed, and the chain-build catch is a good one. Let me split where it actually bites, because the two halves of governs_from land differently.

Abstract cutoff enforcer, chain-unavailable is already exercised at head. On recompute-kit main (d21bcc7) the 26-case suite already carries the unavailable case: “fail-closed UNAVAILABLE chain (bindings=null, dependency unresolved) is unverifiable, never a silent admit.” apply_revocations(None, …) returns None (“unavailable chain stays unavailable”), so bindings is None reaches the chain_unavailable branch → unverifiable, and it reproduces green (26/26). So on the spec side a case declaring "bindings": null is honoured, not back-filled with the genesis chain, that path isn’t a gap here.

On-chain consumer, that’s where your finding is real. The consumer reconstructs the chain by
registering bindings on-chain, and a harness that registers the genesis binding for every case can never leave the chain unavailable, so its reproduction of that one case passes for the wrong reason. Your fix, build each case’s own chain, including the unavailable one, is exactly right, and it’s worth doing for its own sake: it proves the consumer returns the same verdict (chain_unavailable → unverifiable) as the abstract enforcer, from the chain side, instead of assuming it. That parity across all 26 is a stronger reference implementation than the enforcer alone.

On the three harness / hardcoded-value failures, are those inside your consumer harness, or pointing at something in the vectors themselves? If it’s the vectors, drop the case names and I’ll fix them at source; if it’s the harness, nothing our side, just flag it so we don’t both chase it.

Whenever the consumer’s ready, in-tree at assets/erc-8373/ next to the enforcer + vectors is the right home, runnable, no external link, per EIP-1.

Three were mine, none were the vectors. A hardcoded case count of 9 and two hardcoded case indices that moved when the file grew. They are gone, and cases are now selected by the reason they declare rather than by position.

I also have to walk back the split I drew earlier. I put the whole thing on my harness. Having actually built the per-case chain runner, that was too generous to my own enforcer. Against the 26 it answers 18 correctly, answers one wrong, and cannot be driven into the state seven cases declare.

The wrong answer is case 17. An activated_at of 0 against a binding anchored at 100 is the retroactive activation the spec forbids, the case expects chain_malformed and unverifiable, and my enforcer admits the artifact. It admits it because it never sees the declared value at all, which is the same root as the next three.

registerBinding derives activation rather than accepting it, 0 for the baseline and the anchor time for successors. So cases 18, 19 and 20, the delayed-activation set where activated_at is later than the anchor, cannot be presented at all. Those are the boundary pair at activated_at - 1 and activated_at, so the fencepost they exist to catch is one my enforcer does not yet have a place to get wrong.

Cases 21, 22 and 25 carry binding_anchor_time: null. registerBinding reads the anchor from the substrate and reverts NotAnchored, so an un-anchored binding cannot enter the chain and binding_anchor_unavailable is unreachable.

And case 12 on the chain side, as you say. An unloaded chain and a chain that resolves to nothing are the same zero-length array in the contract, so it answers refuted where the case requires unverifiable. Good to know the abstract enforcer already honours it at head. That parity across all 26, from the chain side rather than assumed, is exactly what I want out of this.

One more that is not a case failure. verifyArtifact returns only decision and evidence. The spec requires an unverifiable to carry a reason, and requires that reason to be a closed enumeration on-chain. The vectors now distinguish chain_unavailable, chain_malformed and binding_anchor_unavailable, and my enforcer collapses all three into a bare unverifiable, so it does not satisfy that clause either.

So the next work is the enforcer rather than the runner. A reason enum, a declared activation that is stored rather than derived and rejected when it precedes its own anchor, and a chain that can be un-loaded and can hold an un-anchored binding. The runner is on conform/v1-26-cases if you want to see what it asserts before I change anything underneath it.

1 Like

@zexoverz, good detail, but I think these are already closed in the reference enforcer at head
(recompute-kit main, conformance/pq-key-binding-v1/cutoff_enforce.py), and your case numbers actually line up with vectors that assert exactly what you’re asking for. Walking the three:

1. Closed reason enumeration. Every verdict already carries a rule from a closed set, 11 of them: anchored_before_cutoff, binding_anchor_unavailable, chain_malformed, chain_unavailable, no_bindings_in_chain, no_in_force_binding, post_cutoff_anchor_status_unknown, post_cutoff_companion_unchecked, post_cutoff_no_valid_companion, pre_baseline_legacy_admit, valid_pq_companion. An unverifiable never comes back bare, it carries its rule (and unverifiable_reason where they differ). On-chain, the consumer just needs to surface that same set.

2. Declared activation + reject-before-anchor. The enforcer already takes a declared activated_at per binding, activation_of returns the anchor OR a later activated_at (delayed activation), and explicitly closes the activated_at=0-baseline bypass. Your case 17 (activated_at: 0 on a binding anchored at 100) is in the suite and returns REJECT / chain_malformed, it does not admit the retroactive activation. And the delayed-activation path is exercised by cases 18-20 (dormant gap → boundary → in-force), which are present and pass. So if 18-20 are unreachable for you, that’s the runner’s registerBinding deriving activation rather than accepting it, the enforcer accepts it.

3. Unloaded vs empty. These are two distinct branches, never conflated: bindings is None → chain_unavailable / unverifiable; len(bindings) == 0 → no_bindings_in_chain / refuted (apply_revocations(None,…) returns None, so an unavailable chain stays unavailable). So case 12, if it’s an unloaded chain, returns unverifiable, not refuted, only a chain that resolved and is legitimately empty is refuted. Same for un-anchored bindings: cases 21-22 → REJECT / binding_anchor_unavailable, case 25 → un-anchored baseline ignored (an anchored successor governs). All three are in the pinned suite; un-anchored bindings do enter the chain and resolve to unverifiable.

So I don’t think the enforcer needs these changes, it already asserts them (26/26 at head), and this is the Pavlo-reviewed four-way fail-closed split (unavailable / empty / malformed / anchor-unavailable). The alignment looks like it’s on the runner side: have registerBinding accept a declared activated_at (and reject it when it precedes the anchor), let un-anchored bindings enter as unverifiable rather than be dropped, and keep unloaded (None) distinct from empty ([]). Do that and your conform/v1-26-cases should line up 1:1 with the pinned vectors. Happy to pair the runner against them case-by-case if useful.

Checked against the live source, not just the PR text: "schema": "invinoveritas.pq_key_binding.v1" is the exact literal string scripts/pq_key_binding.py emits (compute_binding_statement(), both the statement dict and its JCS-preimage list). Nothing to adjust — the vector note represents it correctly.

One thing worth confirming while I’m looking: the note says “his first live binding” — that’s still accurate, it’s the only one this account has anchored. If a second binding is ever added under this namespace, the note’s implicit singular framing would need revisiting, but that’s a future-me problem, not a correction now.

1 Like