Skip to content

feat(xid): consensus-key vote-ext finality, leaf-binding, and link-identity CLI - #22

Merged
MudDev merged 30 commits into
mainfrom
feat/xid-finality-attestation
Aug 17, 2026
Merged

feat(xid): consensus-key vote-ext finality, leaf-binding, and link-identity CLI#22
MudDev merged 30 commits into
mainfrom
feat/xid-finality-attestation

Conversation

@MudDev

@MudDev MudDev commented Aug 15, 2026

Copy link
Copy Markdown

What

Chain-side of the client-verifiable xID finality work (the EpixNet client that consumes this is in EpixNet PR #412).

Finality via CometBFT consensus-key vote extensions

Validators attest to the xID state digest through ABCI++ vote extensions, signed by their existing consensus key (CometBFT's ExtensionSignature) so validators only need to upgrade: no attest key, no registration, works with HSM/remote signers, and equivocation is covered by normal double-sign slashing.

ExtendVote returns {height, block_time, digest} unsigned; PreBlocker reconstructs MarshalDelimited(CanonicalVoteExtension{extension, height, round, chain_id}), verifies each ExtensionSignature against the validator's staking ConsPubKey (mirrors baseapp.ValidateVoteExtensions), and persists the signature + raw extension + round with real voting power. IsDigestFinalized is power-based (strict signedPower·3 > totalBonded·2). A thin client verifies these signatures against a pinned validator set no CometBFT light client (the deployment target is mobile).

Leaf-binding (forgery fix)

resolve_with_proof now returns leaf_preimage (json.Marshal(domainDigestEntry)) so the client can bind the returned domain payload to the proven Merkle leaf, closing a gap where a hostile RPC could serve a genuine inclusion proof beside arbitrary owner/identity/DNS data.

link-identity / unlink-identity CLI

The MsgLinkIdentity/MsgUnlinkIdentity handlers existed but had no CLI. Adds epixd tx xid link-identity [name] [tld] [address] [label] and unlink-identity [name] [tld] [address], used to drive the cross-repo channel revocation test.

proto-gen migration

Completes the x/xid proto-gen migration (removes the hand-written placeholder types that duplicated generated output).

Testing

  • make build green.
  • Devnet end-to-end: with vote extensions enabled, the digest reaches finalized: true automatically (no per-validator action) with real consensus-key signatures; the EpixNet client verifies those signatures + the exact leaf preimage (cross-repo KATs in #412).

Design

EpixNet/docs/xid-lightclient-finality.md.

MudDev added 8 commits August 14, 2026 20:44
…eimage) + complete proto-gen migration

Chain-side foundation for client-verifiable xID finality. Full `make build` green.

Proto contract (proto/xid/v1):
  - MsgRegisterAttestKey{signer, validator_cons_addr, ed25519_pubkey}.
  - Attestation gains validator_cons_addr / ed25519_pubkey / voting_power.
  - StateDigest gains block_time; QueryAttestations gains block_time /
    total_voting_power / height; resolve_with_proof gains leaf_preimage.

Keeper (x/xid/keeper):
  - RegisterAttestKey handler: verifies the signer's operator key owns the
    consensus address, stores valcons -> ed25519 attest pubkey (new prefixAttestKey,
    appended so existing prefixes keep their byte values).
  - computeLeafPreimage returns the exact canonical bytes hashed into a domain leaf;
    ResolveWithProof now returns leaf_preimage (hex) so clients bind returned data
    to the proven leaf instead of trusting the payload.
  - QueryAttestations returns block_time / total_voting_power / height;
    totalBondedPower helper (voting power, not validator count).

Complete the documented proto-gen migration ("hand-written types ... run make
proto-gen then replace"): the message types were hand-copied gogoproto boilerplate
in types.go + six query_*.go files, whose init() would have double-registered and
panicked once real generation ran. Verified state-safe (byte-identical to generated
output; LinkedIdentity JSON tags match), then deleted the 6 placeholders so the
generated .pb.go is authoritative, keeping the value-typed API via nullable=false so
no keeper code churned.

Next: ABCI++ vote-extension signing + PreBlocker persist + power-based finality
(#3c), then KATs + devnet.
AttestationSignBytes(chainID, height, blockTime, digestHex) — the exact canonical
message a validator signs with its ed25519 attest key: domain(16) ‖ len(chain_id)
u32-BE ‖ chain_id ‖ height u64-BE ‖ block_time i64-BE ‖ len(digest) u32-BE ‖ digest
(raw 32 bytes). Byte-for-byte identical to attest_sign_bytes in the EpixNet client
(crates/epix-chain/src/finality.rs); a frozen KAT in BOTH repos (same vector) guards
against silent Go<->Rust divergence. Used by the vote-extension signer/verifier next.
The deterministic (proto) payload a validator returns from ExtendVote: its ed25519
signature over AttestationSignBytes(chain_id, height, block_time, digest) plus the
exact (height, block_time, digest) it signed — so VerifyVoteExtension (which gets no
time) and PreBlocker can reconstruct the sign-bytes. Completes the proto contract for
the vote-extension handlers. Module builds green.
…d green

Validators sign the xID state digest each block; a light client verifies >2/3 of
pinned voting power over (chain_id, height, block_time, digest) — no RPC trust, no
tendermint light client on the (mobile) client.

evmd/vote_extensions.go (new):
  - ExtendVote signs AttestationSignBytes(chainID, req.Height, req.Time, digest)
    with the node's ed25519 attest key; payload carries the signed
    (height, block_time, digest, pubkey, sig) so the verifier — which gets no time —
    can reconstruct the sign-bytes.
  - VerifyVoteExtension: deterministic per-vote check (registered pubkey match,
    height==req.Height, digest==current, sig verifies); empty ext (non-signer) ok.
  - PrepareProposal WRAPS the EVM mempool handler, prepending the prev height's
    ExtendedCommitInfo as tx[0] only when extensions are active (baseapp skips the
    non-sdk.Tx bytes during execution — confirmed in baseapp FinalizeBlock, so no
    custom ProcessProposal needed and the injected blob is never executed).
  - PreBlocker consumes tx[0]: verifies each attestation, uses REAL staking power
    (never the injected power, so a forged commit can't inflate finality), picks the
    canonical (>=majority-power) block_time per digest, persists the signed
    attestations + the per-digest signed block_time.
  - Attest privkey loaded from <home>/config/xid_attest_key (nil => non-signer).

Keeper: IsDigestFinalized is now voting-POWER based (>2/3), with the legacy
count/auto:consensus path kept as a fallback for pre-enable-height compat; BeginBlock
stamps the digest's height+block_time every block (fresh even when name data is
unchanged) and skips auto:consensus for validators that use the signed path;
RecordSignedAttestation + Set/GetDigestBlockTime; QueryAttestations returns the
SIGNED (height, block_time) so the client reconstructs the exact sign-bytes.

Design verified by research (ABCI++ v0.54.3/v0.39.3, the block_time-in-payload
solution, PBTS timing, the baseapp non-tx skip). make build green; xid tests pass.
epixd tx xid register-attest-key [valcons] [ed25519-pubkey-hex] --from <operator> —
submits MsgRegisterAttestKey so a validator can bind its attestation pubkey on-chain.
Verified end-to-end on a local devnet: registering the key makes PreBlocker persist
the validator's signed digest attestations and IsDigestFinalized flip to power-based
finalized=true.
…s only upgrade

Replaces the separate registered attest key with CometBFT's own vote-extension
signature (the ExtensionSignature, signed by each validator's consensus key). So a
validator does NOTHING but upgrade — no key generation, no register-attest-key, no
config file — and attests the digest automatically every block. Works with HSM /
remote signers (the app never touches the key), and because the attestation IS the
validator's precommit, equivocation is covered by CometBFT double-sign slashing.

evmd/vote_extensions.go: ExtendVote returns {height, block_time, digest} UNSIGNED
(CometBFT signs it). PreBlocker reconstructs
MarshalDelimited(CanonicalVoteExtension{extension,height,round,chain_id}) and
verifies each ExtensionSignature against the validator's staking ConsPubKey (mirrors
baseapp.ValidateVoteExtensions), uses REAL staking power, and persists the signature
+ raw extension + round. Dropped attest-privkey loading.

x/xid: Attestation gains vote_extension (bytes) + round; RecordSignedAttestation
takes the full attestation; QueryAttestations returns the signed (height, block_time)
+ round + extension per validator. BeginBlock auto:consensus kept only as the
pre-enable-height count fallback.

Devnet-verified: single-validator devnet, vote extensions enabled at height 3, NO
registration -> finalized:true (power-based) automatically; the client verifies the
real consensus-key signature (see EpixNet devnet_finality_kat). make build green.

(MsgRegisterAttestKey / attest-key registry now dead — removed in a follow-up.)
…key vote-ext)

The consensus-key finality path (previous commit) makes the separate attest key and
its on-chain registration unnecessary, so remove the now-dead surface:
MsgRegisterAttestKey (proto rpc + message), the register-attest-key CLI command, the
keeper attest-key registry (SubmitAttestKey/SetAttestKey/GetAttestKey), the
AttestKeyKey helpers (prefixAttestKey left RESERVED for state-compat),
ErrInvalidAttestKey, and the now-unused Go AttestationSignBytes helper + KAT (the
client verifies CometBFT's CanonicalVoteExtension instead). make build green.
The MsgLinkIdentity/MsgUnlinkIdentity handlers existed but had no CLI, so
linked identities (a name's authorized "devices" for channel delivery) could
only be managed by hand-crafting txs. Add `epixd tx xid link-identity
[name] [tld] [address] [label]` and `unlink-identity [name] [tld] [address]`.
Used to drive the devnet channel-revocation end-to-end test.
@MudDev MudDev changed the title xID finality: consensus-key vote-ext attestation + leaf-binding + link-identity CLI feat(xid): consensus-key vote-ext finality, leaf-binding, and link-identity CLI Aug 15, 2026
MudDev added 2 commits August 15, 2026 16:53
Whitespace-only: fix var-block alignment and stray blank lines left by the
attest-key removal and the proto-gen migration, so golangci-lint's gofmt gate
passes. No functional change.
The workflows inherited depot-ubuntu-* runners from upstream cosmos/evm, but
the EpixZone fork has no Depot runners, so lint / test / system-test /
jsonrpc-compatibility (and build.yml's binary matrix) queued until they were
cancelled or timed out on every PR. Point them at version-matched
GitHub-hosted runners (ubuntu-24.04 / ubuntu-22.04 / ubuntu-22.04-arm) so CI
actually runs on the fork.
@github-actions github-actions Bot added the CI label Aug 16, 2026
Upstream cosmos/evm's v6->v7 system test builds v0.6.0 and applies a
"v0.6.0-to-v0.7.0" upgrade. EpixChain never released a v0.6.x tag — its v0.5.5
release IS built from the cosmos/evm v0.6.x codebase (it still registers the
precisebank store), and the handler for the migration into the v0.7 line is
UpgradeName_v0_7_0 ("v0.7.0"), which deletes precisebank.

So the test failed two ways on the fork: `git checkout v0.6.0` (no such tag) and
an upgrade name matching no registered handler. Build v0.5.5 as the legacy
binary and submit the "v0.7.0" upgrade — the real v6->v7 migration.
MudDev added 6 commits August 15, 2026 20:00
Apply the repo's gci formatter (custom import-section order) to the xid files
this branch edits, so golangci-lint's gci gate passes for our changes.
Import-order only; no functional change.
prefixAttestKey (the removed attest-key registry) was the second-to-last store
prefix, and the only prefix after it — prefixDigestBlockTime — is also new on
this unreleased branch. Every pre-existing prefix sits before it, so removing
it renumbers nothing that has on-disk data: it's safe to delete outright rather
than carry a reserved iota gap into the release. Nothing references it.

Also annotate the 3 G115 int64<->uint64 conversions this branch adds (block
height / unix block_time, always non-negative) so gosec passes without checks.
No behaviour change.
Format the root-module custom code (x/vrf, x/xid, x/epixmint, x/topholders,
precompiles/*) to the repo's own .golangci.yml formatter config. This backlog
built up because golangci-lint never ran on the fork (its jobs required Depot
runners the fork lacks). Import-grouping/whitespace only; no logic change.
…ules

golangci-lint never ran on the fork (its jobs needed Depot runners), so debt
accumulated across x/vrf, x/xid, x/epixmint, x/topholders, precompiles/*.
Cleared it to match the repo's existing conventions:

- gosec G115: per-line //nolint:gosec // G115 on the safe int/uint height/len
  conversions, exactly as upstream cosmos/evm annotates its own (100+ existing).
- .golangci.yml: exclude generated protobuf files (*.pb.go / *.pb.gw.go /
  *.pulsar.go) from linting.
- staticcheck SA1019: remove deprecated sdk.WrapSDKContext (ctx already is a
  context.Context); //nolint:staticcheck on module.AppModule / NewAminoCodec /
  MustSortJSON (deprecated-but-functional, matches x/erc20 & x/feemarket).
- govet copylocks (REAL bug): x/topholders Keeper holds a sync.RWMutex and was
  passed by value through the module/genesis — take *Keeper so there is one
  instance, not copies with divergent cache state.
- revive if-return / indent-error-flow, staticcheck S1009, gocritic appendAssign
  (avoid mutating a shared slice in xid merkle), thelper, unconvert, unparam.

No behaviour change except the topholders pointer fix and the merkle append.
The system tests were tuned for Depot's 16-core runners; on GitHub-hosted
runners the node needs longer than 20s to be ready (TestEIP712BankSend failed
at ~20.7s waiting for the first block). Give slower runners headroom.
Follow-ups to the backlog cleanup: removing sdk.WrapSDKContext left unused sdk
imports in vrf keeper/genesis tests, and gci regrouping + an unparam on a test
helper. No behaviour change.
MudDev added 5 commits August 15, 2026 21:31
test-unit-cover runs the integration suite under -race; on GitHub's 4-core
runners evmd/tests/integration alone took ~17.5m and hit the 15m per-package
timeout. Depot's 16-core runners finished in time; give the smaller runners
headroom.
Companion to the coverage-args bump: the run-tests prerequisite (root module,
which includes the heavy tests/integration/* packages) also ran with -timeout=15m
and would time out under -race on GitHub's 4-core runners.
EpixChain's ante handler (ante/min_validator_delegation.go) rejects any
MsgCreateValidator whose self-delegation is below epixmint's network minimum
(1,000,000 EPIX, set by the v0.7.0 upgrade). The system-test framework funds its
validators with a tiny stake, so on a chain reset the genesis gentx is rejected
and the node never produces a block — which surfaced as TestEIP712* timing out
in WaitForCommit (the EIP-712 signing itself was fine). Set
epixmint.params.min_validator_self_delegation = 0 in the shared test genesis.

Verified locally: TestEIP712BankSend / WithBalanceCheck / MultipleBankSends all pass.
ReadBlocking released the read lock and only THEN captured the Cond channel to
wait on. A Broadcast racing in that window closed the old channel and installed
a new one, so the waiter blocked on a channel that would never be signaled — if
that was the notification for the final item, the reader blocked forever. In
production this is usually masked by a steady stream of new events; the finite
TestStreamReadBlocking exposed it (a subscriber stuck at <N items).

Fix: capture Cond.NotifyChan() while still holding the read lock (which excludes
Add/Broadcast), then release and WaitChan on it — any subsequent Broadcast closes
exactly that channel. Lock order (stream then cond) is unchanged, so no deadlock.

Also rewrite the flaky test deterministically: read from offset 0 (the buffer
retains all items) so scheduling can't drop the early ones, and use a completion
barrier instead of fixed 100ms sleeps. Stable across 300x under -race.
…erage)

test-unit-cover ran `go test -race -coverpkg=<~180 pkgs> ./...` across the full
integration suite for both the root and evmd modules. The -race detector's ~2x
memory on top of whole-repo coverage instrumentation exhausted the GitHub-hosted
runner, which SIGTERM'd the job mid-run (exit 143, "runner received a shutdown
signal") — not a timeout. Drop -race from the two coverage commands: coverage
numbers are unchanged (coverpkg is untouched), memory ~halves, and the job
finishes well within the runner's limits. (Race detection is still available via
the separate `make test-race` target if wired as its own light job later.)

Also replace the racy `rokroskar/workflow-run-cleanup-action@master` cleanup-runs
job with native `concurrency` (cancel superseded runs, never on main), and give
test-unit-cover an explicit 60m timeout.
MudDev added 8 commits August 16, 2026 12:08
test-unit-cover drops -race (whole-repo coverage + the race detector OOMs the
free runner), so add a separate, cheap race job that runs `go test -race` on the
packages where concurrency actually lives — rpc (incl. rpc/stream), mempool,
indexer, server — and excludes the chain-spinning integration suites. Runs in
~1 minute locally and keeps CI race coverage exactly where it caught the
lost-wakeup bug, without the memory blowup.
Move the runners converted off Depot from fixed 22.04/24.04 pins to
ubuntu-latest, so CI rides the newest GA Ubuntu image and advances to 26.04 (and
beyond) automatically once it's GA — no future edits, no preview-image risk. The
one ARM job (build matrix) uses ubuntu-24.04-arm, the newest GA ARM label, since
there is no ubuntu-latest-arm.
The vote-extension machinery (ExtendVote/VerifyVoteExtension/PrepareProposal
injection/PreBlocker consumption) already ships in the v0.7.1 binary but is
dormant: CometBFT only requests vote extensions once
ConsensusParams.Abci.VoteExtensionsEnableHeight is set, and
processXidVoteExtensions early-returns while it is 0.

The v0.7.2 handler runs migrations, then sets the enable height to the block
after the upgrade (the earliest value CometBFT accepts, since it must be > the
height producing the param update). baseapp returns the current consensus
params as ConsensusParamUpdates at the end of every FinalizeBlock, so this is
what tells CometBFT to start collecting per-validator digest attestations.

No store keys change (the xid store key has existed since v0.5.5).
ClearAttestationsForDigest deleted the attestation rows and count for a
superseded digest but left DigestBlockTimeKey behind, orphaning one small
entry per historical digest forever. Once the attestations are gone the digest
is unverifiable, so its block_time is dead weight — delete it alongside them so
attestation state fully self-prunes to the current digest.
…sions

Vote extensions are consumed one block after they are signed (ExtendVote at
H-1 -> injected into H's proposal -> PreBlocker at H). At a digest transition
the previous block's votes carry the OLD digest, so recording them re-inserted
a signed attestation (and re-set the block_time) for a digest that
UpdateDomainInTree had just cleared in H-1 — leaving a residual attestation set
per rotation that never got cleaned, and defeating the DigestBlockTime prune.

processXidVoteExtensions now skips any vote whose digest is not the current
state digest. Stale-digest votes are useless anyway (clients only verify
against the current finalized digest); the only cost is that a just-changed
digest takes one extra block to finalize.

Add a keeper regression test asserting ClearAttestationsForDigest removes
attestations, count, and block_time, scoped to a single digest.
Silences the 'Node 20 is being deprecated' warnings by moving every action
that has a node24 release to its first node24 major:
- actions/checkout v4->v5, setup-go v5->v6, cache v4->v5, setup-node v4->v5
- actions/dependency-review-action v4->v5, setup-python v5->v6
- actions/upload-artifact v4->v6 (v5 is still node20), stale v9->v10, labeler v5->v6
- docker/setup-buildx-action v3->v4, peter-evans/repository-dispatch v3->v4

foundry-rs/foundry-toolchain@v1 already runs on node24. A handful of
third-party actions have no node24 release yet (technote-space/get-diff-action
is node16; markdownlint-cli2-action and bufbuild/buf-* are node20) and are
left unchanged.
Two remaining sources of node-runtime deprecation warnings had no node24
release to bump to, so replace them with native implementations:

- technote-space/get-diff-action (node16, abandoned; 13 usages) -> a local
  composite action .github/actions/changed-files that computes the same
  GIT_DIFF gate with git + bash. Patterns are translated to git :(glob)
  pathspecs (verified to preserve get-diff semantics: **/**.go matches any .go
  incl. root, *.toml stays root-only, literal paths stay anchored). Resolves
  the base from BASE input / pull_request.base.sha / merge_group.base_sha /
  HEAD~1, fetches it under shallow clones, and fails safe (runs the gated
  steps) if the base or diff can't be computed. Downstream 'if: env.GIT_DIFF'
  guards are unchanged.

- rokroskar/workflow-run-cleanup-action@master (node12; system-test +
  jsonrpc-compatibility) -> native 'concurrency:' groups, matching the old
  job's ref filter (never cancel main or release tags).
@MudDev
MudDev merged commit b1f15a1 into main Aug 17, 2026
17 checks passed
@MudDev
MudDev deleted the feat/xid-finality-attestation branch August 17, 2026 03:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant