Skip to content

node: Update accountant audit logic - #4947

Open
djb15 wants to merge 2 commits into
wormhole-foundation:mainfrom
djb15:node/update-accoutant-audit-logic
Open

node: Update accountant audit logic#4947
djb15 wants to merge 2 commits into
wormhole-foundation:mainfrom
djb15:node/update-accoutant-audit-logic

Conversation

@djb15

@djb15 djb15 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Observation batches are now packed against the exact serialised transaction size rather than always batching up to a maximum batch size of 100, deferring messages that don't fit to a later batch. A message too large to ever fit is submitted in its own batch, with a new metric and associated log.

@djb15

djb15 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

@claude

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @djb15's task in 4m 39s —— View job


Code Review: Update accountant audit logic

  • Gather context and read changed files
  • Review metrics.go changes
  • Review submit_obs.go changes
  • Review submit_obs_test.go changes
  • Post review findings

Overall: This PR looks good. The implementation is well-structured, the size model is correct, and the tests are thorough. No blocking issues found.

Summary

The PR replaces the previous fixed-count batching (up to 100 observations per batch, regardless of serialized size) with size-aware packing that ensures each batch's marshaled submit_observations message stays within the 64KB wasm contract input limit. Messages that don't fit in the current batch are deferred to a later one; messages too large to ever fit are isolated in their own batch and submitted anyway (in case the size estimate is conservative), with appropriate logging and metrics.

What I verified

Size model correctness: I traced the size computation end-to-end:

  • marshaledObservationSize (submit_obs.go:149) marshals each Observation to JSON and returns its byte length. This correctly accounts for vaa.Address's custom MarshalJSON (hex string, fixed 66 bytes including quotes — verified at sdk/vaa/structs.go:132-134) and []byte fields (base64-encoded by encoding/json).
  • submitObservationsMsgOverhead (submit_obs.go:174-190) is computed at init time by marshaling a SubmitObservationsMsg with worst-case field values (max uint32 for guardian set index and signature index, all-0xFF signature bytes) and empty observations, then subtracting the 2-byte "" from the empty observations string. This gives the exact overhead excluding the observations content.
  • submitObservationsMsgSize (submit_obs.go:167-169) adds the overhead + quotes + base64.StdEncoding.EncodedLen(obsArraySize). This matches how SubmitObservationsToContract works: the observations array is marshaled to JSON bytes, assigned to Observations []byte, and the outer message is marshaled — encoding/json base64-encodes the []byte field using base64.StdEncoding.
  • The JSON array size tracking in packObservationBatches correctly models [obs1,obs2,...,obsN] as brackets + obs1 + comma + obs2 + ... + comma + obsN.
  • TestSubmitObservationsMsgSizeMatchesMarshaledMsg (submit_obs_test.go:132-147) validates that the computed size exactly matches actual marshaling, serving as a strong regression guard.

Bin packing algorithm: First-fit packing (submit_obs.go:122-146). For each message, it tries existing batches in order and places the message in the first batch with room (both count ≤ 100 and size ≤ 64KB). If none fit, a new batch is created. Oversized messages (too large even alone) are flagged but still submitted in their own batch. Message ordering within each batch is preserved. The algorithm is O(n·b) where b is the number of batches — bounded and efficient for realistic inputs.

Refactoring: The makeObservation helper (submit_obs.go:281-292) correctly deduplicates the Observation construction that previously lived inline in SubmitObservationsToContract. The extracted code is identical to the original (verified by diff).

Metrics: Two new Prometheus counters (metrics.go:65-74) — batchSizeSplits and oversizedObservations — follow existing naming conventions and are incremented at the appropriate points.

Tests: The test suite (submit_obs_test.go:149-246) covers: single batch, count-limited splitting, size-limited splitting, deferred large messages, and oversized message isolation. The shared invariants check (every message in exactly one batch, count/size limits respected) is a strong correctness guard.

Findings

Two low-severity inline comments posted — no blocking issues.

Comment thread node/pkg/accountant/submit_obs.go Outdated
Comment thread node/pkg/accountant/submit_obs_test.go
@djb15
djb15 marked this pull request as ready for review August 17, 2026 21:41
batchSizeSplits.Inc()
}
for _, msg := range oversized {
// The message is submitted anyway (alone, so it can't take any other observations down with it) in case our size accounting is too conservative.

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.

Should we add a log in the case where an oversized message does not fail? This would help give us a signal to change the limit.

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.

Also do they still get submitted? The loop below seems to only submit messages under batches, not oversized


const (
DefaultSubmitObservationBatchSize = 100 // Observations per batch (limited by wasm contract input size of 64KB)
DefaultSubmitObservationBatchSize = 100 // Maximum observations per batch, also subject to maxSubmitObservationsMsgSize

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.

Could you change this to be uint8 (rather than the default int)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We don't use the non default int type for constants in most other places in the Guardian, why the ask to change that?

const (
DefaultSubmitObservationBatchSize = 100 // Observations per batch (limited by wasm contract input size of 64KB)
DefaultSubmitObservationBatchSize = 100 // Maximum observations per batch, also subject to maxSubmitObservationsMsgSize
maxSubmitObservationsMsgSize = 64 * 1024 // Maximum size of the marshaled submit_observations message (the wasm contract input is limited to 64KB)

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.

Could you change this to be e.g. uint16?

// so a large message that does not fit in the current batch is deferred to a later batch rather than failing the messages
// around it. A message too large to fit even in a batch by itself is returned in oversized as well as being placed in its own
// batch.
func packObservationBatches(msgs []*common.MessagePublication, maxBatchCount int, maxMsgSize int) (batches [][]*common.MessagePublication, oversized []*common.MessagePublication) {

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.

Suggested change
func packObservationBatches(msgs []*common.MessagePublication, maxBatchCount int, maxMsgSize int) (batches [][]*common.MessagePublication, oversized []*common.MessagePublication) {
func packObservationBatches(msgs []*common.MessagePublication, maxBatchCount uint8, maxMsgSize uint16) (batches [][]*common.MessagePublication, oversized []*common.MessagePublication) {

We can use stricter types here to make it impossible to accidentally call these with negative values, or values that would be too big to make sense.

// batch.
func packObservationBatches(msgs []*common.MessagePublication, maxBatchCount int, maxMsgSize int) (batches [][]*common.MessagePublication, oversized []*common.MessagePublication) {
// batchSizes[i] is the size the marshaled observations array for batches[i] would have, including the enclosing brackets.
var batchSizes []int

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.

Suggested change
var batchSizes []int
var batchSizes []uint16

return submitObservationsMsgOverhead + jsonQuotesSize + base64.StdEncoding.EncodedLen(obsArraySize)
}

// submitObservationsMsgOverhead is the number of bytes in a marshaled submit_observations message excluding the base64-encoded

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.

Is this value a constant ultimately? Maybe we could pre-compute the value and use unit tests to lock it in.

func makeObservation(msg *common.MessagePublication) Observation {
return Observation{
TxHash: msg.TxID,
Timestamp: uint32(msg.Timestamp.Unix()), // #nosec G115 -- This conversion is safe until year 2106

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.

Would be nice to use the SDK's TimeFromUnix here instead of fighting the linter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I need to get those CodeQL rules going!!

t.Helper()
emitterAddr, err := vaa.StringToAddress("0x0290fb167208af455bb137780163b7b7a9a10c16")
require.NoError(t, err)
return &common.MessagePublication{

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.

Just for completeness it would be nice to add all of the fields here, i.e. IsReobservation, Unreliable, VerificationState


// marshaledMsgSizeForBatch builds the submit_observations message for a batch the same way SubmitObservationsToContract does,
// with worst-case values for the fields other than the observations, and returns its marshaled size.
func marshaledMsgSizeForBatch(t *testing.T, batch []*common.MessagePublication) int {

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.

It might be better to not use int here as the return type

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Vs a smaller type it's practically impossible for us to overflow an int. And based on the logic in the function it will never be negative so I'm not sure why we should go more defensive here?

}

// marshaledMsgSizeForBatch builds the submit_observations message for a batch the same way SubmitObservationsToContract does,
// with worst-case values for the fields other than the observations, and returns its marshaled size.

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.

Is this a constant value we could save instead of using a function?

func packObservationBatches(msgs []*common.MessagePublication, maxBatchCount int, maxMsgSize int) (batches [][]*common.MessagePublication, oversized []*common.MessagePublication) {
// batchSizes[i] is the size the marshaled observations array for batches[i] would have, including the enclosing brackets.
var batchSizes []int
for _, msg := range msgs {

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.

Let's add a nil check here

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.

2 participants