feat(xid): consensus-key vote-ext finality, leaf-binding, and link-identity CLI - #22
Merged
Conversation
…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.
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.
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.
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.
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.
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).
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.
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.ExtendVotereturns{height, block_time, digest}unsigned;PreBlockerreconstructsMarshalDelimited(CanonicalVoteExtension{extension, height, round, chain_id}), verifies eachExtensionSignatureagainst the validator's stakingConsPubKey(mirrorsbaseapp.ValidateVoteExtensions), and persists the signature + raw extension + round with real voting power.IsDigestFinalizedis power-based (strictsignedPower·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_proofnow returnsleaf_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/MsgUnlinkIdentityhandlers existed but had no CLI. Addsepixd tx xid link-identity [name] [tld] [address] [label]andunlink-identity [name] [tld] [address], used to drive the cross-repo channel revocation test.proto-gen migration
Completes the
x/xidproto-gen migration (removes the hand-written placeholder types that duplicated generated output).Testing
make buildgreen.finalized: trueautomatically (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.