Skip to content

Fix: Finalize swept pending tx#999

Open
louisinger wants to merge 3 commits intoarkade-os:masterfrom
louisinger:finalize-swept-pending-tx
Open

Fix: Finalize swept pending tx#999
louisinger wants to merge 3 commits intoarkade-os:masterfrom
louisinger:finalize-swept-pending-tx

Conversation

@louisinger
Copy link
Copy Markdown
Collaborator

@louisinger louisinger commented Apr 1, 2026

we need 2 test cases :

  • finalize a pending tx after it has been swept by the server
  • finalize a pending tx after it has expired but was not swept by the server

@altafan please review

Summary by CodeRabbit

  • Bug Fixes

    • Expired offchain transactions now propagate "swept" status from consumed inputs to created outputs, ensuring swept funds are not incorrectly available; outputs are marked swept immediately if inputs indicate sweeping or input lookup fails.
  • Tests

    • Added end-to-end test validating the lifecycle of expired offchain transactions and correct propagation of the swept flag.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 1, 2026

Walkthrough

Finalized offchain transactions are now decoded including inputs; if an expired offchain tx has any input vtxo already marked Swept (or fetching inputs errors), the tx is considered swept and newly created output vtxos are marked Swept without running dust/script checks.

Changes

Cohort / File(s) Summary
Projection sweep logic
internal/infrastructure/db/service.go
Finalized offchain-tx path now decodes inputs (ins) as well as outputs (outs); when an expired offchain tx is finalized it fetches decoded input vtxos and if any input is Swept (or GetVtxos errors) marks the transaction txSwept. New output vtxos set Swept based on txSwept (skipping dust/script checks when true).
Tests & test helpers
internal/infrastructure/db/service_test.go
Adds mockTxDecoder and taprootPkScript test helpers; introduces TestFinalizePendingTxProjection which seeds input vtxos with configurable Swept, emits OffchainTx lifecycle events (including expired Acceptance), polls for created output vtxos, and asserts output Swept matches expectations.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    actor Client
    participant Service
    participant TxDecoder
    participant VtxoStore
    Client->>Service: OffchainTxRequested
    Client->>Service: OffchainTxAccepted (ExpiryTimestamp expired)
    Client->>Service: OffchainTxFinalized
    Service->>TxDecoder: DecodeTx(tx) 
    TxDecoder-->>Service: inputs (outpoints), outputs (pkScripts, amounts)
    Service->>VtxoStore: GetVtxos(decoded inputs)
    alt GetVtxos error or any input Swept
        VtxoStore-->>Service: error / Swept found
        Service->>VtxoStore: CreateVtxos(outputs with Swept = true)
    else inputs not swept
        VtxoStore-->>Service: inputs not swept
        Service->>VtxoStore: CreateVtxos(outputs with Swept = script.IsSubDustScript(...))
    end
    VtxoStore-->>Service: ack created vtxos
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • altafan
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix: Finalize swept pending tx' directly reflects the main change: handling finalized pending transactions that have been swept, which is demonstrated by the new test cases and the modified projection logic in service.go.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@louisinger louisinger requested a review from altafan April 1, 2026 14:53
@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Apr 1, 2026

🔍 Review — #999

Fix: Finalize swept pending tx

Summary

When an offchain tx is finalized after its expiry, the new VTXOs may need to be marked as swept if the inputs were already swept by the server. Previously the code unconditionally set Swept based only on the dust-script check (IsSubDustScript). This PR adds input-inspection logic to propagate the swept status from inputs to outputs.

What Changed

Single file: internal/infrastructure/db/service.go

  1. Captures ins from DecodeTx — previously discarded with _, now used to look up input VTXOs when the tx is expired at finalization.

  2. Swept propagation logic (lines 536-550):

    • Only triggers when offchainTx.ExpiryTimestamp > 0 AND time.Now() is past expiry
    • Fetches input VTXOs via s.vtxoStore.GetVtxos(ctx, ins)
    • If any input VTXO has Swept == true, all new outputs are marked swept
    • If the store returns an error, defaults to txSwept = true (safe fallback — avoids creating spendable VTXOs from potentially swept inputs)
  3. Output swept assignment (lines 568-571):

    • outputSwept is true if either txSwept (input was swept) OR IsSubDustScript(out.PkScript) (sub-dust output)
    • Replaces the old isDust variable

Security Analysis

✅ Safe fallback on error: txSwept = err != nil means if the VTXO store lookup fails, outputs are conservatively marked swept. This prevents a scenario where a DB error could create spendable VTXOs from swept inputs.

✅ Correct swept propagation: If the server swept the VTXO tree (inputs are swept), the outputs of a finalized tx built from those inputs should also be swept — they exist in the same expired tree. This is the right behavior.

⚠️ time.Now() in projection update: Using wall-clock time is reasonable here since this runs during event processing, but worth noting that in tests with mocked time this could behave differently. Not a blocker.

⚠️ Any-input-swept semantics: If a tx has multiple inputs and only one is swept, ALL outputs are marked swept. This is conservative and correct — if any input came from a swept tree, the outputs derived from it cannot be independently settled.

Missing test coverage

The PR description mentions two needed test cases:

  1. Finalize a pending tx after it has been swept by the server
  2. Finalize a pending tx after it has expired but was NOT swept by the server

Neither is included in this PR. These would be valuable to add, especially the second case which verifies that non-swept expired txs produce spendable outputs.

Verdict

Clean, minimal fix with correct safety semantics. The conservative error handling is the right call. Tests should follow to cover both swept and non-swept expiry scenarios.

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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/infrastructure/db/service.go`:
- Around line 541-543: The code currently sets txSwept = err != nil after
calling s.vtxoStore.GetVtxos, which incorrectly treats DB read errors as
permanent swept state; remove the assignment and instead handle GetVtxos errors
explicitly (e.g., return the error up, log and skip processing, or retry) so
transient read failures don't mark vtxos swept; determine txSwept only from the
actual inputVtxos result (or explicit spent flags) after a successful call to
s.vtxoStore.GetVtxos rather than based on err.
🪄 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: 1cf6a5f5-cea2-48d9-b80f-15ff544c4aea

📥 Commits

Reviewing files that changed from the base of the PR and between f924dd4 and 91781e2.

📒 Files selected for processing (1)
  • internal/infrastructure/db/service.go

Comment on lines +541 to +543
inputVtxos, err := s.vtxoStore.GetVtxos(ctx, ins)
// if an error happened, we assume the vtxo is swept. it should never happen but it's to avoid skipping adding vtxo to db
txSwept = err != 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

Do not infer txSwept=true from repository errors.

At Line 543, txSwept = err != nil turns transient DB read failures into permanent swept flags for all new outputs. That can incorrectly lock spendable vtxos.

Proposed fix
 		if offchainTx.ExpiryTimestamp > 0 &&
 			time.Now().After(time.Unix(offchainTx.ExpiryTimestamp, 0)) {
 			inputVtxos, err := s.vtxoStore.GetVtxos(ctx, ins)
-			// if an error happened, we assume the vtxo is swept. it should never happen but it's to avoid skipping adding vtxo to db
-			txSwept = err != nil
-
-			for _, inputVtxo := range inputVtxos {
-				if inputVtxo.Swept {
-					txSwept = true
-					break
-				}
-			}
+			if err != nil {
+				log.WithError(err).Warn(
+					"failed to infer swept state from input vtxos; falling back to script-based swept check",
+				)
+			} else {
+				for _, inputVtxo := range inputVtxos {
+					if inputVtxo.Swept {
+						txSwept = true
+						break
+					}
+				}
+			}
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/infrastructure/db/service.go` around lines 541 - 543, The code
currently sets txSwept = err != nil after calling s.vtxoStore.GetVtxos, which
incorrectly treats DB read errors as permanent swept state; remove the
assignment and instead handle GetVtxos errors explicitly (e.g., return the error
up, log and skip processing, or retry) so transient read failures don't mark
vtxos swept; determine txSwept only from the actual inputVtxos result (or
explicit spent flags) after a successful call to s.vtxoStore.GetVtxos rather
than based on err.

@bitcoin-coder-bob
Copy link
Copy Markdown
Collaborator

@louisinger I added the 2 test cases you outlined in the PR description

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Apr 2, 2026

Iterative review — new commit 3890387a (test additions)

Good test coverage for the swept-status propagation logic. Two cases are the right ones to test:

  1. ✅ Input swept → output swept (server already reclaimed)
  2. ✅ Input not swept → output not swept (expired but unclaimed)

Observations:

  • The mockTxDecoder is shared across subtests but decodeFn is reassigned per subtest — works here because subtests run sequentially, but worth noting if parallelism is ever added.

  • taprootPkScript silently discards the error from hex.DecodeString. Fine for test code with a known-good constant, but a t.Fatal would be more defensive.

  • The require.Eventually with 5s timeout / 100ms poll is appropriate for the async projection handler. The test correctly re-fetches after the poll to get a clean assertion.

  • The mock decoder is defined at the outer scope but the decodeFn closure captures outTxid, in1, in2 from the inner subtest scope — this is correct but subtle. The comment "swapped per-subtest" helps.

Overall this is solid test coverage for a critical edge case. LGTM on the test additions. The core logic in service.go was reviewed in the prior pass and the swept-propagation approach is sound.

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: 1

🧹 Nitpick comments (1)
internal/infrastructure/db/service_test.go (1)

2201-2206: Preserve failure cause in async polling.

If projection fails internally, this eventually check times out without surfacing the last DB error, which makes failures harder to triage.

💡 Proposed fix
+			var lastErr error
 			require.Eventually(t, func() bool {
 				vtxos, err := svc.Vtxos().GetVtxos(ctx, []domain.Outpoint{
 					{Txid: outTxid, VOut: 0},
 				})
-				return err == nil && len(vtxos) > 0
-			}, 5*time.Second, 100*time.Millisecond)
+				lastErr = err
+				return err == nil && len(vtxos) > 0
+			}, 5*time.Second, 100*time.Millisecond,
+				"projected output vtxo not found (last error: %v)", lastErr)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/infrastructure/db/service_test.go` around lines 2201 - 2206, The
test's require.Eventually call hides the underlying error from
svc.Vtxos().GetVtxos when the predicate keeps returning false; capture the last
error inside the polling closure (e.g., a variable lastErr set to err each
iteration) and after require.Eventually returns assert or fail with that
captured error so the real DB/projection failure is surfaced; modify the test
around the require.Eventually invocation that calls svc.Vtxos().GetVtxos to
record the last err and then use require.NoError(lastErr) or t.Fatalf with
lastErr if the eventually check still fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/infrastructure/db/service_test.go`:
- Around line 2036-2042: taprootPkScript currently ignores hex.DecodeString
errors and doesn't validate the decoded key length, which lets malformed fixture
hex produce truncated/zero-padded 34-byte scripts; change taprootPkScript to
validate input by handling the hex.DecodeString error and verifying len(pk) ==
32, and fail fast on invalid input (preferably by accepting a *testing.T
parameter and calling t.Fatalf with a clear message, or return an error) before
constructing the 34-byte script so malformed fixtures cause test failures rather
than silent false positives.

---

Nitpick comments:
In `@internal/infrastructure/db/service_test.go`:
- Around line 2201-2206: The test's require.Eventually call hides the underlying
error from svc.Vtxos().GetVtxos when the predicate keeps returning false;
capture the last error inside the polling closure (e.g., a variable lastErr set
to err each iteration) and after require.Eventually returns assert or fail with
that captured error so the real DB/projection failure is surfaced; modify the
test around the require.Eventually invocation that calls svc.Vtxos().GetVtxos to
record the last err and then use require.NoError(lastErr) or t.Fatalf with
lastErr if the eventually check still fails.
🪄 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: 5d9b02fb-1af7-439d-8e20-aa647acb2151

📥 Commits

Reviewing files that changed from the base of the PR and between 91781e2 and 3890387.

📒 Files selected for processing (1)
  • internal/infrastructure/db/service_test.go

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Apr 2, 2026

Iterative review — new commit 088acf5

Minor test hardening: taprootPkScript now takes *testing.T, validates hex decode errors, and asserts 32-byte key length. Good practice — silent _ on decode errors could mask bad test data.

LGTM for this increment. No concerns.

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: 1

🧹 Nitpick comments (1)
internal/infrastructure/db/service_test.go (1)

2072-2074: Prefer per-subtest decoder instance to avoid shared mutable state.

Creating one decoder outside the loop makes subtests depend on mutable shared state. Instantiating it inside each t.Run keeps isolation tight and future-proofs for t.Parallel().

♻️ Suggested refactor
-	// The mock decoder is swapped per-subtest to return unique outpoints.
-	decoder := &mockTxDecoder{}
-
 	tests := []struct {
 		name        string
 		inputSwept  bool
 		expectSwept bool
@@
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
+			decoder := &mockTxDecoder{}
 			// Fresh db per subtest for clean isolation.
 			dbDir := t.TempDir()
 			svc, err := db.NewService(db.ServiceConfig{
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/infrastructure/db/service_test.go` around lines 2072 - 2074, The
test creates a single shared decoder variable (decoder := &mockTxDecoder{})
outside the subtest loop which introduces shared mutable state; change the code
to instantiate a fresh mockTxDecoder inside each t.Run (e.g., inside the subtest
closure create decoder := &mockTxDecoder{}) so each subtest gets its own decoder
instance, update any references in the subtest to use that local decoder, and
ensure this enables safe use of t.Parallel() in the subtests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/infrastructure/db/service_test.go`:
- Around line 2122-2126: The mock decode function (decoder.decodeFn) currently
ignores its tx argument which can hide wiring bugs; update decoder.decodeFn to
assert the incoming tx equals the expected transaction payload used in the test
(e.g., compare to the test's raw/serialized tx variable) and fail the test if it
differs (using the test's t.Fatalf/t.Helper or require/assert helper). Keep the
returned outTxid, in1, in2 and outputs unchanged but add that assertion at the
start of decoder.decodeFn so wrong payloads cause a visible test failure.

---

Nitpick comments:
In `@internal/infrastructure/db/service_test.go`:
- Around line 2072-2074: The test creates a single shared decoder variable
(decoder := &mockTxDecoder{}) outside the subtest loop which introduces shared
mutable state; change the code to instantiate a fresh mockTxDecoder inside each
t.Run (e.g., inside the subtest closure create decoder := &mockTxDecoder{}) so
each subtest gets its own decoder instance, update any references in the subtest
to use that local decoder, and ensure this enables safe use of t.Parallel() in
the subtests.
🪄 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: bc5c8405-9aa8-45a8-a183-31c4b29ec4fa

📥 Commits

Reviewing files that changed from the base of the PR and between 3890387 and 088acf5.

📒 Files selected for processing (1)
  • internal/infrastructure/db/service_test.go

Comment on lines +2122 to +2126
decoder.decodeFn = func(tx string) (string, []ports.TxIn, []ports.TxOut, error) {
return outTxid, []ports.TxIn{in1, in2}, []ports.TxOut{
{Amount: 1000, PkScript: outputPkScript},
}, 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

Validate the decoded tx argument in the mock to avoid false positives.

The mock ignores tx, so the test can pass even if the projection decodes the wrong transaction payload. Add an assertion path in the mock to make wiring regressions visible.

🧪 Suggested tightening
+	expectedDecodedTx := "mock_final_ark_tx"
@@
-			decoder.decodeFn = func(tx string) (string, []ports.TxIn, []ports.TxOut, error) {
+			decoder.decodeFn = func(tx string) (string, []ports.TxIn, []ports.TxOut, error) {
+				if tx != expectedDecodedTx {
+					return "", nil, nil, fmt.Errorf("unexpected tx passed to decoder: %q", tx)
+				}
 				return outTxid, []ports.TxIn{in1, in2}, []ports.TxOut{
 					{Amount: 1000, PkScript: outputPkScript},
 				}, nil
 			}
@@
-					FinalArkTx: "mock_final_ark_tx",
+					FinalArkTx: expectedDecodedTx,
 import (
 	"context"
 	"crypto/rand"
 	"encoding/hex"
+	"fmt"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/infrastructure/db/service_test.go` around lines 2122 - 2126, The
mock decode function (decoder.decodeFn) currently ignores its tx argument which
can hide wiring bugs; update decoder.decodeFn to assert the incoming tx equals
the expected transaction payload used in the test (e.g., compare to the test's
raw/serialized tx variable) and fail the test if it differs (using the test's
t.Fatalf/t.Helper or require/assert helper). Keep the returned outTxid, in1, in2
and outputs unchanged but add that assertion at the start of decoder.decodeFn so
wrong payloads cause a visible test failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants