Skip to content

Add filters to indexer.GetVirtualTxs#1090

Open
bitcoin-coder-bob wants to merge 10 commits into
masterfrom
bob/GetVirtualTxs-filters
Open

Add filters to indexer.GetVirtualTxs#1090
bitcoin-coder-bob wants to merge 10 commits into
masterfrom
bob/GetVirtualTxs-filters

Conversation

@bitcoin-coder-bob

@bitcoin-coder-bob bitcoin-coder-bob commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Closes #1088.

Summary

Adds filter support to the unary indexer.GetVirtualTxs RPC so clients can "fill the gap" after the streaming GetSubscription connection drops, with the same expression shape and matching semantics they used on the stream.

Wire surface

  • GetVirtualTxsRequest.filter (SubscriptionFilter): a single CEL expression, capped at 4 KiB. Supported predicates, combined with &&:
    • has(tx.extension)
    • hasPacket(tx.extension, N), where N is an int packet type in the range [0, 255]
    • tx.extension[N] == 'hex', an exact equality on the hex-encoded packet bytes, matching the streaming filter's semantics
    • Unsupported shapes (OR, NOT, !=, size(), .contains(), non-bool, etc.) are rejected with InvalidArgument. filter.scripts must be empty.
  • GetVirtualTxsRequest.time_range: a oneof over TimeRangeAfter / TimeRangeBefore / TimeRangeWithin applied to starting_timestamp. Bounds are inclusive, so within(t, t) selects rows at exactly t.
  • Lookup modes:
    • Pure-txid lookups (no filter, no time-range) preserve the master behavior of resolving any combination of offchain (ark), checkpoint, and round-commitment txs from the caller's txid list. After the offchain repo runs, any txids that didn't resolve fall back to Rounds().GetTxsWithTxids so callers walking a mixed chain (e.g. client-lib's redeem path) keep working.
    • Filtered lookups (any CEL expression or time-range) are narrowed to offchain txs, since tx.extension[N] and starting_timestamp are only defined for offchain txs.
  • Safety bounds on the filtered path: when a filter is supplied without a txids set, the server applies a SQL LIMIT of domain.OffchainTxsScanLimit = 10000 rows over distinct base offchain_tx.txids (via CTE, so checkpoint join expansion doesn't eat the cap). Callers using the gap-fill pattern should pair the filter with a time_range bound to keep the scan set small. Cursor-based pagination over the filtered set is a planned follow-up.

Domain + storage

  • domain.OffchainTx.Packets []int carries the list of ARK extension packet types so repos do not have to decode raw PSBTs at query time. Populated by OffchainTx.Request(...) via the OffchainTxRequested event. The slice is snapshotted at both event-raise and state-apply time so a later mutation of the caller's backing array can't alias aggregate state.
  • domain.OffchainTxRepository.GetOffchainTx becomes GetOffchainTxs(ctx, filter) with a domain.OffchainTxFilter{WithTxids, WithExtension, WithPacket, WithAfterDate, WithBeforeDate} plus a Validate() method.
  • MatchPackets lives on OffchainTxFilter in domain and is shared by all three backends. It decodes the persisted PSBT, looks up the requested packet type, and compares serialized bytes, with no base64-substring trickery. Range-checks the packet-type byte to [0, 255].
  • SQLite and Postgres add a packets TEXT column (CSV of packet types) backed by an async, keyset-paginated backfill. The backfill runs in a goroutine started in the constructor with a cancellable context; Close() cancels it and waits for the goroutine to drain so we never tear down the DB out from under it. Rows whose PSBT cannot be decoded are marked with the empty string so they aren't revisited every restart. The helper is exposed as pgdb.BackfillPackets / sqlitedb.BackfillPackets so tests can drive it synchronously.
  • New partial index offchain_tx_with_packets_idx ON offchain_tx (txid) WHERE packets IS NOT NULL AND packets <> '' on both backends; SQL WHERE clauses use the matching predicate so the planner uses the index.
  • Up/down migration ordering: the down migration drops the packets column before recreating the view so CREATE VIEW SELECT offchain_tx.* doesn't register a dependency on the column it's about to drop.
  • SelectOffchainTxs* queries gain ORDER BY starting_timestamp DESC, txid ASC so paginated responses are stable, and stage_code <> 0 to skip undefined-stage rows (matching badger).
  • SQLite query placeholder ordering: txid IN (sqlc.slice('txids')) must remain the last WHERE clause so the sqlc-generated ?1..?5 numbered placeholders for the other args don't collide with the slice expansion when len(WithTxids) > 1. There's an in-SQL comment explaining this.
  • Badger backend sorts results by (starting_timestamp DESC, txid ASC) to match the SQL backends, so cross-backend pagination via after=lastSeen is deterministic. The badger path still loads the whole offchain_tx table before predicate evaluation (badgerhold limitation); a NOTE in the repo points production users to the SQL backends.

Application + handler

  • Token-whitelist scope-down: both exposureWithheld and exposurePrivate paths populate filter.WithTxids from the token's whitelist when empty, so a future caller bypassing parseTxids can't silently exceed the token's scope.
  • SubmitOffchainTx now propagates malformed-extension errors as INVALID_ARK_PSBT instead of silently persisting the tx as "no extension".
  • FinalizeOffchainTx no longer conflates DB errors with TX_NOT_FOUND: a real DB error logs and returns INTERNAL_ERROR; only an empty result returns TX_NOT_FOUND.
  • parseVirtualTxsFilter uses Compile plus a cel.BoolType check so non-bool expressions fail at the handler. asIntLiteral uses a type switch and range-checks to the packet-type byte range. Filter expressions over 4 KiB are rejected before parsing.
  • decodePacketsColumn logs a warning on corrupt CSV entries (e.g. a stored value of "0,abc,255") instead of silently dropping them, so storage corruption surfaces in logs.

Test plan

  • make lint (locally: 0 issues).
  • make test / SQLite-side integration suite, including the new TestBackfillPackets (in-memory) which covers carrier, no-extension, and malformed-PSBT rows plus idempotency.
  • Postgres-side integration (TestService/repo_manager_with_postgres_stores, TestIntentTxidMigration, TestBackfillPackets), which runs in CI (the local pg container in my dev box uses a different role).
  • End-to-end suite (TestUnilateralExit, TestUnrolledVtxoRejoinBatch, TestOffchainTx, TestReactToFraud, TestSweep, TestAsset) regression-tests the pure-txid fallback to Rounds().GetTxsWithTxids, since the client-lib redeem path walks mixed chains through this RPC.
  • Manual: subscribe via GetSubscription with a filter, drop the connection, replay with GetVirtualTxs(filter, after=lastSeen), confirm the unary RPC returns the same rows the stream would have delivered.
  • Manual: roll the binary against a non-empty existing DB, confirm the backfill log line fires and queries return correctly after it completes.

Summary by CodeRabbit

  • New Features

    • Virtual transaction queries now support strict CEL-based filtering for offchain packet types (including optional packet payload matching).
    • Added time-range filtering (after, before, within) to narrow virtual transaction results by timestamp bounds.
    • Unified the request model so both direct txid queries and intent-based queries accept the same filter and time-range parameters.
  • Bug Fixes

    • Improved result scoping and validation for withheld/private virtual transaction access to avoid returning out-of-scope transactions.

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8fd8beb7-6a62-46a9-9f4a-81c086352deb

📥 Commits

Reviewing files that changed from the base of the PR and between ea90fa5 and 321e4e3.

📒 Files selected for processing (5)
  • internal/infrastructure/db/postgres/sqlc/queries/query.sql.go
  • internal/infrastructure/db/postgres/sqlc/query.sql
  • internal/infrastructure/db/service_test.go
  • internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go
  • internal/infrastructure/db/sqlite/sqlc/query.sql
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/infrastructure/db/sqlite/sqlc/query.sql
  • internal/infrastructure/db/service_test.go
  • internal/infrastructure/db/postgres/sqlc/query.sql

Walkthrough

Extends GetVirtualTxs with CEL-based and time-range filtering, persists ARK extension packet-type metadata per offchain transaction for structured matching, replaces single-record repository reads with filtered batch queries, and implements asynchronous packet-type backfill with graceful lifecycle management across Badger, Postgres, and SQLite backends.

Changes

Offchain Transaction Filtering with Packet Support

Layer / File(s) Summary
API request contracts
api-spec/openapi/swagger/ark/v1/indexer.openapi.json, api-spec/protobuf/ark/v1/indexer.proto
GetVirtualTxs endpoint gains filter (CEL expressions), after/before/within time-range parameters, and documents restricted CEL subset, unsupported shapes, and rejection behavior.
Domain filter specification
internal/core/domain/offchain_tx_repo.go
Introduces OffchainTxFilter struct with WithTxids, WithExtension, WithPacket map, WithAfterDate/WithBeforeDate fields; adds OffchainTxsScanLimit constant and Validate() method; replaces GetOffchainTx(txid) with GetOffchainTxs(ctx, filter).
CEL parsing and AST extraction
internal/core/application/offchain_tx_filter.go, internal/core/application/offchain_tx_filter_test.go
Adds ExtractOffchainTxFilter to compile/validate CEL expressions, walk AST for supported predicates (has/hasPacket/extension equality), populate OffchainTxFilter fields, and unit tests for success and error rejection cases.
Offchain domain packet metadata
internal/core/domain/offchain_tx.go, internal/core/domain/offchain_tx_event.go
Adds Packets []int field to OffchainTx and OffchainTxRequested event; Request method includes packets parameter; event replay deep-copies packets to avoid aliasing.
Packet extraction and matching helpers
internal/core/domain/offchain_tx_packets.go, internal/core/domain/offchain_tx_packets_test.go
Adds PacketTypesFromMsgTx, PacketTypesFromPSBT64 extraction functions and MaxPacketType constant; implements OffchainTxFilter.MatchPackets for type presence and exact hex-payload matching with PSBT decode on demand; tests validate extraction and matching logic.
SQL schema and indexes
internal/infrastructure/db/{postgres,sqlite}/migration/20260603162258_offchain_tx_packets.{up,down}.sql, 20260603195239_offchain_tx_packets_index.{up,down}.sql
Adds packets TEXT column to offchain_tx; creates partial index on txid where packets is non-NULL and non-empty; view recreation preserves checkpoint join semantics.
SQLC models and query layer
internal/infrastructure/db/{postgres,sqlite}/sqlc/queries/models.go, query.sql.go, query.sql
Extends OffchainTx and OffchainTxVw models with Packets sql.NullString; replaces single-row SelectOffchainTx with bulk SelectOffchainTxs (CTE-capped, extension/after/before filtering), SelectOffchainTxsByTxids (batch by txid), SelectOffchainTxsWithoutPackets (cursor pagination for backfill), and UpdateOffchainTxPackets (write packets); UpsertOffchainTx persists packets on insert/conflict.
Badger repository filtering
internal/infrastructure/db/badger/ark_repo.go
Implements GetOffchainTxs with in-memory filtering by stage, txid, packet/extension presence, timestamp bounds, and MatchPackets; sorts by starting_timestamp desc then txid asc; applies scan limit only when no txids specified.
Postgres repository filtering and backfill
internal/infrastructure/db/postgres/offchain_tx_repo.go, offchain_tx_backfill_test.go
Implements GetOffchainTxs using SQL predicates and MatchPackets; adds async BackfillPackets with cursor-batch pagination, packet decoding from PSBT, empty-on-failure sentinel (prevents retry loops), lifecycle hooks (startBackfill/Close), and CSV encode/decode helpers; test validates backfill idempotence and decode failure handling.
SQLite repository filtering and backfill
internal/infrastructure/db/sqlite/offchain_tx_repo.go, offchain_tx_backfill_test.go
Implements GetOffchainTxs using CTE-capped pagination, same predicate filtering as Postgres; adds async BackfillPackets with cursor-batch scanning, CSV encoding, and lifecycle management; test validates backfill semantics on in-memory DB.
Indexer service integration
internal/core/application/indexer.go
GetVirtualTxs and GetVirtualTxsByIntent now accept OffchainTxFilter; auth-token scoping defensively fills empty WithTxids from whitelist and validates each txid; internal getVirtualTxs uses repository GetOffchainTxs and conditionally falls back to Rounds repo only for pure txid lookups; helpers txidWhitelistToSlice and isPureTxidLookup added.
Service integration
internal/core/application/service.go, utils.go
SubmitOffchainTx extracts packet types and passes to Request; switches duplicate detection to bulk GetOffchainTxs with WithTxids; FinalizeOffchainTx and GetPendingOffchainTxs adopt bulk reads; added extractPacketTypes helper.
gRPC handler and parsing
internal/interface/grpc/handlers/indexer.go, indexer_virtualtxs_filter_test.go
GetVirtualTxs handler calls parseVirtualTxsFilter to validate scripts, compile CEL, extract extension/packet constraints, and project time ranges; handler threads filter through intent and txid-based paths; parsing unit tests validate CEL errors, time-range bounds, and scripts rejection.
Domain model test updates
internal/core/domain/offchain_tx_test.go
Offchain state machine tests updated to pass packets parameter (nil) to Request method calls across request, accept, finalize, and fail stage transitions.
Repository integration tests
internal/infrastructure/db/service_test.go
Tests switched to use bulk GetOffchainTxs with filter; validates WithExtension visibility (hides rows without extension), WithAfterDate exclusion, unrelated txid emptiness, and invalid date-range error handling.
Application test infrastructure
internal/core/application/indexer_exposure_test.go
Test factory newTestIndexer accepts optional offchain repo mock; exposure tests refactored to wire offchain mock, call GetVirtualTxs/GetVirtualTxsByIntent with OffchainTxFilter, and assert offchain expectations; valid/invalid paths use offchain bulk reads instead of rounds fallback where applicable.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • arkanaai
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add filters to indexer.GetVirtualTxs' clearly and accurately describes the main objective of the pull request, matching the core functionality added across the changeset.
Linked Issues check ✅ Passed The PR implements all primary objectives from issue #1088: CEL-expression filtering, time-range filtering, Packets field persistence, OffchainTxRepository API replacement with filter-based GetOffchainTxs, database migrations, backfill strategy, and comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing filters for indexer.GetVirtualTxs as specified in #1088. Changes to domain models, repositories, migrations, handlers, and tests are all necessary in-scope additions for the feature.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bob/GetVirtualTxs-filters

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.

@arkanaai arkanaai Bot left a comment

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.

Arkana Code Review — indexer.GetVirtualTxs Filter Support

Thorough review of the full diff. The CEL filter allowlist design is solid — the AST walker is strict, correctly rejects OR/NOT/!=/size()/.contains(), validates hex payloads, and range-checks packet types to [0, 255]. Good work there. Several issues need addressing before merge.


🔴 CRITICAL — Unbounded queries / full table scan (DoS vector)

SelectOffchainTxs (the no-txids path) in both Postgres and SQLite has no LIMIT clause. When a caller sends GetVirtualTxs with only a time-range filter and no txids, the query returns ALL matching rows. Pagination (paginate()) runs in Go after the full result set is loaded into memory. On a mature server with millions of offchain txs, this is an OOM/DoS vector.

  • Postgres: query.sql, SelectOffchainTxs query
  • SQLite: query.sql, SelectOffchainTxs query
  • Badger: ark_repo.go, GetOffchainTxs — does r.store.Find(&all, nil) loading the entire table into memory regardless of filter

Fix: Push LIMIT into the SQL queries (e.g., LIMIT maxPageSizeVirtualTxs + 1), or add a guard in GetOffchainTxs that rejects unbounded queries. The handler currently guards this via parseTxids requiring non-empty txids, but that guard is fragile — a future code path could bypass it.


🔴 HIGH — No max-length guard on CEL expression string

offchain_tx_filter.go: txfilter.TxFilterEnv.Compile(expression) runs full CEL parsing before the AST walker validates the shape. There is no length limit on the expression string. A malicious client could send a multi-MB CEL expression to force CPU-intensive parsing before type-check rejects it.

Fix: Add if len(expression) > 4096 { return error } before Compile().


🟡 MEDIUM — Backfill creates silent data-consistency window

startBackfill (both postgres/offchain_tx_repo.go and sqlite/offchain_tx_repo.go) runs a background goroutine to populate the new packets column. During backfill, queries with WithExtension: true or WithPacket will silently miss rows that haven't been backfilled yet (their packets column is still NULL, excluded by packets IS NOT NULL AND packets <> '').

There is no mechanism for the RPC handler to know whether backfill is complete, and no warning to clients. A client doing gap-fill after a subscription drop could receive incomplete results.

Suggestions:

  1. Expose a backfill-complete flag, and either warn or block filter queries until done.
  2. Or fall back to PSBT decoding for rows with NULL packets during the backfill window.

🟡 MEDIUM — FinalizeOffchainTx conflates DB errors with "not found"

internal/core/application/service.go, FinalizeOffchainTx:

offchainTxs, err := s.repoManager.OffchainTxs().GetOffchainTxs(...)
if err != nil || len(offchainTxs) == 0 {
    return "", fmt.Errorf("TX_NOT_FOUND")
}

A database timeout or connection error now returns TX_NOT_FOUND instead of a proper internal error. This is protocol-criticalFinalizeOffchainTx commits funds. Masking real DB errors behind "not found" could cause silent failures.

Fix: Check err and len(offchainTxs) == 0 separately with different error codes.


🟡 MEDIUM — SQL vs Badger inconsistency on failed-tx filtering

The SQL queries filter with COALESCE(fail_reason, '') = '', which excludes rows with a non-empty fail_reason. The badger implementation checks off.IsFailed(), which checks the domain Stage.Failed flag. A row could have Stage.Failed = true but an empty FailReason — SQL would include it, badger would exclude it. This backend inconsistency could cause hard-to-debug behavior differences.

  • Postgres/SQLite: query.sql, COALESCE(fail_reason, '') = ''
  • Badger: ark_repo.go, off.IsFailed()

🟡 MEDIUM — Semantic breaking change not gated

GetVirtualTxs now returns only offchain txs. Previously it also returned checkpoint and round-commitment txs via Rounds().GetTxsWithTxids(). Existing consumers (rust-sdk, go-sdk, ts-sdk, fulmine, emulator) that query checkpoint txids will silently get empty results. The proto comment documents this, but there's no versioning or deprecation period.

Cross-repo consumers found:

Consumer Risk
rust-sdk (ark-grpc/src/client.rs:472) Has vendored indexer.proto — needs proto sync
go-sdk → emulator, fulmine Interface signature may need updating
ts-sdk → banco REST gateway, additive fields — safe

Suggestion: Consider a include_round_txs field (default true for backward compat) or a versioned RPC.


🟢 LOW — context.Background() for backfill (no graceful shutdown)

Both postgres/offchain_tx_repo.go and sqlite/offchain_tx_repo.go call startBackfill(context.Background()). The goroutine runs until the process exits with no graceful cancellation. On large databases, this could consume DB resources for an extended period during shutdown.

Fix: Accept a context from the constructor and cancel it on Close().


🟢 LOW — Migration uses DROP+CREATE VIEW

20260603162258_offchain_tx_packets.up.sql (both backends): DROP VIEW IF EXISTS offchain_tx_vw then CREATE VIEW creates a brief window where concurrent queries against the view fail. Postgres supports CREATE OR REPLACE VIEW which is atomic.


🟢 LOW — No test coverage for backfill

startBackfill / backfillPackets has no dedicated test. service_test.go runs against a clean DB where backfill has nothing to do. There's no test verifying that pre-existing rows with packets IS NULL are correctly populated.


🟢 LOW — Duplicated encode/decode functions

encodePacketsColumn and decodePacketsColumn are duplicated verbatim between postgres/offchain_tx_repo.go and sqlite/offchain_tx_repo.go. Extract to a shared package.


⚠️ PROTOCOL-CRITICAL FLAG

Changes to FinalizeOffchainTx and SubmitOffchainTx error handling touch the offchain tx lifecycle. These paths handle fund commitment. Even though the changes look correct, human review is required per protocol-critical code policy.


Summary

Priority Count Items
🔴 Critical/High 2 Unbounded queries, CEL expression length
🟡 Medium 3 Backfill consistency window, FinalizeOffchainTx error masking, SQL/Badger inconsistency
🟢 Low 4 context.Background, DROP+CREATE, no backfill tests, code duplication

The filter design (CEL allowlist, AST walker, MatchPackets) is well-engineered. The main issues are infrastructure-level: unbounded queries, missing guards at system boundaries, and a data-consistency gap during backfill. Fix the critical/high items and the FinalizeOffchainTx error masking, and this is in good shape.

🤖 Reviewed by Arkana

@bitcoin-coder-bob bitcoin-coder-bob force-pushed the bob/GetVirtualTxs-filters branch from fecb579 to 16d2581 Compare June 4, 2026 15:48
@bitcoin-coder-bob

Copy link
Copy Markdown
Collaborator Author

@arkanaai @coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get PR details and file list
gh pr view 1090 --repo arkade-os/arkd --json title,body,files,additions,deletions,changedFiles | jq '{title, additions, deletions, changedFiles, files: [.files[].path]}'

Length of output: 2857


🏁 Script executed:

#!/bin/bash
# Get the PR diff
gh pr diff 1090 --repo arkade-os/arkd 2>/dev/null | head -5000

Length of output: 195665


🏁 Script executed:

#!/bin/bash
# Get the PR diff - second part
gh pr diff 1090 --repo arkade-os/arkd 2>/dev/null | tail -n +5001 | head -5000

Length of output: 883



@bitcoin-coder-bob Here is the full review of PR #1090.


Review Summary

This is a well-structured PR that adds CEL-expression and time-range filtering to GetVirtualTxs. The domain modelling, CEL AST walker, and backward-compatible rounds-fallback are thoughtfully designed. Below are the findings, grouped by severity.


Correctness

1. Badger backend: scan cap applies to output, not to the scan itself

In internal/infrastructure/db/badger/ark_repo.go, GetOffchainTxs first loads every offchain tx from BadgerDB (r.store.Find(&all, nil)), then filters and caps:

// Cap unconstrained queries to bound memory, matching the SQL
// backends' SelectOffchainTxs LIMIT.
if len(wantTxids) == 0 && len(out) >= domain.OffchainTxsScanLimit {
    break
}

The cap is checked against out (filtered results), not against the scan cursor. On a node with 50 k offchain txs, all 50 k are deserialized from BadgerDB into all []domain.OffchainTx before a single filter predicate runs. The SQL backends push the LIMIT to the DB engine. Consider using BadgerDB's iterator API with early exit, or at least document that the badger path has a full-table-scan cost that the SQL path does not.

2. startBackfill uses context.Background() — potential goroutine leak

Both internal/infrastructure/db/sqlite/offchain_tx_repo.go and internal/infrastructure/db/postgres/offchain_tx_repo.go call:

func (v *offchainTxRepository) startBackfill(ctx context.Context) {
    go func() {
        if err := BackfillPackets(ctx, v.db); err != nil { … }
    }()
}

But the call site passes context.Background():

repo.startBackfill(context.Background())

If the repo is closed (e.g., in a test that creates and immediately tears down a repo), the backfill goroutine has no way to be cancelled and will run against the already-closed DB, producing an error. Consider tying this to a context derived from the repo's own lifecycle (e.g., stored in the struct and cancelled in Close()).

3. Ordering inconsistency between Badger and SQL backends

The SQL queries order results ORDER BY starting_timestamp DESC, txid ASC. The Badger backend iterates over the slice returned by r.store.Find whose ordering is defined by the Badger key scan order — not by starting_timestamp. A CEL-filtered unary call against a Badger node and a Postgres node may return the same rows in different order, which breaks deterministic pagination for clients doing after=lastSeen catchup.


Minor Issues

4. Postgres backfill test hardcodes a local DSN

internal/infrastructure/db/postgres/offchain_tx_backfill_test.go line 18:

dsn := "postgres://root:secret@localhost:5432/event?sslmode=disable"

This test will always be skipped or fail in CI unless a Postgres container happens to be present. The PR description acknowledges Postgres tests need CI, but if the CI runner doesn't expose that DSN, the test effectively never runs in automation. Consider using t.Skip guarded by an env-var (e.g., TEST_POSTGRES_DSN) so the intent is explicit and the test doesn't just error out.

5. decodePacketsColumn silently drops corrupt CSV entries

n, err := strconv.Atoi(p)
if err != nil {
    continue   // silent drop
}

A persisted value like "0,abc,255" would silently decode to [0, 255], hiding storage corruption. At least add a log warning here matching the pattern used in BackfillPackets.

6. MatchPackets iterates f.WithPacket in map order (non-deterministic)

MatchPackets decodes off.ArkTx lazily (on first non-empty hexPayload). Map iteration order in Go is random, so the packet type that triggers the PSBT decode varies across calls. This is correct (it decodes at most once), but the randomness in which packet type is checked first could make the error message in case of a PSBT decode failure reference whichever type happened to be visited first. Very minor, but worth noting.

7. hasPacket followed by equality on the same packet is order-dependent in the AST walker

// hasPacket then equality on same packet merges to payload
"hasPacket(tx.extension, 9) && tx.extension[9] == 'aa'"

The test covers this, but the merge logic depends on processing hasPacket first:

if _, ok := f.WithPacket[pt]; !ok {
    f.WithPacket[pt] = ""
}

If the == predicate is processed first, it sets WithPacket[pt] = "aa", then hasPacket hits the !ok check (which is now ok = true), so it's a no-op — correct. If hasPacket is first, it sets "", and then == sets "aa". Both orders are correct. The existing conflict check (existing != "" && existing != hexData) handles the conflict case. This is fine, just worth confirming.

8. GetVirtualTxsByIntent with a CEL filter is a surprising combination

GetVirtualTxsByIntent calls filter.WithTxids = txids (from intent outpoints) and then calls getVirtualTxs. If the caller also supplies a CEL expression, it could return 0 results even though the intent-derived txids exist — because the CEL predicate would be applied in SQL/memory. This is probably intentional (handler rejects intent + filter? Let me check…) — looking at the handler, GetVirtualTxsByIntent is only invoked when request.GetIntent() != nil, and parseVirtualTxsFilter runs regardless, so a request with both intent and a filter expression will attempt to apply both. The intent path builds txids from the intent's outpoints and then applies the CEL filter on top. This might be surprising to clients — consider rejecting intent + filter with InvalidArgument if you don't want to support this combination.


Nits

  • parseTxids is still called in the handler even when the intent path is taken (request.GetTxids() would be empty in that case), so parsing always runs. No correctness issue since an empty txid list passes validation.
  • The SelectOffchainTxsByTxids Postgres query uses ANY($1::varchar[]) (correct), while the SQLite generated code correctly expands the /*SLICE:txids*/ placeholder — both look fine.
  • The TimeRangeAfter/TimeRangeBefore/TimeRangeWithin types could have been a single TimeRange message with optional start/end, but the current separate-message design maps cleanly to the protobuf oneof.

🐇 (\(\) (ears up for a thorough review!)


🧠 Learnings used
Learnt from: altafan
Repo: arkade-os/arkd PR: 1081
File: pkg/client-lib/indexer/grpc/client.go:716-738
Timestamp: 2026-05-26T14:02:02.196Z
Learning: In `pkg/client-lib/indexer/grpc/client.go` (Go, gRPC client for the arkd indexer), the `Scripts` and `Outpoints` fields in `GetVtxosOptions` are mutually exclusive filter types — a caller will never populate both at the same time. The `paginatedGetVtxos` function safely iterates over each in separate loops without risk of duplicate VTXOs.

@bitcoin-coder-bob

Copy link
Copy Markdown
Collaborator Author

@arkanaai @coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

@bitcoin-coder-bob Sure, I'll kick off a fresh review of PR #1090!


🧠 Learnings used
Learnt from: bitcoin-coder-bob
Repo: arkade-os/arkd PR: 1001
File: internal/core/application/token_cache.go:130-142
Timestamp: 2026-04-08T17:54:38.746Z
Learning: In the arkade-os/arkd repository (PR `#1001`), the `ListTokens` and `RevokeTokens` admin endpoints in `internal/core/application/token_cache.go` treat the filters `token`, `hash`, `outpoint`, and `txid` as mutually exclusive — only one filter is expected to be provided at a time. The early-return fast paths in `tokenCache.list` and `tokenCache.revoke` (e.g., `if hash != "" { return c.listByHash(hash) }`) are intentional and correct by design. Do not suggest combined/AND semantics for these filter parameters.

Learnt from: altafan
Repo: arkade-os/arkd PR: 1081
File: pkg/client-lib/indexer/grpc/client.go:716-738
Timestamp: 2026-05-26T14:02:02.196Z
Learning: In `pkg/client-lib/indexer/grpc/client.go` (Go, gRPC client for the arkd indexer), the `Scripts` and `Outpoints` fields in `GetVtxosOptions` are mutually exclusive filter types — a caller will never populate both at the same time. The `paginatedGetVtxos` function safely iterates over each in separate loops without risk of duplicate VTXOs.

Learnt from: bitcoin-coder-bob
Repo: arkade-os/arkd PR: 904
File: internal/core/application/indexer.go:489-490
Timestamp: 2026-02-09T20:46:28.105Z
Learning: In ValidateIntentWithProof and DeleteIntentsByProof functions in internal/core/application/indexer.go and internal/core/application/service.go, the pattern `if err != nil || len(vtxosResult) == 0` after calling GetVtxos is intentional. Both DB errors and missing vtxos are handled the same way by falling back to the on-chain wallet lookup, providing a defensive fail-safe mechanism that uses the blockchain as the authoritative source when vtxo state is uncertain.

Learnt from: bitcoin-coder-bob
Repo: arkade-os/arkd PR: 1074
File: api-spec/openapi/swagger/ark/v1/indexer.openapi.json:443-502
Timestamp: 2026-05-21T15:28:32.765Z
Learning: In the arkade-os/arkd repository, duplicate query parameters (e.g., `filter.scripts.modify.removeScripts` appearing twice) in the generated OpenAPI spec (`api-spec/openapi/swagger/ark/v1/indexer.openapi.json`) are a known pre-existing artifact of the meshapi-gateway codegen with `oneof`+`repeated` fields — not a bug introduced by individual PRs. First observed in PR `#951` for `ScriptsFilter.modify.removeScripts`. Should be tracked as an upstream meshapi-gateway issue rather than flagged per-PR.

Learnt from: bitcoin-coder-bob
Repo: arkade-os/arkd PR: 0
File: :0-0
Timestamp: 2026-05-21T16:29:27.116Z
Learning: In arkade-os/arkd, the HTTP route family `/v1/indexer/script/...` (including `/v1/indexer/script/updateScripts`) is intentionally renamed/refactored as a whole in PR `#951`, not piecemeal in individual feature PRs like `#1074`. Do not flag the misleading path in feature PRs that build on top of this route family.
✅ Action performed

Review finished.

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.

@arkanaai

arkanaai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review: Add filters to indexer.GetVirtualTxs

Solid PR overall — the layering is clean, the design rationale is well-documented, and the test coverage (unit tests for MatchPackets, parseVirtualTxsFilter, backfill tests for both PG and SQLite) is thorough. A few things worth looking at before merge:


🐛 Bug: down migration ordering will fail in PostgreSQL

In 20260603162258_offchain_tx_packets.down.sql the order is:

DROP VIEW IF EXISTS offchain_tx_vw;          -- (1) view dropped
CREATE VIEW offchain_tx_vw AS SELECT offchain_tx.*, ...;  -- (2) new view created — packets column still exists, so it's included
ALTER TABLE offchain_tx DROP COLUMN packets;  -- (3) fails: view created in step (2) depends on the packets column

PostgreSQL resolves SELECT offchain_tx.* into explicit column references at CREATE VIEW time, so the view created in step (2) will have a dependency on packets. Step (3) will then error with:

cannot drop column packets of table offchain_tx because other objects depend on it

The fix is to swap steps (2) and (3):

DROP VIEW IF EXISTS offchain_tx_vw;
ALTER TABLE offchain_tx DROP COLUMN packets;  -- column gone first
CREATE VIEW offchain_tx_vw AS SELECT offchain_tx.*, ...;  -- now * doesn't include packets

Same issue in the SQLite down migration (SQLite won't error, but the view would silently reference a ghost column until rebuilt).


⚠️ Silent scan truncation on filtered queries

When WithPacket is set (specific packet-type equality check), the SQL layer can only push down the broad packets IS NOT NULL AND packets <> '' guard, not the per-type comparison. Go then calls MatchPackets to apply the exact filter. With OffchainTxsScanLimit = 10000 as the SQL LIMIT, if the DB has >10k rows with extensions, the client may silently receive an incomplete result set — not all matching rows — with no indication that truncation occurred.

This is acknowledged as a follow-up for SQL pagination pushdown, which is fair. But since the primary use case is "fill the gap after a stream drops" (typically a bounded time window), it's worth adding a note to the RPC docs or response that the time-range bound should be used alongside the extension filter to keep the scan set small. Without a time-range constraint, a pure hasPacket(tx.extension, N) filter against a mature node could silently return a truncated set.


Minor notes

isPureTxidLookup + intent path: In GetVirtualTxsByIntent, filter.WithTxids is set internally from the intent's outpoints (overwriting any prior value). This is correct, but it means a CEL filter combined with intent-based auth disables the rounds-repo fallback, which is intentional but may surprise callers who aren't aware of the "offchain-only when filter is set" scope restriction. The proto comment covers this — just confirming the logic is consistent.

ctx.Value("tx") in badger: Carrying a *badger.Txn in context with a plain string key is inherited from the existing codebase, not introduced here. Still a smell worth cleaning up eventually (unexported key type to avoid collisions), but not a blocker.

WithPacket → SQL with_extension pushdown: When WithPacket is set but WithExtension is false, the SQL doesn't apply the packets IS NOT NULL AND packets <> '' guard. The badger backend correctly uses len(filter.WithPacket) > 0 as an additional trigger for this check. In the SQL path, rows with empty packets will be fetched and then silently dropped by MatchPackets — correct but avoidably wasteful. Could pass withExtension = withExtension || len(filter.WithPacket) > 0 at the repo boundary.


Summary: The down migration ordering bug in PostgreSQL is the only must-fix before merge. The scan limit concern is worth documenting but acceptable given the "time-range as a bound" usage pattern. Everything else is minor. Good work on the backfill design (non-blocking, idempotent, keyset-paginated, marks malformed rows so they aren't retried) and on fixing the FinalizeOffchainTx DB-error vs not-found conflation.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 11

Caution

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

⚠️ Outside diff range comments (2)
internal/core/domain/offchain_tx.go (1)

73-97: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Defensively copy packet slices at event/state boundaries.

Line 73 and Line 243 assign packet slices by reference. Because slices alias backing arrays, later mutation of the original/event slice can silently mutate aggregate state. Snapshot the slice when raising and when applying the event.

Suggested fix
 func (s *OffchainTx) Request(
 	arkTxid, arkTx string, unsignedCheckpointTxs map[string]string, packets []int,
 ) (Event, error) {
+	packetCopy := append([]int(nil), packets...)
 	if s.IsFailed() || s.Stage.Code != int(OffchainTxUndefinedStage) {
 		return nil, fmt.Errorf("not in a valid stage to request offchain tx")
 	}
@@
 	event := OffchainTxRequested{
@@
-		Packets:               packets,
+		Packets:               packetCopy,
 	}
@@
 	case OffchainTxRequested:
@@
-		s.Packets = e.Packets
+		s.Packets = append([]int(nil), e.Packets...)

Also applies to: 243-243

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/core/domain/offchain_tx.go` around lines 73 - 97, The Packets slice
is being assigned by reference into the OffchainTxRequested event and later into
state (causing aliasing bugs); make defensive copies both when creating the
event (replace direct assignment of Packets into OffchainTxRequested with a new
slice copy, e.g. append([]int(nil), packets...)) and when applying the
OffchainTxRequested event to state (copy the event.Packets into state instead of
assigning the slice directly). Update the event creation site where
OffchainTxRequested is constructed and the event application/reducer that
handles OffchainTxRequested (the code that reads event.Packets and writes it
into the aggregate/state) to use make+copy or append to produce an independent
slice.
internal/core/application/indexer.go (1)

430-456: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Token whitelist is bypassed when filter.WithTxids is empty.

At Line 448, auth scope is only enforced by iterating filter.WithTxids; then Line 456 forwards the unmodified filter to storage. Since the gRPC path can pass filters without txids, a valid token can query outside its txid scope (private mode = full auth bypass; withheld mode can incorrectly avoid signature stripping).

Suggested fix
 func (i *indexerService) GetVirtualTxs(
 	ctx context.Context, authToken string, filter domain.OffchainTxFilter, page *Page,
 ) (*VirtualTxsResp, error) {
 	var valid bool
 	switch i.txExposure {
@@
 	case exposureWithheld:
@@
-			valid = true
-			for _, txid := range filter.WithTxids {
+			valid = true
+			if len(filter.WithTxids) == 0 {
+				filter.WithTxids = make([]string, 0, len(txidWhitelist))
+				for txid := range txidWhitelist {
+					filter.WithTxids = append(filter.WithTxids, txid)
+				}
+			}
+			for _, txid := range filter.WithTxids {
 				if _, ok := txidWhitelist[txid]; !ok {
 					valid = false
 					break
 				}
 			}
@@
 	case exposurePrivate:
@@
+		if len(filter.WithTxids) == 0 {
+			filter.WithTxids = make([]string, 0, len(txidWhitelist))
+			for txid := range txidWhitelist {
+				filter.WithTxids = append(filter.WithTxids, txid)
+			}
+		}
 		for _, txid := range filter.WithTxids {
 			if _, ok := txidWhitelist[txid]; !ok {
 				return nil, fmt.Errorf("auth token is not for txid %s", txid)
 			}
 		}
 		valid = true
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/core/application/indexer.go` around lines 430 - 456, The auth token
whitelist is effectively bypassed when filter.WithTxids is empty; in
validateAuthToken/tokenCache.getTxids handling you must enforce the token's txid
scope before calling getVirtualTxs. After computing hash via i.validateAuthToken
and retrieving txidWhitelist via i.tokenCache.getTxids, if filter.WithTxids is
empty, populate filter.WithTxids with the whitelisted txids (or return an error
if none) so the storage query is limited to the token scope; if filter.WithTxids
is non-empty, validate each entry against txidWhitelist as currently done.
Ensure these changes occur before calling i.getVirtualTxs and keep references to
validateAuthToken, tokenCache.getTxids, filter.WithTxids and getVirtualTxs to
locate the code.
🧹 Nitpick comments (1)
internal/core/domain/offchain_tx_test.go (1)

54-55: ⚡ Quick win

Add one happy-path assertion for non-empty packet propagation.

All updated Request calls currently pass nil, so this suite doesn’t validate the newly added Packets flow. Add one valid request case with non-empty packets and assert both aggregate state and emitted OffchainTxRequested.Packets.

Suggested test update
 		t.Run("valid", func(t *testing.T) {
 			offchainTx := domain.NewOffchainTx()
+			packets := []int{0, 255}
@@
-			event, err := offchainTx.Request(txid, arkTx, unsignedCheckpointTxs, nil)
+			event, err := offchainTx.Request(txid, arkTx, unsignedCheckpointTxs, packets)
 			require.NoError(t, err)
 			require.NotNil(t, event)
@@
 			require.Equal(t, unsignedCheckpointTxs, offchainTx.CheckpointTxs)
+			require.Equal(t, packets, offchainTx.Packets)
 			require.NotEmpty(t, offchainTx.StartingTimestamp)
+
+			reqEvt, ok := event.(domain.OffchainTxRequested)
+			require.True(t, ok)
+			require.Equal(t, packets, reqEvt.Packets)

Also applies to: 124-126, 136-137, 266-267, 344-345

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/core/domain/offchain_tx_test.go` around lines 54 - 55, Add a
happy-path assertion that exercises the new Packets flow by calling Request with
a non-nil, non-empty packets slice (instead of the current nil) and asserting
both the aggregate state and the emitted OffchainTxRequested event contain that
same packets data; update the test that currently calls offchainTx.Request(txid,
arkTx, unsignedCheckpointTxs, nil) to include a sample packets value, then
assert the returned aggregate (from Request) reflects packet propagation and
that any emitted OffchainTxRequested.Packets equals the sample packets slice;
apply the same pattern to the other analogous Request test cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@api-spec/protobuf/ark/v1/indexer.proto`:
- Around line 267-277: The RPC uses a broad SubscriptionFilter type but
parseVirtualTxsFilter only accepts a single expression and forbids scripts;
define and use a unary-specific filter message (e.g., UnarySubscriptionFilter or
VirtualTxsFilter) that exposes exactly one optional expressions string and no
scripts, replace the field declaration SubscriptionFilter filter = 5; with that
new message type, and update the parser/function parseVirtualTxsFilter to accept
and validate the new unary-specific message so the proto and generated OpenAPI
accurately reflect the allowed shape.

In `@internal/core/application/indexer_exposure_test.go`:
- Around line 199-205: The tests set expectations on mockedRoundRepo
(rounds.On(...)) but only call off.AssertExpectations(t) in TestGetVirtualTxs,
so round expectations are never verified; update each test case that calls
setupMocks (the setupMocks closure, mockedRoundRepo and mockedOffchainTxRepo
usage in TestGetVirtualTxs) to also call rounds.AssertExpectations(t) after
off.AssertExpectations(t) (or replace the single AssertExpectations(t) with
assertions for both mocks) so the mockedRoundRepo expectations are asserted for
all cases where rounds.On(...) is used.

In `@internal/core/domain/offchain_tx_repo.go`:
- Around line 32-55: OffchainTxFilter's WithAfterDate/WithBeforeDate use 0 as a
sentinel so a legitimate timestamp 0 cannot be expressed; change the struct to
track presence explicitly (either make WithAfterDate and WithBeforeDate pointers
*int64 or add boolean flags e.g. WithAfterDatePresent/WithBeforeDatePresent) and
update Validate to check presence rather than value (e.g. if present and <0
error; if both present enforce order). Also update any SQL repo code that
currently tests "> 0" for pushdown to instead test the presence flags or nil
pointers so a timestamp of 0 is honored.

In `@internal/infrastructure/db/badger/ark_repo.go`:
- Around line 261-276: GetOffchainTxs currently reads all offchain_tx rows into
memory via r.store.Find / r.store.TxFind and only afterwards applies
OffchainTxsScanLimit, causing unbounded scans; change the logic in
GetOffchainTxs to apply the scan cap at the query stage by using badgerhold
query predicates with a limit/stop condition (or iterate with an early exit) so
no more than OffchainTxsScanLimit entries are loaded; update both the tx-aware
branch (ctx.Value("tx") handling using tx.(*badger.Txn) with r.store.TxFind) and
the non-tx branch (r.store.Find) to use the bounded query/iterator approach and
stop once the limit is reached, returning the partial results and no full-table
read.

In
`@internal/infrastructure/db/postgres/migration/20260603162258_offchain_tx_packets.down.sql`:
- Around line 1-14: The rollback tries to recreate view offchain_tx_vw using
offchain_tx.* before removing the packets column, causing DROP COLUMN packets to
fail because the view depends on it; fix by reordering the down migration so the
ALTER TABLE offchain_tx DROP COLUMN packets runs before recreating/creating the
offchain_tx_vw (or alternatively recreate the view selecting explicit columns
that exclude packets), updating the migration to drop the column first then
create the view (refer to offchain_tx_vw and ALTER TABLE offchain_tx DROP COLUMN
packets to locate the statements).

In `@internal/infrastructure/db/postgres/offchain_tx_backfill_test.go`:
- Around line 26-28: Replace the hardcoded dsn with an env-configured one: read
the DSN via os.Getenv("POSTGRES_DSN") at the top of the test in
offchain_tx_backfill_test.go, and if it is empty call t.Skip("skipping Postgres
integration test; set POSTGRES_DSN to run") to gate the test; otherwise pass
that value into sql.Open instead of the literal string (keep the require.NoError
checks as-is). Use the variable name dsn, sql.Open and require.NoError
referenced in the test so the change is localized and obvious.

In `@internal/infrastructure/db/postgres/sqlc/query.sql`:
- Around line 290-299: The query SelectOffchainTxs currently applies LIMIT to
rows emitted by offchain_tx_vw (which can duplicate txids due to checkpoint
joins) so the cap is row-based not tx-based; change the query to first select
distinct base transactions (e.g., select txid and starting_timestamp from the
underlying offchain_tx base table or a subquery that selects distinct txids
using offchain_tx_vw but grouped by txid) applying the LIMIT there, and then
join that limited set back to offchain_tx_vw to expand rows per txid; update
references to offchain_tx_vw and the ORDER BY/LIMIT to ensure LIMIT
sqlc.arg('lim')::int is applied to the deduplicated txid set (SelectOffchainTxs
-> use a subquery/CTE that produces unique txids before joining to
offchain_tx_vw).

In `@internal/infrastructure/db/sqlite/offchain_tx_repo.go`:
- Around line 33-34: The backfill goroutine is started with context.Background()
via repo.startBackfill which leaves it running past repository shutdown; change
startBackfill to accept a context (or use a repo-scoped context created with
context.WithCancel stored on the repo struct) and start it with that cancellable
context, store the cancel func and a sync.WaitGroup (or other join mechanism) on
the repo, then modify Close to call the cancel func and wait for the
goroutine(s) to exit (using the WaitGroup or equivalent) so the backfill stops
and any DB access is finished before Close returns; update calls to
repo.startBackfill, the repo struct, and Close to use these new fields (context
cancel + wait) accordingly.

In `@internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go`:
- Around line 767-775: The SQL template for SelectOffchainTxsByTxids mixes
slice-expanded anonymous placeholders for txids (txid IN (/*SLICE:txids*/?))
with explicitly numbered placeholders (?2..?6), causing bind parameter
misalignment when len(txids)>1; update the query template to use only anonymous
placeholders for the subsequent filter arguments (replace CAST(?2..?6 AS
INTEGER) with CAST(? AS INTEGER) placeholders in the conditions that reference
packets, starting_timestamp bounds, and then regenerate the sqlc code so the
generated SelectOffchainTxsByTxids function and its callers use consistent
anonymous ? bindings.

In `@internal/infrastructure/db/sqlite/sqlc/query.sql`:
- Around line 296-304: The query SelectOffchainTxs currently applies LIMIT `@lim`
to the expanded offchain_tx_vw result, which can under-return unique offchain
txids; instead first select the capped set of unique offchain txids (e.g., a
subquery selecting DISTINCT txid or using ROW_NUMBER() OVER (PARTITION BY txid)
to pick one row per txid) applying the same filters and ORDER BY/LIMIT `@lim`,
then join that subquery back to offchain_tx_vw to retrieve the full
view-expanded rows for only those capped txids; update the SelectOffchainTxs SQL
to perform that two-step (subquery cap by txid then join to offchain_tx_vw)
using the same parameters (`@with_extension`, `@with_after/`@after_ts,
`@with_before/`@before_ts, `@lim`).

In `@internal/interface/grpc/handlers/indexer.go`:
- Around line 391-403: The else branch currently always calls
parseTxids(request.GetTxids()) and errors when missing txids; change it to only
parse and set filter.WithTxids when the request actually contains txids (e.g.
len(request.GetTxids())>0 or request.GetTxids()!=nil), otherwise skip parsing
and leave filter.WithTxids empty/nil, then call e.indexerSvc.GetVirtualTxs(ctx,
request.GetToken(), filter, page) as-is; keep the same error handling for
parseTxids when present and use the existing symbols parseIndexerIntent,
parseTxids, GetVirtualTxsByIntent, and GetVirtualTxs to locate and update the
logic.

---

Outside diff comments:
In `@internal/core/application/indexer.go`:
- Around line 430-456: The auth token whitelist is effectively bypassed when
filter.WithTxids is empty; in validateAuthToken/tokenCache.getTxids handling you
must enforce the token's txid scope before calling getVirtualTxs. After
computing hash via i.validateAuthToken and retrieving txidWhitelist via
i.tokenCache.getTxids, if filter.WithTxids is empty, populate filter.WithTxids
with the whitelisted txids (or return an error if none) so the storage query is
limited to the token scope; if filter.WithTxids is non-empty, validate each
entry against txidWhitelist as currently done. Ensure these changes occur before
calling i.getVirtualTxs and keep references to validateAuthToken,
tokenCache.getTxids, filter.WithTxids and getVirtualTxs to locate the code.

In `@internal/core/domain/offchain_tx.go`:
- Around line 73-97: The Packets slice is being assigned by reference into the
OffchainTxRequested event and later into state (causing aliasing bugs); make
defensive copies both when creating the event (replace direct assignment of
Packets into OffchainTxRequested with a new slice copy, e.g. append([]int(nil),
packets...)) and when applying the OffchainTxRequested event to state (copy the
event.Packets into state instead of assigning the slice directly). Update the
event creation site where OffchainTxRequested is constructed and the event
application/reducer that handles OffchainTxRequested (the code that reads
event.Packets and writes it into the aggregate/state) to use make+copy or append
to produce an independent slice.

---

Nitpick comments:
In `@internal/core/domain/offchain_tx_test.go`:
- Around line 54-55: Add a happy-path assertion that exercises the new Packets
flow by calling Request with a non-nil, non-empty packets slice (instead of the
current nil) and asserting both the aggregate state and the emitted
OffchainTxRequested event contain that same packets data; update the test that
currently calls offchainTx.Request(txid, arkTx, unsignedCheckpointTxs, nil) to
include a sample packets value, then assert the returned aggregate (from
Request) reflects packet propagation and that any emitted
OffchainTxRequested.Packets equals the sample packets slice; apply the same
pattern to the other analogous Request test cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c91e2fd0-9c28-4a30-aced-a274fa6173a9

📥 Commits

Reviewing files that changed from the base of the PR and between 6db7a6b and a16fa71.

⛔ Files ignored due to path filters (2)
  • api-spec/protobuf/gen/ark/v1/indexer.pb.go is excluded by !**/*.pb.go, !**/gen/**
  • api-spec/protobuf/gen/ark/v1/indexer.pb.rgw.go is excluded by !**/gen/**
📒 Files selected for processing (36)
  • api-spec/openapi/swagger/ark/v1/indexer.openapi.json
  • api-spec/protobuf/ark/v1/indexer.proto
  • internal/core/application/indexer.go
  • internal/core/application/indexer_exposure_test.go
  • internal/core/application/offchain_tx_filter.go
  • internal/core/application/offchain_tx_filter_test.go
  • internal/core/application/service.go
  • internal/core/application/utils.go
  • internal/core/domain/offchain_tx.go
  • internal/core/domain/offchain_tx_event.go
  • internal/core/domain/offchain_tx_packets.go
  • internal/core/domain/offchain_tx_packets_test.go
  • internal/core/domain/offchain_tx_repo.go
  • internal/core/domain/offchain_tx_test.go
  • internal/infrastructure/db/badger/ark_repo.go
  • internal/infrastructure/db/postgres/migration/20260603162258_offchain_tx_packets.down.sql
  • internal/infrastructure/db/postgres/migration/20260603162258_offchain_tx_packets.up.sql
  • internal/infrastructure/db/postgres/migration/20260603195239_offchain_tx_packets_index.down.sql
  • internal/infrastructure/db/postgres/migration/20260603195239_offchain_tx_packets_index.up.sql
  • internal/infrastructure/db/postgres/offchain_tx_backfill_test.go
  • internal/infrastructure/db/postgres/offchain_tx_repo.go
  • internal/infrastructure/db/postgres/sqlc/queries/models.go
  • internal/infrastructure/db/postgres/sqlc/queries/query.sql.go
  • internal/infrastructure/db/postgres/sqlc/query.sql
  • internal/infrastructure/db/service_test.go
  • internal/infrastructure/db/sqlite/migration/20260603162258_offchain_tx_packets.down.sql
  • internal/infrastructure/db/sqlite/migration/20260603162258_offchain_tx_packets.up.sql
  • internal/infrastructure/db/sqlite/migration/20260603195239_offchain_tx_packets_index.down.sql
  • internal/infrastructure/db/sqlite/migration/20260603195239_offchain_tx_packets_index.up.sql
  • internal/infrastructure/db/sqlite/offchain_tx_backfill_test.go
  • internal/infrastructure/db/sqlite/offchain_tx_repo.go
  • internal/infrastructure/db/sqlite/sqlc/queries/models.go
  • internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go
  • internal/infrastructure/db/sqlite/sqlc/query.sql
  • internal/interface/grpc/handlers/indexer.go
  • internal/interface/grpc/handlers/indexer_virtualtxs_filter_test.go

Comment on lines +267 to +277
// Optional filter on tx contents. The expressions field carries CEL
// expressions evaluated against the tx envelope (see SubscriptionFilter).
// Only the structured subset of CEL is supported on this unary RPC: a
// flat AND of `has(tx.extension)`, `hasPacket(tx.extension, N)`, and
// `tx.extension[N] == 'hex'` predicates. `tx.extension[N] == 'hex'`
// matches exactly (same semantics as the streaming SubscriptionFilter).
// Unsupported shapes (OR, NOT, !=, size(), .contains(), non-bool
// expressions, etc.) are rejected with InvalidArgument. The scripts
// field must be left empty; non-empty scripts are also rejected with
// InvalidArgument.
SubscriptionFilter filter = 5;

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 | 🟠 Major | 🏗️ Heavy lift

Use a unary-specific filter message here.

Reusing SubscriptionFilter makes this RPC's wire contract broader than the implementation: callers can legally send repeated expressions and scripts, but parseVirtualTxsFilter only accepts one expression and rejects any non-empty scripts. The generated OpenAPI inherits the same shape, so both gRPC and REST clients are told they can send requests that will always fail with InvalidArgument.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api-spec/protobuf/ark/v1/indexer.proto` around lines 267 - 277, The RPC uses
a broad SubscriptionFilter type but parseVirtualTxsFilter only accepts a single
expression and forbids scripts; define and use a unary-specific filter message
(e.g., UnarySubscriptionFilter or VirtualTxsFilter) that exposes exactly one
optional expressions string and no scripts, replace the field declaration
SubscriptionFilter filter = 5; with that new message type, and update the
parser/function parseVirtualTxsFilter to accept and validate the new
unary-specific message so the proto and generated OpenAPI accurately reflect the
allowed shape.

Comment on lines +199 to 205
setupMocks: func(off *mockedOffchainTxRepo, rounds *mockedRoundRepo) {
off.On("GetOffchainTxs", mock.Anything, mock.Anything).
Return([]*domain.OffchainTx{}, nil)
// Pure-txid fallback into the rounds repo for txids
// the offchain repo didn't return.
rounds.On("GetTxsWithTxids", mock.Anything, testTxids).
Return([]string{}, nil)

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 | ⚡ Quick win

mockedRoundRepo expectations are never asserted in TestGetVirtualTxs.

You set rounds.On(...) in several cases, but at Line 291 only off.AssertExpectations(t) is called. That can hide regressions in the rounds fallback path.

Suggested fix
 				require.NoError(t, err)
 				require.Len(t, resp.Txs, tc.wantTxs)

 				off.AssertExpectations(t)
+				rounds.AssertExpectations(t)
 			})
 		}
 	})

Also applies to: 216-221, 230-235, 248-253, 291-292

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/core/application/indexer_exposure_test.go` around lines 199 - 205,
The tests set expectations on mockedRoundRepo (rounds.On(...)) but only call
off.AssertExpectations(t) in TestGetVirtualTxs, so round expectations are never
verified; update each test case that calls setupMocks (the setupMocks closure,
mockedRoundRepo and mockedOffchainTxRepo usage in TestGetVirtualTxs) to also
call rounds.AssertExpectations(t) after off.AssertExpectations(t) (or replace
the single AssertExpectations(t) with assertions for both mocks) so the
mockedRoundRepo expectations are asserted for all cases where rounds.On(...) is
used.

Comment on lines +32 to +55
type OffchainTxFilter struct {
WithTxids []string
WithExtension bool
WithPacket map[int]string
WithAfterDate int64
WithBeforeDate int64
}

// Validate enforces the structural invariants of the filter. The empty
// filter is allowed. WithAfterDate / WithBeforeDate may be set together
// (forming a "within" range) or individually. Bounds are inclusive on
// both sides, so before == after is permitted and selects rows whose
// starting_timestamp equals that value.
func (f OffchainTxFilter) Validate() error {
if f.WithAfterDate < 0 {
return fmt.Errorf("with_after_date must be non-negative")
}
if f.WithBeforeDate < 0 {
return fmt.Errorf("with_before_date must be non-negative")
}
if f.WithAfterDate > 0 && f.WithBeforeDate > 0 && f.WithBeforeDate < f.WithAfterDate {
return fmt.Errorf("with_before_date must be greater than or equal to with_after_date")
}
return nil

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 | 🟠 Major | 🏗️ Heavy lift

The time-range fields can't represent a valid 0 timestamp.

WithAfterDate and WithBeforeDate are optional, but this shape has no presence bit, so 0 is indistinguishable from "unset". The SQL repositories gate bound pushdown on > 0, which means after.timestamp = 0 and within(0, 0) silently drop the bound even though the public contract says the range is inclusive. This needs explicit presence flags or pointer timestamps instead of raw int64 sentinels.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/core/domain/offchain_tx_repo.go` around lines 32 - 55,
OffchainTxFilter's WithAfterDate/WithBeforeDate use 0 as a sentinel so a
legitimate timestamp 0 cannot be expressed; change the struct to track presence
explicitly (either make WithAfterDate and WithBeforeDate pointers *int64 or add
boolean flags e.g. WithAfterDatePresent/WithBeforeDatePresent) and update
Validate to check presence rather than value (e.g. if present and <0 error; if
both present enforce order). Also update any SQL repo code that currently tests
"> 0" for pushdown to instead test the presence flags or nil pointers so a
timestamp of 0 is honored.

Comment on lines +261 to +276
// NOTE: badgerhold has no per-field ordering or LIMIT push-down, so
// this call reads every offchain_tx row into memory before any
// predicate runs. Acceptable for the small-scale dev/test setups
// that use the badger backend; production deployments should use
// the SQL backends, which push the LIMIT to the DB engine.
var all []domain.OffchainTx
if ctx.Value("tx") != nil {
tx := ctx.Value("tx").(*badger.Txn)
if err := r.store.TxFind(tx, &all, nil); err != nil {
return nil, err
}
} else {
if err := r.store.Find(&all, nil); err != nil {
return nil, err
}
}

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 | 🟠 Major | 🏗️ Heavy lift

Scan cap is applied too late to protect Badger from full-table reads.

GetOffchainTxs loads all offchain_tx records before filtering and only then truncates to OffchainTxsScanLimit. This keeps no-txids requests unbounded in storage scan cost and memory, despite the cap.

Also applies to: 328-332

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/infrastructure/db/badger/ark_repo.go` around lines 261 - 276,
GetOffchainTxs currently reads all offchain_tx rows into memory via r.store.Find
/ r.store.TxFind and only afterwards applies OffchainTxsScanLimit, causing
unbounded scans; change the logic in GetOffchainTxs to apply the scan cap at the
query stage by using badgerhold query predicates with a limit/stop condition (or
iterate with an early exit) so no more than OffchainTxsScanLimit entries are
loaded; update both the tx-aware branch (ctx.Value("tx") handling using
tx.(*badger.Txn) with r.store.TxFind) and the non-tx branch (r.store.Find) to
use the bounded query/iterator approach and stop once the limit is reached,
returning the partial results and no full-table read.

Comment thread internal/infrastructure/db/postgres/sqlc/query.sql
Comment thread internal/infrastructure/db/sqlite/offchain_tx_repo.go Outdated
Comment thread internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go
Comment thread internal/infrastructure/db/sqlite/sqlc/query.sql Outdated
Comment on lines 391 to +403
if request.GetIntent() != nil {
intent, parseErr := parseIndexerIntent(request.GetIntent())
if parseErr != nil {
return nil, status.Error(codes.InvalidArgument, parseErr.Error())
}
resp, err = e.indexerSvc.GetVirtualTxsByIntent(ctx, *intent, page)
resp, err = e.indexerSvc.GetVirtualTxsByIntent(ctx, *intent, filter, page)
} else {
txids, parseErr := parseTxids(request.GetTxids())
if parseErr != nil {
return nil, status.Error(codes.InvalidArgument, parseErr.Error())
}
resp, err = e.indexerSvc.GetVirtualTxs(ctx, request.GetToken(), txids, page)
filter.WithTxids = txids
resp, err = e.indexerSvc.GetVirtualTxs(ctx, request.GetToken(), filter, page)

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 | 🟠 Major | 🏗️ Heavy lift

Allow filtered/token-based lookups without txids.

This branch still calls parseTxids(request.GetTxids()) whenever intent is absent, so token + filter/time_range requests fail with missing txids even though GetVirtualTxsRequest now documents lookup "by id and/or by filter". The application layer already has an unpinned scan path, so the replay/catch-up flow stays blocked until the handler and HTTP binding both stop requiring a txid list.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/interface/grpc/handlers/indexer.go` around lines 391 - 403, The else
branch currently always calls parseTxids(request.GetTxids()) and errors when
missing txids; change it to only parse and set filter.WithTxids when the request
actually contains txids (e.g. len(request.GetTxids())>0 or
request.GetTxids()!=nil), otherwise skip parsing and leave filter.WithTxids
empty/nil, then call e.indexerSvc.GetVirtualTxs(ctx, request.GetToken(), filter,
page) as-is; keep the same error handling for parseTxids when present and use
the existing symbols parseIndexerIntent, parseTxids, GetVirtualTxsByIntent, and
GetVirtualTxs to locate and update the logic.

@bitcoin-coder-bob bitcoin-coder-bob marked this pull request as ready for review June 5, 2026 19:13
@bitcoin-coder-bob bitcoin-coder-bob requested a review from altafan June 5, 2026 19:13
Resolve conflict in sqlite offchain_tx_repo.go: adopt master's split
read/write SQLiteDB interface (withReadQuerier for GetOffchainTxs reads,
v.db.Write() for writes and the packets backfill) on top of the PR's
filter-based GetOffchainTxs and packets backfill.
Resolve conflicts from #1123 (return only accepted/finalized offchain
txs). The PR replaced the singular SelectOffchainTx with the plural
filter queries SelectOffchainTxs[ByTxids]; fold #1123's intent into them
by changing the base filter to (stage_code = 2 OR stage_code = 3) for
both sqlite and postgres, regenerate sqlc, and drop the now-unused
singular query. service_test.go keeps the PR's filter-pushdown coverage
and adopts #1123's request->accept->fail->finalize subtest, converted to
GetOffchainTxs.
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.

Add filters to GetVirtualTxs rpc

1 participant