Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions protocol/localstatequery/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ client.Release()
- **GetRewardProvenance**: Reward calculation details
- **GetStakePools**: Registered stake pools
- **GetStakePoolParams**: Stake pool parameters
- **GetOpCertCounters**: On-chain operational-certificate counters (pool cold-key hash → highest accepted opcert issue number), the counter a synced node enforces
- **DebugChainDepState**: Full consensus chain-dependent state (opcert counters, last slot, and evolving/candidate/epoch nonces); supports both the Praos (Babbage+) and TPraos (Shelley–Alonzo) serialisations

## Notes

Expand Down
175 changes: 175 additions & 0 deletions protocol/localstatequery/chain_dep_state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Copyright 2026 Blink Labs Software
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package localstatequery

import (
"fmt"

"github.com/blinklabs-io/gouroboros/cbor"
"github.com/blinklabs-io/gouroboros/ledger"
lcommon "github.com/blinklabs-io/gouroboros/ledger/common"
)

// ChainDepStateProtocol identifies which consensus protocol produced a
// DebugChainDepState result. The two protocols serialise the chain-dependent
// state with different on-wire layouts, distinguished by a leading version
// number, so callers can tell which optional fields are populated.
type ChainDepStateProtocol int

const (
// ChainDepStateProtocolTPraos is the Transitional Praos protocol used by
// the Shelley, Allegra, Mary and Alonzo eras (serialisation version 1).
ChainDepStateProtocolTPraos ChainDepStateProtocol = 1
// ChainDepStateProtocolPraos is the Praos protocol used by the Babbage
// era and later, including Conway (serialisation version 0).
ChainDepStateProtocolPraos ChainDepStateProtocol = 0
)

// chainDepState serialisation version tags, as emitted by
// Ouroboros.Consensus.Protocol.{Praos,TPraos} `encodeVersion`.
const (
chainDepStateVersionPraos uint64 = 0
chainDepStateVersionTPraos uint64 = 1
)

// DebugChainDepStateResult is the typed result of a DebugChainDepState query
// (Shelley sub-query 13). It decodes the consensus chain-dependent state and
// exposes the authoritative on-chain operational-certificate counters that a
// synced node enforces when validating blocks.
//
// The wire layout is the `encodeVersion`-wrapped serialisation from
// ouroboros-consensus:
//
// Praos (Babbage+): [0, [lastSlot, opCertCounters, evolvingNonce,
// candidateNonce, epochNonce, previousEpochNonce,
// labNonce, lastEpochBlockNonce]]
// TPraos (Shelley..Alonzo): [1, [lastSlot,
// [opCertCounters, evolvingNonce, candidateNonce]]]
//
// OpCertCounters and the two shared nonces are present for both protocols; the
// remaining nonce fields are Praos-only and are nil when Protocol is TPraos.
type DebugChainDepStateResult struct {
// Protocol is the consensus protocol the state was serialised for.
Protocol ChainDepStateProtocol
// LastSlot is the slot of the last block applied to the acquired state, or
// Origin if no block has been applied yet.
LastSlot WithOriginSlot
// OpCertCounters maps each block-issuer key hash (a stake pool's cold-key
// hash) to the highest operational-certificate issue number the chain has
// accepted for it. This is the authoritative counter the node enforces: a
// new block whose opcert issue number is lower is rejected.
OpCertCounters map[ledger.Blake2b224]uint64
// EvolvingNonce and CandidateNonce are present for both protocols.
EvolvingNonce lcommon.Nonce
CandidateNonce lcommon.Nonce
// EpochNonce, PreviousEpochNonce, LabNonce and LastEpochBlockNonce are
// Praos-only (Babbage era and later); they are nil for TPraos results.
EpochNonce *lcommon.Nonce
PreviousEpochNonce *lcommon.Nonce
LabNonce *lcommon.Nonce
LastEpochBlockNonce *lcommon.Nonce
}

// OpCertCounter returns the on-chain operational-certificate counter for a
// single block issuer (a stake pool's cold-key hash), and whether the chain
// has recorded a counter for it. A pool that has never minted a block under an
// operational certificate has no counter and returns (0, false).
func (r *DebugChainDepStateResult) OpCertCounter(
poolKeyHash ledger.Blake2b224,
) (uint64, bool) {
v, ok := r.OpCertCounters[poolKeyHash]
return v, ok
}

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
Comment on lines +96 to +106

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

}
switch outer.Version {
case chainDepStateVersionPraos:
// Praos (Babbage era and later): an 8-element array.
var p struct {
cbor.StructAsArray
LastSlot WithOriginSlot
OpCertCounters map[ledger.Blake2b224]uint64
EvolvingNonce lcommon.Nonce
CandidateNonce lcommon.Nonce
EpochNonce lcommon.Nonce
PreviousEpochNonce lcommon.Nonce
LabNonce lcommon.Nonce
LastEpochBlockNonce lcommon.Nonce
}
if _, err := cbor.Decode(outer.Inner, &p); err != nil {
return err
}
r.Protocol = ChainDepStateProtocolPraos
r.LastSlot = p.LastSlot
r.OpCertCounters = p.OpCertCounters
r.EvolvingNonce = p.EvolvingNonce
r.CandidateNonce = p.CandidateNonce
// Copy into fresh locals so the pointers do not alias the decode
// scratch struct.
epochNonce := p.EpochNonce
previousEpochNonce := p.PreviousEpochNonce
labNonce := p.LabNonce
lastEpochBlockNonce := p.LastEpochBlockNonce
r.EpochNonce = &epochNonce
r.PreviousEpochNonce = &previousEpochNonce
r.LabNonce = &labNonce
r.LastEpochBlockNonce = &lastEpochBlockNonce
case chainDepStateVersionTPraos:
// TPraos (Shelley through Alonzo): [lastSlot, PrtclState], where
// PrtclState is [opCertCounters, evolvingNonce, candidateNonce].
var t struct {
cbor.StructAsArray
LastSlot WithOriginSlot
PrtclState struct {
cbor.StructAsArray
OpCertCounters map[ledger.Blake2b224]uint64
EvolvingNonce lcommon.Nonce
CandidateNonce lcommon.Nonce
}
}
if _, err := cbor.Decode(outer.Inner, &t); err != nil {
return err
}
r.Protocol = ChainDepStateProtocolTPraos
r.LastSlot = t.LastSlot
r.OpCertCounters = t.PrtclState.OpCertCounters
r.EvolvingNonce = t.PrtclState.EvolvingNonce
r.CandidateNonce = t.PrtclState.CandidateNonce
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// 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.

default:
return fmt.Errorf(
"unsupported ChainDepState serialisation version: %d",
outer.Version,
)
}
// Normalise a missing/absent counter map to an empty (non-nil) map so
// callers can range over it and look up keys without a nil check.
if r.OpCertCounters == nil {
r.OpCertCounters = map[ledger.Blake2b224]uint64{}
}
return nil
}
121 changes: 121 additions & 0 deletions protocol/localstatequery/chain_dep_state_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright 2026 Blink Labs Software
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//go:build chain_dep_state_integration

// This file is excluded from the default Go build. To run it:
//
// GOUROBOROS_NTC_SOCKET=/path/to/node.socket \
// GOUROBOROS_NETWORK_MAGIC=764824073 \
// go test -tags=chain_dep_state_integration \
// -run TestDebugChainDepStateIntegration -v \
// ./protocol/localstatequery/...
//
// The test connects to a running, synced cardano-node over its node-to-client
// socket and exercises DebugChainDepState / GetOpCertCounters end-to-end
// against the real wire format. It satisfies the "spot-check against a live
// node" acceptance criterion for the on-chain opcert counter (issue #1890) and
// is gated by a build tag so CI without a node does not attempt the call.

package localstatequery_test

import (
"context"
"net"
"os"
"strconv"
"testing"
"time"

ouroboros "github.com/blinklabs-io/gouroboros"
"github.com/blinklabs-io/gouroboros/protocol/localstatequery"
)

func TestDebugChainDepStateIntegration(t *testing.T) {
socketPath := os.Getenv("GOUROBOROS_NTC_SOCKET")
if socketPath == "" {
t.Skip("GOUROBOROS_NTC_SOCKET not set; skipping integration test")
}
magicStr := os.Getenv("GOUROBOROS_NETWORK_MAGIC")
if magicStr == "" {
t.Fatal("GOUROBOROS_NETWORK_MAGIC must be set when GOUROBOROS_NTC_SOCKET is set")
}
magic, err := strconv.ParseUint(magicStr, 10, 32)
if err != nil {
t.Fatalf("invalid GOUROBOROS_NETWORK_MAGIC %q: %s", magicStr, err)
}

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

d := net.Dialer{}
conn, err := d.DialContext(ctx, "unix", socketPath)
if err != nil {
t.Fatalf("dial %s: %s", socketPath, err)
}

oConn, err := ouroboros.New(
ouroboros.WithConnection(conn),
ouroboros.WithNetworkMagic(uint32(magic)),
ouroboros.WithNodeToNode(false),
ouroboros.WithKeepAlive(false),
)
if err != nil {
t.Fatalf("ouroboros handshake: %s", err)
}
defer func() {
_ = oConn.Close()
}()

client := oConn.LocalStateQuery().Client

state, err := client.DebugChainDepState()
if err != nil {
t.Fatalf("DebugChainDepState: %s", err)
}
switch state.Protocol {
case localstatequery.ChainDepStateProtocolPraos,
localstatequery.ChainDepStateProtocolTPraos:
default:
t.Fatalf("unexpected protocol tag: %d", state.Protocol)
}
if len(state.OpCertCounters) == 0 {
t.Fatal("expected at least one on-chain opcert counter on a synced node")
}
t.Logf(
"chain-dep-state OK: protocol=%d slot=%+v counters=%d",
state.Protocol,
state.LastSlot,
len(state.OpCertCounters),
)

// The convenience helper must agree with the full result.
counters, err := client.GetOpCertCounters()
if err != nil {
t.Fatalf("GetOpCertCounters: %s", err)
}
if len(counters) != len(state.OpCertCounters) {
t.Fatalf(
"GetOpCertCounters size mismatch: got %d want %d",
len(counters),
len(state.OpCertCounters),
)
}
for pool, want := range state.OpCertCounters {
if got, ok := counters[pool]; !ok || got != want {
t.Fatalf("counter for %s: got (%d,%v) want (%d,true)",
pool, got, ok, want)
}
}
}
Loading