Skip to content

Commit cb21d3a

Browse files
authored
node: address stellar watcher review feedback (#89)
refactor(stellar): return parsed fields from XDR parser Avoid passing a partially-constructed MessagePublication between functions: parseMessageFromXDR is now a watcher method returning only the parsed event fields, and callers build the complete struct with TxID, timestamp and w.chainID in one place. Consistency level is parsed as uint8 with an explicit overflow check, and publish logs use ZapFields for the common message format. Refs wormhole-foundation#4858 review
1 parent 8f3436d commit cb21d3a

2 files changed

Lines changed: 99 additions & 58 deletions

File tree

node/pkg/watchers/stellar/watcher.go

Lines changed: 63 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -369,22 +369,24 @@ func (w *watcher) handleReobservationRequest(ctx context.Context, txHash, rpcURL
369369
continue
370370
}
371371

372-
mp := w.parseEventJSON(e, logger)
373-
if mp == nil {
372+
parsed := w.parseEventJSON(e, logger)
373+
if parsed == nil {
374374
continue
375375
}
376376

377-
mp.TxID = txIDBytes
378-
mp.Timestamp = timestamp
379-
mp.EmitterChain = w.chainID
380-
mp.IsReobservation = true
377+
mp := &common.MessagePublication{
378+
TxID: txIDBytes,
379+
Timestamp: timestamp,
380+
Nonce: parsed.nonce,
381+
Sequence: parsed.sequence,
382+
ConsistencyLevel: parsed.consistencyLevel,
383+
EmitterChain: w.chainID,
384+
EmitterAddress: parsed.emitterAddress,
385+
Payload: parsed.payload,
386+
IsReobservation: true,
387+
}
381388

382-
logger.Info("reobserved stellar message",
383-
zap.Uint64("ledger", ledger),
384-
zap.String("tx", txHash),
385-
zap.Uint64("seq", mp.Sequence),
386-
zap.Uint8("consistency", mp.ConsistencyLevel),
387-
)
389+
logger.Info("reobserved stellar message", mp.ZapFields(zap.Uint64("ledger", ledger))...)
388390

389391
select {
390392
case w.msgC <- mp:
@@ -511,8 +513,8 @@ func (w *watcher) pollOnce(ctx context.Context, logger *zap.Logger) (bool, error
511513
ledger := e.Get("ledger").Uint()
512514
txHash := e.Get("txHash").Str
513515

514-
mp := w.parseEventJSON(e, logger)
515-
if mp == nil {
516+
parsed := w.parseEventJSON(e, logger)
517+
if parsed == nil {
516518
continue
517519
}
518520

@@ -521,8 +523,6 @@ func (w *watcher) pollOnce(ctx context.Context, logger *zap.Logger) (bool, error
521523
logger.Warn("failed to decode txHash", zap.String("txHash", txHash), zap.Error(err))
522524
continue
523525
}
524-
mp.TxID = txIDBytes
525-
mp.EmitterChain = w.chainID
526526

527527
// Use ledgerClosedAt from the event for a deterministic timestamp.
528528
// All guardians observing the same event will use the same timestamp,
@@ -536,16 +536,21 @@ func (w *watcher) pollOnce(ctx context.Context, logger *zap.Logger) (bool, error
536536
)
537537
continue
538538
}
539-
mp.Timestamp = ts
539+
540+
mp := &common.MessagePublication{
541+
TxID: txIDBytes,
542+
Timestamp: ts,
543+
Nonce: parsed.nonce,
544+
Sequence: parsed.sequence,
545+
ConsistencyLevel: parsed.consistencyLevel,
546+
EmitterChain: w.chainID,
547+
EmitterAddress: parsed.emitterAddress,
548+
Payload: parsed.payload,
549+
}
540550

541551
stellarMessagesObserved.WithLabelValues(w.networkName).Inc()
542552

543-
logger.Info("stellar message published",
544-
zap.Uint64("ledger", ledger),
545-
zap.String("tx", txHash),
546-
zap.Uint64("seq", mp.Sequence),
547-
zap.Uint8("consistency", mp.ConsistencyLevel),
548-
)
553+
logger.Info("stellar message published", mp.ZapFields(zap.Uint64("ledger", ledger))...)
549554

550555
select {
551556
case w.msgC <- mp:
@@ -574,9 +579,20 @@ func (w *watcher) pollOnce(ctx context.Context, logger *zap.Logger) (bool, error
574579
return advanced, nil
575580
}
576581

582+
// parsedMessage holds the message fields parsed from a message_published event value.
583+
// The caller combines these with the transaction context (TxID, timestamp, chain ID)
584+
// to build a complete MessagePublication.
585+
type parsedMessage struct {
586+
nonce uint32
587+
sequence uint64
588+
consistencyLevel uint8
589+
emitterAddress vaa.Address
590+
payload []byte
591+
}
592+
577593
// parseEventJSON checks the event topics for a message_published event and parses the event data.
578594
// Returns nil if the event is not a message_published event or cannot be parsed.
579-
func (w *watcher) parseEventJSON(e gjson.Result, logger *zap.Logger) *common.MessagePublication {
595+
func (w *watcher) parseEventJSON(e gjson.Result, logger *zap.Logger) *parsedMessage {
580596
topics := e.Get("topic").Array()
581597
if len(topics) < minEventTopicCount {
582598
return nil
@@ -596,30 +612,29 @@ func (w *watcher) parseEventJSON(e gjson.Result, logger *zap.Logger) *common.Mes
596612
return nil
597613
}
598614

599-
return parseMessageFromXDR(valueBytes, logger)
615+
parsed, err := w.parseMessageFromXDR(valueBytes)
616+
if err != nil {
617+
logger.Warn("failed to parse message_published event, skipping", zap.Error(err))
618+
return nil
619+
}
620+
return parsed
600621
}
601622

602-
// parseMessageFromXDR parses the XDR-encoded Soroban event value into a MessagePublication.
603-
func parseMessageFromXDR(data []byte, logger *zap.Logger) *common.MessagePublication {
623+
// parseMessageFromXDR parses the XDR-encoded Soroban event value into the published message fields.
624+
func (w *watcher) parseMessageFromXDR(data []byte) (*parsedMessage, error) {
604625
var scVal stellarxdr.ScVal
605626

606-
_, err := stellarxdr.Unmarshal(bytes.NewReader(data), &scVal)
607-
if err != nil {
608-
logger.Debug("failed to unmarshal XDR", zap.Error(err))
609-
return nil
627+
if _, err := stellarxdr.Unmarshal(bytes.NewReader(data), &scVal); err != nil {
628+
return nil, fmt.Errorf("failed to unmarshal XDR: %w", err)
610629
}
611630

612631
eventMap, ok := scVal.GetMap()
613632
if !ok {
614-
logger.Debug("event value is not a map")
615-
return nil
633+
return nil, fmt.Errorf("event value is not a map")
616634
}
617635

618-
var nonce uint32
619-
var sequence uint64
636+
msg := &parsedMessage{}
620637
var emitterAddress []byte
621-
var payload []byte
622-
var consistencyLevel uint32
623638

624639
for _, entry := range *eventMap {
625640
keySymbol, ok := entry.Key.GetSym()
@@ -630,49 +645,39 @@ func parseMessageFromXDR(data []byte, logger *zap.Logger) *common.MessagePublica
630645
switch string(keySymbol) {
631646
case "nonce":
632647
if val, ok := entry.Val.GetU32(); ok {
633-
nonce = uint32(val)
648+
msg.nonce = uint32(val)
634649
}
635650
case "sequence":
636651
if val, ok := entry.Val.GetU64(); ok {
637-
sequence = uint64(val)
652+
msg.sequence = uint64(val)
638653
}
639654
case "emitter_address":
640655
if val, ok := entry.Val.GetBytes(); ok {
641656
emitterAddress = val
642657
}
643658
case "payload":
644659
if val, ok := entry.Val.GetBytes(); ok {
645-
payload = val
660+
msg.payload = val
646661
}
647662
case "consistency_level":
648663
if val, ok := entry.Val.GetU32(); ok {
649-
consistencyLevel = uint32(val)
664+
if val > math.MaxUint8 {
665+
return nil, fmt.Errorf("consistency level %d overflows uint8", val)
666+
}
667+
msg.consistencyLevel = uint8(val)
650668
}
651669
}
652670
}
653671

654672
if len(emitterAddress) == 0 {
655-
logger.Warn("message_published event has empty emitter address, skipping")
656-
return nil
673+
return nil, fmt.Errorf("message_published event has empty emitter address")
657674
}
658675

659-
var emitter vaa.Address
660676
if len(emitterAddress) >= stellarAddressLen {
661-
copy(emitter[:], emitterAddress[:stellarAddressLen])
677+
copy(msg.emitterAddress[:], emitterAddress[:stellarAddressLen])
662678
} else {
663-
copy(emitter[:], emitterAddress)
679+
copy(msg.emitterAddress[:], emitterAddress)
664680
}
665681

666-
return &common.MessagePublication{
667-
TxID: nil,
668-
Timestamp: time.Time{},
669-
Nonce: nonce,
670-
Sequence: sequence,
671-
ConsistencyLevel: uint8(consistencyLevel),
672-
EmitterChain: vaa.ChainIDUnset,
673-
EmitterAddress: emitter,
674-
Payload: payload,
675-
IsReobservation: false,
676-
Unreliable: false,
677-
}
682+
return msg, nil
678683
}

node/pkg/watchers/stellar/watcher_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,42 @@ func TestPollOnce_SkipsEmptyEmitter(t *testing.T) {
428428
}
429429
}
430430

431+
func TestPollOnce_SkipsConsistencyLevelOverflow(t *testing.T) {
432+
// An event whose consistency_level does not fit in uint8 must be dropped.
433+
emitter := make([]byte, 32)
434+
emitter[0] = 0x01
435+
436+
mock := newMockRPC(200)
437+
mock.events = []mockEvent{
438+
{
439+
id: "0000000100-0000000001",
440+
ledger: 100,
441+
ledgerClosedAt: "2024-01-01T00:00:00Z",
442+
contractID: testContract,
443+
txHash: fmt.Sprintf("%064x", 1),
444+
topic0B64: makeTopicB64(t, "wormhole"),
445+
topic1B64: makeTopicB64(t, "message_published"),
446+
valueB64: makeEventValueB64(t, 1, 1, emitter, []byte("pay"), 256),
447+
},
448+
}
449+
450+
srv := httptest.NewServer(mock)
451+
defer srv.Close()
452+
453+
msgC := make(chan *common.MessagePublication, 10)
454+
w := newTestWatcher(srv.URL, 128, msgC)
455+
456+
_, err := w.pollOnce(context.Background(), zap.NewNop())
457+
require.NoError(t, err)
458+
459+
select {
460+
case mp := <-msgC:
461+
t.Fatalf("expected no message but got seq=%d", mp.Sequence)
462+
case <-time.After(50 * time.Millisecond):
463+
// correct: nothing published
464+
}
465+
}
466+
431467
func TestPollOnce_NoEventsAdvancesNextLedger(t *testing.T) {
432468
// When getEvents returns no events, nextLedger must advance to latestLedger
433469
// from the getEvents response. No separate getLatestLedger call should be made.

0 commit comments

Comments
 (0)