allow unrolled VTXOs to join batches with configurable expiry margin#961
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
5fc7f95 to
30f64bb
Compare
|
[Arkana Review — PR #961: allow unrolled VTXOs to join batches with configurable expiry margin] Reviewed at: 2026-03-14 14:00 UTC SummaryThis PR re-opens the collaborative path for unrolled VTXOs: instead of hard-rejecting them at Overall the approach is correct. A few things worth discussing before merge: What looks goodExit delay selection ( Fee double-counting fix: Filtering unrolled VTXOs out of
E2E test ( Questions / potential issues1. Double-entry in The unrolled VTXO is appended to both
2. Script validation / tapscript ownership In 3. A margin of 0 means "fall back to 4. The Nits
VerdictConcept is correct and the feature is valuable — restoring collaborative re-entry for unrolled VTXOs without weakening the exit guarantee is the right approach. Questions 1 and 2 above should be addressed before merge. — Arkana |
response to questions 1 and 2: Q1: Does batch finalization handle unrolled VTXOs in vtxoInputs gracefully? Yes. The key guard is domain.Vtxo.RequiresForfeit() (internal/core/domain/vtxo.go): This returns false for unrolled VTXOs, so the forfeit tx creation and connector chain linkage paths are never entered for them. On the commitment tx side, unrolled VTXOs are signed as boarding inputs Additionally, fee computation explicitly filters them out of vtxoInputs to avoid double-counting since they're already counted as boarding inputs: Q2: Is there a guard against a crafty client submitting the same outpoint in both lists via the raw API? Partially. There's a seenOutpoints map in RegisterIntent (around line 1570) that prevents the same outpoint from appearing more than once in the proof transaction inputs: However, this only deduplicates within the single proof tx input list. The code path that decides whether an input is a VTXO vs. boarding input is based on a DB lookup (Unrolled flag on the stored In short: the DB state is the authority, not the client's input classification, so a crafty client can't force an outpoint into both paths. The dual-entry for unrolled VTXOs is an intentional |
30f64bb to
2a9addd
Compare
…llow-join-batch # Conflicts: # Makefile # internal/config/config.go
🔍 Arkana PR Review —
|
|
@arakanaai regarding your double spend concerns: dual tracking is by design and there's no double-spend vector. The same outpoint enters the PSBT exactly once (as a boarding input). The |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
internal/test/e2e/e2e_test.go (2)
423-439: Use a timeout-scoped context forNotifyIncomingFundsto reduce flakiness.If no event arrives, this goroutine can block until the test context is canceled. A short
context.WithTimeouthere would make failures deterministic in CI.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/test/e2e/e2e_test.go` around lines 423 - 439, The NotifyIncomingFunds goroutine can block indefinitely; wrap the test context with a short timeout and pass that into alice.NotifyIncomingFunds to fail fast—create a timeout context (e.g., ctxTimeout, cancel := context.WithTimeout(ctx, shortDuration)) before launching the goroutine, defer cancel, use ctxTimeout when calling alice.NotifyIncomingFunds (instead of ctx), and ensure wg.Done still runs and incomingErr receives the returned error so the test deterministically fails when no event arrives.
414-420: Add a negative-case assertion for wrong tapscript binding.This new test covers the happy path. Consider adding a companion case that passes an intentionally wrong tapscript and expects rejection, so script/outpoint binding is explicitly protected in test coverage.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/test/e2e/e2e_test.go` around lines 414 - 420, Add a negative test that verifies binding between tapscript and outpoint is enforced: after the existing happy-path where Receive unrolls VTXO and constructs boardingUtxo (types.Utxo with Outpoint unrolledVtxo.Outpoint and Tapscripts offchainAddr.Tapscripts), create a companion case that substitutes a wrong/foreign tapscript (e.g., modify offchainAddr.Tapscripts or build a different tapscript array) and call Receive (or the same code path that consumes boardingUtxo); assert that the call is rejected/fails (error returned or explicit validation failure) to ensure script/outpoint binding is checked. Reference the existing symbols: Receive, boardingUtxo, unrolledVtxo, offchainAddr.Tapscripts, and types.Utxo when adding the negative assertion.
🤖 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/config/config.go`:
- Line 428: The assignment of UnrolledVtxoMinExpiryMargin currently takes
viper.GetInt64(UnrolledVtxoMinExpiryMargin) without bounds checking; add a
validation step where you read the value into a local (e.g., val :=
viper.GetInt64(UnrolledVtxoMinExpiryMargin)), verify val >= 0, and if not return
a configuration error (or fail fast) with a clear message referencing
UnrolledVtxoMinExpiryMargin; apply the same non-negative validation to the other
occurrence of UnrolledVtxoMinExpiryMargin in the file so negative values are
rejected rather than accepted silently.
In `@internal/core/domain/vtxo.go`:
- Line 70: The recoverable VTXO filter that currently uses the negation of
RequiresForfeit() is too broad after RequiresForfeit() changed; update the
recoverable condition (the filter in indexer.go that checks
!vtxo.RequiresForfeit()) to also check the Unrolled flag by adding &&
!vtxo.Unrolled so unrolled outputs are excluded from recoverable VTXOs, keeping
semantics aligned with RequiresForfeit() in internal/core/domain/vtxo.go and the
client types.
---
Nitpick comments:
In `@internal/test/e2e/e2e_test.go`:
- Around line 423-439: The NotifyIncomingFunds goroutine can block indefinitely;
wrap the test context with a short timeout and pass that into
alice.NotifyIncomingFunds to fail fast—create a timeout context (e.g.,
ctxTimeout, cancel := context.WithTimeout(ctx, shortDuration)) before launching
the goroutine, defer cancel, use ctxTimeout when calling
alice.NotifyIncomingFunds (instead of ctx), and ensure wg.Done still runs and
incomingErr receives the returned error so the test deterministically fails when
no event arrives.
- Around line 414-420: Add a negative test that verifies binding between
tapscript and outpoint is enforced: after the existing happy-path where Receive
unrolls VTXO and constructs boardingUtxo (types.Utxo with Outpoint
unrolledVtxo.Outpoint and Tapscripts offchainAddr.Tapscripts), create a
companion case that substitutes a wrong/foreign tapscript (e.g., modify
offchainAddr.Tapscripts or build a different tapscript array) and call Receive
(or the same code path that consumes boardingUtxo); assert that the call is
rejected/fails (error returned or explicit validation failure) to ensure
script/outpoint binding is checked. Reference the existing symbols: Receive,
boardingUtxo, unrolledVtxo, offchainAddr.Tapscripts, and types.Utxo when adding
the negative assertion.
🪄 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: 78c5a505-410c-4b8c-8d98-6ed1a7f44a58
📒 Files selected for processing (6)
internal/config/config.gointernal/core/application/service.gointernal/core/application/service_test.gointernal/core/application/types.gointernal/core/domain/vtxo.gointernal/test/e2e/e2e_test.go
|
Followup review — new commits since last review:
if !vtxo.RequiresForfeit() && !vtxo.Spent && !vtxo.Unrolled {Good catch. Without this, unrolled VTXOs would appear in the recoverable list (since Note: No new concerns. Previous review findings (negative margin validation) still apply. |
|
This PR has been open for 7+ days without review. @bitcoin-coder-bob is anyone looking at this? |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 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/core/application/service.go`:
- Around line 3936-3944: The checkUnrolledVtxoExpiry function currently falls
back to s.sessionDuration when s.unrolledVtxoMinExpiryMargin == 0, but
startRound can switch the effective round duration to scheduledSession.Duration;
update the fallback so the margin is based on the actual active round duration
used by startRound (i.e., if a scheduledSession is set use
scheduledSession.Duration, otherwise use s.sessionDuration or the same variable
startRound uses for the active round), keeping the primary symbol references
s.unrolledVtxoMinExpiryMargin, s.sessionDuration, scheduledSession.Duration and
the checkUnrolledVtxoExpiry function to locate and change the margin
calculation.
- Around line 1958-1968: EstimateIntentFee classifies DB-backed (unrolled) VTXOs
differently than RegisterIntent, causing mismatched fee quotes; update
EstimateIntentFee to mirror RegisterIntent's logic by filtering vtxoInputs the
same way (use feeVtxoInputs / check v.Unrolled) and pass the same onchainInputs
and feeVtxoInputs into s.feeManager.ComputeIntentFees so DB-backed unrolled
VTXOs are treated as onchain inputs rather than offchainOutputs; ensure the same
symbols (vtxoInputs, feeVtxoInputs, v.Unrolled, onchainInputs, offchainOutputs,
ComputeIntentFees, EstimateIntentFee, RegisterIntent) are used to locate and
align the classification logic.
- Around line 1604-1617: The current early "continue" in the loop that appends
an unrolled VTXO to boardingUtxos and vtxoInputs skips the normal asset
bookkeeping and leaves assetInputs incomplete; update the handling in the loop
(where boardingIntentInput is created and vtxoInputs appended) to also record
any asset metadata into assetInputs the same way the normal asset-input path
does (use the same mapping logic applied later for psbtInput/vtxo), or remove
the premature continue and fall through to the existing asset-input code path so
that validateBoardingInput (called later in processBoardingInputs) sees a
complete assetInputs map for isUnrolledVtxo entries; ensure you reference
boardingUtxos, vtxoInputs, assetInputs, boardingIntentInput, psbtInput, vtxo and
isUnrolledVtxo when making the change.
🪄 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: 2c83a29e-1a7c-4003-9ed0-379688a80c16
📒 Files selected for processing (7)
internal/config/config.gointernal/core/application/indexer.gointernal/core/application/service.gointernal/core/application/types.gointernal/core/domain/vtxo.gointernal/core/domain/vtxo_test.gointernal/test/e2e/e2e_test.go
✅ Files skipped from review due to trivial changes (2)
- internal/core/application/indexer.go
- internal/core/application/types.go
🚧 Files skipped from review as they are similar to previous changes (3)
- internal/core/domain/vtxo.go
- internal/config/config.go
- internal/test/e2e/e2e_test.go
There was a problem hiding this comment.
🧹 Nitpick comments (1)
internal/core/application/service.go (1)
3949-3973: LGTM with a documentation suggestion.The margin calculation correctly addresses the scheduled-session concern by using the larger of
sessionDurationandscheduledSession.Durationwhen the explicit margin is not configured (zero). This ensures unrolled VTXOs have sufficient time before CSV expiry regardless of which session type is active.One minor clarification for maintainability: the naming
unrolledVtxoMinExpiryMarginsuggests a floor/minimum, but when set to a positive value it acts as a direct override rather than a minimum. Consider adding a brief comment in the config documentation clarifying that a non-zero value replaces (not supplements) the session-duration fallback.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/core/application/service.go` around lines 3949 - 3973, The comment requests clarifying that unrolledVtxoMinExpiryMargin acts as an override rather than a minimum: update the documentation/config comment for the unrolledVtxoMinExpiryMargin setting (and add a short inline comment near service.checkUnrolledVtxoExpiry or where unrolledVtxoMinExpiryMargin is defined) to state that when unrolledVtxoMinExpiryMargin > 0 it replaces the session-duration-based margin (sessionDuration / scheduledSession.Duration) instead of being applied as a floor or additive value; reference the variable name unrolledVtxoMinExpiryMargin and the checkUnrolledVtxoExpiry function so maintainers can find and read the clarified comment.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@internal/core/application/service.go`:
- Around line 3949-3973: The comment requests clarifying that
unrolledVtxoMinExpiryMargin acts as an override rather than a minimum: update
the documentation/config comment for the unrolledVtxoMinExpiryMargin setting
(and add a short inline comment near service.checkUnrolledVtxoExpiry or where
unrolledVtxoMinExpiryMargin is defined) to state that when
unrolledVtxoMinExpiryMargin > 0 it replaces the session-duration-based margin
(sessionDuration / scheduledSession.Duration) instead of being applied as a
floor or additive value; reference the variable name unrolledVtxoMinExpiryMargin
and the checkUnrolledVtxoExpiry function so maintainers can find and read the
clarified comment.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 2743ebc5-caac-42ab-8d89-686e6f2b1740
⛔ Files ignored due to path filters (1)
api-spec/protobuf/gen/ark/v1/indexer.pb.rgw.gois excluded by!**/gen/**
📒 Files selected for processing (2)
internal/core/application/service.gointernal/core/application/service_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
- internal/core/application/service_test.go
There was a problem hiding this comment.
🔍 Arkana PR Review — arkd#961 (follow-up, new commits since 2026-03-14)
Reviewed at: 2026-04-23
Focus: Changes since last review (commits from 2026-03-18 through 2026-04-23)
Changes since last review
Seven substantive commits landed since my March 14 review:
correct unrolled VTXO handling in fee estimation, asset tracking, and expiry margin— Adds unrolled VTXO handling toEstimateIntentFeeand asset input tracking.simplify checkUnrolledVtxoExpiry and enforce margin >= sessionDuration— Eliminates the0 = fallbackambiguity by requiringmargin > 0 && margin >= sessionDurationin config validation.exclude unrolled VTXOs from recoverable filter— Adds!vtxo.Unrolledguard inGetVtxosrecoverable filtering.assert CompleteUnroll finds no funds after unrolled VTXO rejoins batch— E2E test now verifies the on-chain UTXO is consumed after batch re-entry.Update envs & Add CompleteUnroll to all unilateral exit integration tests— Broader test coverage + env config for dev/light/docker-compose.- Merge commits + lint fixes.
Resolution of previous concerns
All four questions from my March 14 review are now addressed:
Q1 (double-entry in vtxoInputs + boardingUtxos): ✅ Resolved. RequiresForfeit() now returns false for unrolled VTXOs (!v.Unrolled guard at vtxo.go:79). This means ForfeitTxsStore.Init() correctly skips them, EndFinalization() won't demand forfeit txs for them, and CountSpentVtxos() excludes them. The dual-list approach is safe.
Q2 (script validation / tapscript ownership): ✅ Confirmed safe. processBoardingInputs calls newBoardingInput which computes the taproot key from client-provided tapscripts and compares the derived P2TR script against the on-chain output (utils.go:209, bytes.Equal). Additionally, processBoardingInputs itself compares witness UTXO PkScript against the fetched on-chain prevout script. A client cannot substitute fake tapscripts.
Q3 (defaultUnrolledVtxoMinExpiryMargin = 0 semantics): ✅ Resolved completely. Default is now 300 (5 minutes). Config validation rejects <= 0 and enforces >= sessionDuration. No ambiguity remains.
Q4 (isUnrolledVtxo flag origin): ✅ Still correct. Only set from the DB lookup of vtxo.Unrolled in RegisterIntent. No path for a non-unrolled VTXO to arrive at processBoardingInputs with isUnrolledVtxo = true.
New changes review
EstimateIntentFee fix (service.go:2298-2306): Correctly mirrors RegisterIntent — unrolled VTXOs are counted as onchain inputs for fee purposes. Without this, fee estimates would be wrong for clients attempting to rejoin with unrolled VTXOs. ✅
checkUnrolledVtxoExpiry simplification (service.go:3974-3983): Clean. The function is now a straight comparison: csvExpiresAt.Before(now.Add(margin)). No fallback logic needed since config enforces margin >= sessionDuration. The boundary test correctly validates that csvExpiresAt == now + margin is accepted (not expired). ✅
Config validation (config.go:497-507): Solid. Two checks: margin > 0 and margin >= sessionDuration. Error messages include both values for debuggability. ✅
Recoverable filter (indexer.go:298): Correct — unrolled VTXOs should not appear in the recoverable list since they're on-chain awaiting CSV expiry or batch re-entry. ✅
E2E test improvements: TestUnrolledVtxoRejoinBatch now asserts CompleteUnroll returns "no mature funds available" after the VTXO rejoins a batch — confirming the on-chain UTXO is consumed. The require.Eventually polling pattern for indexing is better than fixed sleeps. CompleteUnroll assertions added to existing unilateral exit tests too. ✅
Env files (arkd.dev.env, arkd.light.env, docker-compose.regtest.yml): Margin set to 10 seconds in test environments — appropriate for fast regtest rounds where sessionDuration is also ~10s. ✅
One remaining nit
The api-spec/protobuf/gen/ark/v1/indexer.pb.rgw.go changes (reordering trie.New arguments) appear to be auto-generated from a protobuf rebuild. Not harmful, but worth confirming these weren't accidentally included — they're unrelated to this PR's intent.
Verdict
Approved. All previous concerns addressed. New changes are correct and well-tested. The EstimateIntentFee fix and config hardening are meaningful improvements over the original submission.
— Arkana
closes #703
Allow unrolled VTXOs to rejoin a batch via the collaborative (boarding) path instead of rejecting them outright. When an unrolled VTXO still has sufficient CSV time remaining before its exit path becomes available it is treated as a boarding input so it can be swept back into the Ark without requiring unilateral exit.
Why do we want this? An unrolled VTXO is stuck on-chain then the user's funds are out of the Ark and the only way back in was to wait for the CSV to expire, then sweep the funds into a new boarding UTXO. That's slow and costs an extra transaction. With this change, the user can immediately rejoin the Ark by presenting the unrolled VTXO as a boarding input in the next batch, skipping the CSV wait and the extra on-chain step. Funds go from on-chain back to off-chain in a single round.
Changes
RegisterIntent: Instead of returningVTXO_ALREADY_UNROLLED, unrolled VTXOs are now appended as boarding inputs withisUnrolledVtxo=trueand tracked invtxoInputsfor state settlement.processBoardingInputsandvalidateBoardingInputuse the unilateral exit delay (not boarding exit delay) when verifying unrolled VTXO scripts and expiry. Reason: Because the VTXO's script was originally built with the unilateral exit delay when it was created inside the Ark's VTXO tree. Unrolling just reveals that output on-chain, it doesn't rewrite the script. The CSV lock embedded in the tapscript is still the unilateral exit delay. The boarding exit delay is a separate value used for UTXOs that users create outside the Ark (boarding UTXOs). Those are constructed by the user's wallet with the boarding-specific delay. So when Validate() checks the script at line 3864, it needs to match what's actually in the tapscript. If we passedboardingExitDelay, validation would fail because the on-chain script containsunilateralExitDelay.checkUnrolledVtxoExpiryensures the VTXO's CSV doesn't expire before the batch can finalize. Margin defaults to session duration but is configurable viaUNROLLED_VTXO_MIN_EXPIRY_MARGIN.onchainInputslist passed toComputeIntentFeesviaboardingInput.witnessUtxo). If we didn't filter them out ofvtxoInputs, they'd be double-counted — charged the onchain input fee and the offchain input fee.RequiresForfeit()returnsfalsefor unrolled VTXOs since they have no off-chain state to forfeit.UNROLLED_VTXO_MIN_EXPIRY_MARGINenv var (seconds, default0= fallback to session duration). It is the minimum remaining CSV time an unrolled VTXO must have before the server accepts it into a batch. If the VTXO's exit path becomes available too soon, the batch might not finalize in time then the user could unilaterally exit mid-round, taking their funds while also being included in the new VTXO tree. When set to 0 (default), it falls back tosessionDurationwhich is the time a single round takes to complete. A custom value lets operators require a larger buffer if needed.checkUnrolledVtxoExpiryand an e2e test covering the full unrolled-VTXO-rejoins-batch flow.Summary by CodeRabbit
New Features
Bug Fixes
Tests