Skip to content

feat(localstatequery): decode chain-dep-state opcert counters - #1898

Merged
chrisguiney merged 2 commits into
blinklabs-io:mainfrom
chrisguiney:feat/lsq-opcert-counter
Jul 22, 2026
Merged

feat(localstatequery): decode chain-dep-state opcert counters#1898
chrisguiney merged 2 commits into
blinklabs-io:mainfrom
chrisguiney:feat/lsq-opcert-counter

Conversation

@chrisguiney

@chrisguiney chrisguiney commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements a typed LocalStateQuery path to read the authoritative on-chain operational-certificate counter a synced node enforces, so any node-to-client consumer (operator, dingoctl, etc.) can read it over an NtC socket without depending on a node-specific API.

Closes #1890. Also implements the previously-any DebugChainDepState result decoder (closes the #865 TODO).

What changed

  • DebugChainDepState result decoder (chain_dep_state.go): the query result is now decoded into a typed DebugChainDepStateResult instead of an any stub. It exposes:
    • OpCertCounters map[ledger.Blake2b224]uint64 — block-issuer key hash (pool cold-key hash) → highest accepted opcert issue number (the counter the node enforces).
    • LastSlot, plus the evolving/candidate nonces and (Praos-only) epoch nonces, so callers can combine with slotsPerKESPeriod from GetGenesisConfig to derive KES-period info.
    • OpCertCounter(poolKeyHash) per-pool lookup helper.
  • GetOpCertCounters() client helper: typed convenience wrapper returning just the counter map.
  • The decoder is version-discriminated and handles both consensus serialisations, reusing the existing WithOriginSlot and ledger/common Nonce types:
    • Praos (Babbage era and later, incl. Conway) — encodeVersion 0, 8-element state.
    • TPraos (Shelley through Alonzo) — encodeVersion 1, nested PrtclState.

Wire format provenance

Layouts were taken from the upstream Haskell instances (ouroboros-consensus Praos/TPraos, cardano-ledger Nonce/PrtclState, cardano-slotting WithOrigin), not inferred. WithOrigin SlotNo ([0]/[1,slot]) and Nonce ([0]/[1,hash]) match this repo's existing WithOriginSlot and lcommon.Nonce.

Tests

Table-driven decode tests over wire fixtures: Praos golden (incl. exact-byte pin), TPraos golden, chain-origin/empty counters, the buildShelleyQuery envelope, and unknown-version rejection, plus the lookup helper.

A build-tagged integration test (-tags=chain_dep_state_integration, GOUROBOROS_NTC_SOCKET/GOUROBOROS_NETWORK_MAGIC) provides the live-node spot-check harness for the "matches the counter a synced node enforces" acceptance criterion — this environment has no node access, so that spot-check is left to run against a synced node.

make lint (0 issues), make format (no changes), go vet ./..., and the package test suite all pass.

Notes

  • Two docs referenced by the issue (ROADMAP.md, docs/plans/lsq.md) don't exist in the repo, so the new capability is documented in the package README.md instead.
  • #864 (DebugNewEpochState) is intentionally left as-is — ChainDepState is the authoritative enforcement source for the counter and fully satisfies the acceptance criteria.

🤖 Generated with Claude Code


Summary by cubic

Adds a typed DebugChainDepState decoder and a GetOpCertCounters() helper to read authoritative on-chain opcert counters over LocalStateQuery, with robust decoding across Praos and TPraos and safe reuse of result structs.

  • New Features

    • Typed DebugChainDepStateResult with OpCertCounters (pool cold-key ledger.Blake2b224 → highest opcert issue number), LastSlot, evolving/candidate/epoch nonces, and OpCertCounter() lookup.
    • Version-discriminated decoder for Praos (encodeVersion 0) and TPraos (encodeVersion 1), reusing WithOriginSlot and ledger/common Nonce.
    • GetOpCertCounters() convenience wrapper returning only the counter map.
  • Bug Fixes

    • Clear Praos-only nonce pointers in the TPraos decode path to prevent stale values when decoding into a reused result; added a reuse regression test and updated tests to testify assert/require.

Written for commit 0e86431. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added support for querying consensus chain-dependent state, including operational-certificate counters, slots, and nonces.
    • Added a convenient method to retrieve operational-certificate counters directly.
    • Supports both Praos and TPraos result formats.
    • Documented the new LocalStateQuery options.
  • Tests

    • Added coverage for query construction, result decoding, protocol variations, invalid versions, and counter lookups.
    • Added optional integration coverage against a running node.

Implement the DebugChainDepState result decoder to expose the
authoritative on-chain operational-certificate counters a synced node
enforces (block-issuer key hash -> highest accepted opcert issue number),
plus a GetOpCertCounters convenience helper. This lets any node-to-client
consumer read the counters over LSQ without depending on a node-specific
API.

The decoder is version-discriminated and handles both the Praos (Babbage
era and later, including Conway) and TPraos (Shelley through Alonzo)
serialisations, reusing the existing WithOriginSlot and ledger/common
Nonce types. Table-driven decode tests cover both protocols, chain
origin, empty counters, the query envelope, and unknown-version
rejection; a build-tagged integration test provides the live-node
spot-check harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Chris Guiney <chris@guiney.net>
@chrisguiney
chrisguiney requested review from a team as code owners July 22, 2026 19:45
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds typed decoding for LocalStateQuery chain-dependent state across Praos and TPraos serializations, including slots, nonces, and operational-certificate counters. Adds client documentation and a GetOpCertCounters convenience method. Tests cover CBOR query construction, decoding variants, golden bytes, unknown versions, counter lookup, and live-node integration.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Implements typed DebugChainDepState decoding, GetOpCertCounters, and fixture/integration tests for authoritative opcert counters.
Out of Scope Changes check ✅ Passed All changes support LocalStateQuery opcert counter decoding and its tests/docs; no unrelated functionality stands out.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: decoding chain-dep-state opcert counters in localstatequery.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
protocol/localstatequery/chain_dep_state_test.go (1)

28-31: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Keep fixture constants within the 80-column limit.

Lines 28 and 31 exceed the configured limit. Split each compile-time string literal across concatenated lines and run golines.

As per coding guidelines, “Enforce 80-column line length limit using golines.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@protocol/localstatequery/chain_dep_state_test.go` around lines 28 - 31, Wrap
the fixture string literals assigned to poolKeyHashHex and poolKeyHashHex2
across concatenated lines so every line stays within 80 columns, then run
golines to enforce the formatting.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@protocol/localstatequery/chain_dep_state_test.go`:
- Around line 33-44: Replace the manual fatal checks in blake224 and the related
tests in protocol/localstatequery/chain_dep_state_test.go:33-44 with testify
require assertions for fixture/precondition failures and assert for checks that
should continue. Apply the same conversion to environment, connection, and
result assertions in
protocol/localstatequery/chain_dep_state_integration_test.go:45-56, importing
testify assert/require as needed.

In `@protocol/localstatequery/chain_dep_state.go`:
- Around line 156-162: Update the TPraos decode branch around ChainDepState
result assignment to explicitly clear all four Praos-only nonce pointers,
including EpochNonce and the other nonce fields, before returning. Add a
regression test that reuses a result after a Praos decode and verifies every
Praos-only nonce is nil for the subsequent TPraos decode.
- Around line 96-106: Update DebugChainDepStateResult.UnmarshalCBOR to call
r.SetCbor(data) before decoding and use cbor.DecodeStoreCbor for decoding.
Preserve the existing temporary array decoding and field-copy behavior so Cbor()
returns the exact received wire bytes.

---

Nitpick comments:
In `@protocol/localstatequery/chain_dep_state_test.go`:
- Around line 28-31: Wrap the fixture string literals assigned to poolKeyHashHex
and poolKeyHashHex2 across concatenated lines so every line stays within 80
columns, then run golines to enforce the formatting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 620e7ef6-5218-4676-b8c2-bdf5f23483a5

📥 Commits

Reviewing files that changed from the base of the PR and between fa89f2f and bc925af.

📒 Files selected for processing (6)
  • protocol/localstatequery/README.md
  • protocol/localstatequery/chain_dep_state.go
  • protocol/localstatequery/chain_dep_state_integration_test.go
  • protocol/localstatequery/chain_dep_state_test.go
  • protocol/localstatequery/client.go
  • protocol/localstatequery/queries.go

Comment on lines +33 to +44
func blake224(t *testing.T, s string) ledger.Blake2b224 {
t.Helper()
var h ledger.Blake2b224
raw, err := hex.DecodeString(s)
if err != nil {
t.Fatalf("decode key hash %q: %s", s, err)
}
if len(raw) != len(h) {
t.Fatalf("key hash %q: expected %d bytes, got %d", s, len(h), len(raw))
}
copy(h[:], raw)
return h

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'github\.com/stretchr/testify/(assert|require)' \
  --glob '*.go' --glob 'go.mod' --glob 'go.sum' .

Repository: blinklabs-io/gouroboros

Length of output: 13214


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- module dependency ---\n'
rg -n 'github\.com/stretchr/testify' go.mod go.sum || true

printf '\n--- target files imports and assertions ---\n'
for f in \
  protocol/localstatequery/chain_dep_state_test.go \
  protocol/localstatequery/chain_dep_state_integration_test.go
do
  echo "### $f"
  sed -n '1,220p' "$f" | cat -n
  echo "-- assert/require usage --"
  rg -n 'testify/(assert|require)|assert\.|require\.|\.(Fatal|Error|Fatalf|Errorf|Skip|Skipf)\(' "$f" || true
  echo "-- t.Fatal direct usage --"
  rg -n '\b(t\.Fatal|t\.Fatalf|t\.Error|t\.Errorf|t\.Skip|t\.Skipf)\(' "$f" || true
done

Repository: blinklabs-io/gouroboros

Length of output: 252


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- module dependency ---'
rg -n 'github\.com/stretchr/testify' go.mod go.sum || true

printf '%s\n' ''
printf '%s\n' '--- target files imports and assertions ---'
for f in \
  protocol/localstatequery/chain_dep_state_test.go \
  protocol/localstatequery/chain_dep_state_integration_test.go
do
  printf '%s\n' "### $f"
  sed -n '1,220p' "$f" | cat -n
  printf '%s\n' '-- testify usage --'
  rg -n 'testify/(assert|require)|assert\.|require\.|\.(Fatal|Error|Fatalf|Errorf|Skip|Skipf)\(' "$f" || true
  printf '%s\n' '-- testing.T.Fatal/error/skip direct usage --'
  rg -n '\b(t\.Fatal|t\.Fatalf|t\.Error|t\.Errorf|t\.Skip|t\.Skipf)\(' "$f" || true
done

Repository: blinklabs-io/gouroboros

Length of output: 19835


Use testify assertions in both new test files. testify is already a module dependency, and this project’s test guideline requires fatal assertions with require and continuing assertions with assert.

  • protocol/localstatequery/chain_dep_state_test.go: convert fixture/precondition assertions to require, and assertions intended to continue testing to assert.
  • protocol/localstatequery/chain_dep_state_integration_test.go: convert environment/connection/result assertions to require/assert as appropriate.
📍 Affects 2 files
  • protocol/localstatequery/chain_dep_state_test.go#L33-L44 (this comment)
  • protocol/localstatequery/chain_dep_state_integration_test.go#L45-L56
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@protocol/localstatequery/chain_dep_state_test.go` around lines 33 - 44,
Replace the manual fatal checks in blake224 and the related tests in
protocol/localstatequery/chain_dep_state_test.go:33-44 with testify require
assertions for fixture/precondition failures and assert for checks that should
continue. Apply the same conversion to environment, connection, and result
assertions in
protocol/localstatequery/chain_dep_state_integration_test.go:45-56, importing
testify assert/require as needed.

Source: Coding guidelines

Comment on lines +96 to +106
func (r *DebugChainDepStateResult) UnmarshalCBOR(data []byte) error {
// Both protocols wrap the state in `encodeVersion N`, which serialises as
// a 2-element array [version, innerState]. The version selects the layout
// of innerState.
var outer struct {
cbor.StructAsArray
Version uint64
Inner cbor.RawMessage
}
if _, err := cbor.Decode(data, &outer); err != nil {
return err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -A12 -B3 \
  'DecodeStoreCbor|SetCbor\(|Cbor\(\)' \
  cbor protocol --glob '*.go'

Repository: blinklabs-io/gouroboros

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the exact decoder context and nearby usage without running repository code.
sed -n '1,230p' protocol/localstatequery/chain_dep_state.go

printf '\n--- matching QueryWrapper usages ---\n'
rg -n "DebugChainDepState|ChainDepStateQuery|ChainDepStateResult|SetCbor|Cbor\\(\\)" protocol/localstatequery --glob '*.go'

Repository: blinklabs-io/gouroboros

Length of output: 12148


Preserve the received CBOR bytes on DebugChainDepStateResult.

The decoder receives CBOR in UnmarshalCBOR but never stores it, and it decodes the structs into temporary values before copying fields. Add cbor.DecodeStoreCbor, call r.SetCbor(data) before decoding, and keep the current array/field copy semantics so Cbor() returns the exact wire bytes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@protocol/localstatequery/chain_dep_state.go` around lines 96 - 106, Update
DebugChainDepStateResult.UnmarshalCBOR to call r.SetCbor(data) before decoding
and use cbor.DecodeStoreCbor for decoding. Preserve the existing temporary array
decoding and field-copy behavior so Cbor() returns the exact received wire
bytes.

Source: Coding guidelines

Comment on lines +156 to +162
r.Protocol = ChainDepStateProtocolTPraos
r.LastSlot = t.LastSlot
r.OpCertCounters = t.PrtclState.OpCertCounters
r.EvolvingNonce = t.PrtclState.EvolvingNonce
r.CandidateNonce = t.PrtclState.CandidateNonce
// EpochNonce and the other Praos-only nonces do not exist in TPraos;
// they are left nil.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear Praos-only fields for TPraos decodes.

Reusing a result after a Praos decode leaves the four nonce pointers populated: this branch assigns TPraos fields but never sets them to nil. That contradicts the documented TPraos contract and can expose stale nonce data. Clear all four pointers in this branch and add a reuse regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@protocol/localstatequery/chain_dep_state.go` around lines 156 - 162, Update
the TPraos decode branch around ChainDepState result assignment to explicitly
clear all four Praos-only nonce pointers, including EpochNonce and the other
nonce fields, before returning. Add a regression test that reuses a result after
a Praos decode and verifies every Praos-only nonce is nil for the subsequent
TPraos decode.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread protocol/localstatequery/chain_dep_state.go
- Clear the Praos-only nonce pointers (EpochNonce, PreviousEpochNonce,
  LabNonce, LastEpochBlockNonce) in the TPraos decode branch so decoding
  into a reused result cannot expose stale Praos values; add a reuse
  regression test.
- Convert the new decode and integration tests to testify require/assert
  per the project testing guideline.
- Wrap the fixture key-hash constants to stay within the 80-column limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Chris Guiney <chris@guiney.net>
@chrisguiney

Copy link
Copy Markdown
Contributor Author

Thanks @coderabbitai and @cubic-dev-ai. Addressed in 0e86431:

  • Clear Praos-only nonces on TPraos decode (coderabbit major + cubic P2): the TPraos branch now explicitly sets EpochNonce, PreviousEpochNonce, LabNonce, and LastEpochBlockNonce to nil, so decoding into a reused result can't leak stale Praos values. Added TestDebugChainDepStateReusedResultClearsPraosNonces (Praos decode → TPraos decode into the same value → asserts all four are nil).
  • testify assertions (coderabbit major): converted both new test files to require/assert per AGENTS.md §Testing.
  • 80-column limit (coderabbit nitpick): wrapped the fixture key-hash constants; ran golines --max-len=80.

Declined, with reasoning:

  • Add cbor.DecodeStoreCbor/SetCbor(data) to DebugChainDepStateResult (coderabbit minor): the DecodeStoreCbor/.Cbor() pattern exists to preserve original bytes for hashing/re-serialisation (blocks, txs, certs), per CLAUDE.md/AGENTS.md. This is a read-only LSQ query result that is never hashed or re-encoded, and no other result type in protocol/localstatequery embeds DecodeStoreCbor (only the query QueryWrapper does). Adding it here would be inconsistent with the package and serve no consumer. Happy to revisit if a caller needs the raw bytes.

make lint (0 issues), golines, go vet (incl. integration tag), and the package test suite all pass.

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Thanks @coderabbitai and @cubic-dev-ai. Addressed in 0e86431:

  • Clear Praos-only nonces on TPraos decode (coderabbit major + cubic P2): the TPraos branch now explicitly sets EpochNonce, PreviousEpochNonce, LabNonce, and LastEpochBlockNonce to nil, so decoding into a reused result can't leak stale Praos values. Added TestDebugChainDepStateReusedResultClearsPraosNonces (Praos decode → TPraos decode into the same value → asserts all four are nil).
  • testify assertions (coderabbit major): converted both new test files to require/assert per AGENTS.md §Testing.
  • 80-column limit (coderabbit nitpick): wrapped the fixture key-hash constants; ran golines --max-len=80.
    ...

@chrisguiney Looks good — the commit matches what you described, and the code already reflects it in the diffs above:

  • TPraos nonce clearing: the nil assignments are there in chain_dep_state.go:156-161, and TestDebugChainDepStateReusedResultClearsPraosNonces covers the Praos→TPraos reuse case.
  • Testify conversion: both test files use require/assert.
  • Line length: the poolKeyHashHex / poolKeyHashHex2 constants are wrapped.
  • DecodeStoreCbor skip: makes sense — no other LSQ result type in the package carries it, and there's no hashing or re-serialisation need here. Consistent call.

Everything lines up.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@chrisguiney Thanks for the thorough follow-up. The reused-result regression test directly covers the stale Praos nonce concern, and the assertion/formatting updates address the remaining findings.

Your rationale for not retaining raw CBOR on this read-only LocalStateQuery result is sound; that suggestion does not apply here.


✏️ Learnings added
Learnt from: chrisguiney
URL: https://github.com/blinklabs-io/gouroboros/pull/1898

Timestamp: 2026-07-22T20:17:57.078Z
Learning: In the Go `protocol/localstatequery` package, result types are read-only decoded query results and do not embed `cbor.DecodeStoreCbor`. Use `DecodeStoreCbor` only for values that need to preserve original CBOR for hashing or re-serialization (for example, blocks, transactions, and certificates), not for `DebugChainDepStateResult`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@chrisguiney
chrisguiney merged commit aa4f32f into blinklabs-io:main Jul 22, 2026
10 checks passed
@chrisguiney
chrisguiney deleted the feat/lsq-opcert-counter branch July 22, 2026 20:58
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.

LocalStateQuery: expose on-chain opcert counter / KES-period-info

2 participants