Skip to content

Scale the DAG#908

Open
bitcoin-coder-bob wants to merge 51 commits intomasterfrom
bob/dag-1
Open

Scale the DAG#908
bitcoin-coder-bob wants to merge 51 commits intomasterfrom
bob/dag-1

Conversation

@bitcoin-coder-bob
Copy link
Copy Markdown
Collaborator

@bitcoin-coder-bob bitcoin-coder-bob commented Feb 10, 2026

Closes #833

Summary by CodeRabbit

  • New Features
    • Added token-based pagination for VTXO chain queries with pageToken and nextPageToken parameters.
    • Added depth field to VTXOs to track their position in transaction chains.
    • Implemented marker-based system for enhanced VTXO tracking and management.

NOTE:

  • ❌ Existing VTXOs won't benefit from bulk sweep optimization
  • ✅ New VTXOs (created after migration) will get proper marker inheritance from application code
  • e2e test require changes to the sdk to support new proto field added (depth on the vtxo) so they are not expanded on in this PR

Heres a breakdown on the efficiency gains and db query savings:

  The branch introduces a marker system — DAG checkpoints placed every 100 depths in the VTXO chain. These markers enable bulk operations instead of per-VTXO operations.                                  
                                                                                                                                                                                                           
  New structures:
  - marker table: checkpoints every 100 depths with parent_markers for traversal                                                                                                                           
  - swept_marker table: replaces the per-VTXO swept boolean column                                                                                                                                         
  - markers JSONB column on vtxo: links each VTXO to its covering markers                                                                                                                                  
  - depth column on vtxo: integer chain depth

Tests: the tests in the added `internal/interface/grpc/handlers/parser_test.go` may be overkill, I can remove the on request.
                                                                                                                                                                                                           
  ---                                                                                                                                                                                                      
  GetVtxoChain — Major Savings                                                                                                                                                                             

  Before (master): BFS loop fetching VTXOs one at a time via individual SELECT queries per iteration.

  After (bob/dag-1): A prefetchVtxosByMarkers phase bulk-loads all relevant VTXOs into an in-memory cache before the loop starts. The loop then hits cache instead of DB.
  ┌────────────────────────────────┬──────────────────────┬─────────────────────────────────────────────────┬──────────────────────────┐
  │             Metric             │   Before (master)    │                After (bob/dag-1)                │         Savings          │
  ├────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────────┼──────────────────────────┤
  │ VTXO SELECT queries (depth N)  │ N individual queries │ 1 bulk query (Postgres) or ceil(N/100) (SQLite) │ ~100x fewer VTXO lookups │
  ├────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────────┼──────────────────────────┤
  │ Marker traversal queries       │ 0                    │ ceil(N/100)                                     │ New cost, but tiny       │
  ├────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────────┼──────────────────────────┤
  │ Total DB round-trips (N=100)   │ ~201                 │ ~103                                            │ ~49% reduction           │
  ├────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────────┼──────────────────────────┤
  │ Total DB round-trips (N=500)   │ ~1001                │ ~508                                            │ ~49% reduction           │
  ├────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────────┼──────────────────────────┤
  │ Total DB round-trips (N=1000)  │ ~2001                │ ~1012                                           │ ~49% reduction           │
  ├────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────────┼──────────────────────────┤
  │ Total DB round-trips (N=10000) │ ~20001               │ ~10102                                          │ ~50% reduction           │
  └────────────────────────────────┴──────────────────────┴─────────────────────────────────────────────────┴──────────────────────────┘
  The remaining ~50% comes from GetOffchainTx calls (1 per preconfirmed VTXO) which are not cached by this change. The VTXO-fetching portion specifically goes from O(N) to O(N/100) — a 100x reduction in
  that category.

  ---
  Sweep Operations — Massive Savings

  Before (master): Sweeping required 1 UPDATE vtxo SET swept=true per VTXO.

  After (bob/dag-1): Sweeping inserts 1 row into swept_marker — all VTXOs sharing that marker are swept instantly.
  ┌──────────────────────────────────────┬────────────────────┬────────────────────────────────┬────────────────┐
  │              Operation               │       Before       │             After              │  Improvement   │
  ├──────────────────────────────────────┼────────────────────┼────────────────────────────────┼────────────────┤
  │ Sweep 100 VTXOs (1 marker)           │ 100 UPDATEs        │ 1 INSERT                       │ 100x           │
  ├──────────────────────────────────────┼────────────────────┼────────────────────────────────┼────────────────┤
  │ Sweep 1,000 VTXOs (10 markers)       │ 1,000 UPDATEs      │ 10 INSERTs                     │ 100x           │
  ├──────────────────────────────────────┼────────────────────┼────────────────────────────────┼────────────────┤
  │ Sweep 10,000 VTXOs (100 markers)     │ 10,000 UPDATEs     │ 100 INSERTs                    │ 100x           │
  ├──────────────────────────────────────┼────────────────────┼────────────────────────────────┼────────────────┤
  │ Sweep full tree via BulkSweepMarkers │ N/A (must iterate) │ 1 recursive CTE + batch insert │ New capability │
  └──────────────────────────────────────┴────────────────────┴────────────────────────────────┴────────────────┘
  The improvement ratio is consistently ~MarkerInterval (100x) for write operations.

  ---
  ListVtxos / GetVtxos — No Query Count Change, Correctness Improvement

  The query count is unchanged (1 query before, 1 query now). What changed:

  - Before: swept was a static boolean column — fast to read but could go stale
  - After: swept is dynamically computed via an EXISTS subquery against the indexed swept_marker table — always correct, marginally more expensive per-row

  ---
  Summary Table
  ┌─────────────────────────────┬─────────────────────────────────────┬──────────────────────────┐
  │          Category           │           Before → After            │          Factor          │
  ├─────────────────────────────┼─────────────────────────────────────┼──────────────────────────┤
  │ VTXO chain lookups          │ N queries → 1 bulk query (Postgres) │ ~100x fewer              │
  ├─────────────────────────────┼─────────────────────────────────────┼──────────────────────────┤
  │ Total GetVtxoChain DB calls │ ~2N → ~N + N/100                    │ ~50% reduction           │
  ├─────────────────────────────┼─────────────────────────────────────┼──────────────────────────┤
  │ Sweep writes                │ N UPDATEs → N/100 INSERTs           │ ~100x fewer              │
  ├─────────────────────────────┼─────────────────────────────────────┼──────────────────────────┤
  │ ListVtxos query count       │ 1 → 1                               │ No change                │
  ├─────────────────────────────┼─────────────────────────────────────┼──────────────────────────┤
  │ Swept status accuracy       │ Static (can be stale)               │ Dynamic (always correct) │
  └─────────────────────────────┴─────────────────────────────────────┴──────────────────────────┘
  The bottleneck remaining in GetVtxoChain is the per-hop GetOffchainTx call, which still runs O(N) times. If that were also bulk-fetched or cached, total round-trips would drop to ~O(N/100), yielding
  closer to a 100x overall reduction.


Yes this PR has a lot of lines of code. 62% are in test files. 0.8% come from the api-sepc folder, and the other 37.2% are "actual" code changes (4,254 LoC)

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.40% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Scale the DAG' directly relates to the PR's primary objective of implementing a marker-based system to improve DAG (VTXO) scalability. This is the main change throughout the PR.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bob/dag-1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@bitcoin-coder-bob
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 10, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@internal/core/application/indexer.go`:
- Around line 408-413: The loop that walks parent markers (using
marker.ParentMarkerIDs and i.repoManager.Markers().GetMarker) can infinite-loop
on cyclic parent chains; add a visited set (map[string]struct{}) keyed by marker
ID and check it before appending IDs or calling GetMarker to break cycles, and
also deduplicate ParentMarkerIDs when appending to markerIDs so you don't re-add
the same ID; update the loop to mark the current marker ID as visited, skip any
parent IDs already visited, and stop traversal if the next parent is seen.

In `@internal/infrastructure/db/badger/marker_repo.go`:
- Around line 501-514: GetVtxoChainByMarkers currently does a full table scan
via r.vtxoStore.Find(&dtos, &badgerhold.Query{}) and filters in-memory; change
it to query by marker IDs to avoid loading all vtxos: iterate markerIDs (or
batch them) and call r.vtxoStore.Find with badgerhold.Where("MarkerID").Eq(id)
for each id (or badgerhold.Where("MarkerID").In(batch) if supported), collect
matched vtxoDTOs, convert dto.Vtxo into the vtxos slice and return; ensure you
still respect markerIDSet and handle errors per-query.

In
`@internal/infrastructure/db/postgres/migration/20260210100000_add_vtxo_depth.up.sql`:
- Around line 8-13: The Postgres view vtxo_vw currently returns NULL for the
commitments column when no rows exist because it uses
string_agg(vc.commitment_txid, ','); change the SELECT to wrap string_agg with
COALESCE (e.g., COALESCE(string_agg(...), '')) so commitments always yields an
empty string like the SQLite view; update the SELECT that references vtxo,
vtxo_commitment_txid and the commitments alias to use
COALESCE(string_agg(vc.commitment_txid, ','), '').

In `@internal/infrastructure/db/service.go`:
- Around line 752-821: sweepVtxosWithMarkers currently marks a marker swept
before guaranteeing the marker's VTXOs were successfully removed, risking
inconsistent state; change the ordering so you attempt to sweep the marker's
VTXOs first (use markerStore.SweepVtxosByMarker and fall back to
vtxoStore.SweepVtxos for markerVtxos[markerID]) and only if that sweep returns
success call markerStore.SweepMarker(markerID, sweptAt); on any sweep error keep
the marker unmarked, log the failure, and accumulate the fallback count as now
done — update the loop in sweepVtxosWithMarkers to perform
SweepVtxosByMarker/SweepVtxos before calling SweepMarker and adjust error
handling accordingly.
🧹 Nitpick comments (12)
internal/test/e2e/utils_test.go (1)

742-744: Acknowledge the TODO placeholder.

The TODO is clear about the dependency on the SDK proto package exposing Depth. Consider tracking this with a GitHub issue so it doesn't get lost.

Would you like me to open a GitHub issue to track re-enabling setupRawIndexerClient and getVtxoDepthByOutpoint once the SDK proto exposes Depth?

internal/core/domain/marker_test.go (2)

9-35: Good coverage of boundary cases.

The table-driven test covers a solid range including edges (0, 99, 100, 101). Consider using t.Run with a subtest name for each case to get more granular test output on failure.

♻️ Optional: use subtests for better diagnostics
 	for _, tt := range tests {
+		t.Run(fmt.Sprintf("depth_%d", tt.depth), func(t *testing.T) {
 		result := IsAtMarkerBoundary(tt.depth)
 		require.Equal(t, tt.expected, result,
 			"IsAtMarkerBoundary(%d) should be %v", tt.depth, tt.expected)
+		})
 	}

(You'd need to add "fmt" to imports.)


42-55: These tests only verify struct literal construction, not behavior.

TestMarkerStruct and TestSweptMarkerStruct test that Go struct fields hold the values you assign — they don't test any domain logic. They're fine as documentation of the data model but provide no regression protection. Consider adding tests for actual marker operations (creation, parent resolution, etc.) as the marker logic matures.

internal/infrastructure/db/postgres/migration/20260211020000_add_markers.up.sql (1)

2-6: Consider adding NOT NULL DEFAULT '[]'::jsonb to parent_markers.

The column currently allows NULL, which means application code must handle both NULL and empty array. Using a NOT NULL default simplifies queries and Go code that deserializes this field.

♻️ Suggested change
 CREATE TABLE IF NOT EXISTS marker (
     id TEXT PRIMARY KEY,
     depth INTEGER NOT NULL,
-    parent_markers JSONB  -- JSON array of parent marker IDs
+    parent_markers JSONB NOT NULL DEFAULT '[]'::jsonb  -- JSON array of parent marker IDs
 );
internal/core/domain/marker_repo.go (2)

5-44: Large interface — consider whether it could be split, and watch for unbounded queries.

The interface has 16 methods mixing marker lifecycle, sweep operations, and VTXO queries. This is functional but may violate the Interface Segregation Principle as it grows.

More concretely, methods like GetVtxosByMarker, GetVtxosByDepthRange, and GetVtxoChainByMarkers (lines 30, 37, 41) return unbounded []Vtxo slices. If marker/depth ranges can span many VTXOs, callers may hit memory pressure. Consider whether pagination or a limit parameter is warranted for these, especially GetVtxosByDepthRange which could span a very wide range.


6-7: Clarify upsert semantics in the doc comment.

The comment says "creates or updates a marker," but the method signature uses error as the only signal. It may be useful to document whether an update replaces ParentMarkerIDs entirely or merges, and whether updating a marker that has already been swept is allowed.

internal/infrastructure/db/sqlite/migration/20260211000000_add_markers.up.sql (1)

2-6: Consider adding an index on parent_markers for BFS descendant lookups.

The marker table stores parent_markers as a JSON text column. The Badger implementation does BFS by querying markers whose ParentMarkerIDs contains a given ID. If a similar query pattern is used in SQLite (e.g., using json_each to find children), performance could degrade without an index strategy. This is fine for now if the query load is low, but worth keeping in mind.

internal/infrastructure/db/badger/marker_repo.go (2)

42-106: Constructor uses interface{} variadic config — consider a typed options struct.

The NewMarkerRepository(config ...interface{}) pattern with positional interface{} arguments is fragile and hard to use correctly. While this matches the existing codebase pattern (e.g., NewVtxoRepository), a typed config struct would be safer. This is fine for now if consistency with the existing pattern is preferred.


116-136: Retry loop doesn't respect context cancellation.

The retry loops (here and in similar patterns at lines 243, 390, 435) sleep unconditionally without checking ctx.Done(). If the context is cancelled, the function will still retry up to maxRetries times with 100ms sleeps. This is a minor concern given the small retry count.

internal/infrastructure/db/sqlite/marker_repo.go (1)

141-166: Make descendant sweeping atomic to avoid partial state.

If an insert fails mid-loop, some markers are swept and others aren’t. Wrapping the inserts in a single transaction avoids partial sweeps and reduces round-trips.

♻️ Suggested transaction wrapper
 func (m *markerRepository) SweepMarkerWithDescendants(
 	ctx context.Context,
 	markerID string,
 	sweptAt int64,
 ) (int64, error) {
 	// Get all descendant marker IDs (including the root marker) that are not already swept
 	descendantIDs, err := m.querier.GetDescendantMarkerIds(ctx, markerID)
 	if err != nil {
 		return 0, fmt.Errorf("failed to get descendant markers: %w", err)
 	}
 
-	// Insert each descendant into swept_marker
-	var count int64
-	for _, id := range descendantIDs {
-		err := m.querier.InsertSweptMarker(ctx, queries.InsertSweptMarkerParams{
-			MarkerID: id,
-			SweptAt:  sweptAt,
-		})
-		if err != nil {
-			return count, fmt.Errorf("failed to sweep marker %s: %w", id, err)
-		}
-		count++
-	}
-
-	return count, nil
+	tx, err := m.db.BeginTx(ctx, nil)
+	if err != nil {
+		return 0, err
+	}
+	q := queries.New(tx)
+	defer tx.Rollback()
+
+	var count int64
+	for _, id := range descendantIDs {
+		if err := q.InsertSweptMarker(ctx, queries.InsertSweptMarkerParams{
+			MarkerID: id,
+			SweptAt:  sweptAt,
+		}); err != nil {
+			return count, fmt.Errorf("failed to sweep marker %s: %w", id, err)
+		}
+		count++
+	}
+	if err := tx.Commit(); err != nil {
+		return 0, err
+	}
+	return count, nil
 }
internal/infrastructure/db/postgres/marker_repo.go (1)

144-169: Make descendant sweeping atomic to avoid partial state.

Same concern as the sqlite implementation—if the loop fails mid-way, markers can be partially swept.

♻️ Suggested transaction wrapper
 func (m *markerRepository) SweepMarkerWithDescendants(
 	ctx context.Context,
 	markerID string,
 	sweptAt int64,
 ) (int64, error) {
 	// Get all descendant marker IDs (including the root marker) that are not already swept
 	descendantIDs, err := m.querier.GetDescendantMarkerIds(ctx, markerID)
 	if err != nil {
 		return 0, fmt.Errorf("failed to get descendant markers: %w", err)
 	}
 
-	// Insert each descendant into swept_marker
-	var count int64
-	for _, id := range descendantIDs {
-		err := m.querier.InsertSweptMarker(ctx, queries.InsertSweptMarkerParams{
-			MarkerID: id,
-			SweptAt:  sweptAt,
-		})
-		if err != nil {
-			return count, fmt.Errorf("failed to sweep marker %s: %w", id, err)
-		}
-		count++
-	}
-
-	return count, nil
+	tx, err := m.db.BeginTx(ctx, nil)
+	if err != nil {
+		return 0, err
+	}
+	q := queries.New(tx)
+	defer tx.Rollback()
+
+	var count int64
+	for _, id := range descendantIDs {
+		if err := q.InsertSweptMarker(ctx, queries.InsertSweptMarkerParams{
+			MarkerID: id,
+			SweptAt:  sweptAt,
+		}); err != nil {
+			return count, fmt.Errorf("failed to sweep marker %s: %w", id, err)
+		}
+		count++
+	}
+	if err := tx.Commit(); err != nil {
+		return 0, err
+	}
+	return count, nil
 }
internal/infrastructure/db/sqlite/sqlc/query.sql (1)

467-480: Replace LIKE-based JSON matching with json_each() for robustness.

The recursive CTE uses m.parent_markers LIKE '%"' || dm.id || '"%' to check if a marker ID exists in a JSON array. While this works in practice, it's fragile: marker IDs containing SQL LIKE wildcards (%, _) would cause incorrect matches since the code doesn't escape them. The Postgres version correctly uses @> jsonb_build_array(dm.id) (line 473); SQLite should use the equivalent json_each() for consistency and correctness:

Suggested replacement
-    SELECT m.id FROM marker m
-    INNER JOIN descendant_markers dm ON (
-        m.parent_markers LIKE '%"' || dm.id || '"%'
-    )
+    SELECT m.id FROM marker m
+    INNER JOIN descendant_markers dm ON EXISTS (
+        SELECT 1 FROM json_each(m.parent_markers) je WHERE je.value = dm.id
+    )

Comment on lines +8 to +13
CREATE VIEW vtxo_vw AS
SELECT v.*, string_agg(vc.commitment_txid, ',') AS commitments
FROM vtxo v
LEFT JOIN vtxo_commitment_txid vc
ON v.txid = vc.vtxo_txid AND v.vout = vc.vtxo_vout
GROUP BY v.txid, v.vout;
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.

⚠️ Potential issue | 🟡 Minor

Inconsistent NULL handling between Postgres and SQLite views.

The SQLite vtxo_vw uses COALESCE(group_concat(...), '') (returns empty string when no commitments), but this Postgres view uses bare string_agg(...) (returns NULL when no commitments). This can cause behavioral differences across backends if downstream code doesn't uniformly handle both NULL and empty string.

Consider wrapping with COALESCE for consistency:

Suggested fix
 CREATE VIEW vtxo_vw AS
-SELECT v.*, string_agg(vc.commitment_txid, ',') AS commitments
+SELECT v.*, COALESCE(string_agg(vc.commitment_txid, ','), '') AS commitments
 FROM vtxo v
 LEFT JOIN vtxo_commitment_txid vc
 ON v.txid = vc.vtxo_txid AND v.vout = vc.vtxo_vout
 GROUP BY v.txid, v.vout;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
CREATE VIEW vtxo_vw AS
SELECT v.*, string_agg(vc.commitment_txid, ',') AS commitments
FROM vtxo v
LEFT JOIN vtxo_commitment_txid vc
ON v.txid = vc.vtxo_txid AND v.vout = vc.vtxo_vout
GROUP BY v.txid, v.vout;
CREATE VIEW vtxo_vw AS
SELECT v.*, COALESCE(string_agg(vc.commitment_txid, ','), '') AS commitments
FROM vtxo v
LEFT JOIN vtxo_commitment_txid vc
ON v.txid = vc.vtxo_txid AND v.vout = vc.vtxo_vout
GROUP BY v.txid, v.vout;
🤖 Prompt for AI Agents
In
`@internal/infrastructure/db/postgres/migration/20260210100000_add_vtxo_depth.up.sql`
around lines 8 - 13, The Postgres view vtxo_vw currently returns NULL for the
commitments column when no rows exist because it uses
string_agg(vc.commitment_txid, ','); change the SELECT to wrap string_agg with
COALESCE (e.g., COALESCE(string_agg(...), '')) so commitments always yields an
empty string like the SQLite view; update the SELECT that references vtxo,
vtxo_commitment_txid and the commitments alias to use
COALESCE(string_agg(vc.commitment_txid, ','), '').

@bitcoin-coder-bob
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 12, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/infrastructure/db/badger/vtxo_repo.go (2)

101-119: ⚠️ Potential issue | 🟠 Major

GetVtxos returns nil, nil when any single outpoint is missing, discarding all previously collected vtxos.

getVtxo returns (nil, nil) for ErrNotFound (Line 476-477), so the strings.Contains(err.Error(), "not found") check on Line 108 is dead code for that path. Instead, execution falls through to Line 113 where vtxo == nil triggers return nil, nil, silently dropping all vtxos already appended. This should continue instead, matching the likely intent of skipping missing outpoints.

Proposed fix
 	for _, outpoint := range outpoints {
 		vtxo, err := r.getVtxo(ctx, outpoint)
 		if err != nil {
-			if strings.Contains(err.Error(), "not found") {
-				continue
-			}
 			return nil, err
 		}
 		if vtxo == nil {
-			return nil, nil
+			continue
 		}
 		vtxos = append(vtxos, *vtxo)
 	}

277-322: ⚠️ Potential issue | 🟡 Minor

Inconsistent filter: query uses Ge (>=) but post-filter uses > (strictly greater).

Lines 286-288 and 295-297 fetch vtxos with Amount >= amountFilter, but Lines 306 and 311 then exclude vtxos where Amount == amountFilter by checking vtxo.Amount > amountFilter. This means vtxos with amount exactly equal to the filter are fetched from the DB but silently dropped. Either the query should use Gt or the post-filter should use >=.

🤖 Fix all issues with AI agents
In `@internal/core/application/service_test.go`:
- Around line 149-154: The test case "no spent vtxos" expects depth 1 but the
service's logic leaves newDepth at 0 when spentOutpoints is empty; update the
test in service_test.go for the case named "no spent vtxos (empty)" to set
expectedDepth to 0 so it matches the actual behavior of the service (referencing
newDepth, maxDepth and spentOutpoints in the service implementation).

In `@internal/infrastructure/db/badger/marker_repo.go`:
- Around line 258-276: SweepMarker currently does a full table scan by calling
r.vtxoStore.Find(&allDtos, &badgerhold.Query{}) for every marker (and
BulkSweepMarkers calls SweepMarker in a loop), causing N full scans; change
SweepMarker to query only VTXOs that contain the marker by using
r.vtxoStore.Find(&filteredDtos,
badgerhold.Where("MarkerIDs").Contains(markerID)) (same pattern as
getDescendantMarkerIds), iterate filteredDtos (type vtxoDTO) and call
r.vtxoStore.Update(outpoint.String(), dto) to set Swept=true and UpdatedAt; this
ensures each marker triggers a targeted query instead of scanning all VTXOs and
avoids the N×full-scan behavior in BulkSweepMarkers.
- Around line 438-462: GetVtxosByMarker currently loads all VTXOs then filters
in memory; change the find to use an indexed query so Badger filters by
MarkerIDs: replace the badgerhold.Query{} call in
markerRepository.GetVtxosByMarker with
badgerhold.Where("MarkerIDs").Contains(markerID) (keeping the same
r.vtxoStore.Find(&dtos, query) pattern), then retain the existing loop to
compute vtxo.Swept via r.isAnyMarkerSwept(dto.MarkerIDs) and append matching
DTOs to the result slice.

In `@internal/infrastructure/db/postgres/marker_repo.go`:
- Around line 159-184: SweepMarkerWithDescendants does inserts in a loop without
a transaction, causing partial commits on failure; wrap the entire operation in
a DB transaction so either all descendant InsertSweptMarker calls succeed or
none do. Start a transaction (e.g., via m.db.BeginTx or your repo's transaction
helper), run GetDescendantMarkerIds and then perform each
queries.InsertSweptMarker using the transactional querier/context (or passing tx
into the querier methods), rollback on any error and commit at the end, and
return the count only after a successful commit; reference functions:
SweepMarkerWithDescendants, GetDescendantMarkerIds, and InsertSweptMarker.

In
`@internal/infrastructure/db/postgres/migration/20260210100000_add_depth_and_markers.down.sql`:
- Around line 1-14: Move the view drops to before any column/table drops: drop
views intent_with_inputs_vw and vtxo_vw first, then drop index idx_vtxo_markers,
drop columns markers and depth from table vtxo, and finally drop marker and
swept_marker tables; update the script so vtxo_vw and intent_with_inputs_vw are
removed prior to altering vtxo to avoid PostgreSQL dependency errors.

In
`@internal/infrastructure/db/postgres/migration/20260210100000_add_depth_and_markers.up.sql`:
- Around line 1-5: Change the new column definition for markers on table vtxo to
be non-nullable with a default empty JSON array by altering the ADD COLUMN
statement for markers to "ADD COLUMN IF NOT EXISTS markers JSONB NOT NULL
DEFAULT '[]'::jsonb" (keep the existing GIN index creation), and ensure any
separate backfill step that populates markers for existing rows is consistent
with this default (remove/adjust redundant backfill or ensure it uses
'[]'::jsonb for rows without markers).

In `@internal/infrastructure/db/postgres/sqlc/queries/query.sql.go`:
- Around line 1770-1776: The SQL in the constant selectVtxosByArkTxid used by
the method SelectVtxosByArkTxid filters on the wrong column (txid); update the
query string to use WHERE ark_txid = $1 (or WHERE ark_txid = `@ark_txid` in the
.sql source) so the function returns VTXOs created by the given ark transaction;
update the selectVtxosByArkTxid SQL in both the Postgres and SQLite query.sql
sources so the generated query and the Queries.SelectVtxosByArkTxid
implementation both filter on ark_txid instead of txid.
- Around line 118-131: The recursive CTE used by GetDescendantMarkerIds scans
marker.parent_markers with the jsonb containment operator (@>), causing repeated
sequential scans; add a migration that creates a GIN index on the parent_markers
column (marker.parent_markers) so the recurrence m.parent_markers @>
jsonb_build_array(dm.id) can use the index; implement the migration file that
executes CREATE INDEX IF NOT EXISTS idx_marker_parent_markers ON marker USING
GIN (parent_markers) and ensure it is applied in your migrations pipeline.

In `@internal/infrastructure/db/service.go`:
- Around line 210-219: The code appends badgerVtxoRepo.GetStore() onto
config.DataStoreConfig which can mutate the original slice's backing array;
instead create a new slice copy of config.DataStoreConfig before appending to
avoid side effects. Locate the block that builds markerConfig (using
config.DataStoreConfig, badgerVtxoRepo.GetStore() and markerStoreFactory) and
replace the direct append with creating a new slice sized to hold the elements,
copying config.DataStoreConfig into it, then append badgerVtxoRepo.GetStore() to
that new slice and pass the new slice to markerStoreFactory.
- Around line 492-496: CreateRootMarkersForVtxos failures are currently only
warned and can leave persisted VTXOs referencing missing markers; update the
block where s.markerStore.CreateRootMarkersForVtxos(ctx, newVtxos) is called to
either (a) retry the CreateRootMarkersForVtxos call with the same retry/backoff
strategy used for persisting VTXOs (mirror the loop around the VTXO
persistence), or (b) if retrying fails, return the error to fail-fast so the
caller can roll back or handle incomplete state; locate the call to
s.markerStore.CreateRootMarkersForVtxos and implement a retry loop (or propagate
the error) and ensure logs include context about the affected VTXO set when
giving up.
- Around line 538-577: The GetVtxos DB failure path leaves newDepth at 0 and
parentMarkerIDs nil which makes IsAtMarkerBoundary(0) treat chained VTXOs as
root markers; update the error path in the block that calls s.vtxoStore.GetVtxos
(inside the loop over offchainTx.CheckpointTxs and subsequent processing) to
avoid creating misleading root markers by either returning the error upward
(propagate the GetVtxos error) or setting newDepth to a sentinel (e.g., a
special unknown value) and ensuring downstream logic
(IsAtMarkerBoundary/newDepth handling) treats that sentinel as “unknown” (no
root marker creation) instead of depth 0, and document the chosen approach in
the same function where newDepth and parentMarkerIDs are computed.

In
`@internal/infrastructure/db/sqlite/migration/20260210000000_add_depth_and_markers.down.sql`:
- Around line 28-33: The down migration tries to copy the removed swept column
from vtxo into vtxo_temp causing "no such column: swept"; fix by reconstructing
swept or restoring the column before the INSERT: either add a temporary swept
column to vtxo (or vtxo_temp) prior to the INSERT (so INSERT INTO vtxo_temp
SELECT ... swept ... FROM vtxo succeeds), or change the INSERT SELECT to compute
swept from swept_marker (join vtxo with swept_marker and derive swept) so the
SELECT no longer references the missing swept column; look for symbols
vtxo_temp, vtxo, swept, swept_marker and vtxo_new when applying the change.

In `@internal/infrastructure/db/sqlite/sqlc/query.sql`:
- Around line 471-484: GetDescendantMarkerIds currently matches parent_markers
via m.parent_markers LIKE '%"' || dm.id || '"%' which is brittle (false
positives for '%'/'_' and overlapping prefixes) and forces full scans; replace
the LIKE with a JSON-aware check using SQLite's json_each (e.g., JOIN/EXISTS
over json_each(m.parent_markers) j WHERE j.value = dm.id) or, better, migrate
parent_markers to a normalized join table (parent_marker mapping) and update
descendant_markers to join that table; also add an integration test for
GetDescendantMarkerIds using marker IDs containing characters like '%'/'_' and
overlapping prefixes to ensure correctness, and document the current limitation
of the LIKE approach in the schema/query comments.
🧹 Nitpick comments (20)
internal/core/application/sweeper_test.go (2)

734-735: Non-obvious Txid values for i >= 26.

string(rune('A'+i)) for i in 0..49 produces ASCII letters A–Z for i < 26, but non-letter characters ([, \, ], …) for i >= 26. This doesn't break the test (uniqueness is preserved), but fmt.Sprintf("child-%d", i) would be clearer and consistent with TestCreateCheckpointSweepTask_LargeMarkerSet (line 1189).

Suggested fix
-		childOutpoints[i] = domain.Outpoint{Txid: "child" + string(rune('A'+i)), VOut: 0}
+		childOutpoints[i] = domain.Outpoint{Txid: fmt.Sprintf("child-%d", i), VOut: 0}

22-158: Consider generating mocks to reduce boilerplate.

~400 lines of hand-rolled mocks for WalletService, VtxoRepository, MarkerRepository, and TxBuilder. Most methods are stubs returning zero values. Using a tool like mockery or counterfeiter would auto-generate these, reduce maintenance burden as interfaces evolve, and keep the test file focused on test logic.

internal/core/domain/marker_repo.go (1)

41-47: VTXO retrieval methods on MarkerRepository blur the boundary with VtxoRepository.

GetVtxosByDepthRange, GetVtxosByArkTxid, and GetVtxoChainByMarkers return []Vtxo and are essentially VTXO queries. Placing them here is understandable since they're marker/depth-optimized, but it means callers now need to know which repository to ask for VTXOs depending on the query pattern. If the interface continues to grow, consider whether a dedicated chain-traversal service or moving these to VtxoRepository with marker-aware implementations would keep the boundaries cleaner.

internal/infrastructure/db/sqlite/sqlc/query.sql (1)

261-270: Liquidity queries now scan every vtxo row with a correlated LIKE subquery.

SelectExpiringLiquidityAmount and SelectRecoverableLiquidityAmount both use EXISTS (SELECT 1 FROM swept_marker sm WHERE v.markers LIKE '%"' || sm.marker_id || '"%'). This is essentially a cross join between vtxo and swept_marker with a LIKE predicate on every pair — O(vtxos × swept_markers) string scans per query. As the number of swept markers grows, these queries will degrade.

Consider caching swept status on the vtxo row itself (a denormalized swept flag updated during BulkSweepMarkers), or evaluating sweep status in the application layer where the marker set is already available.

Also applies to: 273-279

internal/infrastructure/db/postgres/sqlc/query.sql (1)

500-514: Inconsistent projection: SELECT * vs sqlc.embed(vtxo_vw) across vtxo queries.

SelectVtxosByDepthRange, SelectVtxosByArkTxid, and SelectVtxoChainByMarker use SELECT * FROM vtxo_vw, while all other vtxo queries (e.g., SelectAllVtxos, SelectVtxo, SelectSweepableUnrolledVtxos) use SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw. This generates different Go return types — flat structs vs. nested struct { VtxoVw VtxoVw } — requiring different mapping code in the repository layer.

Consider using sqlc.embed(vtxo_vw) consistently so the generated Go types are uniform.

Suggested fix
 -- name: SelectVtxosByDepthRange :many
 -- Get all VTXOs within a depth range, useful for filling gaps between markers
-SELECT * FROM vtxo_vw
+SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw
 WHERE depth >= `@min_depth` AND depth <= `@max_depth`
 ORDER BY depth DESC;
 
 -- name: SelectVtxosByArkTxid :many
 -- Get all VTXOs created by a specific ark tx (offchain tx)
-SELECT * FROM vtxo_vw WHERE txid = `@ark_txid`;
+SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw WHERE txid = `@ark_txid`;
 
 -- name: SelectVtxoChainByMarker :many
 -- Get VTXOs whose markers JSONB array contains any of the given marker IDs
-SELECT * FROM vtxo_vw
+SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw
 WHERE markers ?| `@marker_ids`::TEXT[]
 ORDER BY depth DESC;
internal/infrastructure/db/sqlite/vtxo_repo.go (1)

538-548: Silent error swallowing in parseMarkersJSONFromVtxo could mask data corruption.

If the JSON in the markers column is malformed, this function silently returns nil without any logging. While defensive, this could make it hard to diagnose data integrity issues.

Consider adding a log warning on parse failure, consistent with how other parse errors are handled elsewhere in the codebase.

Optional: add warning log on parse failure
 func parseMarkersJSONFromVtxo(markersJSON string) []string {
 	if markersJSON == "" {
 		return nil
 	}
 	var markerIDs []string
 	if err := json.Unmarshal([]byte(markersJSON), &markerIDs); err != nil {
+		// Log warning to help diagnose data corruption
+		log.WithError(err).Warn("failed to parse markers JSON from vtxo")
 		return nil
 	}
 	return markerIDs
 }
internal/core/application/indexer_test.go (1)

15-299: Consider using a mock generation tool to reduce boilerplate.

The manual mock implementations (~280 lines of stubs) are correct but add significant maintenance burden. Tools like mockery or moq could auto-generate these from the interfaces and keep them in sync as the repository interfaces evolve.

That said, the explicit nil-interface handling in Markers() (lines 288–294) is a valuable pattern worth keeping regardless.

internal/infrastructure/db/badger/marker_repo.go (1)

524-528: Dead error handling — assign-then-discard pattern is misleading.

Line 525 calls r.SweepMarker(...) and assigns to err, then line 528 discards it with _ = err. This is confusing — use _ = directly on the call.

Simplify the error discard
-	if err := r.SweepMarker(ctx, markerID, time.Now().Unix()); err != nil {
-		// Non-fatal - the vtxos are already marked as swept
-		_ = err
-	}
+	// Non-fatal - the vtxos are already marked as swept
+	_ = r.SweepMarker(ctx, markerID, time.Now().Unix())
internal/infrastructure/db/sqlite/marker_repo.go (2)

398-508: Four nearly identical rowToVtxoFrom* functions — consider a shared mapper.

rowToVtxoFromMarkerQuery, rowToVtxoFromDepthRangeQuery, rowToVtxoFromArkTxidQuery, and rowToVtxoFromChainQuery all perform the same mapping from VtxoVw embedded in different sqlc row types. Since the inner row.VtxoVw is the same type (queries.VtxoVw), you could extract a shared vtxoVwToDomain(vw queries.VtxoVw) domain.Vtxo and call it from each wrapper, reducing ~100 lines of duplication.

Note that vtxo_repo.go already has rowToVtxo(row queries.VtxoVw) which does essentially the same mapping — you could reuse that directly.

Consolidate using the existing rowToVtxo from vtxo_repo.go
 func rowToVtxoFromMarkerQuery(row queries.SelectVtxosByMarkerIdRow) domain.Vtxo {
-	var commitmentTxids []string
-	if commitments, ok := row.VtxoVw.Commitments.(string); ok && commitments != "" {
-		commitmentTxids = strings.Split(commitments, ",")
-	}
-	return domain.Vtxo{
-		Outpoint: domain.Outpoint{
-			Txid: row.VtxoVw.Txid,
-			VOut: uint32(row.VtxoVw.Vout),
-		},
-		// ... all fields ...
-	}
+	return rowToVtxo(row.VtxoVw)
 }

Apply the same pattern to all four functions.


510-519: Duplicate parseMarkersJSON — already exists as parseMarkersJSONFromVtxo in vtxo_repo.go.

Both functions in this package have identical logic. Consolidate into a single shared function.

internal/core/application/indexer.go (2)

416-431: Consider batching GetMarker calls to reduce DB round-trips during BFS.

Each iteration of the BFS loop issues an individual GetMarker DB call (line 420). For deep marker chains (e.g., depth 20000 with markers every 100 levels = ~200 markers), this results in ~200 sequential queries. A batch approach using GetMarkersByIds on the current queue batch would be significantly faster.

♻️ Sketch of batched BFS
 	for len(queue) > 0 {
-		currentID := queue[0]
-		queue = queue[1:]
-
-		marker, err := i.repoManager.Markers().GetMarker(ctx, currentID)
-		if err != nil || marker == nil {
+		// Fetch all markers in current queue batch at once
+		batch := queue
+		queue = nil
+		markers, err := i.repoManager.Markers().GetMarkersByIds(ctx, batch)
+		if err != nil {
 			continue
 		}
-
-		for _, parentID := range marker.ParentMarkerIDs {
-			if !visited[parentID] {
-				visited[parentID] = true
-				markerIDs = append(markerIDs, parentID)
-				queue = append(queue, parentID)
+		for _, marker := range markers {
+			for _, parentID := range marker.ParentMarkerIDs {
+				if !visited[parentID] {
+					visited[parentID] = true
+					markerIDs = append(markerIDs, parentID)
+					queue = append(queue, parentID)
+				}
 			}
 		}
 	}

465-474: Cache is mutated via the cache map parameter — document this side effect.

getVtxosFromCacheOrDB updates the caller's map in-place (line 473). This is correct for the current usage pattern, but the mutation is non-obvious. A brief doc note on the side effect would improve maintainability.

internal/infrastructure/db/postgres/migration/20260210100000_add_depth_and_markers.up.sql (2)

73-83: Correlated EXISTS subquery in the view may degrade as swept_marker grows.

The vtxo_vw view computes swept via EXISTS (SELECT 1 FROM swept_marker sm WHERE v.markers @> jsonb_build_array(sm.marker_id)). This scans swept_marker for each VTXO row. While the GIN index on markers helps the containment check, this is effectively a semi-join where the outer side (swept_marker) is iterated per-vtxo. As the number of swept markers grows, this scan may become expensive for queries that touch many VTXOs.

Consider whether a reverse lookup (joining vtxo markers against swept_marker PK) or a materialized approach would scale better for your expected data volumes.


25-30: Remove intermediate view creation — they are dropped and recreated without ever being used.

The views created at lines 25-30 and 32-40 are dropped at lines 65-66 before being recreated at lines 73-93. The backfill queries (lines 44-62) query vtxo directly, so these intermediate views are never referenced and can be removed to simplify the migration.

♻️ Simplified migration flow
-- Drop views before dropping the swept column (views depend on it via v.*)
-DROP VIEW IF EXISTS intent_with_inputs_vw;
-DROP VIEW IF EXISTS vtxo_vw;
-
-CREATE VIEW vtxo_vw AS
-SELECT v.*, string_agg(vc.commitment_txid, ',') AS commitments
-FROM vtxo v
-LEFT JOIN vtxo_commitment_txid vc
-ON v.txid = vc.vtxo_txid AND v.vout = vc.vtxo_vout
-GROUP BY v.txid, v.vout;
-
-CREATE VIEW intent_with_inputs_vw AS
-SELECT vtxo_vw.*,
-       intent.id,
-       intent.round_id,
-       intent.proof,
-       intent.message
-FROM intent
-LEFT OUTER JOIN vtxo_vw
-ON intent.id = vtxo_vw.intent_id;
-
 -- Backfill: Create a marker for every existing VTXO using its outpoint as marker ID
internal/infrastructure/db/sqlite/migration/20260210000000_add_depth_and_markers.up.sql (1)

22-41: Intermediate view recreation appears unused — backfill queries reference vtxo directly.

Same as the Postgres migration: the views created at lines 26-41 are dropped again at lines 96-97 without being referenced by the backfill statements (lines 45-62). They add migration complexity without benefit.

internal/infrastructure/db/postgres/marker_repo.go (2)

249-274: TOCTOU between count query and sweep insert in SweepVtxosByMarker.

CountUnsweptVtxosByMarkerId (line 260) and InsertSweptMarker (line 266) are not atomic. The returned count may not reflect the actual number of VTXOs affected by the sweep. Since the count is only used for logging/metrics, this isn't a correctness issue, but worth noting.


426-436: Silent error swallowing in parseMarkersJSONB — consider logging.

Unmarshal errors at line 432 are silently swallowed. If corrupted marker JSON ends up in the database, this would silently produce nil marker IDs, making affected VTXOs invisible to marker-based queries. A debug-level log would aid troubleshooting without adding noise.

internal/core/application/service_test.go (1)

562-567: outputs[0].MarkerIDs is re-sorted on every loop iteration.

The sort at line 563 mutates outputs[0].MarkerIDs in-place on each iteration. Move it before the loop.

♻️ Minor optimization
 			// All outputs must have the same marker IDs
+			sort.Strings(outputs[0].MarkerIDs)
 			for i := 1; i < len(outputs); i++ {
-				sort.Strings(outputs[0].MarkerIDs)
 				sort.Strings(outputs[i].MarkerIDs)
 				require.Equal(t, outputs[0].MarkerIDs, outputs[i].MarkerIDs,
 					"output %d has different markers than output 0", i)
 			}
internal/infrastructure/db/badger/vtxo_repo.go (2)

23-26: Duplicate accessors: GetStore() and Store() return the same value.

Both methods on Lines 23-26 and Lines 421-424 return r.store with identical signatures. Pick one and remove the other to avoid confusion about which to call.

Also applies to: 421-424


625-637: Redundant visited check in GetSweepableVtxosByCommitmentTxid.

Line 627 checks !visited[outpointKey], and Line 628 checks !seen on the same key. Since visited maps to bool, !visited[key] is true iff the key is absent (zero-value false), making the inner check always true when reached. This also means Line 633-635 (enqueue ArkTxid) is unreachable for already-visited outpoints — which is correct — but the double-check is confusing. Compare with the cleaner pattern in GetAllChildrenVtxos (Lines 668-676).

Simplify to match GetAllChildrenVtxos pattern
 		for _, vtxo := range vtxos {
 			outpointKey := vtxo.Outpoint.String()
-			if !visited[outpointKey] {
-				if _, seen := visited[outpointKey]; !seen {
-					visited[outpointKey] = true
-					outpoints = append(outpoints, vtxo.Outpoint)
-				}
-
-				if vtxo.ArkTxid != "" {
-					queue = append(queue, vtxo.ArkTxid)
-				}
+			if !visited[outpointKey] {
+				visited[outpointKey] = true
+				outpoints = append(outpoints, vtxo.Outpoint)
+				if vtxo.ArkTxid != "" {
+					queue = append(queue, vtxo.ArkTxid)
+				}
 			}
 		}

# Conflicts:
#	internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go
vtxoCache := make(map[string]domain.Vtxo)
loadedMarkers := make(map[string]bool)

for len(nextVtxos) > 0 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Besides pagination improvements, we must get rid of this loop. It is not scalable and has already caused memory issues. I noticed you added the GetVtxoChainByMarkers method, shouldn't we leverage it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think youre right but this may be better suited as a follow-up PR given the scope of the change.

  • The loop doesn't just fetch VTXOs — it also fetches offchain transactions, decodes checkpoint PSBTs, builds ChainTx with spends/types, and handles the batch tree branch case
  • Pagination would need to work completely differently with marker-based fetching
  • The response ordering (chain from leaf to root) would need to be reconstructed from a flat set of marker-fetched VTXOs

This PR adds the marker infrastructure which is a prerequisite for this optimization which I would say we should do as a follow-up PR

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

+1 for another PR, easier to review too. But we need to do it before merging this one.
We have performance issue using that loop. for a 5k tx chain, it takes 15s on master to respond. With this branch it's 12s thanks to cursor improvements, which is not significant.
we need to know if the marker really improve response time to client (I was expecting less than 3s for 5k txs chain). Just to confirm it's the "right" change for this vtxo chain getter

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

PR: #973

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

the loop wasn't removed but I believe the PR captures the spirit of the ask which is removing the bottleneck (all the DB round trip calls). It changes the DB access pattern from O(chain_length) individual GetVtxos calls to O(chain_length / MarkerInterval) bulk fetches. The VTXOs are prefetched into vtxoCache and the loop reads from the cache instead of hitting the DB each iteration.


// MarkerInterval is the depth interval at which markers are created.
// VTXOs at depth 0, 100, 200, etc. create new markers.
const MarkerInterval = 100
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

what's happening to prod if we change this value ? like we have a DB with MarkerInterval 100 and we migrate to 150, do we need a specific migration for this ?

Copy link
Copy Markdown
Collaborator Author

@bitcoin-coder-bob bitcoin-coder-bob Mar 16, 2026

Choose a reason for hiding this comment

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

that would be problematic. newMarker would would create and skip at wrong intervals relative to the existing data, and parent marker linking would be inconsistent. I don't think we could have a migration to fix this, unless the migration is simply to rebuild the DAG if the constant is changed (pain). We need to treat this value as a "please do not change" constant. I could document that accordingly.

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 16, 2026

🔍 Arkana PR Review

Major feature: Scale the DAG — VTXO chain pagination, depth tracking, indexer caching

Summary

Large PR (~13.6k lines) that adds:

  1. VTXO depth field in proto definitions (service, indexer, types) — tracks VTXO position in the transaction DAG
  2. Cursor-based pagination for GetVtxoChain via opaque page_token / next_page_token (base64-encoded JSON frontier)
  3. Indexer-level VTXO caching with marker-window prefetching to reduce DB round-trips during chain traversal
  4. Comprehensive indexer tests with mocked repository layer

Security Analysis

⚠️ Page token is user-controlled input:

  • decodeChainCursor accepts a base64-encoded JSON string from the client. While it only deserializes into []Outpoint (txid + vout), a malicious client could craft tokens pointing to arbitrary outpoints. The existing GetVtxos call will simply return nothing for non-existent outpoints, so this is safe — but worth noting.

✅ Bounds on pagination:

  • maxPageSizeVtxoChain is used as the default when page_token is provided without a page object. The early termination logic in the BFS loop correctly saves unprocessed VTXOs to the frontier for the next page.

✅ Visited set prevents infinite loops:

  • visited map ensures each VTXO is processed at most once during chain traversal. Important since the DAG could theoretically contain cycles in adversarial scenarios.

✅ Cache is request-scoped:

  • vtxoCache and loadedMarkers are created per GetVtxoChain call, not shared across requests. No cache poisoning risk.

Observations

  1. Proto field numbering: depth is field 15 on both Vtxo and IndexerVtxo. Fields 1-15 use 1-byte tags in protobuf — this is the last "cheap" field number. Future additions will use 2-byte tags. Not a problem, just noting it.

  2. Backward compatibility: The old offset-based Page pagination is preserved alongside cursor-based pagination. Clients using the old API continue to work. Good.

  3. Marker prefetching: ensureVtxosCached loads all VTXOs sharing a marker ID into the cache. This is an optimization bet — if VTXOs in the same marker window are likely to be traversed together (which they are in VTXO trees), this dramatically reduces DB queries. Smart.

  4. Test coverage: 856-line test file with mocked repository layer. Covers pagination, cycle detection, and empty chain cases. Solid.

Cross-repo impact

SDKs consuming the indexer API will see the new depth field and can use cursor-based pagination for GetVtxoChain. This is additive — no breaking changes.

Verdict

Well-engineered scalability improvement. The cursor-based pagination and caching are important for large VTXO chains. The page token input surface is minimal risk. Recommend careful review of the BFS termination logic to ensure no edge case drops VTXOs during pagination boundaries.

Copy link
Copy Markdown
Contributor

@arkanaai arkanaai bot left a comment

Choose a reason for hiding this comment

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

🔍 Arkana Review — arkd#908 (Scale the DAG)

Summary: Introduces a marker-based DAG checkpoint system for VTXO chains. Markers are created every 100 depths, enabling bulk sweep operations (1 INSERT per 100 VTXOs vs 1 UPDATE per VTXO) and prefetch-based chain traversal via ensureVtxosCached. Adds token-based pagination for GetVtxoChain, a depth field on VTXOs, and corresponding migrations for both Postgres and SQLite.

This is a large PR (10.5K lines, ~4.2K non-test). I've focused on the data model, migration safety, security implications, and sweep correctness.


✅ Architecture — well designed

  1. Marker model is clean: MarkerInterval = 100, markers created at depth boundaries (0, 100, 200...), parent marker IDs for DAG traversal. The NewMarker() function is deterministic and side-effect free.

  2. Swept marker as append-only table — excellent choice. Instead of mutating a swept boolean on individual VTXOs, inserting into swept_marker marks all VTXOs sharing that marker as swept atomically. This is both faster (100x fewer writes) and more correct (no stale swept state).

  3. Dynamic swept computation via EXISTS subquery in vtxo_vw:

EXISTS (
  SELECT 1 FROM swept_marker sm
  WHERE v.markers @> jsonb_build_array(sm.marker_id)
) AS swept

This is always correct by construction — no stale state possible. The GIN index on markers makes this efficient.

  1. Token-based pagination for GetVtxoChainencodeChainCursor / decodeChainCursor using base64-encoded JSON is correct. The backward compat handling (nil page + empty token = full chain) preserves existing client behavior.

  2. Marker window prefetch in ensureVtxosCached — when a cache miss occurs, loading all VTXOs sharing the same marker pre-warms the cache for subsequent BFS iterations. The loadedMarkers dedup map prevents redundant marker loads.

🔒 Security

  1. Sweep correctness — the bulk sweep path (BulkSweepMarkers) sweeps by marker ID. Since markers are tied to depth boundaries and VTXOs inherit marker IDs from their parents, sweeping a marker sweeps exactly the VTXOs in its 100-depth window. No double-spend vector — a VTXO can only be swept once because swept_marker has a PK on marker_id.

  2. Migration safety — the migration adds columns with DEFAULT 0 / DEFAULT '[]', which is safe for existing data. The view recreation (DROP VIEW IF EXISTS + CREATE VIEW) is atomic within the migration transaction. ⚠️ One concern: the DROP VIEW IF EXISTS vtxo_vw will invalidate any in-flight queries using the old view. On a busy production server, this could cause brief errors. Consider deploying during low-traffic or ensuring the migration runs in a maintenance window.

  3. No exit path impact — unilateral exit paths are not modified. Marker/depth tracking is metadata for operational efficiency, not consensus-critical.

  4. Existing VTXOs don't get markers — explicitly noted in the PR. Pre-migration VTXOs have depth=0 and markers=[], so they fall back to per-VTXO sweep behavior. This is correct — no false swept status.

🟡 Observations

  1. GetVtxosByMarker error is swallowed in ensureVtxosCached — line if err != nil { continue }. The test (TestEnsureVtxosCached_GetVtxosByMarkerErrorSwallowed) explicitly validates this behavior. This is acceptable since the VTXO itself is still cached via the direct DB lookup, but consider at least logging the error at debug level so operational issues with the marker index don't go unnoticed.

  2. SweepMarkerWithDescendants — recursive CTE for bulk sweeping is powerful but could be expensive on deep marker chains. Ensure there's a depth limit or that this is only called during scheduled sweeps, not on hot paths.

  3. SQLite migration is 138 lines — significantly more complex than Postgres due to SQLite's limited ALTER TABLE support. Consider adding a comment in the migration file noting which sections are SQLite workarounds.

  4. Proto field number 15 for depth — leaving gaps (14 is assets, 15 is depth) is fine for proto. Just noting it's consistent across both Vtxo and IndexerVtxo messages.

  5. No down migration for swept_marker20260210100000_add_depth_and_markers.down.sql exists but I couldn't verify its contents in this diff. Ensure it drops swept_marker, marker, and removes the depth/markers columns.

Cross-repo impact

  • SDKs need to expose the new depth field on VTXOs — this is additive and non-breaking (new field, default 0).
  • Pagination via pageToken / nextPageToken on GetVtxoChain is additive — existing clients that don't set these fields get the old behavior.
  • The e2e test gap (noted in PR: "require changes to the SDK to support new proto field") should be tracked as a follow-up issue.

Test coverage

Test files account for ~62% of the diff. Key scenarios covered: cursor round-trip, cache hits/misses, marker dedup, DB error propagation, pagination (first page, resume with token, short chain, page size limits), and sweeper checkpoint creation with markers. Comprehensive for a change of this scope.

Verdict: Strong architectural improvement with clear performance wins. The marker model is well-designed and the migration is safe. The two items worth attention are: (1) logging swallowed errors in ensureVtxosCached, and (2) ensuring the view recreation in migration happens during low-traffic. LGTM for the design — recommend a second human review given the scope (10K+ lines touching DB schema, sweep logic, and indexer).

# Conflicts:
#	internal/core/application/utils_test.go
@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 18, 2026

🔍 Review — Scale the DAG (marker-based VTXO chain optimization)

Large PR (10.5k+ / 900-), but the core idea is sound: replace per-VTXO traversal with marker-based checkpoints every 100 depths, enabling bulk operations. 62% is test code. Reviewed the new domain model, DB migrations, indexer changes, sweeper refactor, and event plumbing.


✅ Architecture

  • Marker model (domain/marker.go): Clean. Fixed 100-depth interval, deterministic ID scheme (txid:marker:depth), parent marker tracking for DAG traversal. The NewMarker function correctly handles boundary vs. inherited markers.
  • Token-based pagination for GetVtxoChain: replaces the old offset-based approach with a frontier-encoded cursor (base64 JSON). This is the right call for BFS pagination — offset pagination doesn't compose with graph traversal.
  • Bulk sweep via swept_marker table: Replaces N individual UPDATE vtxo SET swept=true with 1 marker INSERT per ~100 VTXOs. The append-only design is clean and audit-friendly.
  • Dynamic swept status: vtxo_vw now computes swept via EXISTS subquery against swept_marker. Correctness improvement over stale booleans. Marginal per-row cost is acceptable given the indexed swept_marker table.

⚠️ Concerns

1. Postgres swept view performance (critical for large deployments)

The view uses:

EXISTS (SELECT 1 FROM swept_marker sm WHERE v.markers @> jsonb_build_array(sm.marker_id))

This scans swept_marker for every row in the vtxo result set, checking @> containment. With many swept markers + many VTXOs, this could degrade. Consider:

  • An index on swept_marker(marker_id) (currently only PK, which helps, but the @> direction matters)
  • Benchmarking ListVtxos with 10k+ VTXOs and 100+ swept markers

The follow-up migration 20260219082956_fix_vtxo_vw_perf.up.sql might address this — worth confirming it does.

2. ensureVtxosCached silently swallows marker window errors

windowVtxos, err := i.repoManager.Markers().GetVtxosByMarker(ctx, markerID)
if err != nil {
    continue  // silently drops the error
}

This is intentional (the tests verify it), but a degraded marker store could cause the indexer to fall back to per-VTXO DB lookups without any observability. Suggest at minimum a log.Warn here so operators can spot degradation.

3. Pagination frontier correctness

The early termination in GetVtxoChain collects remaining unprocessed VTXOs + newNextVtxos into the frontier:

remaining := make([]domain.Outpoint, 0)
for _, v := range vtxos {
    if !visited[v.Outpoint.String()] {
        remaining = append(remaining, v.Outpoint)
    }
}
remaining = append(remaining, newNextVtxos...)

This correctly includes both the current iteration's unvisited VTXOs and the pending queue. One edge case: if vtxos contains duplicates from the cache that overlap with newNextVtxos, the frontier could include the same outpoint twice. The visited map handles this on the next page, but the token is slightly larger than necessary. Minor.

4. Depth computation in service.go event handler

var maxDepth uint32
for _, v := range spentVtxos {
    if v.Depth > maxDepth {
        maxDepth = v.Depth
    }
}
for i := range newVtxos {
    newVtxos[i].Depth = maxDepth + 1
}

This runs in the OffchainTxAccepted event handler AND in SubmitOffchainTx. The duplication is intentional (event handler for replays, submit for live path), but both need to stay in sync. If the depth logic ever changes, it's easy to miss one.

5. Migration backfill creates one marker per existing VTXO

The Postgres migration does:

INSERT INTO marker (id, depth, parent_markers)
SELECT v.txid || ':' || v.vout, v.depth, '[]'::jsonb FROM vtxo v;

For large existing databases, this could be a slow migration (one marker row per VTXO). The PR description acknowledges existing VTXOs won't benefit from bulk sweep optimization, which is fine, but the migration itself could lock the table. Worth noting in deployment instructions.

Cross-repo impact

  • SDKs (ts-sdk, rust-sdk, dotnet-sdk): The new depth field on the VTXO proto is additive (field 15) — backward compatible. SDKs that don't know about depth will simply ignore it. The page_token/next_page_token on GetVtxoChain is also additive.
  • wallet: No breaking changes. The wallet's VTXO display won't be affected.

Tests

Thorough coverage: cursor encode/decode round-trips, cache hit/miss, marker dedup, nil marker repo fallback, DB error propagation, pagination first/resume/short-chain. The setupPreconfirmedChain helper is well-structured for reuse.

Security

  • No key material changes
  • No new trust assumptions — markers are server-side bookkeeping, not part of the cryptographic protocol
  • The swept_marker pattern doesn't affect unilateral exit paths (those are on-chain)
  • Token-based pagination cursor is opaque base64 but contains outpoint data — not security-sensitive since it's already public on-chain data

Verdict: Solid optimization. Address the view performance concern and the silent error swallowing, and this is ready. The PR description's efficiency tables are accurate for the claimed improvement factors.

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 19, 2026

🔍 Arkana PR Review — Scale the DAG

Summary: Introduces a marker-based DAG checkpoint system for VTXO chains, replacing per-VTXO sweep operations with bulk marker-based sweeps and adding in-memory caching for GetVtxoChain traversal. Also adds token-based pagination for GetVtxoChain.

Scale: +10,521 / -893 lines. ~62% tests, ~37% application code. 3 storage backends (Postgres, SQLite, Badger).


Architecture Review: ✅ Sound design

The marker system is well-conceived:

  • Markers every 100 depths — amortizes O(N) → O(N/100) for VTXO lookups and sweep writes
  • Swept status via swept_marker table — replaces the stale static boolean with a dynamic, always-correct computation
  • Token-based cursor pagination — proper BFS frontier serialization for GetVtxoChain
  • Backward compatibility — migration backfills markers for existing VTXOs, nil page + empty token returns full chain

Security Review

  1. Page token injection ⚠️ The pageToken is a base64-encoded JSON frontier of outpoints. While it is validated (base64 decode + JSON unmarshal), a malicious client could craft a token with arbitrary outpoints, potentially causing the BFS to start from any VTXO they know the outpoint of. This is probably acceptable since GetVtxoChain already requires knowledge of the starting outpoint, but worth documenting that the token is not cryptographically authenticated. If the indexer is public-facing, consider HMAC-signing the token.

  2. No double-spend vector ✅ The swept status change from static column to dynamic EXISTS subquery is correctness-preserving. The swept_marker table is append-only, so no race where a VTXO could flip back to unswept.

  3. Exit path integrity ✅ Unilateral exit is unaffected — markers are purely an indexer/sweep optimization. The VTXO tree structure and forfeit transactions are untouched.

  4. Marker ID predictability — Marker IDs use {txid}:marker:{depth} format, which is deterministic and collision-free given unique txids. Root markers use {txid}:{vout}. Both are fine.

Specific Code Observations

indexer.goensureVtxosCached

  • Good: gracefully handles nil Markers() repo, logs and continues on marker window load failure.
  • The cache is per-request (in-memory map), not shared across concurrent requests. This is correct for correctness but means concurrent GetVtxoChain calls for overlapping chains won't benefit from each other's cache warming. Acceptable for now.

sweeper.gocreateCheckpointSweepTask

  • The sweep path now fetches VTXOs → collects unique markers → BulkSweepMarkers. This is correct but note: if a VTXO has no markers (pre-migration data without backfill), len(uniqueMarkers) == 0 causes early return with nil error. Ensure the migration backfill covers all existing VTXOs or this could silently skip sweeps.

service.go — Depth computation in SubmitOffchainTx

  • Depth is max(parent depths) + 1. This is correct for DAG depth. The parentMarkerSet collection properly deduplicates across all spent VTXOs.

Dust VTXO handling

  • Dust VTXOs get their own per-outpoint marker and are immediately swept via BulkSweepMarkers. This replaces the old Swept: isDust boolean. Clean approach — allows dust to be swept without affecting sibling non-dust VTXOs sharing the same inherited markers.

Postgres migration

  • The swept column migration drops the column and recreates views with dynamic EXISTS subquery. The backfill uses EXTRACT(EPOCH FROM NOW())::BIGINT for swept_at — note this gives seconds, not milliseconds. Application code uses time.Now().UnixMilli(). This inconsistency is non-critical (swept_at is just a timestamp) but worth aligning.
  • The @> operator with GIN index on markers JSONB is efficient for Postgres. Good indexing strategy.

SQLite migration

  • SQLite lacks native JSONB — the json_each() approach for swept status check is reasonable. Watch for performance at scale since json_each is not as optimized as Postgres GIN.

Cross-Repo Impact

  • Proto changes: depth field added to Vtxo and IndexerVtxo messages, page_token/next_page_token added to GetVtxoChainRequest/Response. SDKs (ts-sdk, rust-sdk, go-sdk, dotnet-sdk) will need proto regeneration but the new fields are additive — existing clients remain compatible.
  • VtxoRepository.SweepVtxos removed — any out-of-tree code calling this method will break. The method is replaced by marker-based sweeping in service.go.

Test Coverage

Thorough — 856 lines for indexer tests, 1166 for sweeper tests, 661 for domain marker tests, 3427 for DB service tests (including pagination round-trips). Tests correctly simulate the real scenarios (cache misses, marker dedup, error swallowing, cross-page BFS).

Items to Address

  1. 🟡 Consider HMAC-signing page tokens if the indexer endpoint is public-facing (prevents frontier manipulation).
  2. 🟡 Migration swept_at unit inconsistency — Postgres backfill uses seconds, app code uses milliseconds.
  3. 🟡 E2E tests deferred — PR notes SDK changes needed for depth field. Ensure these are tracked.

No blocking issues. Well-structured PR with strong test coverage.

@bitcoin-coder-bob
Copy link
Copy Markdown
Collaborator Author

bitcoin-coder-bob commented Mar 19, 2026

per #908 (comment) I have added the milliseconds change into the db migrations. Concerns 1 and 3 not worth addressing. Done in: 86f9190

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 19, 2026

🔍 Arkana PR Review — arkd#908 (follow-up)

2 new commits since last review (3231064e, ce2ef674):

  1. 3231064e — Log warning on marker window error: Previously, ensureVtxosCached silently continued when GetVtxosByMarker failed. Now it logs the error with context (marker ID) before falling back to per-VTXO lookups. Good improvement — silent failures in the caching layer would have been hard to debug in production.

  2. ce2ef674 — Linting: Line-break formatting on the log statement above. No logic change.

Assessment

  • Correctness: ✅ Both changes are trivial and correct
  • Security: No impact — logging only, no change to sweep/exit/forfeit paths
  • Operational: The warning log will surface degraded performance early, which is valuable for the marker system rollout

No new concerns. Prior review findings still apply (page token signing, swept_at migration — both already addressed in earlier commits).

* optimize GetVtxoChain with marker-based bulk preloading

* Test to prove reductin in calls to GetVtxos

* sorting in tests to guarantee vtxo id ordering
@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 20, 2026

🔍 Arkana PR Review — arkd#908 (follow-up)

1 new commit (f13064c7): Merges the preloadVtxosByMarkers optimization from #973 — adds eager marker-DAG traversal at the start of GetVtxoChain, bulk-loading all reachable VTXOs into the in-memory cache before the BFS loop begins.

Code Review

preloadVtxosByMarkers — Clean BFS over the marker DAG with a visited set preventing cycles. Each iteration:

  1. Bulk-fetches VTXOs tagged with current marker IDs via GetVtxoChainByMarkers
  2. Fetches marker objects to discover parent markers
  3. Enqueues unvisited parents

This is the key piece that turns the O(N) per-VTXO DB round-trips into O(N/100) marker-based bulk fetches.

Guard clause: Correctly checks i.repoManager.Markers() != nil before attempting preload — backward compatible with pre-marker data.

Tests (287 new lines):

  • matchIDs helper — sorts both sides before comparing, handles non-deterministic map iteration. Good.
  • TestPreloadVtxosByMarkers_WalksMarkerChain — 3-level marker chain, verifies full cache population and exact call counts.
  • TestPreloadVtxosByMarkers_NoCycleLoop — Marker cycle (A→B→A). Confirms termination via visited set. Important edge case.
  • TestGetVtxoChain_WithMarkers_UsesPreload — Integration: verifies GetVtxos is called only once (frontier fetch), not per-hop.
  • TestGetVtxoChain_PreloadReducesDBCalls — 500-VTXO chain proving GetVtxos=1 vs 500 without preload. This is the key regression test.

Observation

The preloadVtxosByMarkers function eagerly loads the entire marker chain regardless of pagination (pageSize). For very deep chains (10k+), this means the first paginated request pays the full preload cost even if the client only requests 1 page. This is likely fine in practice (the bulk fetch is cheap compared to per-VTXO queries), but worth noting if pagination use cases involve shallow reads of deep chains.

Security

No impact on exit paths, forfeit validation, or sweep correctness. The preload is purely a read-side optimization feeding the same BFS loop.

✅ No blocking issues. Tests are thorough and the optimization is well-structured.

# Conflicts:
#	internal/interface/grpc/handlers/parser_test.go
@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 21, 2026

👋 @bitcoin-coder-bob — this PR has been open for 39 days with outstanding change requests from @louisinger. Last push was over 30 days ago. Is this still being worked on, or is it blocked on something? Let us know if we can help unblock.

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 23, 2026

🔍 Arkana PR Review (incremental — new commits since last review)

Changes since last review: Added comprehensive benchmark suite (indexer_bench_test.go) and new unit tests for fanout, diamond, marker-boundary, and overlapping-marker scenarios.

Assessment: ✅ Excellent test additions

Benchmarks (indexer_bench_test.go):

  • Covers linear chains (with/without markers), binary fanout trees, and diamond convergence patterns
  • Lightweight fake repos avoid testify/mock overhead — good choice for benchmarks
  • benchCheckpointPSBT creates real PSBT objects, so the benchmark captures actual serialization costs
  • The BenchmarkCheckpointPSBTParse microbenchmark isolates the PSBT decode cost, which the PR body identifies as the remaining bottleneck — useful for tracking optimization progress

New unit tests (indexer_test.go):

  • TestGetVtxoChain_Fanout — verifies correct traversal of 2-branch checkpoint fan-outs. Validates both item count and type distribution.
  • TestGetVtxoChain_Diamond — tests convergence deduplication (D appears exactly once despite two paths reaching it). Critical correctness test for the DAG traversal.
  • TestGetVtxoChain_MarkerBoundaryStart — validates marker preload at depth 0 with no parent markers. Asserts only 1 GetVtxos call (frontier fetch).
  • TestGetVtxoChain_OverlappingMarkers — tests deduplication when a VTXO has multiple markers and one is a parent of another. Correctly asserts only 1 batch of marker fetches.

The matchOutpoints helper is a solid addition — order-independent matching for outpoint slices, consistent with the existing matchIDs pattern.

No security concerns in this increment — all changes are test/bench code. The core DAG traversal and marker logic were reviewed in the previous pass.

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 28, 2026

Hey @bitcoin-coder-bob@louisinger requested changes on Mar 16. Any update on this? Has the feedback been addressed in recent commits?

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Apr 2, 2026

Iterative review — new commit 453417ea (offchainTxCache race fix)

This commit addresses a real race condition: the DB projection updates asynchronously, so GetVtxos could return stale spendable VTXOs that are already pending-spent in an in-flight offchain tx. Clients would then build duplicate transactions.

The fix:

  • Adds offchainTxCache ports.OffChainTxStore to indexerService
  • In GetVtxos, after fetching from DB, cross-checks each unspent VTXO against the live offchain tx cache
  • If Includes() returns true, marks it Spent = true before returning

Review notes:

  1. Correctness ✅ — The check only marks VTXOs as spent, never unmarks them. This is safe: worst case is a VTXO appears spent slightly before the projection catches up, which is the conservative direction.

  2. Error handlingspent, _ := i.offchainTxCache.Includes(...) silently swallows errors. On error, spent defaults to false, meaning the VTXO remains spendable. This is the right default (fail-open for reads), but consider logging the error at debug level so cache failures are observable.

  3. Nil guard ✅ — if i.offchainTxCache != nil correctly handles the case where the live store isn't available.

  4. Config wiring ✅ — config.go correctly gates on c.liveStore != nil before accessing OffchainTxs().

  5. Performance — The loop iterates all returned VTXOs and calls Includes() per VTXO. For typical page sizes this is fine. If GetVtxos ever returns thousands, a batch Includes method would be more efficient, but that's a future optimization.

  6. Cross-repo note — SDK clients that cache VTXO lists locally should be aware that the server now reflects pending-spent status faster. This shouldn't break anything (it's strictly more accurate), but worth mentioning in release notes.

Good fix for a subtle but important race. 👍

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.

Scale the DAG

2 participants