Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ results in CI or internal records. See docs/releasing.md. -->

## [Unreleased]

## [v4.1.4] - 2026-10-02

### Fixed

- Classify `voteAbciDigest` actions in replica and mempool metrics under their own label and the `governance` category.
- Read `executed_round` in consensus heartbeats and acknowledgments so updated testnet nodes continue reporting heartbeat delays. Reject malformed, conflicting or duplicate round fields.

## [v4.1.3] - 2026-09-26

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions internal/actiontypes/actiontypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ var known = map[string]struct{}{
"trailingStop": {}, "twapCancel": {}, "twapOrder": {}, "updateIsolatedMargin": {}, "updateLeverage": {},
"usdClassTransfer": {}, "usdSend": {}, "userSetAbstraction": {}, "userDexAbstraction": {},
"ValidatorSignWithdrawalAction": {}, "vaultTransfer": {},
"VoteEthFinalizedWithdrawalAction": {}, "voteAppHash": {}, "withdraw3": {},
"VoteEthFinalizedWithdrawalAction": {}, "voteAppHash": {}, "voteAbciDigest": {}, "withdraw3": {},

// Current/retained replica_cmds vocabulary.
"activateOutcomeDeployer": {}, "authorizeAqav2Role": {}, "CSignerAction": {},
Expand Down Expand Up @@ -66,7 +66,7 @@ func Category(action string) string {
"VoteEthFinalizedWithdrawalAction", "VoteGlobalAction", "SetGlobalAction",
"CSignerAction", "CValidatorAction", "ValidatorSignWithdrawalAction",
"NetChildVaultPositionsAction", "validatorL1UpdateReferenceOracle",
"validatorL1Vote", "validatorL1Status", "validatorL1Stream", "voteL1Hash",
"validatorL1Vote", "validatorL1Status", "validatorL1Stream", "voteL1Hash", "voteAbciDigest",
"l1ValidatorVoteBridgeDeposit", "gossipPriorityBid":
return "governance"
case "claimRewards", "reserveRequestWeight":
Expand Down
1 change: 1 addition & 0 deletions internal/actiontypes/actiontypes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func TestNormalizeAndCategoryAreBounded(t *testing.T) {
{raw: "outcomeDeploy", label: "outcomeDeploy", known: true, category: "deployment"},
{raw: "validatorL1UpdateReferenceOracle", label: "validatorL1UpdateReferenceOracle", known: true, category: "governance"},
{raw: "validatorL1Status", label: "validatorL1Status", known: true, category: "governance"},
{raw: "voteAbciDigest", label: "voteAbciDigest", known: true, category: "governance"},
{raw: "noop", label: "noop", known: true, category: "system"},
{raw: "random-address-or-payload", label: Other, known: false, category: Other},
{raw: "", label: Other, known: false, category: Other},
Expand Down
85 changes: 85 additions & 0 deletions internal/monitors/consensus_heartbeat_schema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package monitors

import (
"fmt"
"testing"
"time"

"github.com/validaoxyz/hyperliquid-exporter/internal/config"
"github.com/validaoxyz/hyperliquid-exporter/internal/metrics"
"go.opentelemetry.io/otel"
)

func setupHeartbeatSchemaCounter(t *testing.T) {
t.Helper()
sentCounter, err := otel.Meter("heartbeat-schema-test").Int64Counter("test_heartbeat_schema_sent")
if err != nil {
t.Fatal(err)
}
old := metrics.HLConsensusHeartbeatSentCounter
metrics.HLConsensusHeartbeatSentCounter = sentCounter
t.Cleanup(func() { metrics.HLConsensusHeartbeatSentCounter = old })
}

func TestConsensusHeartbeatExecutedRoundJoins(t *testing.T) {
setupHeartbeatSchemaCounter(t)
for _, fields := range []string{`"executed_round":77`, `"round":77`, `"round":77,"executed_round":77`} {
t.Run(fields, func(t *testing.T) {
m := NewConsensusMonitor(&config.Config{})
sent := fmt.Sprintf(`["2026-10-02T08:15:10.000000000",["out",{"Heartbeat":{"validator":"0x1111..1111","random_id":424,%s}}]]`, fields)
ack := fmt.Sprintf(`["2026-10-02T08:15:10.010000000",["in",{"sender":"0x2222..2222","msg":{"HeartbeatAck":{"validator":"0x2222..2222","random_id":424,%s}}}]]`, fields)
before := validatorHistogramCount(t, metrics.HLConsensusHeartbeatPeerAckDelay)
if err := m.processConsensusLine(sent); err != nil {
t.Fatal(err)
}
if err := m.processConsensusLine(ack); err != nil {
t.Fatal(err)
}
key := heartbeatKey{validator: "0x1111..1111", randomID: 424, round: 77}
want := time.Date(2026, 10, 2, 8, 15, 10, 10_000_000, time.UTC)
if !m.heartbeats[key].matched || !m.heartbeatAcks[heartbeatAckKey{heartbeatKey: key, source: "0x2222..2222"}].Equal(want) {
t.Fatal("heartbeat acknowledgement did not join the sent heartbeat")
}
if got := validatorHistogramCount(t, metrics.HLConsensusHeartbeatPeerAckDelay) - before; got != 1 {
t.Fatalf("peer delay observation count = %d, want 1", got)
}
})
}
}

func TestConsensusHeartbeatRejectsInvalidRoundAliases(t *testing.T) {
setupHeartbeatSchemaCounter(t)
for _, fields := range []string{
``, `,"executed_round":null`, `,"executed_round":0`, `,"executed_round":-1`,
`,"executed_round":"77"`, `,"executed_round":1.5`, `,"executed_round":18446744073709551616`,
`,"round":77,"executed_round":78`, `,"round":77,"executed_round":null`,
`,"round":77,"executed_round":0`, `,"round":null,"executed_round":77`,
`,"round":0,"executed_round":77`, `,"round":"77","executed_round":77`,
`,"round":"bad","round":77`, `,"round":"bad","Round":77`,
`,"round":77,"round":77`, `,"round":null,"round":77`,
`,"executed_round":77,"executed_round":78`,
`,"executed_round":77,"Executed_Round":77`,
`,"round":77,"executed_round":78,"executed_round":77`,
} {
for _, kind := range []string{"Heartbeat", "HeartbeatAck"} {
t.Run(kind+fields, func(t *testing.T) {
m := NewConsensusMonitor(&config.Config{})
key := heartbeatKey{validator: "0x1111..1111", randomID: 424, round: 77}
if kind == "HeartbeatAck" {
m.heartbeats[key] = heartbeatInfo{}
}
payload := fmt.Sprintf(`{"%s":{"validator":"0x1111..1111","random_id":424%s}}`, kind, fields)
inner := `["out",` + payload + `]`
if kind == "HeartbeatAck" {
inner = `["in",{"sender":"0x1111..1111","msg":` + payload + `}]`
}
if err := m.processConsensusLine(`["2026-10-02T08:15:10.000000000",` + inner + `]`); err == nil {
t.Fatal("accepted an invalid heartbeat round")
}
if len(m.heartbeatAcks) != 0 || m.heartbeats[key].matched || (kind == "Heartbeat" && len(m.heartbeats) != 0) {
t.Fatal("rejected heartbeat changed correlation state")
}
})
}
}
}
64 changes: 64 additions & 0 deletions internal/monitors/consensus_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package monitors

import (
"encoding/json"
"fmt"
"time"
)

Expand Down Expand Up @@ -56,6 +57,69 @@ type HeartbeatAckMessage struct {
Round uint64 `json:"round"`
}

func (h *HeartbeatMessage) UnmarshalJSON(data []byte) error {
decoded, err := decodeHeartbeat(data)
if err != nil {
return err
}
*h = decoded
return nil
}

func (h *HeartbeatAckMessage) UnmarshalJSON(data []byte) error {
decoded, err := decodeHeartbeat(data)
if err != nil {
return err
}
*h = HeartbeatAckMessage(decoded)
return nil
}

type heartbeatRound struct {
value uint64
present bool
}

func (r *heartbeatRound) UnmarshalJSON(data []byte) error {
if r.present {
return fmt.Errorf("duplicate heartbeat round")
}
var value uint64
if err := unmarshalRequiredJSON(data, &value); err != nil || value == 0 {
return fmt.Errorf("invalid heartbeat round")
}
r.value, r.present = value, true
return nil
}

// Testnet heartbeats use executed_round from October 2026. Validate each
// occurrence so duplicate keys cannot hide a malformed or conflicting value.
func decodeHeartbeat(data []byte) (HeartbeatMessage, error) {
var wire struct {
Validator string `json:"validator"`
RandomID uint64 `json:"random_id"`
Round heartbeatRound `json:"round"`
ExecutedRound heartbeatRound `json:"executed_round"`
}
if err := json.Unmarshal(data, &wire); err != nil {
return HeartbeatMessage{}, err
}
var round uint64
for _, field := range []heartbeatRound{wire.Round, wire.ExecutedRound} {
if !field.present {
continue
}
if round != 0 && round != field.value {
return HeartbeatMessage{}, fmt.Errorf("conflicting heartbeat rounds")
}
round = field.value
}
if round == 0 {
return HeartbeatMessage{}, fmt.Errorf("missing heartbeat round")
}
return HeartbeatMessage{Validator: wire.Validator, RandomID: wire.RandomID, Round: round}, nil
}

// parsed status log line
type StatusLogEntry struct {
Timestamp string `json:"-"`
Expand Down
12 changes: 12 additions & 0 deletions internal/monitors/mempool_txs_monitor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ import (
"testing"
)

func TestParseMempoolTxsClassifiesVoteAbciDigest(t *testing.T) {
line := []byte(`["2026-10-02T08:15:10.000000000",{"tx_hash":"constructed","signed_actions":[{"action":{"type":"voteAbciDigest","height":100,"abciDigest":"constructed","signature":"constructed"}},{"action":{"type":"unrecognizedFutureAction"}}]}]`)
got, reason, ok := parseMempoolTxsLineDetailed(line)
if !ok || reason != "" {
t.Fatalf("parse = %v/%s", ok, reason)
}
want := map[string]int{"voteAbciDigest": 1, "other": 1}
if !reflect.DeepEqual(got.actionCounts, want) || !reflect.DeepEqual(got.operationCounts, want) || got.signedActions != 2 || got.operations != 2 || got.parserEvents["unknown_action"] != 1 {
t.Fatalf("voteAbciDigest classification or unknown-action reporting is incorrect: %+v", got)
}
}

func TestParseMempoolTxsLine(t *testing.T) {
line := []byte(`["2026-06-01T17:59:25.990970382",{"tx_hash":"0xabc","signed_actions":[{"action":{"type":"order","orders":[{"b":true,"t":{"limit":{"tif":"Alo"}}},{"b":false,"t":{"limit":{"tif":"Ioc"}}}]}},{"action":{"type":"batchModify","modifies":[{"order":{"b":true,"t":{"limit":{"tif":"Gtc"}}}},{"order":{"b":false,"t":{"limit":{"tif":"FrontendMarket"}}}}]}},{"action":{"type":"cancel","cancels":[{"a":1,"o":2},{"a":2,"o":3}]}},{"action":{"type":"futureAction"}}]}]`)

Expand Down
13 changes: 13 additions & 0 deletions internal/replica/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,19 @@ func TestExtractMetricsRejectsZeroActionBundleAndMissingDiscriminant(t *testing.
}
}

func TestExtractMetricsClassifiesVoteAbciDigest(t *testing.T) {
p, block := parseReplicaFixture(t, `{"height":100,"abci_block":{"time":"2026-10-02T08:15:10Z","round":200,"proposer":"0x1111..1111","signed_action_bundles":[["bundle",{"signed_actions":[{"action":{"type":"voteAbciDigest","height":100,"abciDigest":"constructed","signature":"constructed"}},{"action":{"type":"unrecognizedFutureAction"}}]}]]}}`)
defer p.ReturnBlock(block)
got, err := p.ExtractMetrics(block)
if err != nil {
t.Fatal(err)
}
want := map[string]int{"voteAbciDigest": 1, "other": 1}
if !reflect.DeepEqual(got.ActionCounts, want) || !reflect.DeepEqual(got.OperationCounts, want) || got.TotalActions != 2 || got.TotalOperations != 2 || got.ParserEvents["unknown_action"] != 1 {
t.Fatalf("voteAbciDigest classification or unknown-action reporting is incorrect: %+v", got)
}
}

func TestExtractMetricsClassifiesValidatorL1StatusAndRetainsUnknownActions(t *testing.T) {
body, err := os.ReadFile("testdata/validator_l1_status.constructed.json")
if err != nil {
Expand Down
Loading