diff --git a/internal/core/application/service.go b/internal/core/application/service.go index 7f4258a01..ac1df6a3e 100644 --- a/internal/core/application/service.go +++ b/internal/core/application/service.go @@ -8,7 +8,6 @@ import ( "math" "runtime" "slices" - "sort" "strings" "sync" "sync/atomic" @@ -330,15 +329,21 @@ func (s *service) registerEventHandlers() { return } - // Calculate depth for new vtxos: max(parent depths) + 1 - var maxDepth uint32 - for _, v := range spentVtxos { - if v.Depth > maxDepth { - maxDepth = v.Depth - } - } + // Depth for new vtxos is max(parent depths) + 1, shared with the + // accept path via the same domain helper. + // + // The parent marker IDs are dropped on purpose. Projecting the + // Accepted event already assigned marker IDs to these vtxos from + // the ParentMarkerIDs recorded at Accept, so only depth has to be + // carried onto the event payload here. + // + // An empty spent set yields depth 0. It is unreachable, since + // submission rejects a checkpoint tx without an input and the ark tx + // must pass bitcoin sanity, so an accepted tx always spends at least + // one vtxo. Were it reached, 0 is the depth of a tx with no parents. + depth, _ := domain.ChainDepthAndParentMarkers(spentVtxos) for i := range newVtxos { - newVtxos[i].Depth = maxDepth + 1 + newVtxos[i].Depth = depth } // Make sure to mark new vtxos as swept if any of the spent inputs is swept as well or @@ -1131,27 +1136,7 @@ func (s *service) SubmitOffchainTx( } // Compute depth and parent markers from spent VTXOs for the accepted event. - var maxDepth uint32 - parentMarkerSet := make(map[string]struct{}) - for _, v := range spentVtxos { - if v.Depth > maxDepth { - maxDepth = v.Depth - } - for _, markerID := range v.MarkerIDs { - if markerID != "" { - parentMarkerSet[markerID] = struct{}{} - } - } - } - var newDepth uint32 - if len(spentVtxos) > 0 { - newDepth = maxDepth + 1 - } - parentMarkerIDs := make([]string, 0, len(parentMarkerSet)) - for id := range parentMarkerSet { - parentMarkerIDs = append(parentMarkerIDs, id) - } - sort.Strings(parentMarkerIDs) + newDepth, parentMarkerIDs := domain.ChainDepthAndParentMarkers(spentVtxos) change, err := offchainTx.Accept( fullySignedArkTx, signedCheckpointTxsMap, diff --git a/internal/core/domain/marker.go b/internal/core/domain/marker.go index 9484e81b2..a16d75403 100644 --- a/internal/core/domain/marker.go +++ b/internal/core/domain/marker.go @@ -1,6 +1,9 @@ package domain -import "fmt" +import ( + "fmt" + "sort" +) // MarkerInterval is the depth interval at which markers are created. // VTXOs at depth 0, 100, 200, etc. create new markers. @@ -27,6 +30,39 @@ type Marker struct { ParentMarkerIDs []string } +// ChainDepthAndParentMarkers computes the chain depth and inherited parent +// marker IDs for a new tx from the vtxos it spends. Depth is +// max(parent depths) + 1, or 0 when nothing is spent. ParentMarkerIDs is the +// deduplicated union of the spent vtxos' non-empty marker IDs, sorted so the +// result is deterministic regardless of input order. +// +// This is the single source of truth for the computation recorded on the +// OffchainTxAccepted event, so the call sites that need it cannot drift. +func ChainDepthAndParentMarkers(spent []Vtxo) (uint32, []string) { + var maxDepth uint32 + parentSet := make(map[string]struct{}) + for _, v := range spent { + if v.Depth > maxDepth { + maxDepth = v.Depth + } + for _, id := range v.MarkerIDs { + if id != "" { + parentSet[id] = struct{}{} + } + } + } + var depth uint32 + if len(spent) > 0 { + depth = maxDepth + 1 + } + parents := make([]string, 0, len(parentSet)) + for id := range parentSet { + parents = append(parents, id) + } + sort.Strings(parents) + return depth, parents +} + // NewMarker computes marker information for a new offchain transaction. // If the depth is at a marker boundary, it returns a new Marker and the marker IDs // to assign to the child VTXOs (just the new marker ID). diff --git a/internal/core/domain/marker_test.go b/internal/core/domain/marker_test.go index 57873b0ab..81a362765 100644 --- a/internal/core/domain/marker_test.go +++ b/internal/core/domain/marker_test.go @@ -659,3 +659,38 @@ func TestDepth20k_MarkerBoundaryAndInheritance(t *testing.T) { require.False(t, isAtMarkerBoundary(20099)) }) } + +func TestChainDepthAndParentMarkers(t *testing.T) { + t.Run("empty spent input yields depth 0 and no markers", func(t *testing.T) { + depth, markers := ChainDepthAndParentMarkers(nil) + require.Equal(t, uint32(0), depth) + require.Empty(t, markers) + }) + + t.Run("single parent increments depth and inherits its marker", func(t *testing.T) { + depth, markers := ChainDepthAndParentMarkers([]Vtxo{ + {Depth: 5, MarkerIDs: []string{"a"}}, + }) + require.Equal(t, uint32(6), depth) + require.Equal(t, []string{"a"}, markers) + }) + + t.Run("depth is max parent plus one, markers deduped sorted empties dropped", func(t *testing.T) { + depth, markers := ChainDepthAndParentMarkers([]Vtxo{ + {Depth: 3, MarkerIDs: []string{"b"}}, + {Depth: 7, MarkerIDs: []string{"a"}}, + {Depth: 7, MarkerIDs: []string{"a", ""}}, + }) + require.Equal(t, uint32(8), depth) + require.Equal(t, []string{"a", "b"}, markers) + }) + + t.Run("parents without markers still increment depth", func(t *testing.T) { + depth, markers := ChainDepthAndParentMarkers([]Vtxo{ + {Depth: 2, MarkerIDs: nil}, + {Depth: 4, MarkerIDs: nil}, + }) + require.Equal(t, uint32(5), depth) + require.Empty(t, markers) + }) +}