Skip to content

fix: verify commitment tx with arkd indexer before signing in SubmitFinalization - #133

Merged
chris-ricketts merged 5 commits into
masterfrom
fix/reject-unknown-commitment-tx
Aug 7, 2026
Merged

chris-ricketts merged 5 commits into
masterfrom
fix/reject-unknown-commitment-tx

Conversation

@chris-ricketts

@chris-ricketts chris-ricketts commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

SubmitFinalization previously signed any structurally valid commitment tx carrying inputs that matched a signed intent proof. It now confirms the commitment tx is an artifact produced by arkd before signing anything — forfeits included.

  • Gate at the top of SubmitFinalization: the commitment tx's txid must resolve via indexer.GetCommitmentTx. Unknown txid → rejection, no signatures produced.
  • Bounded retry (5–8 attempts, 200ms → 2s backoff) absorbs the race between the RoundFinalizationStarted event and the indexer projection write in arkd (deferred saveEvents in startFinalization).
  • New optional env EMULATOR_ARKD_INDEXER_URL; falls back to EMULATOR_ARKD_URL when unset, matching deployments where the indexer shares the arkd endpoint. No breaking change.
  • Service now holds an indexer.Indexer client alongside the existing transport client (built in New, closed in Close).

Test

  • TestSubmitFinalizationRejectsUnknownCommitmentTx: runs a boarding settlement while the batch handler overrides the commitment tx with a locally-built PSBT; expects finalization to fail with not known to arkd indexer.
  • delegateBatchEventsHandler gains an OverrideCommitmentTx hook (zero value = no override), honored by both the forfeit and boarding finalization paths in test utils.

Notes

  • Full suite green locally against the regtest compose environment.

@msinkec / @louisinger please review

Summary by CodeRabbit

  • New Features

    • Added configurable indexer support for verifying commitment transactions.
    • Added automatic fallback to the primary arkd URL when no indexer URL is configured.
    • Added retries with bounded backoff when checking commitment transactions.
  • Bug Fixes

    • Finalization now rejects unknown or unindexed commitment transactions before signing or submitting forfeits.
  • Tests

    • Added coverage for rejecting commitment transactions that are not known to the arkd indexer.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@chris-ricketts, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 13 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 927b3f67-19cb-44d9-8c10-e11f9de737e5

📥 Commits

Reviewing files that changed from the base of the PR and between b2033d0 and 67b9774.

📒 Files selected for processing (1)
  • internal/application/finalization_test.go

Walkthrough

The emulator now configures an arkd indexer, verifies commitment transactions with retries before finalization signing, and tests rejection of unknown commitment transactions.

Changes

Commitment indexer validation

Layer / File(s) Summary
Indexer client configuration and lifecycle
internal/config/config.go, internal/application/service.go, README.md
The configuration loads ArkdIndexerURL with an ArkdURL fallback. The application service creates and closes the indexer client.
Commitment lookup before signing
internal/application/finalization.go
SubmitFinalization validates the commitment transaction through the indexer and retries lookup with bounded exponential backoff and jitter before signing.
Unknown commitment integration coverage
test/utils_test.go, test/tx_test.go
Tests can override the commitment transaction and verify rejection when the transaction is not known to the arkd indexer.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SubmitFinalization
  participant ArkdIndexer
  participant ForfeitSigner
  SubmitFinalization->>ArkdIndexer: Look up commitment transaction ID
  ArkdIndexer-->>SubmitFinalization: Return recognition result
  SubmitFinalization->>ForfeitSigner: Sign only recognized commitment inputs
Loading

Possibly related PRs

Suggested reviewers: kukks, louisinger

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: verifying the commitment transaction with the arkd indexer before signing in SubmitFinalization.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/reject-unknown-commitment-tx

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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)
test/tx_test.go (1)

787-1023: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared boarding setup into a helper.

Lines 787-1023 duplicate almost the entire body of TestBoarding (lines 1064-1307). Only the emulator client construction, the commitment tx override, and the final assertions differ. Any change to the boarding flow now needs two edits, and the two copies will drift.

Extract the common part into a helper that returns the values both tests need, for example the intent, the intent id, the boarding client.TapscriptsVtxo, the tree signer session, the emulator client, the wallet, and the explorer. Each test then only sets OverrideCommitmentTx and asserts.

🤖 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 `@test/tx_test.go` around lines 787 - 1023, Extract the duplicated boarding
setup from TestSubmitFinalizationRejectsUnknownCommitmentTx and TestBoarding
into a shared test helper. Have the helper return the common intent, intent ID,
boarding client.TapscriptsVtxo, tree signer session, emulator client, wallet,
and explorer; keep emulator construction configurable as needed. Refactor both
tests to use the helper, leaving only each test’s OverrideCommitmentTx
configuration and final assertions in the test bodies.
🤖 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 `@internal/application/finalization.go`:
- Around line 23-46: Bound the commitment lookup in the finalization flow around
retryWithBackoff by deriving a context with the configured
commitmentTxLookupTimeout and ensuring it is canceled after the lookup. Update
the retry callback to stop immediately for definitive not-found responses, while
retrying only transport or unavailable errors; preserve the existing arkd
indexer validation and wrapped failure reporting.

---

Nitpick comments:
In `@test/tx_test.go`:
- Around line 787-1023: Extract the duplicated boarding setup from
TestSubmitFinalizationRejectsUnknownCommitmentTx and TestBoarding into a shared
test helper. Have the helper return the common intent, intent ID, boarding
client.TapscriptsVtxo, tree signer session, emulator client, wallet, and
explorer; keep emulator construction configurable as needed. Refactor both tests
to use the helper, leaving only each test’s OverrideCommitmentTx configuration
and final assertions in the test bodies.
🪄 Autofix

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 Plus

Run ID: 9a7b538c-1cb8-4c84-af8f-2d7690cce56b

📥 Commits

Reviewing files that changed from the base of the PR and between 1345bf0 and b2033d0.

📒 Files selected for processing (6)
  • README.md
  • internal/application/finalization.go
  • internal/application/service.go
  • internal/config/config.go
  • test/tx_test.go
  • test/utils_test.go

Comment on lines +23 to +46
// Sign nothing unless the commitment tx is an arkd-built artifact: a
// client-chosen commitment tx (e.g. an ark tx spending a pending
// checkpoint outpoint) would let a replayed proof bypass the arkade
// script execution that justified it. arkd indexes its commitment txs
// on round finalization; the write may lag the RoundFinalizationStarted
// event, so retry.
if finalization.CommitmentTx == nil {
return nil, fmt.Errorf("commitment tx is required")
}
if s.indexerClient == nil {
return nil, fmt.Errorf("arkd indexer client is not configured")
}
commitmentTxid := finalization.CommitmentTx.UnsignedTx.TxID()
err := retryWithBackoff(ctx, commitmentTxRetryConfig,
func() error {
_, err := s.indexerClient.GetCommitmentTx(ctx, commitmentTxid)
return err
}, nil,
)
if err != nil {
return nil, fmt.Errorf(
"commitment tx %s not known to arkd indexer: %w", commitmentTxid, err,
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the total lookup time and stop retrying on a definitive "not found".

The retry loop retries every error, including a permanent not-found response from the indexer. With these parameters, a request with an unknown commitment tx blocks the handler goroutine for several seconds before it fails (200ms, 400ms, 800ms, 1.6s, 2s, 2s, 2s with jitter). An unauthenticated caller can submit many such requests and hold goroutines and indexer connections. The integration test also pays this cost on every run.

Two mitigations:

  • Derive a bounded context for the lookup, so the total wait has a hard ceiling.
  • Treat a definitive not-found response as terminal instead of retryable, and retry only transport or unavailable errors.
🛡️ Proposed bound on total lookup time
 	commitmentTxid := finalization.CommitmentTx.UnsignedTx.TxID()
-	err := retryWithBackoff(ctx, commitmentTxRetryConfig,
+	lookupCtx, cancel := context.WithTimeout(ctx, commitmentTxLookupTimeout)
+	defer cancel()
+	err := retryWithBackoff(lookupCtx, commitmentTxRetryConfig,
 		func() error {
-			_, err := s.indexerClient.GetCommitmentTx(ctx, commitmentTxid)
+			_, err := s.indexerClient.GetCommitmentTx(lookupCtx, commitmentTxid)
 			return err
 		}, nil,
 	)

Add the ceiling next to the retry configuration:

const commitmentTxLookupTimeout = 5 * time.Second

Also applies to: 314-321

🤖 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/application/finalization.go` around lines 23 - 46, Bound the
commitment lookup in the finalization flow around retryWithBackoff by deriving a
context with the configured commitmentTxLookupTimeout and ensuring it is
canceled after the lookup. Update the retry callback to stop immediately for
definitive not-found responses, while retrying only transport or unavailable
errors; preserve the existing arkd indexer validation and wrapped failure
reporting.

@arkana-ai-bot arkana-ai-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 review — #133

The commitment-tx indexer gate closes a real attack surface: before this change a client who had previously obtained the emulator's signature on an intent proof could replay that proof during SubmitFinalization with a client-chosen commitment tx, getting forfeit signatures without arkd having actually produced a batch for those VTXOs. Placing the gate unconditionally before any signing — forfeits and boarding path alike — is the correct location.

Protocol / security

Boarding path is covered. When all signedInputs are consumed by forfeits the function returns early at internal/application/finalization.go:93; when inputs remain (boarding), execution reaches finalization.go:98. The indexer gate runs before either branch, so both paths are protected. ✓

txid as proof. commitmentTxid is the Bitcoin double-SHA256 of the unsigned transaction (finalization.go new block, UnsignedTx.TxID()). A matching txid proves content identity — no crafted PSBT metadata can satisfy the check with a different transaction. ✓

PSBT metadata trust boundary. Forfeit output validation continues to use prevouts from the intent proof (which carries the emulator's own signature) and the connector tree, not from the commitment PSBT itself. The indexer check does not introduce a new trusted source for those values. ✓

Findings

1. Missing retry log — internal/application/finalization.go (new retryWithBackoff call)

onErr: nil is passed, so the retry loop can spin up to 8 attempts over ≈6 s with zero log output. finalizeRetryConfig's caller in tx.go:253 logs on every attempt; this site should do the same. When the indexer lags or is unreachable, operators currently have no signal that finalization is retrying rather than hanging.

// suggested
func(attempt int, e error) {
    log.WithField("txid", commitmentTxid).WithField("attempt", attempt).
        Warnf("commitment tx not yet indexed, retrying: %s", e)
},

2. MinAttempts: 5 ignores ctx for the first four retries — internal/application/finalization.go:313

The retryWithBackoff implementation (tx.go:393) calls time.Sleep without checking ctx.Done() for attempt < MinAttempts. With the current config that is up to ≈200 + 400 + 800 + 1 600 ms ≈ 3 s of uncancellable sleep. This matches the pattern used by arkdConnectRetryConfig (MinAttempts=0 there, but the design is the same), and is probably acceptable for finalization. Worth a short comment explaining the intentional trade-off, similar to the one on arkdConnectRetryConfig.

3. Mutable package-level commitmentTxRetryConfig — internal/application/finalization.go:311 and finalization_test.go:126

TestSubmitFinalizationRejectsUnknownCommitmentTx saves and restores the global in t.Cleanup, which is safe today because no test in this package calls t.Parallel(). If that changes, there is a data race. A parameter-injection approach (accepting a retryConfig via a functional option or passing it through service) would be more robust, but this is a style concern not a correctness defect.

4. MaxElapsed: 0 on commitmentTxRetryConfig — internal/application/finalization.go:311

MaxElapsed unset means the elapsed-time budget check (tx.go:388) is skipped, leaving only MaxAttempts: 8 to bound the loop. That is sufficient, but it is asymmetric with finalizeRetryConfig. Acceptable as-is; a comment noting the omission is intentional would help future readers.

No cross-repo breakage

Service interface signature is unchanged. New() gains arkdIndexerURL but its only caller is config.AppService (updated in this PR). ts-sdk, rust-sdk, and dotnet-sdk are consumers of the emulator's gRPC protocol, not its Go API, and the on-wire contract is unchanged.

Tests

TestSubmitFinalizationRejectsUnknownCommitmentTx correctly verifies that:

  • SubmitFinalization returns an error containing "not known to arkd indexer", and
  • the forfeit input carries no signature after rejection.

Existing forfeit-output-validation tests are correctly updated to inject &mockIndexerClient{} (the happy-path mock that always returns &indexer.CommitmentTx{}), so they continue to exercise the signing logic.

A test covering the happy path with a slow indexer (i.e., first N calls return an error, then succeed) would strengthen confidence in the retry path, but is not a blocker.

Summary

One actionable item before merge: add retry logging (finding 1). Findings 2–4 are informational. Overall the change is a sound security improvement with correct placement, adequate test coverage, and no cross-repo impact.

@chris-ricketts
chris-ricketts merged commit da4bb8c into master Aug 7, 2026
6 checks passed
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.

3 participants