ERC-8107: Using ENS as a Trust Layer for Autonomous Agents

Hi ENS community,

I’ve been working on ERC-8107, an ENS-based Trust Registry for autonomous agents, and I’d love to get some feedback from people closer to the ENS protocol.

The idea is fairly simple: ENS already gives us persistent identities. Could we build on that to let autonomous agents decide who they trust, whose judgment they accept, and who can participate in coordination?

We’ve implemented ERC-8107 in our game, Autonomous Acres, and the Ethereum Magicians review has helped refine the design.

One important lesson has been separating endorsement, withdrawal and explicit denial.

[
\operatorname{Withdraw}(A,B)\not\Rightarrow\operatorname{Deny}(A,B)
]

Withdrawing my endorsement shouldn’t mean rejecting everyone else’s judgement. We’re now separating these operations, with explicit denial controlled only by the current ENS name controller.

Where I’d especially value ENS community feedback is around three areas:

  1. Authority: Are we interpreting ENS controller and NameWrapper operator permissions correctly, particularly when names are transferred or locked?

  2. ENSv2 migration: Our current understanding is that migration can leave existing trust relationships in place while removing the former controller’s ability to manage them through ENSv1. We’re also concerned about the consequences of Graveyard cleanup for agent subnames.

  3. Long-term compatibility: We’re leaning towards keeping ERC-8107 explicitly ENSv1-scoped and building an ENSv2 successor once its authority model is established, rather than introducing adapters prematurely.

We’re trying to keep the standard small. Every additional mechanism creates something else that needs securing and maintenance.

I’d rather learn where our assumptions might be wrong now than discover them after Final.

For anyone interested:

Specification: https://eips.ethereum.org/EIPS/eip-8107

Ethereum Magicians discussion: https://ethereum-magicians.org/t/erc-8107-ens-trust-registry-for-agent-coordination/27200

Draft implementation: https://github.com/ethereum/ERCs/pull/2064

I’d especially welcome thoughts from anyone working on ENSv2, the NameWrapper or AI-agent identity.

Do you see any fundamental problems with this approach, or anything we’re overlooking about ENS authority and migration?

Thanks in advance

Hi kbryan, great question to ask before Final. I replayed it on the deployed contracts at block 26,175,900, then on a fork, with real names as witnesses. Your controllerOf matches the chain, with two edges.

Wrapped name, who can manage it setResolver new subname transfer
owner, or operator via setApprovalForAll yes yes yes
approved via approve no no no
anyone after the name was transferred no no no

Fuses block exactly what they say, and a transfer clears the per-name approval unless CANNOT_APPROVE is burned. Unwrapped names: ENS.owner and the registry operator.

The two edges:
• An unemancipated wrapped subname keeps its owner and setResolver after expiry. “address(0) once expired” holds for emancipated names.
• In the 90-day grace period, controllerOf returns the holder while the NameWrapper refuses all management (canModifyName is false).

ENSv2, measured on a fork with the stage-4 role: an operator can migrate a wrapped name and become its v2 owner, after which controllerOf returns the Graveyard as you expect. Graveyard.clear has no admin and anyone can call it; it reverts on active, in-grace and locked names, and agent subnames under a migrated parent stay manageable until someone clears them.

Calls to rerun: The holder of a role is not always the one who executes it: one measured case

Would it make sense for controllerOf to also require canModifyName, so a name loses trust authority the moment it loses ENS management?

Thanks, Spap. I really appreciate the work you put into this.

It’s one thing to read through the ENS contracts, but actually testing the behaviour on a fork gives us something concrete to work with.

The expiry cases you found are particularly helpful. You’re right that our wording around address(0) is too broad, especially for unemancipated subnames. We’ll correct that and add the appropriate tests.

Your question about canModifyName got me thinking.

I understand the reasoning. If someone can’t manage their ENS name anymore, should they still have the authority to make trust decisions through it?

I’m leaning towards keeping those two things separate.

My concern is that if we tie controllerOf directly to canModifyName, we could end up preventing someone from revoking an endorsement, denying a compromised agent or invalidating outstanding attestations during the grace period.

That feels like the wrong trade-off. Losing management permissions shouldn’t necessarily mean losing the ability to protect yourself or others from trust you’ve already granted.

Where I think there’s room for discussion is whether that same controller should still be able to issue new endorsements during the grace period. That’s a different question, and probably one we should answer explicitly.

The ENSv2 migration findings are useful too. In particular, coordinators will need to understand that existing trust relationships can survive migration while control over the underlying name changes.

For now, we’re keeping ERC-8107 focused on ENSv1 and treating ENSv2 support as a separate successor registry. I’d rather keep the authority model understandable than introduce compatibility logic we haven’t fully reasoned through.

I’ll address the expiry and lifecycle cases in a follow-up PR before we move to Final.

Thanks again for digging into this. I’d be curious whether you see a downside to separating ownership-based trust authority from the permissions needed to manage the ENS name itself.

@RealSpap, thanks again. Testing on a fork paid off. I reran your cases against the deployed contracts, and you were right on both edge cases. Several of our statements were wrong because of them. The fixes are in a follow-up PR: [ethereum/ERCs#2068](Update ERC-8107: Clarify ENS expiry, controller authority and ENSv2 lifecycle by KBryan · Pull Request #2068 · ethereum/ERCs · GitHub). The contract behaves exactly as before, and the interface and ID are unchanged.

**What we had wrong**

- **“An expired wrapped name has no authority.”** That’s only true when `PARENT_CANNOT_CONTROL` is burned. An unemancipated subname keeps its holder after expiry, and only its fuses reset. Many have a wrapper expiry of 0, `localdulow.ubnet.eth` for example. The spec now has a lifecycle table:

  • wrapped `.eth` names lose authority at the end of grace;

  • emancipated subnames lose it at their expiry;

  • unemancipated subnames never lose it through expiry.

- **“Authority ends when the registration expires.”** For a wrapped `.eth` name, authority runs 90 days longer, through the grace period. We also used to say that a re-registered unwrapped name only hands over authority once the new owner reclaims it. In fact, the registrar controller updates the registry immediately.

**On `canModifyName`**

We’re keeping authority tied to ownership, and the spec now says why. During grace the name hasn’t changed hands: nobody else can register it, and a renewal keeps it with the holder. Requiring `canModifyName` would also take away the controls you most need in that window, which are withdrawing an endorsement, denying a compromised agent and invalidating outstanding signatures. It also wouldn’t cover the unwrapped case, where the registry can’t see expiry at all.

**ENSv2**

I checked the migration claims on a Sepolia fork, against the deployed migration controllers and Graveyard:

- After migration, `controllerOf` returns the Graveyard, and stored trust keeps applying with nobody able to change it.

- Subnames stay manageable until someone calls `clear`.

- Only locked names with `CANNOT_SET_RESOLVER` keep their v1 resolver.

Two things were new to us. First, an operator can migrate a name and so freeze its owner’s trust state. Second, `clear` re-registers expired, never-migrated names to the Graveyard, which ends a lapsed holder’s authority over an unwrapped name.

**Still open**

During grace, should a holder still be able to issue *new* endorsements? Right now they can. The alternative is to split the authority model, so that withdrawal and denial still work in grace, but new endorsements don’t. I lean towards keeping one rule, but I’d like other views before Last Call.

Thanks for rerunning them on the deployed contracts, and for the lifecycle table: that is the part a coordinator will actually read.

On your open question, I would split, but with a smaller split than a second authority model:

• In grace, withdrawal, denial and invalidation keep working, as you have it. Nobody else can take the name, so these actions can only protect.
• A new endorsement issued in grace is the one most likely to outlive its issuer: if the name is not renewed, the next registrant inherits it, because endorsements are keyed by the namehash and nothing records which registration issued them.
• So: allow new endorsements in grace, but give them an implicit expiry at the end of grace unless the name is renewed. One rule, no second model, and a coordinator can check it with nameExpires alone.

The same field closes the re-registration gap outside grace: recording the expiry at issuance lets a coordinator read “same name, later expiry, different registrant” as a controller change rather than a continuation.

Whichever rule you pick, I can run the grace and re-registration cases on a fork before Last Call, the same way as the first two edges, and post the transactions here. Which one would you like me to run first?

Thanks @RealSpap. I think you’ve identified the bigger risk. It’s not that an endorsement survives expiry, but that it can outlive the registration that issued it.

I’d prefer to address this without changing the contract before Last Call.

For signers, we already have an expiry field. Someone signing during grace SHOULD cap it at the end of grace and re-sign after renewal.

For coordinators, I agree with treating grace-period endorsements cautiously. One small correction: nameExpires alone isn’t enough. We also need to know when the endorsement was stored, which we can get from the TrustSet event, along with the .eth labelhash.

Re-registration is trickier. A later expiry doesn’t necessarily mean the same registrant renewed. Binding trust to a specific registration would solve that more cleanly, but it introduces storage and interface changes I’d rather explore with ENSv2.

For v1, I’d keep the existing short-expiry and control-change monitoring mitigations, while being clear about their limitations.

Let’s run re-registration first. An endorsement issued before grace, the name lapsing, and someone else registering it. I want to see exactly what trust survives and what our current safeguards catch.

Then let’s test endorsements issued during grace, with and without capped expiry, followed by renewal and non-renewal.

That should give us something concrete to work from before Last Call. Appreciate you taking the time to test this.

@RealSpap, while running the re-registration fork tests, I hit a result about NameWrapper expiry that concerns ENS behaviour, not ERC-8107. I’d like to share it privately with you or the right ENS contact before posting anything. Could you message me or point me to the right person to send it to?

Thanks @kbryan. I ran the re-registration and grace cases on a mainnet fork at block 26,192,453 (Oct 14, 18:11 UTC), against the reference TrustRegistry on ERCs master as of Oct 14, whose code matches PR #2068 apart from comments. A fork produces no transaction hashes, so what I can share is the block, the addresses and the test output. Notation: G approves T, T approves the agent X, T is a name that can lapse, and M re-registers T and approves M2.

Confirmations of what the spec and the PR already say: an approval issued before grace survives re-registration, wrapped or not, and M controls T from the same transaction. In grace, an uncapped approval outlives its issuer if T is not renewed; capped at the end of grace, and with enforceExpiry on, it expires and the holder must re-sign after renewing. You were right about my nameExpires suggestion: a later expiry flags the holder’s own renewal too. You are also right that nameExpires alone is not enough: a coordinator also needs to know when the endorsement was stored, which the TrustSet event and the .eth labelhash give. I have not yet measured what that guard catches.

My own measurement: “controller differs from the one recorded at issuance” separates a renewal from a re-registration, provided the coordinator records the controller itself when it sees the approval, since the registry does not return it.

One result goes beyond the PR text: capping only T->X is not enough. M signs T->M2, and G->T->M2 still verifies with enforceExpiry on, wrapped or not. Of the expiry options tested, only an expiry on the incoming approval G->T stops it.

The takeover premium halves daily from $100M: about $50M on day 1, under $1,000 from day 17, zero from day 21. This is ENSv1 before ENSv2, .eth second-level names, one scope.

So the expiry that bounds a re-registration is the one on the approval that points to the name, set to the end of its grace, at the cost of a re-signature at each renewal. Should the spec say that explicitly?

RealSpap

Happy to help. You can reach me on X: @RealSpap. Please tell me up front what you would like from me and by when. If it is a vulnerability, ENS only takes those through its Immunefi bounty, so that is where the details should go; I’m glad to hear it in general terms, but I won’t be the one to relay it.

I sent it to immunefi and another researcher has already reported this issue in submission #85998.

Thanks Spap, this is very useful, and G→T→M2 is a real gap. Short answer to your question: yes. It’s now in the spec, in ethereum/ERCs#2077: Update ERC-8107: Correct wrapped-name renewal, stale addr records and re-registration bounds by KBryan · Pull Request #2077 · ethereum/ERCs · GitHub

Paths through a re-registered name. As you showed, capping a name’s own endorsements doesn’t bound a path through it. The new registrant inherits every attestation that names T as trustee, and can endorse M2 from T straight away. The PR adds two SHOULDs to Wrapped Name Lifecycle:

  • Trustors endorsing a .eth second-level name SHOULD cap the expiry at the end of the trustee’s grace period, and re-issue the endorsement after the name renews. That’s your G→T cap. An attestation signed before T’s registrar expiry, with the 90-day maximum the spec already recommends, meets it with nothing extra.
  • Coordinators SHOULD apply the continuity check to every .eth second-level name on the path, trustees included, not only the trustor. This bounds the path when G didn’t cap its expiry, and it saves a new signature after each renewal.

Continuity check. The check now orders the endorsement’s TrustSet against NameRegistered/NameRenewed for the labelhash by block number and then log index, not by timestamp. Your registration and endorsement in the same transaction is why: they share a block, so only their position in it orders them. I kept events rather than “controller recorded at issuance” for two reasons. A coordinator that wasn’t watching when the approval was stored can still run the check. And a plain transfer changes the controller without any re-registration. I agree the controller comparison is a good second signal if you are already recording it.

Two other changes in the same PR, from the same fork work:

  • Wrapped renewals. #2068 said a wrapped name’s NameWrapper expiry is the end of grace. That’s only true when the renewal goes through the NameWrapper. A renewal through the current ETHRegistrarController moves nameExpires but not the NameWrapper expiry (bigname#908). So the spec now has a separate liveness check for a wrapped trustor: read controllerOf/getData at decision time rather than inferring it from NameRenewed.
  • Stale addr records. After a re-registration, addr still names the previous holder until M changes it, so address-based gates keep admitting the old holder. validateParticipantAddress now also returns false when the terminal node has no controller. The interface ID is unchanged. A registration that sets addr in the same transaction leaves no window at all.

On scope, agreed: this is an ENSv1 window, and the spec says so.

Once #2077 merges I’ll set a new Last Call deadline. If you get a chance, could you rerun G→T→M2 with the coordinator-side check? I’d like to know whether it catches the cases you measured.

Thanks @kbryan, this is very useful on my side too. Yes: I will rerun G→T→M2 with the coordinator-side continuity check (TrustSet ordered against NameRegistered/NameRenewed by block number, then log index) on the same mainnet fork, for the cases I measured, and post the block and the output here. I will say here when it is ready.

Thanks @kbryan for merging #2077. I replayed G->T->M2 (notation as before) with the coordinator-side check the PR describes: continuity applied to every .eth name on the path, trustees included, ordered by block then log index, with enforceExpiry on.

At fork block 26,192,453 it rejected every re-registered path I had measured, G->T->M2 included, even with no cap set by the trustor. It did not reject wrongly at issuance, nor after a renewal through the path that syncs the wrapper (no cap). It also rejects the same holder re-registering their own name, which is cautious. The liveness read (controllerOf) caught one more case: a wrapped name renewed by the active controller, which the registry alone accepts.

Limits: the check is my implementation of the PR text, not your code, and it reads the logs emitted on the fork, not mainnet history; ENSv1, one fork block; validateParticipantAddress was not exercised; 29 of 29 tests passed on two archive nodes with identical results.

One question: I count a renewal when its NameRenewed follows the TrustSet and comes before the end of the grace in force at issuance, and not later, so a later registrant’s renewal never lifts the bound. Is that the intent? M never renews here, so these cases do not test it.

RealSpap

Thanks @RealSpap. A second implementation written from the text, rather than from my code, is the confirmation I was hoping for.

On your question: yes, for the case you describe. Under the #2077 wording, a renewal counts only if it falls between the `TrustSet` and the end of the grace period in force at issuance, so a later registrant’s renewal never lifts the bound. As you say, though, your cases don’t test the other side, and that wording has a gap there: a single renewal in that window lifts the bound for good. If the holder renews once, later lets the name lapse, and someone else registers it, the path passes. On a mainnet fork, G→X→M2 passed after X was renewed once, lapsed and was registered by someone else.

I’ve proposed replacing the rule in #2080. It is still a draft and changes only the spec text and tests; the contract and `0x94f0ee34` are unchanged. The replacement is a registration boundary: an attestation becomes ineligible once the BaseRegistrar emits `NameRegistered` for the label of a `.eth` second-level name on it, as trustor or trustee, after its `TrustSet`. `NameRenewed` never moves the boundary, so the question of which renewals count goes away. The same check now applies to any name on the path below a `.eth` second-level name, since the ancestor’s next registrant can reassign it. Ordering adds the transaction index. A coordinator that can’t establish the label, or the full event history up to its decision, doesn’t rely on the attestation.

Everything you observed is also the intended result under the new rule:
- re-registered paths are rejected, with or without a cap;
- nothing is rejected at issuance or after a renewal;
- the same holder re-registering is rejected deliberately, because the rule tracks registration rather than control. Every trustor that endorsed the name then signs again, not only the holder for its own endorsements; the text now says so.

The `controllerOf` case is the reason the text keeps two checks. Continuity asks whether anyone has registered the name again; liveness asks whether it has a controller now. A renewal that calls the BaseRegistrar directly passes continuity but can fail liveness once the old wrapper expiry passes.

If you have time, the run I’d find most useful against #2080 is the one your set leaves out. Renew M (or T) once within grace, then let it lapse and have someone else register it. Under the #2077 reading, that path passes; under the boundary rule, it should fail. Several renewals with no re-registration should still pass. #2080 stays in draft until then.

Thanks @kbryan. I replayed your cases against the head of #2080 (57aecc6a, still a draft) on a mainnet fork at block 26,192,453.

The hole in #2077 is caught by #2080. With no cap set by the trustor, a name renewed once in grace, expired and then registered by someone else still passes #2077 and is rejected by #2080, for the intermediate name, the end name, and M2 at the end of an injected path, wrapped or not. Three renewals with no re-registration pass under both rules, for all three. A renewal by a later registrant does not lift the bound under either rule.

The counterpart: with no renewal at all and no cap, #2077 rejects once grace has ended, while #2080 accepts until the next registration. The liveness case you described also holds: a direct BaseRegistrar renewal of a wrapped trustor is rejected by liveness under both rules after the old NameWrapper expiry, and accepted again after a NameWrapper renewal.

On re-issuing: if both edges are re-issued after the registration, the path passes again under both rules, but one edge is not enough. If only G->T is re-issued and T->X stays signed by the previous holder, two places in the text of #2080 give different answers. “A coordinator that relies on such an endorsement SHOULD apply two separate checks” checks a trustor only for an endorsement issued past its expiry, and accepts the path. “apply the continuity check above to every .eth second-level name on the path, as trustee as well as trustor” checks every name as trustor too, and rejects it.

For the wrapped, emancipated subname from your comment, re-issued by a live parent, both #2077 and #2080 accept it, and your NameWrapped extension closes it in my runs (measured as a hypothesis, not as a rule of #2080). If the parent itself is re-registered, #2080 rejects it.

Limits: this is my implementation of the coordinator checks, not your code; ENSv1 only; one fork block; three archive nodes with identical results; simulated blocks, so the transaction index is not distinguished from the log index; two scopes, with denial and per-scope revocation not tested.

When only G->T is re-issued, which of the two should apply?

RealSpap

Thanks @RealSpap, this is the run I asked for, and the results are the ones the boundary rule is meant to give.

On your question: the second applies, so the path is rejected. T→X records the previous holder’s judgement. When G re-issues G→T it endorses T’s new holder, but the new holder has not endorsed X. If T→X were accepted, G’s new trust would reach X through an endorsement from a registration that has ended. That is the carry-over the rule exists to stop, and it does not matter that T→X was signed before T’s expiry. For that path to pass again, the new holder of T re-issues T→X. You found that re-issuing both edges is enough.

The conflict you found is a drafting error in #2080. The first passage introduces endorsements issued after the trustor’s expiry because those are the ones most likely to outlive their registration. It was never meant to limit the continuity check to them. The second passage, and the Rationale (“as trustor or trustee or as the ancestor of either”), state the general rule. I’ll make the first passage say so: the continuity check applies to every attestation a `.eth` second-level name sets or receives, whenever it was issued.

The no-renewal counterpart is intended. Continuity asks only whether the name has been registered again, so an uncapped endorsement remains valid until the name is registered again. Expiry and control are covered by the trustor’s cap, `enforceExpiry`, and, for a wrapped name, the liveness check.

On the emancipated subname re-issued by a live parent: #2080 does not cover it, as your run shows. Thanks for measuring the `NameWrapped` extension. I’ll look at it separately before #2080 leaves draft.

@RealSpap A short update to close out my last post.

#2080 has merged, so the “I’ll look at it separately” in my last post is out of date. The merged text includes both points from your run:

- **Re-issuing only G→T.** The first passage now says the continuity check applies to every attestation a `.eth` second-level name sets or receives, whenever it was issued. Both passages now reject the path. It passes again once T’s new holder re-issues T→X.

  • **Emancipated subname re-issued by a live parent.** This is the `NameWrapped` extension you measured. Below a second-level name, a coordinator treats the name as registered again when the NameWrapper emits `NameWrapped` for it, or for an ancestor of it below the 2LD, after the `TrustSet`. Trustors cap attestations to an emancipated subname at its NameWrapper expiry. A live reassignment emits no `NameWrapped`, so it is still treated as a change of control.

Two later PRs touch only the execution step. #2083 separates registry-level eligibility from continuity-checked authorisation. #2085 lets a coordinator pin each name’s `nameExpires` at the block it checks, and accept at execution if the expiry is unchanged or the time is still within that expiry plus grace. Neither changes the continuity rule you tested.

The current text is at master `48dc87a2`. If this all checks out. I think we are good to move to Last Call.

Thanks @kbryan for taking in the two points. I replayed my cases against master at 48dc87a2 on a mainnet fork at block 26,192,453, with the checks written from the merged text.

The hole in #2077 stays closed: the six paths without a cap that #2077 accepted are rejected, wrapped or not, with no false rejection after one or three renewals. With only G->T re-issued the path is rejected, and passes again when the new holder of T re-issues T->X; with both edges re-issued it passes. An emancipated subname that a live parent creates again is rejected through the NameWrapped clause; so are a re-registered parent, a re-wrapped ancestor, and an unwrap followed by a wrap, which passes again after signing again.

Two cases are not caught, reported without judgment: a live parent reassigning a subname, which the text calls a change of control, and the case with no renewal, which #2077 rejected and the merged text accepts until the next registration.

The deadline rule, a timestamp no later than E plus the grace period or an unchanged nameExpires, rejects the three registrations after the check block and executes the cases with nothing registered; a deadline recomputed at execution accepts them.

I called RegistrationBoundary.sol with only its import changed. Of 157 comparable lines it agrees with my rule in 133. Seven of the other 24 are subname cases, where it returns Eligible and the merged text rejects; it looks only at second-level names.

Limits: this is my implementation of the text, not your code; ENSv1 only; one fork block; three archive nodes with identical results; simulated blocs, so the transaction index is not told apart from the log index; two scopes, with denial and per-scope revocation not tested; validateParticipantWithPath is not called.

Do you plan to extend RegistrationBoundary.sol to subnames, or is it meant to stay limited to second-level names?

RealSpap

Thanks, this is very useful. Thank you as well for running RegistrationBoundary.sol itself.

On your question: yes, it should cover subnames, and #2094 extends it. The library was written before #2080 added the rules for names below a .eth second-level name, and it never got updated. So it checked only second-level names and returned Eligible for the rest. That contradicted both the text and the library’s own claim to fail closed. Your seven subname cases are exactly that gap.

With #2094 the library takes the whole name for each node, checked against the node, rather than one level. A name below a second-level name is ineligible once its second-level ancestor is registered again, or once the NameWrapper emits NameWrapped for the name or for an ancestor below the second-level name, after the TrustSet. Trustors and trustees are both covered. The history it reads now includes the NameWrapper’s logs. The new mainnet-fork cases follow yours:

  • a re-registered parent;
  • an emancipated subname created again by its live parent;
  • a re-wrapped ancestor;
  • an unwrap followed by a wrap, which passes again after a new signature;
  • a live reassignment, which stays eligible.

The normative text is unchanged.

The two cases not caught are both intended.

  • **A live parent reassigning a subname** is a change of control. The check concerns registration, and Parent-Controlled Subnames describes this case.
  • **The case with no renewal** is as in my earlier reply. Until the name is registered again, no one else can hold it. The trustor’s cap, enforceExpiry and, for a wrapped name, the liveness check cover expiry and control.#2077’s stricter cap was replaced by the registration boundary on purpose.

The seven subname lines leave 17 of the 24 I can’t yet explain. Could you share them, along with your input, your verdict and the library’s verdict? I’d like to know whether they are a gap in the library, a reading of the text that differs from mine, or something the text should state more clearly. I’d also be glad to see the comparison rerun against #2094.

Hope that covers everything.

Thanks @kbryan, and thanks for #2094.

I reran my 157 comparison lines against master at 077adf82, on the same fork block, 26,192,453, with the library as in the PR and only my call adapted to its new API. Before, 133 lines agreed and 24 differed. Now 140 agree and 17 differ. The seven lines that changed are exactly the seven subname lines (five ReWrapped, two ReRegistered); no other verdict moved.

The 17 you asked for are below, numbered 8 to 24 after the seven subname lines. In all 17 my rule rejects, and the library alone, on the complete input, returns Eligible. Twelve are capped edges whose cap has passed: verifyPath with enforceExpiry set rejects them too, and the library’s header says it is applied in addition to verifyPath, so with both the result matches mine. Two are lines where my rule simulates missing evidence; given the same gap, the library returns Unknown, and counting an Unknown as not accepting gives 142 agree and 15 differ. Three are wrapped trustors whose NameWrapper controller reads zero at the decision: verifyPath and the library accept them, my liveness check rejects them.

You asked whether these are a gap in the library, a reading of the text that differs from mine, or something the text should state more clearly. For fourteen of them I do not see a difference of reading: verifyPath with enforceExpiry set explains the twelve capped lines, and the library failing closed explains the two simulated ones. The three liveness lines follow the text’s liveness check for a wrapped trustor, which you also cite for the case with no renewal; the library checks registration, not control, and its header names verifyPath as the check it is applied with, but not the liveness check. I leave to you which of your three that is. On the two intended cases: in my rerun the live reassignment case of #2094 passes, and the no-renewal lines here (14 to 17, and 22) are rejected by enforceExpiry where a cap is set and by the liveness check otherwise, as you describe.

N My input verifyPath, enforceExpiry set The library alone, before and after #2094 My verdict What my column adds
8 T (middle name) lapses; cap at the end of grace on G>T and T>X; not wrapped; the name expired again and nobody registered it rejects Eligible, Eligible Ineligible enforceExpiry
9 X (beneficiary) lapses; same cap; not wrapped; nobody registered it rejects Eligible, Eligible Ineligible enforceExpiry
10 T lapses; same cap; wrapped; nobody registered it rejects Eligible, Eligible Ineligible enforceExpiry
11 X lapses; same cap; wrapped; nobody registered it rejects Eligible, Eligible Ineligible enforceExpiry
12 injected path G>T>M2 with M2 at the end; same cap; 3 renewals, original cap exceeded; not wrapped rejects Eligible, Eligible Ineligible enforceExpiry
13 same case; wrapped rejects Eligible, Eligible Ineligible enforceExpiry
14 T lapses with no renewal; grace over; same cap; not wrapped; nobody registered it rejects Eligible, Eligible Ineligible enforceExpiry
15 X lapses with no renewal; grace over; same cap; not wrapped; nobody registered it rejects Eligible, Eligible Ineligible enforceExpiry
16 T lapses with no renewal; grace over; same cap; wrapped; nobody registered it rejects Eligible, Eligible Ineligible enforceExpiry
17 X lapses with no renewal; grace over; same cap; wrapped; nobody registered it rejects Eligible, Eligible Ineligible enforceExpiry
18 renewed in the grace period by the same holder, old bounds exceeded, before the re-issue; same cap; not wrapped rejects Eligible, Eligible Ineligible enforceExpiry
19 same case; wrapped rejects Eligible, Eligible Ineligible enforceExpiry
20 T lapses; no cap; wrapped; nobody registered it accepts Eligible, Eligible Ineligible liveness of a wrapped trustor
21 T wrapped and renewed directly by the BaseRegistrar; after the old NameWrapper grace end, the name is still registered; no cap accepts Eligible, Eligible Ineligible liveness of a wrapped trustor
22 T lapses with no renewal; no cap; wrapped; nobody registered it accepts Eligible, Eligible Ineligible liveness of a wrapped trustor
23 not wrapped; path G>T>X; incomplete history between the TrustSet and the decision accepts Eligible on the complete input; after #2094, Unknown (HistoryBehindHead) when given the same gap Ineligible fail closed simulated on my side
24 not wrapped; path G>T>X; label of T unknown to the coordinator accepts Eligible on the complete input; after #2094, Unknown (NoNameProof) when given the same gap Ineligible fail closed simulated on my side

How I work: every case is a fork replay at block 26,192,453, with identical results on three archive nodes, using my own implementation of the text (not your code) and the library as in the PR; ENSv1 only. I also reran the eight fork cases of #2094 at their own block, 26,194,700, on the same three nodes, and all eight pass. They test the library, not my 157 lines.

RealSpap