diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d67c7..eaf0e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/actiontypes/actiontypes.go b/internal/actiontypes/actiontypes.go index 6835c73..a844027 100644 --- a/internal/actiontypes/actiontypes.go +++ b/internal/actiontypes/actiontypes.go @@ -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": {}, @@ -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": diff --git a/internal/actiontypes/actiontypes_test.go b/internal/actiontypes/actiontypes_test.go index 4f77ab2..e7f7d96 100644 --- a/internal/actiontypes/actiontypes_test.go +++ b/internal/actiontypes/actiontypes_test.go @@ -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}, diff --git a/internal/monitors/consensus_heartbeat_schema_test.go b/internal/monitors/consensus_heartbeat_schema_test.go new file mode 100644 index 0000000..6e12a06 --- /dev/null +++ b/internal/monitors/consensus_heartbeat_schema_test.go @@ -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") + } + }) + } + } +} diff --git a/internal/monitors/consensus_types.go b/internal/monitors/consensus_types.go index 157f835..0f8bbc2 100644 --- a/internal/monitors/consensus_types.go +++ b/internal/monitors/consensus_types.go @@ -2,6 +2,7 @@ package monitors import ( "encoding/json" + "fmt" "time" ) @@ -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:"-"` diff --git a/internal/monitors/mempool_txs_monitor_test.go b/internal/monitors/mempool_txs_monitor_test.go index c93f4b8..141a63a 100644 --- a/internal/monitors/mempool_txs_monitor_test.go +++ b/internal/monitors/mempool_txs_monitor_test.go @@ -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"}}]}]`) diff --git a/internal/replica/parser_test.go b/internal/replica/parser_test.go index 677140b..9f0a3aa 100644 --- a/internal/replica/parser_test.go +++ b/internal/replica/parser_test.go @@ -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 {