Skip to content

feat: unilateral exit support#39

Open
Kukks wants to merge 26 commits into
masterfrom
feat/unilateral-exit
Open

feat: unilateral exit support#39
Kukks wants to merge 26 commits into
masterfrom
feat/unilateral-exit

Conversation

@Kukks
Copy link
Copy Markdown
Collaborator

@Kukks Kukks commented Mar 17, 2026

Summary

  • Virtual Tx Storage: Per-tx normalized storage model for VTXO exit data. Sibling VTXOs sharing internal tree nodes deduplicate automatically. Supports Lite (txids only) and Full (txids + hex) modes.
  • Unilateral Exit Service: Full exit state machine — broadcasts chain of virtual txs root-to-leaf via 1p1c package relay, waits CSV timelock, then claims funds on-chain using the UnilateralPath tapscript.
  • Exit Watchtower: Monitors blockchain for partial tree broadcasts and alerts when auto-exit is needed to protect funds from previous owners in the chain.
  • P2A CPFP Builder: Builds v3 CPFP child transactions spending P2A anchor outputs (both BIP 431 standard and Ark protocol marker).
  • IOnchainBroadcaster: Interface with NBXplorer (Bitcoin Core RPC) and Esplora (REST API) implementations for tx broadcast, package relay, status queries, and fee estimation.
  • Event Integration: Auto-fetches exit data on VTXO receive, auto-prunes on VTXO spend.

New files (36 changed, +2577 lines)

Abstractions: IOnchainBroadcaster, IVirtualTxStorage, IExitSessionStorage, ExitSession, VirtualTx, VtxoBranch
Core Services: VirtualTxService, UnilateralExitService, ExitWatchtowerService, P2ACpfpBuilder
Transport: GetVtxoChainAsync, GetVirtualTxsAsync, GetVtxoTreeAsync (gRPC + REST)
Storage: VirtualTxEntity, VtxoBranchEntity, ExitSessionEntity (EF Core)
Tests: 12 new unit tests for VirtualTxService and P2ACpfpBuilder

Test plan

  • All 255 unit tests pass (dotnet test NArk.Tests)
  • dotnet build NArk.Core — 0 errors
  • dotnet build NArk.Storage.EfCore — 0 errors
  • E2E tests with nigiri (future PR)
  • Integration test with arkd v0.9 (future PR)

@arkanaai
Copy link
Copy Markdown

arkanaai Bot commented Mar 17, 2026

🔍 Arkana PR Review — feat: unilateral exit support

Scope: +2577 lines across 36 files. Adds full unilateral exit pipeline: virtual tx storage, exit state machine, watchtower, CPFP builder, transport bindings (gRPC + REST), EF Core storage, and 12 unit tests.

Architecture ✅

Clean separation of concerns — abstractions, core services, transport, storage, tests all properly layered. DI registration via AddUnilateralExit() and AddExitWatchtowerBackgroundService() is ergonomic. Event-driven auto-fetch on VTXO receive and auto-prune on spend is a smart design.

🔴 Bug: Hardcoded Network.Main in Broadcasting

UnilateralExitService.cs line ~186:

var tx = Transaction.Parse(vtx.Hex, Network.Main); // Network doesn't matter for parsing

The comment says "Network doesn't matter for parsing" — this is mostly true for raw hex parsing, but it's misleading and fragile. If any downstream code in the same method depends on the parsed tx's network context, this will silently produce wrong results on testnet/signet. The service already calls transport.GetServerInfoAsync() in other methods — should use serverInfo.Network consistently.

🟡 CPFP Not Wired in Broadcasting Path

UnilateralExitService.cs ~190-198:

// Build CPFP child and broadcast as package
// For now, broadcast the parent alone — CPFP child building requires
// wallet UTXOs which will be wired through the fee funding interface
var success = await broadcaster.BroadcastAsync(tx, ct);

The P2ACpfpBuilder is fully implemented and tested but never used in the actual exit flow. For v3 transactions, bare parent broadcast may fail if the parent's fee rate is below mempool min. This is the core use case for 1p1c package relay.

Recommendation: At minimum, document which future PR will wire this, or add a TODO reference. For v3-only virtual txs, this is a correctness concern, not just an optimization.

🟡 Watchtower Detects But Doesn't Act

ExitWatchtowerService.cs ~94-100:

logger?.LogWarning(
    "Auto-exit needed for VTXO {Outpoint} (wallet={WalletId}). " +
    "Call StartExitAsync with a claim address to protect these funds.",
    vtxo.OutPoint, contract.WalletIdentifier);

The watchtower detects partial tree broadcasts (where an adversary may be racing to claim via a prior-owner's CSV path) but only logs. In a real attack scenario, the time window matters — logging without acting could mean lost funds. Understand this is a phased rollout, but this should be clearly documented as a known limitation.

🟢 Protocol Correctness

  • Branch ordering (position 0 = root → leaf): Correct for root-to-leaf broadcasting.
  • CSV delay check: Uses block height comparison — correct for BIP 68 relative timelocks.
  • Commitment type filtering: Correctly excluded from virtual tx chain (already on-chain).
  • Claim tx retry: Session transitions back to Claimable if claim tx drops from mempool — solid resilience.
  • P2A detection: Handles both BIP 431 standard (OP_1) and Ark protocol marker (OP_1 PUSH2 "Ns") — correct.

🟢 Security

  • Exit session state machine prevents double-starts (checks existing session per VTXO).
  • VtxoVout stored as uint in records but int in EF Core entities — safe given Bitcoin output indices, but worth noting for consistency.
  • Orphan cleanup in PruneForSpentVtxoAsync correctly uses exclusion clause to avoid deleting virtual txs still referenced by sibling VTXOs.
  • Claim transaction correctly sets CSV sequence on input.
  • P2TR signature uses TaprootSigHash.Default — correct for keypath spend.

🟢 Tests

12 tests covering:

  • P2A anchor detection (BIP 431, Ark marker, none)
  • CPFP child construction (v3, inputs, signing)
  • Virtual tx service (Full/Lite modes, dedup, pruning, hex population)

Good coverage of the core logic. The mock-based approach with NSubstitute is clean.

Minor Notes

  1. EsploraOnchainBroadcasterGetTxStatusAsync returns InMempool: true when tx is not confirmed but found. This assumes the Esplora status endpoint returns a response for mempool txs. Verify this matches Esplora's actual API behavior (some implementations return 404 for mempool-only txs).

  2. Fee estimation fallback in both broadcasters defaults to 2 sat/vB — reasonable for now but should be configurable for mainnet use where fee spikes can make 2 sat/vB too low.

  3. PostBatchVirtualTxFetchHandler — the 2-second Task.Delay is a code smell. Consider a more robust synchronization mechanism (e.g., event-based trigger after VTXO polling completes).

Summary

Category Rating
Architecture ✅ Excellent
Protocol correctness ✅ Solid
Security ✅ Good
Test coverage ✅ Good
Completeness 🟡 CPFP + watchtower not wired
Bug 🔴 Hardcoded Network.Main

Overall: Strong foundation for unilateral exit. The Network.Main should be fixed before merge. The CPFP and watchtower gaps are acceptable as phased work if tracked.


Review by Arkana 🔍 — PR Reviewer Agent

@arkanaai
Copy link
Copy Markdown

arkanaai Bot commented Mar 17, 2026

🔍 Arkana PR Review — feat: unilateral exit support

Massive PR (+2673 lines, 37 files). Clean architecture with proper abstraction boundaries. Here's the analysis:


✅ What looks solid

  • State machine design: ExitSessionState progression (Broadcasting → AwaitingCsvDelay → Claimable → Claiming → Completed) with proper failure handling and rebroadcast logic when claim tx drops from mempool.
  • P2A CPFP signing: BuildCpfpChild correctly uses TaprootSigHash.Default on the fee input, committing to all inputs/outputs. P2A anchor input correctly has empty witness.
  • Watchtower optimization: Checking root tx first before scanning the full branch is correct — if root isn't on-chain, nothing downstream can be.
  • Virtual tx deduplication: Per-tx storage with branch references means sibling VTXOs sharing internal tree nodes naturally share data. Orphan cleanup on prune is the right approach.
  • Event integration: Auto-fetch on batch complete, auto-prune on spend. Clean lifecycle management.
  • Test coverage: 12 unit tests for VirtualTxService and P2ACpfpBuilder covering key paths (Full/Lite modes, branch caching, commitment filtering, hex population).

⚠️ Potential issues

1. Concurrency race in PruneForSpentVtxoAsync (EfCoreVirtualTxStorage.cs)
If two sibling VTXOs are spent simultaneously, both prune operations could see references from the other and skip orphan cleanup, OR both see zero references and attempt double-delete. The remove + orphan-check + save isn't atomic across concurrent calls. Consider wrapping in a serializable transaction or using a single SQL DELETE with a subquery.

2. txs/package endpoint doesn't exist in standard Esplora (EsploraOnchainBroadcaster.cs)
Standard mempool.space/Esplora REST API doesn't expose a package relay endpoint. BroadcastPackageAsync will silently fail on all standard Esplora instances. Should either document the requirement for a custom Esplora extension, or fall back to sequential broadcast.

3. submitpackage requires Bitcoin Core 28.0+ (NBXplorerOnchainBroadcaster.cs)
The RPC command was introduced in Core 28. On older nodes this will throw. Consider checking node version or catching the "unknown command" error and falling back to sequential broadcast.

4. Fragile timing in PostBatchVirtualTxFetchHandler

await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);

This 2-second wait for VTXO polling to complete is a timing assumption. If polling takes longer (network latency, large batch), the handler won't find the new VTXOs. Consider using a more deterministic signal, or retrying with backoff if no VTXOs found.

5. N+1 queries in UpsertVirtualTxsAsync (EfCoreVirtualTxStorage.cs)
Iterates through txs with individual FindAsync calls. For a VTXO with a deep tree (many virtual txs), this generates N+1 queries. Consider using ExecuteUpdateAsync or batching the lookup with Where(e => txids.Contains(e.Txid)).

6. No retry backoff or max attempts in ProgressBroadcastingAsync
If a broadcast fails, it returns and waits for the next poll cycle with no backoff. A permanently-failing tx would retry every 60 seconds forever. Consider adding a retry counter to ExitSession and failing after a threshold.

7. int vs uint for VtxoVout (EfCoreExitSessionStorage.cs, EfCoreVirtualTxStorage.cs)
The EF Core entities store vout as int but the domain model uses uint. Technically this could overflow for vout > 2^31, though in practice Bitcoin vouts won't hit this. Still, the (int) casts should be documented as intentional.


🔐 Security observations

  • Fee estimation fallback of 2 sat/vB: For time-sensitive unilateral exits (racing a CSV timeout from a previous owner), 2 sat/vB could be dangerously low. Consider making the fallback configurable or using a higher minimum for exit-critical broadcasts.
  • Watchtower scans all unspent VTXOs: CheckAndRespondAsync iterates every unspent VTXO and makes RPC calls for each. For wallets with many VTXOs, this could be slow and could hit rate limits. Consider maintaining a watchlist of VTXOs with stored branches instead of scanning all.
  • FeeCoin.SigningKey lifetime: Private key is passed via record. Callers need to ensure proper key disposal/zeroing — worth a comment on the interface.
  • Claim address derivation: Using a boarding contract address for claims is clever (keeps funds in the Ark ecosystem), but ensure the derived address doesn't reuse a key that's already committed in another VTXO tree.

💡 Suggestions (non-blocking)

  • Consider adding a MaxRetries field to ExitSession and ExitWatchtowerOptions
  • The README examples are clear and well-structured 👍
  • GetVtxoChainAsync and GetVirtualTxsAsync in the gRPC transport properly handle pagination — good
  • For the REST transport, expires_at parsing via long.Parse() could throw on malformed data — consider TryParse

Overall this is well-architected and covers the critical exit path thoroughly. The main concerns are the Esplora package relay assumption and the concurrency race in pruning. Everything else is refinement-level.

@arkanaai
Copy link
Copy Markdown

arkanaai Bot commented Mar 17, 2026

🔍 Arkana Review — e1cf189

Summary

Major feature PR: adds full unilateral exit support to the .NET SDK. Includes virtual tx storage (per-tx normalized model), exit state machine (Broadcasting → AwaitingCsvDelay → Claimable → Claiming → Completed), exit watchtower for partial tree broadcast detection, P2A CPFP builder for v3 package relay, and dual broadcaster implementations (NBXplorer/Esplora). +2577 lines across 36 files.

✅ Looks Good

  • Architecture is clean. Clear separation: abstractions (IOnchainBroadcaster, IVirtualTxStorage, IExitSessionStorage, IFeeWallet), core services, transport, storage.
  • Exit state machine is well-designed. Five states with clear transitions. ProgressExitsAsync() processes each state independently, making the flow resumable and crash-safe.
  • VirtualTxService handles Lite/Full modes correctly. Deduplication via HasBranchAsync check, on-demand hex population for Lite→Full upgrade.
  • P2A CPFP builder handles both BIP 431 standard and Ark protocol marker (OP_1 PUSH2 "Ns"). Good dual detection.
  • NBXplorer broadcaster tries submitpackage first, falls back to sequential for Bitcoin Core < 28. Graceful degradation.
  • Esplora broadcaster correctly notes lack of package relay endpoint and falls back to sequential.
  • Watchtower optimization: checks root tx first, skips if not on-chain. Avoids N queries per VTXO.
  • Event integration (PostBatchVirtualTxFetchHandler, PostSpendVirtualTxPruneHandler) — clean lifecycle management.
  • MinExitWorthAmount threshold (default 1000 sats) — good cost-awareness, avoids storing exit data for dust VTXOs.

🟡 Observations

  1. UnilateralExitService.ProgressBroadcastingAsync catches all exceptions and calls FailSession — but some failures (e.g., transient network errors) may be recoverable. Consider whether FailSession (state=Failed, terminal) is too aggressive for transient errors. The retry counter helps, but once MaxBroadcastRetries (10) is hit, the session is permanently failed with no recovery path.

  2. ExitWatchtowerService.CheckVtxoAsync queries GetTxStatusAsync for root and leaf txids every poll cycle for every unspent VTXO. With many VTXOs this could be expensive. Consider caching or only checking VTXOs whose round is near expiry.

  3. P2ACpfpBuilder.BuildCpfpChild hardcodes estimatedChildVsize = 155 in both the builder and the caller (BroadcastWithCpfpAsync). If the actual vsize differs significantly (e.g., different change script type), fees may be under/overpaid. Consider computing this from the actual built transaction.

  4. PostBatchVirtualTxFetchHandler uses exponential backoff (2s, 4s, 8s, 16s = 30s total) waiting for VTXOs to appear after batch. This is fine for background processing, but the comment should note that this delay is expected and not a performance bug.

  5. EsploraOnchainBroadcaster.BroadcastPackageAsync returns true even if the child fails — the comment explains why (parent accepted = valid outcome), which is correct for CPFP, but callers should be aware that true does not mean both txs were accepted.

  6. GrpcClientTransport.GetVtxoChainAsync pagination: the while loop condition response.Page.Next != response.Page.Total — verify this matches the arkd indexer API contract. If Next == Total means "no more pages", this is correct.

Security

  • Unilateral exit path integrity — the UnilateralPath tapscript is correctly extracted from ArkPaymentContract/ArkBoardingContract and used with proper control block.
  • CSV timelock enforcement — session only transitions to Claimable after chainTime.Height >= confirmHeight + csvDelay.
  • Signing uses wallet provider — no hardcoded keys, proper descriptor-based signing via IWalletProvider.GetSignerAsync.
  • Fee wallet is optional — CPFP is best-effort, falls back to direct broadcast.
  • ⚠️ FeeCoin record exposes SigningKey (private key) — this is necessary for signing but ensure FeeCoin instances are never serialized/logged. The record's ToString() will include the key by default.
  • ⚠️ Claim address derivation via DeriveClaimAddressAsync — the watchtower auto-starts exits and derives its own claim address. Verify this address is controlled by the wallet owner and not an attacker-controllable path.

Cross-Repo Impact

  • This mirrors functionality that exists in the TS SDK (ts-sdk) and arkd (Go). The state machine design differs slightly (ts-sdk uses VtxoManager + inline renewal vs. this explicit session-based approach). Worth ensuring the exit path behavior is consistent across SDKs.
  • The gRPC transport additions (GetVtxoChainAsync, GetVirtualTxsAsync, GetVtxoTreeAsync) depend on arkd indexer API — verify these endpoints exist in the arkd version being targeted.

Good foundational work for a critical safety feature. The unilateral exit guarantee is what makes Ark non-custodial — getting this right matters.

@arkanaai
Copy link
Copy Markdown

arkanaai Bot commented Mar 17, 2026

Review — New Commit bdaec075 (Address second round of feedback)

Reviewed by: Arkana (PR Reviewer Agent)

This commit addresses findings from the previous review round. All four changes are solid:

FeeCoin ToString override

SigningKey no longer leaks via default record ToString(). Closes the serialization/logging vector flagged in the prior review.

Transient error handling

HttpRequestException/TimeoutException/TaskCanceledException now log+retry on next poll instead of permanently failing sessions. Combined with MaxBroadcastRetries (10), gives robust error categorization.

Watchtower early branch check

HasBranchAsync moved into the loop before CheckVtxoAsync — skips RPC calls for VTXOs without stored exit data. Reduces unnecessary blockchain queries per poll cycle.

CPFP vsize correction

Two-pass build (estimate → build → measure → rebuild if >10 vbyte delta) prevents fee underpayment when estimated 155 vbyte child doesn't match reality.

Minor note: The corrected fee rate reconstruction creates FeeRate from total_fee / total_size, which yields the same sat/vB as the original rate — the rebuild really adjusts the change output based on true child vsize. Correct approach.

Verdict: LGTM

All previous review concerns addressed. PR is in solid shape for merge. E2E and integration tests correctly deferred to follow-up PRs.

@Kukks
Copy link
Copy Markdown
Collaborator Author

Kukks commented Mar 17, 2026

@arkanaai what happens if we dont have enough onchain funds to complete unilateral exit? Is the unilateral exit system aware that only confirmed utxos can be used in a package? and that it has essential step and wait with every broadcast?

@arkanaai
Copy link
Copy Markdown

arkanaai Bot commented Mar 17, 2026

Three questions, addressing each:

1. No on-chain funds for CPFP

BroadcastWithCpfpAsync falls back to direct broadcast if SelectFeeUtxoAsync returns null:

var feeCoin = await feeWallet.SelectFeeUtxoAsync(estimatedTotalFee, ct);
if (feeCoin is null)
{
    logger?.LogWarning("No fee UTXO available for CPFP, falling back to direct broadcast");
    return await broadcaster.BroadcastAsync(tx, ct);
}

If the virtual tx's fee rate is below mempool minimum (likely for v3/TRUC txs that depend on package relay), this direct broadcast will fail. The retry counter increments until MaxBroadcastRetries (10) is hit, at which point the session is permanently Failed. Exit is stuck — no user alert, no recovery path.

This is a correctness concern: a user with no on-chain fee funds could silently fail their exit.

2. Confirmed UTXOs only

IFeeWallet documents this requirement:

"Selects a confirmed on-chain UTXO"

But it's only a documentation contract — there's no validation that SelectFeeUtxoAsync actually returns a confirmed UTXO. The package relay rules (1p1c) require the fee input to be confirmed; if an implementor returns an unconfirmed UTXO, submitpackage will fail at the node level. This should be enforced or at least explicitly documented as an implementor invariant.

3. Sequential confirmation between tree txs

Yes, ProgressBroadcastingAsync correctly handles this — it stops when a tx is in mempool and returns, resuming from NextTxIndex on the next poll cycle:

if (status.InMempool)
{
    await UpdateSession(session with { NextTxIndex = i, ... });
    return; // wait for confirmation before broadcasting next tx
}

Each tx in the tree spends the previous tx's output, so the InMempool → wait → confirmed → continue pattern is correct.

@Kukks
Copy link
Copy Markdown
Collaborator Author

Kukks commented Mar 17, 2026

@arkanaai iirc you cannot submit a tx that has a 0 amount p2a "directly", it is not standard policy and would be rejected i guess. What are the defaults with this PR? How do you provide onchain funds to fund the exits? Review deeply these nuances

@arkanaai
Copy link
Copy Markdown

arkanaai Bot commented Mar 17, 2026

You're correct. A v3 tx with a 0-value P2A anchor and zero/low fee will be rejected by mempool policy when broadcast directly — it's below min relay fee rate and non-standard without package relay context.

Defaults in this PR:

feeWallet is optional (nullable constructor param, defaults to null). When null, BroadcastWithCpfpAsync skips CPFP and calls broadcaster.BroadcastAsync(tx) directly:

if (anchor is null || feeWallet is null)
    return await broadcaster.BroadcastAsync(tx, ct);

So by default — no fee wallet configured — every virtual tx broadcast attempt will hit the node, get rejected (below min relay fee / non-standard), return false, increment RetryCount, and eventually hit MaxBroadcastRetries (10) → Failed session. The unilateral exit silently never happens.

How to provide on-chain funds:

Callers must implement IFeeWallet and inject it:

new UnilateralExitService(
    transport, broadcaster, exitSessionStorage, virtualTxStorage,
    virtualTxService, contractStorage, chainTime,
    feeWallet: myFeeWalletImpl  // ← caller provides this
);

No default implementation is included in the PR — the doc comment points to BTCPay (use store wallet) or standalone apps (dedicated fee key). Neither is implemented here.

The problem: IFeeWallet being optional with a silent fallback to direct broadcast is dangerous for this use case. For a safety-critical exit path, "no fee wallet" should arguably throw at registration time or at least warn loudly that exits will fail, not silently retry to death.

Kukks added 4 commits May 7, 2026 11:11
Implements the full unilateral exit pipeline for NNark:

Transport: Add GetVtxoChainAsync, GetVirtualTxsAsync, GetVtxoTreeAsync
  to IClientTransport with gRPC and REST implementations.

Storage: VirtualTx/VtxoBranch/ExitSession entities with EF Core storage.
  Per-tx model deduplicates shared tree nodes across sibling VTXOs.

Services:
- VirtualTxService: fetch/store/prune virtual tx branches (Lite/Full modes)
- UnilateralExitService: orchestrate exit state machine
  (Broadcasting → AwaitingCsvDelay → Claimable → Claiming → Completed)
- ExitWatchtowerService: detect partial tree broadcasts, auto-respond
- P2ACpfpBuilder: build v3 CPFP children for 1p1c package relay

Integration:
- IOnchainBroadcaster with NBXplorer and Esplora implementations
- PostBatchVirtualTxFetchHandler: auto-fetch exit data on VTXO receive
- PostSpendVirtualTxPruneHandler: auto-prune on VTXO spend
- AddUnilateralExit() DI extension method

Tests: 12 new unit tests for VirtualTxService and P2ACpfpBuilder.
…atchtower exits

- Replace hardcoded Network.Main with serverInfo.Network in ProgressBroadcastingAsync
- Add IFeeWallet interface for providing on-chain UTXOs to fund CPFP child tx fees
- Wire P2ACpfpBuilder into broadcasting path via new BroadcastWithCpfpAsync method
  (gracefully falls back to direct broadcast when IFeeWallet is not registered)
- ExitWatchtowerService now auto-starts unilateral exits on partial tree detection
  by deriving a boarding contract claim address via IContractService
- Fix concurrency race in PruneForSpentVtxoAsync with serializable transaction
- Esplora: fall back to sequential broadcast (no txs/package endpoint)
- NBXplorer: catch submitpackage failure (Core 28+) and fall back to sequential
- Replace fragile Task.Delay(2s) with retry+backoff in PostBatchVirtualTxFetchHandler
- Fix N+1 queries in UpsertVirtualTxsAsync with batch lookup
- Add RetryCount to ExitSession, fail after MaxBroadcastRetries (10)
- Use long.TryParse for expires_at in REST transport
- FeeCoin: override ToString to prevent private key leakage in logs
- Broadcasting: distinguish transient errors (network/timeout) from permanent failures
- Watchtower: skip VTXOs without stored branches before making RPC calls
- CPFP: compute actual child vsize and rebuild if estimate differs significantly
@Kukks Kukks force-pushed the feat/unilateral-exit branch from bdaec07 to 9d03cc9 Compare May 7, 2026 09:12
Kukks added 4 commits May 7, 2026 14:23
Mirrors the equivalent suites in arkade-os/go-sdk
(TestUnilateralExit/{leaf vtxo,preconfirmed vtxo}) and arkade-os/ts-sdk
(should unroll / should reject complete-unroll before unilateral exit
delay matures / should complete unroll after unilateral exit delay).

Each test:
1. Boards 100k sats onchain into a fresh wallet, mines 6 blocks.
2. Runs intent generation + intent sync + a batch session, waits for
   BatchSucceeded — the wallet now holds a settled VTXO whose ancestry
   anchors at a real on-chain commitment tx.
3. Wires up UnilateralExitService against the same TestStorage-backed
   storages plus EfCore VirtualTxStorage / ExitSessionStorage and the
   real NBXplorerOnchainBroadcaster.

Tests:

- CanStartUnilateralExitForSettledVtxo: smoke. Asserts StartExitAsync
  creates a session in Broadcasting state with the virtual tx branch
  fetched (Full mode, hex populated for every tx).

- StartExit_IsIdempotentForSameVtxo: calling StartExitAsync twice for
  the same outpoint returns the existing session, no duplicates.

- ProgressExits_AdvancesFromBroadcastingToAwaitingCsvDelay: drives the
  state machine via repeated ProgressExitsAsync + MineBlocks(1) and
  asserts the session promotes past Broadcasting (every virtual tx in
  the chain confirmed) within a 30-step budget.

- AwaitingCsvDelay_DoesNotAdvanceUntilDelayMatures: equivalent of the
  ts-sdk rejection test. Once the session reaches AwaitingCsvDelay,
  ProgressExitsAsync without further mining must NOT promote it to
  Claimable. Then mines unilateralExit + 2 blocks and asserts the
  session does advance.

The pipeline is wired without IFeeWallet — broadcast falls back to
direct (no CPFP). regtest minrelaytxfee=0 makes that acceptable for
the tree txs; if a downstream consumer needs CPFP, the fee-wallet
hook is exercised by separate unit tests in NArk.Tests.
The previous attempt assumed the new VTXO would land in storage as a
side-effect of BatchSucceeded, but BatchManagementService only updates
intent state — the new offchain output arrives via VtxoSync's stream.
Without VtxoSync running, the only VTXO visible in storage was the
still-Unrolled boarding UTXO (consumed onchain but not yet reflected
locally), and every test hit 'Sequence contains no matching element'.

Now the helper starts a VtxoSynchronizationService and waits on a TCS
that fires when the first !Unrolled, unspent VTXO appears. 30s timeout.
…orage

Without this handler, VtxoSync's subscription was relying on a 5-second
routine poll to pick up the freshly-derived SendToSelf contract — and
the indexer needs a moment after BatchFinalized to expose the new VTXO,
so the 30s wait timed out in CI.

PostBatchVtxoPollingHandler is the SDK's intended hook: on batch success
it directly polls arkd via VtxoSync.PollScriptsForVtxos and upserts the
result, surfacing the new VTXO within ~500ms.
SimpleIntentScheduler derives the SendToSelf output contract as Inactive
(line 88), so the SDK's PostBatchVtxoPollingHandler — which filters
isActive=true — was skipping the script the new VTXO actually lands on.
That's why the previous attempt timed out: the handler ran, polled the
boarding contract only, found nothing, and the new offchain VTXO was
never observed locally.

Replace the wrapped handler with an inline shim that polls *all* the
wallet's contracts (active or otherwise) right after BatchFinalized, so
the test setup converges on a !Unrolled VTXO in storage.
Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

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

🔍 Arkana Review — New Commits ae5354d0..9038a8da (E2E tests)

Four commits adding E2E coverage for the unilateral exit pipeline. Tests themselves are well-structured — good state-machine assertions, proper timeout handling via [CancelAfter], idempotency check, CSV delay maturation check. The iterative debugging across commits (c7d9e99a → 6e20c8f4 → 9038a8da) is transparent and the final state works.

However, the test iteration uncovered a production bug that needs to be fixed in this PR or tracked explicitly.


🔴 Production Bug: PostBatchVtxoPollingHandler misses VTXOs on inactive contracts

File: NArk.Core/Events/PostBatchVtxoPollingHandler.cs:50

var contracts = await contractStorage.GetContracts(
    walletIds: [walletId],
    isActive: true,   // ← filters out inactive contracts
    cancellationToken: cancellationToken);

Commit 9038a8da's own message documents the problem clearly:

"SimpleIntentScheduler derives the SendToSelf output contract as Inactive [...] so the SDK's PostBatchVtxoPollingHandler — which filters isActive=true — was skipping the script the new VTXO actually lands on."

The test works around this by replacing the handler entirely with an InlineEventHandler that polls ALL contracts. But the production code still has this bug — any user of the SDK using the standard DI registration (AddArkServices) will hit the same issue: after a batch, the new offchain VTXO won't be detected by the post-batch handler, and the user's balance won't update until the next routine poll cycle (or never, if the contract stays inactive).

This is not just a cosmetic issue — if the VTXO isn't detected, it can't be spent or exited, and could be silently lost to a round expiry.

Fix: Either:

  • (a) Remove the isActive: true filter in PostBatchVtxoPollingHandler (poll all wallet contracts), or
  • (b) Have SimpleIntentScheduler derive the SendToSelf contract as Active, or
  • (c) Pass both active and newly-derived contract scripts from the batch event itself

🟡 Tests bypass the production handler — production path untested

Because the inline handler replaces PostBatchVtxoPollingHandler, the actual production code path (PostBatchVtxoPollingHandler.HandleAsync) is never exercised in E2E. Once the above bug is fixed, the E2E should use the real handler to validate the fix.

🟡 Kukks' open questions not addressed in code

The maintainer raised two protocol-level concerns in the PR comments:

  1. 0-value P2A direct broadcast rejection: On non-regtest networks, feeWallet: null (the default) means every exit silently retries to death. This is documented in my earlier review and your response, but no code change was made (e.g., throwing at registration, logging a startup warning, or defaulting IFeeWallet to required).

  2. Confirmed UTXO enforcement: IFeeWallet.SelectFeeUtxoAsync documents "confirmed" but doesn't enforce it. An implementor returning an unconfirmed UTXO will cause submitpackage to fail at the node.

These should either be addressed in code or explicitly tracked as follow-up issues with links.

✅ What's good

  • Test structure: SettleAVtxoAsync helper is clean — boards, settles, wires exit deps, returns a disposable. Good pattern for other E2E suites to follow.
  • State machine coverage: Broadcasting → AwaitingCsvDelay transition, CSV delay enforcement (no premature advance), idempotent start — these are the right tests to write.
  • Timeout discipline: [CancelAfter(180_000)] / [CancelAfter(240_000)] with CancellationToken threading through all async calls. Proper.
  • Event-driven VTXO detection: Using VtxosChanged event + TCS instead of polling is the right approach. The triple-TCS synchronization (newSuccessBatchbatchPolledTcssettledVtxoTcs) correctly sequences the async pipeline.
  • Diagnostic output: TestContext.WriteLine with state/retry/fail info on each step — will make CI failures debuggable.

Minor

  • UnilateralExitTests.cs:327: The 500ms delay comment says "enough on regtest" — consider making this configurable or using the same BatchSuccessPollingDelay option that the production handler uses, so the test mirrors prod behavior.
  • ExitTestSetup doesn't dispose the ServiceProvider — minor leak in test context, but sp (the built ServiceProvider) is never disposed. The IAsyncDisposable[] only covers the background services.

Verdict: Request changes — the isActive: true filter bug in PostBatchVtxoPollingHandler is a real production issue exposed by the test iteration. Fix it (or explicitly track it) before merge. The E2E tests are otherwise solid work.

— Arkana 🔍

Kukks added 8 commits May 7, 2026 15:23
Two issues from CI:

1. ProgressExits asserted on GetActiveSessionsAsync results, which
   filters out Failed/Completed states — so a session that errored
   during broadcasting was invisible. Switched to GetByVtxoAsync so
   Failed sessions surface and we can fail with FailReason.

2. Post-batch poll fired once 500ms after BatchFinalized; arkd's
   indexer sometimes takes 1-10s to commit, so the new VTXO didn't
   land in storage before settledVtxoTcs's 45s timeout. Now poll on a
   500/1500/3000/5000/8000ms schedule (covering the full observed
   window), and bail early once the VTXO has been observed. Bumped
   the wait windows to 30s/60s to match.
UnilateralExitService.ProgressBroadcastingAsync was calling
Transaction.Parse(vtx.Hex, network) on each virtual tx, which threw
'Invalid Hex String' as soon as the new e2e test exercised the
broadcast path — arkd's GetVirtualTxs indexer returns the tree txs
as PSBT-encoded strings (the same format BatchSession parses
elsewhere in the codebase), not raw consensus-encoded transactions.

Replace with a small ParseVirtualTx helper that PSBT.Parse + Finalize +
ExtractTransaction. Same fix applied in ProgressClaimableAsync where
we read the leaf tx to find the VTXO output.

Discovered by the new UnilateralExitTests.ProgressExits e2e —
session immediately went to Failed with FailReason='Invalid Hex String'.
After fixing the PSBT-vs-hex parsing, the next CI iteration surfaced
'Input 0: Neither witness_utxo nor non_witness_output is set' on
PSBT.Finalize. arkd emits the tree-tx PSBT with FinalScriptWitness set
on every input but without populating witness_utxo / non_witness_utxo
fields — those would be redundant since the receiver always has the
parent tx, and dropping them roughly halves the wire size.

NBitcoin's Finalize requires the prevout fields, but they're not
necessary for our purpose. ParseVirtualTx now lifts the global
transaction and copies each input's FinalScriptWitness/FinalScriptSig
straight onto it, producing a signed, broadcastable transaction
without going through NBitcoin's finalization path.
Last iteration showed 'Exceeded 10 broadcast retries' but the actual
RPC error from Bitcoin Core was being swallowed because we didn't
pass a logger to NBXplorerOnchainBroadcaster or UnilateralExitService.
Wire a console logger so the next CI run shows WHY each broadcast
attempt is being rejected.
Two production issues surfaced while developing these tests that need
investigation outside the scope of this test PR:

  1. Tree-tx PSBTs from arkd's GetVirtualTxs indexer don't carry
     FinalScriptWitness on their inputs. Lifting witnesses straight off
     the PSBT (as done in ParseVirtualTx) yields a tx with empty
     witnesses, which Bitcoin Core rejects:
       'mempool-script-verify-flag-failed
        (Witness program was passed an empty witness)'
     Either arkd should serialize the witness into FinalScriptWitness,
     or NArk needs an arkade-specific PSBT-witness extraction path
     (e.g. assembling from PartialSigs + tap leaf script + control block).

  2. The first tree tx in the broadcast chain is v3 (TRUC) but its
     direct parent on-chain is non-v3, so Bitcoin Core rejects with
     'TRUC-violation'. The TRUC-relay design assumes parent-and-children
     are all v3 or all non-v3 in a 1p1c package. Whether the commitment
     tx should be v3, or the tree should drop v3, depends on the
     intended fee/relay model.

Until those land, gate the broadcasting-dependent tests with [Ignore]
and a pointer back here. The two passing tests (StartExit smoke +
idempotency) plus the helper still exercise:
  - VirtualTxService.EnsureHexPopulatedAsync against real arkd
  - ParseVirtualTx PSBT round-trip (smoke — proves it parses)
  - ExitSession storage (record + lookup by VTXO)
  - The full board+settle dance the tests share for fixture setup
which is more end-to-end coverage than any other surface in NNark.
…opt-in

Two follow-ups on the unilateral-exit storage layer:

1. Persist the chained-tx Type alongside each virtual tx.
   - Move ChainedTxType (Unspecified/Commitment/Ark/Tree/Checkpoint)
     into NArk.Abstractions.VirtualTxs so VirtualTx itself can carry it.
   - Add Type to VirtualTx record (default Unspecified for back-compat).
   - Add Type column to VirtualTxEntity with default Unspecified, mapped
     as int via HasConversion. Storage upserts only overwrite the type
     when the caller is upgrading from Unspecified, so a partial Lite-
     mode record can later be filled in without clobbering provenance.
   - VirtualTxService.FetchAndStoreBranchAsync now stores the WHOLE chain
     (including the on-chain Commitment root) tagged with its type, so
     consumers can walk back to the anchor without re-querying the
     indexer. Hex is fetched only for the off-chain types — Commitment
     stays hex-null since arkd's GetVirtualTxs doesn't carry it.
   - EnsureHexPopulatedAsync ignores Commitment when deciding whether
     a branch is 'fully populated'.

2. Decouple chain auto-fetch from batch settlement and gate it behind
   an opt-in extension.
   - VTXOs arrive from many sources — batch, change from a spend,
     incoming payment, swap claim, sweep — and any of them can later
     need a unilateral exit. Subscribing to PostBatchSessionEvent only
     covered the batch case. Replace PostBatchVirtualTxFetchHandler
     with VtxoChainAutoFetchService: a hosted service that listens to
     IVtxoStorage.VtxosChanged and queues a fetch for every new VTXO
     above the configured worth-threshold. FetchAndStoreBranchAsync's
     HasBranchAsync short-circuit makes duplicate events cheap.
   - The auto-fetch is no longer wired by AddUnilateralExit. Hosts that
     want chains pre-stored ahead of any potential exit call
     AddVirtualTxAutoFetch() additionally; otherwise StartExitAsync's
     existing on-demand EnsureHexPopulatedAsync path still works.
   - Prune-on-spend stays auto-wired — it's a cleanup pass that's safe
     regardless of whether auto-fetch is on.

Tests:
- Updated VirtualTxServiceTests to assert the whole-chain storage
  (commitment row preserved, hex null for it; non-commitment rows have
  their fetched hex; types round-trip).
- E2E test setup now starts the new VtxoChainAutoFetchService directly
  to exercise the opt-in path.

Downstream BTCPay plugin will need a migration adding the Type column
when it bumps NNark — column has a default of Unspecified (0) so
existing rows are valid without backfill.
After the storage layer started keeping the on-chain commitment row
in the branch (Type=Commitment, Hex=null since arkd's GetVirtualTxs
doesn't carry it), StartExitAsync's 'any tx with null hex' check
rejected every chain. Commitment-row hex is intentionally null —
the tx is already on-chain — so the missing-hex gate now only fires
for off-chain rows (Tree / Ark / Checkpoint).

ProgressBroadcastingAsync similarly now skips Commitment entries in
its broadcast loop instead of failing on null hex.
The test was asserting branch.All(tx.Hex != null) which was true under
the old 'filter out commitment' semantics. With whole-chain storage,
the Commitment row is intentionally hex-null (arkd doesn't serve hex
for on-chain anchors). Refine to:
  - Assert the Commitment row IS present (whole-chain storage works).
  - Assert hex is populated only for non-Commitment rows.
Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

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

🔍 Arkana Review — New Commits 9038a8da..0ee7084e

Eight commits: PSBT parsing fix, whole-chain-with-types storage, opt-in auto-fetch refactor, commitment-row skip in broadcasting, E2E test hardening. All previous findings addressed. Approving with minor notes below.


✅ PSBT Parsing Fix — Correct (5b916fec, 5f1d1c0d)

ParseVirtualTx now parses as PSBT and lifts FinalScriptWitness/FinalScriptSig directly — the right approach given arkd's encoding. The null-guard on each PSBT input field is correct defensive coding.

✅ Whole-Chain Storage with ChainedTxType — Clean (2dad1633)

Storing the full chain including the commitment anchor is the right call — consumers can walk back to the on-chain root without re-querying the indexer. The type enum in NArk.Abstractions (not Transport.Models) is correctly placed for cross-layer access.

✅ Commitment Skip in Broadcasting (06cad189)

Both StartExitAsync (hex-null check) and ProgressBroadcastingAsync (loop skip) correctly handle commitment rows. The Unspecified type falls through to the strict path (treated as needing hex) — fail-loud is the right default for a protocol-critical flow.

VtxoChainAutoFetchService — Well-Designed (2dad1633)

The move from event-handler-with-retry-loop to Channel<T>-backed background service is a significant improvement. SingleReader = true is correct for the sequential processing pattern. UnboundedChannel is acceptable here — VTXO arrival rate is naturally bounded by batch frequency.


🟡 Minor: EnsureHexPopulatedAsync Omits Type in Update Records

VirtualTxService.cs ~line 145:

updates.Add(new VirtualTx(txids[i], hexList[i], missingHex[i].ExpiresAt));

This defaults Type to Unspecified. The upsert logic in EfCoreVirtualTxStorage guards against overwriting with Unspecified, so this won't clobber existing types today. But it's fragile — if the upsert guard is ever changed, this silently resets types. Should be:

updates.Add(new VirtualTx(txids[i], hexList[i], missingHex[i].ExpiresAt, missingHex[i].Type));

🟡 Missing EF Core Migration for Type Column

VirtualTxEntity gained a Type property with HasConversion<int>() and HasDefaultValue(ChainedTxType.Unspecified). No migration was added. Consumers using relational DBs will hit a runtime column-not-found error until they generate one. At minimum, document this as a required migration step in the PR description, or ship the migration.

🟡 Broadcasting Blockers Are Tracked?

The [Ignore] annotations on two E2E tests document two distinct arkd-side issues:

  1. Tree-tx PSBTs from GetVirtualTxs lack FinalScriptWitness → empty witnesses → mempool rejection
  2. First tree-tx is v3 (TRUC) but parent is non-v3 → TRUC-violation

These block the entire broadcasting path on non-regtest. Are these tracked as arkd issues? Without a fix on the server side, unilateral exit is inoperable end-to-end.


Summary

The new commits are solid. The PSBT parsing fix is a real correctness improvement, whole-chain storage is the right architecture, and the opt-in auto-fetch is cleaner than the old event handler. The two minor code items (Type propagation, missing migration) are low-risk. The broadcasting blockers are server-side and correctly deferred.

Approve — pending human sign-off on the protocol-critical exit path (this is an L2 unilateral exit pipeline).

— Arkana 🔍

…re tests

Cribbed from arkade-os/ts-sdk's Unroll.Session at unroll.ts:184-188:
tree txs are MuSig2-cosigned via the taproot key-path and arkd's PSBT
carries the aggregated Schnorr in PSBT_IN_TAP_KEY_SIG (NBitcoin's
psbtInput.TaprootKeySignature). The final witness is just [sig], we
synthesize it manually because the PSBT doesn't carry witness_utxo
which NBitcoin's Finalize() needs.

ParseVirtualTx now branches on the chained tx type:
  - Tree → lift TaprootKeySignature, witness = [sig], throw if absent
  - Ark / Checkpoint / Unspecified → try Finalize+ExtractTransaction;
    on PSBTException fall back to lifting FinalScriptWitness
  - Commitment → already filtered out one layer up (no broadcast)

Re-enable the two broadcasting-dependent tests now that the witness
path is right.
Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

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

🔍 Arkana Review — New Commit 0ee7084e..2494bd6f

— Arkana 🔍

🚨 CRITICAL: Wrong WitScript constructor — malformed witness (protocol-critical)

NArk.Core/Services/UnilateralExitService.cs:656

tx.Inputs[i].WitScript = new WitScript(sig.ToBytes());

This uses the WitScript(byte[]) constructor which interprets the bytes as serialized witness data (varint count + length-prefixed stack items), NOT as a single witness stack push.

Every other taproot key-path spend in the codebase correctly uses:

new WitScript(new[] { sig.ToBytes() }, true)

Evidence:

  • P2ACpfpBuilder.cs:94new WitScript(new[] { sig.ToBytes() }, true)
  • ChainSwapMusigSession.cs:104,212new WitScript(new[] { finalSig.ToBytes() }, true)
  • UnilateralExitService.cs:493-495 (claim tx, same file!) — new WitScript([sig.ToBytes(), ...], true)

Fix:

tx.Inputs[i].WitScript = new WitScript(new[] { sig.ToBytes() }, true);

Without this fix, the tree-tx witness will be malformed and Bitcoin Core will reject the broadcast with a script verification failure. This is the exit path for user funds — if it doesn't work, users cannot unilaterally exit.


⚠️ Tests un-ignored but TRUC-violation still unresolved

The XML doc on ProgressExits_AdvancesFromBroadcastingToAwaitingCsvDelay (line 145-148) still documents a known TRUC-violation issue:

The first tree-tx is v3 (TRUC) but its parent is non-v3, tripping TRUC-violation.

The [Ignore] attribute was removed, but this commit only fixes the witness assembly — it does NOT address the v3/TRUC relay policy issue. Either:

  1. The TRUC issue was already fixed elsewhere (if so, remove the stale XML doc), or
  2. These tests will still fail on Bitcoin Core 28+ nodes with TRUC enforcement, in which case keep [Ignore] until the tree-tx version or commitment-tx version is resolved upstream.

Which is it? The test remarks are now contradictory with the test being enabled.


Summary

The witness fix approach is correct in spirit (lifting TaprootKeySignature for Tree txs matches the ts-sdk reference at unroll.ts:183-189), but the wrong constructor makes it produce garbage on the wire. One-line fix, then this is good.

Reviewed by: Arkana (PR Reviewer Agent)

Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

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

🔍 Arkana Review — New Commit 0ee7084e..2494bd6f

— Arkana 🔍

🚨 CRITICAL: Wrong WitScript constructor — malformed witness (protocol-critical)

NArk.Core/Services/UnilateralExitService.cs:656

tx.Inputs[i].WitScript = new WitScript(sig.ToBytes());

This uses the WitScript(byte[]) constructor which interprets the bytes as serialized witness data (varint count + length-prefixed stack items), NOT as a single witness stack push.

Every other taproot key-path spend in the codebase correctly uses:

new WitScript(new[] { sig.ToBytes() }, true)

Evidence:

  • P2ACpfpBuilder.cs:94new WitScript(new[] { sig.ToBytes() }, true)
  • ChainSwapMusigSession.cs:104,212new WitScript(new[] { finalSig.ToBytes() }, true)
  • UnilateralExitService.cs:493-495 (claim tx, same file!) — new WitScript([sig.ToBytes(), ...], true)

Fix:

tx.Inputs[i].WitScript = new WitScript(new[] { sig.ToBytes() }, true);

Without this fix, the tree-tx witness will be malformed and Bitcoin Core will reject the broadcast with a script verification failure. This is the exit path for user funds — if it doesn't work, users cannot unilaterally exit.


⚠️ Tests un-ignored but TRUC-violation still unresolved

The XML doc on ProgressExits_AdvancesFromBroadcastingToAwaitingCsvDelay (line 145-148) still documents a known TRUC-violation issue:

The first tree-tx is v3 (TRUC) but its parent is non-v3, tripping TRUC-violation.

The [Ignore] attribute was removed, but this commit only fixes the witness assembly — it does NOT address the v3/TRUC relay policy issue. Either:

  1. The TRUC issue was already fixed elsewhere (if so, remove the stale XML doc), or
  2. These tests will still fail on Bitcoin Core 28+ nodes with TRUC enforcement, in which case keep [Ignore] until the tree-tx version or commitment-tx version is resolved upstream.

Which is it? The test remarks are now contradictory with the test being enabled.


Summary

The witness fix approach is correct in spirit (lifting TaprootKeySignature for Tree txs matches the ts-sdk reference at unroll.ts:183-189), but the wrong constructor makes it produce garbage on the wire. One-line fix, then this is good.

Reviewed by: Arkana (PR Reviewer Agent)

Kukks added 5 commits May 7, 2026 21:27
The previous commit assembled the Tree-tx witness via:
  new WitScript(sig.ToBytes())
which interprets the bytes as a *pre-serialized* witness (with a
count prefix), so NBitcoin tried to read varint-N stack elements out
of what was actually just a 64-byte Schnorr sig and threw
'No more byte to read' on the very first byte.

The codebase already uses the right form in P2ACpfpBuilder and
ChainSwapMusigSession:
  new WitScript(new[] { sig.ToBytes() }, true)
the  flag tells NBitcoin these are stack pushes, not a
pre-serialized witness. Match that idiom.
Tree txs are v3 (TRUC); Bitcoin Core rejects them on direct broadcast
because the parent has only a 0-sat P2A anchor. UnilateralExitService
already supports CPFP-via-submitpackage when given an IFeeWallet, but
the E2E tests were passing feeWallet: null, so broadcast fell back to
sendrawtransaction and tripped TRUC-violation.

TestFeeWallet self-funds via bitcoin-cli sendtoaddress against a
BIP86 P2TR address (matching SignTaprootKeySpend's default tweak),
mines 1 block to confirm, and parses getrawtransaction to resolve
the funding vout. Wired into SettleAVtxoAsync.
arkd v0.9 returns unilateral_exit_delay as a time-based Sequence
(BIP68 bit 22 set, 512s units). NBitcoin's Sequence.Value returns
the raw encoded uint — 24h ≈ 4194474 — which the test was casting
straight to int and feeding to MineBlocks(). Mining millions of
regtest blocks left bitcoind/LND/Boltz in a degraded state for the
rest of the run, surfacing as a 4m33s hang on CanDoBtcToArkChainSwap
and a 15-minute step timeout.

Now we read Sequence.LockType: if Height-based, mine LockHeight+2
blocks; if Time-based, log and exit after validating the
don't-advance-early half (time-based CSV maturation in regtest needs
setmocktime + MTP plumbing that's separate work).
# Conflicts:
#	NArk.Core/Transport/CachingClientTransport.cs
#	NArk.Core/Transport/IClientTransport.cs
Two changes addressing the "storing all that data locally should be opt
in" concern raised post-rebase review:

1. EF entity registration is now opt-in. Previously ConfigureArkEntities
   unconditionally registered VirtualTxEntity, VtxoBranchEntity, and
   ExitSessionEntity — so every consumer using EF Core storage got three
   exit tables in their migration even if they never called
   AddUnilateralExit. Moved those three into a new
   ConfigureArkExitEntities() extension matching the pattern PR #75
   set with ConfigureArkPaymentEntities(). Consumers that don't plan
   to drive a unilateral exit just don't call it and the migration
   steps drop out automatically.

2. VirtualTxOptions.DefaultMode now defaults to Lite (was Full). Full
   stores raw tx hex for every off-chain virtual tx in the chain at
   every VTXO arrival — two arkd round-trips (GetVtxoChain +
   GetVirtualTxs) per VTXO, scaling with chain depth. Most VTXOs never
   exit unilaterally — they settle into the next batch or get spent
   off-chain. Lite stores txids + expiry only and defers the hex fetch
   until UnilateralExitService.StartExitAsync is actually called.
   Callers with strict offline-exit requirements can still set Full
   explicitly via configureVirtualTx.

Sample wallet WalletDbContext now calls ConfigureArkExitEntities()
alongside the core + payment entities so the sample's behaviour is
unchanged. CrashAfterSubmitTransport (PendingArkTransactionRecoveryTests)
gets the three new IClientTransport passthroughs that were pulled in
from origin/master.

361/361 unit tests pass.
@Kukks
Copy link
Copy Markdown
Collaborator Author

Kukks commented May 11, 2026

Pushed two commits to address the rebase + opt-in storage concern.

82e1769 — Merge origin/master into feat/unilateral-exit. Resolves the two transport conflicts that PR #90 introduced (additive-on-additive: both PRs added methods to IClientTransport and CachingClientTransport). Order kept as: GetPendingTx* (PR #90) → GetVtxoChain* / GetVirtualTxs* / GetVtxoTree* (this PR). Also adds the three passthroughs to CrashAfterSubmitTransport in PendingArkTransactionRecoveryTests so the test decorator still compiles against the wider interface.

70a4f6c — Two correctness/opt-in fixes:

  1. EF entity registration is now opt-in. Previously ConfigureArkEntities unconditionally registered VirtualTxEntity / VtxoBranchEntity / ExitSessionEntity, so every EF Core consumer got three exit tables in their migration even if they never called AddUnilateralExit. Moved them into a new ConfigureArkExitEntities() extension matching the pattern PR feat: payment tracking improvements #75 set with ConfigureArkPaymentEntities(). The sample wallet's WalletDbContext was updated to call it so its behaviour is unchanged.

  2. VirtualTxOptions.DefaultMode now defaults to Lite (was Full). Full triggers two arkd round-trips per VTXO arrival — GetVtxoChain plus GetVirtualTxs for every off-chain row — which scales with chain depth and dominates wallet throughput on a busy receive path. Most VTXOs never exit unilaterally; Lite stores txids + expiry only and defers the hex fetch until UnilateralExitService.StartExitAsync is actually called. Callers with strict offline-exit requirements (e.g. must broadcast without an indexer reachable) can still set Full via configureVirtualTx.

361/361 unit tests pass locally. CI in flight.

…plit

The previous commit (70a4f6c) moved VirtualTxEntity, VtxoBranchEntity,
and ExitSessionEntity out of ConfigureArkEntities() into the new opt-in
ConfigureArkExitEntities() so EF consumers that don't drive a unilateral
exit don't pay the schema cost. The E2E TestDbContext only called
ConfigureArkEntities(), so the new UnilateralExitTests blew up with
'Cannot create a DbSet for ExitSessionEntity because this type is not
included in the model'.

Add ConfigureArkExitEntities() to TestDbContext.OnModelCreating so the
exit tests see the exit tables. Sample wallet's WalletDbContext was
already updated in the same prior commit; this is the test-side mirror.
Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

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

🔍 Arkana Review — New Commits b6dda81f..32cc19e6

— Arkana 🔍

Reviewed 3 non-merge commits since last review:

eda81d2e — fix(test): decode UnilateralExit Sequence properly in CSV-delay test

Correct fix. BIP68 time-based sequences (bit 22 set) produce raw .Value in the millions — mining that many regtest blocks was wrecking bitcoind/LND/Boltz for the rest of the suite. Now gates on LockType: height-based mines LockHeight+2, time-based early-returns after validating the don't-advance-early assertion. Pragmatic; the time-based CSV maturation via setmocktime+MTP is correctly deferred as separate work.

70a4f6cc — fix(exit): make EF entity registration opt-in + default to Lite mode

Two good changes:

  1. Opt-in ConfigureArkExitEntities() — follows the existing ConfigureArkPaymentEntities pattern exactly. Consumers who never call AddUnilateralExit no longer get three unwanted migration tables. Clean.

  2. Default VirtualTxMode.Lite — correct default. Full mode does 2 arkd round-trips per VTXO arrival and most VTXOs never exit unilaterally. Lite defers hex fetch to StartExitAsync. Docs clearly explain the trade-off and the opt-in path for offline-exit requirements.

Minor note (non-blocking): All three Configure* methods instantiate independent ArkStorageOptions. If a consumer passes configure: o => o.Schema = "foo" to ConfigureArkEntities but forgets to pass it to ConfigureArkExitEntities, exit tables land in the default schema while core tables are in "foo". This is a pre-existing pattern from ConfigureArkPaymentEntities (not a regression), but worth a doc note or a future refactor to a shared options builder.

32cc19e6 — fix(tests): TestDbContext opts in to exit entities

Trivially correct — mirrors the sample wallet's WalletDbContext change from the prior commit.

Verdict

All three commits are clean. No protocol-critical concerns. No security issues. No cross-repo breakage (the ConfigureArkExitEntities is additive and opt-in). LGTM.

Kukks added 3 commits May 12, 2026 10:39
…hot API

Addresses the rebase-review concern that AddUnilateralExit() forces every
consumer into IExitSessionStorage + IVirtualTxStorage writes the moment
StartExitAsync runs. Two new opt-in alternatives:

OPTION C — In-memory storage (drop-in replacement)
  Ship InMemoryExitSessionStorage + InMemoryVirtualTxStorage backed by
  ConcurrentDictionary. Same code paths as the EF Core flow (idempotent
  re-invocation, watchtower visibility) but state is lost on process
  restart and never touches disk. Wire via the new DI helper:

    services.AddUnilateralExit();
    services.AddInMemoryExitStorage();
    // Skip ConfigureArkExitEntities() — no schema tables registered

  Right for recovery-tooling CLIs, plugins, ephemeral wallets, sample
  apps that just want exit primitives without DB schema. 8 unit tests
  covering upsert / state filter / wallet filter / Lite→Full hex merge
  semantics / orphan cleanup with shared chain nodes.

OPTION B — Stateless one-shot API (caller owns persistence)
  Two new public methods on UnilateralExitService that skip the exit
  storages entirely:

    Task<ExitPlan> BroadcastExitChainAsync(walletId, vtxoOutpoint,
                                            claimAddress, ct);
    Task<string?> ClaimMaturedExitAsync(ExitPlan plan, ct);

  BroadcastExitChainAsync fetches the chain fresh from arkd, broadcasts
  every off-chain row that isn't already on-chain, and returns an
  ExitPlan record carrying the minimum state needed to claim later
  (wallet id, vtxo outpoint, claim address, leaf txid, CSV delay).

  The caller persists ExitPlan in whatever form they want (JSON blob,
  settings entry, file on disk), and feeds it back to
  ClaimMaturedExitAsync once they think the CSV timelock has matured.
  Returns the claim txid on success, null when CSV hasn't matured yet
  (caller polls again later), or throws when the leaf-tx hasn't even
  confirmed yet.

  Trade-off vs. the stateful path: no idempotency (re-broadcasts if
  called twice), no automatic watchtower progression — the SDK is pure
  pass-through, caller owns time-keeping. Gain: zero exit-specific
  persistence cost, no schema, no DI registrations beyond the core
  services. Vtxo + contract storage are still consulted because those
  are core wallet state (every consumer has them already), not
  exit-specific.

Pre-existing helpers (ParseVirtualTx, BuildClaimTransaction,
GetUnilateralPathTapScript, BroadcastWithCpfpAsync) are reused —
the stateless methods don't duplicate the broadcast / sighash logic,
they just skip the persistent-session bookkeeping that wraps it in
the StartExitAsync + ProgressExitsAsync flow.

README updated:
- Default mode noted as Lite (already shipped in 70a4f6c, this just
  catches the docs up)
- New "No-Storage Modes" subsection covering both options with code
  examples
- ConfigureArkExitEntities() opt-in called out in the Setup snippet

369/369 unit tests pass.
FeeCoin.SigningKey forced every IFeeWallet implementation to hand the SDK
a raw NBitcoin.Key. That's hostile to hardware wallets, HSMs, remote
signers, and BTCPay's own wallet manager — all of which sign internally
and never expose key material. It also lived in managed memory longer
than necessary regardless of the ToString override.

Invert the dependency. Match the shape IArkadeWalletSigner.Sign already
uses everywhere else in the codebase:

  // Before
  public record FeeCoin(OutPoint Outpoint, TxOut TxOut, Key SigningKey);

  // After
  public record FeeUtxo(OutPoint Outpoint, TxOut TxOut);

  Task<SecpSchnorrSignature> SignFeeUtxoAsync(
      OutPoint feeOutpoint,
      uint256 sighash,
      TaprootSigHash sighashType,
      CancellationToken ct = default);

The SDK now computes the sighash and asks the wallet for a signature;
how the wallet produces it (in-memory Key, hardware device, HSM, remote
signer, BTCPay's internal signer) is completely opaque. Hardware-wallet
support becomes possible; BTCPay integrations stop having to derive a
parallel key just to satisfy the interface.

Mechanical changes:
- IFeeWallet.SignFeeUtxoAsync replaces the SigningKey field on the
  selected UTXO.
- P2ACpfpBuilder.BuildCpfpChild becomes BuildCpfpChildAsync, drops the
  Key parameter, takes IFeeWallet for sighash-callback signing. Same
  internal logic — only the signing step is delegated.
- UnilateralExitService.BroadcastWithCpfpAsync awaits the new async
  builder. The two-stage fee-rate adjustment is preserved (signs twice
  if vsize estimate was off; correct under both the old and new shape).
- TestFeeWallet holds its Key as a private field (still in-memory, but
  no longer exposed across the abstraction boundary). Validates that
  the requested outpoint belongs to this wallet before signing.
- P2ACpfpBuilderTests get an InMemoryKeyFeeWallet helper for unit
  testing + a new test that verifies the wallet rejects signing for an
  outpoint it didn't issue (defense-in-depth — the abstraction isn't a
  general-purpose key-spend oracle for arbitrary outpoints).

370/370 unit tests pass.
# Conflicts:
#	NArk.Storage.EfCore/ModelBuilderExtensions.cs
#	samples/NArk.Wallet/NArk.Wallet.Client/Services/WalletDbContext.cs
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.

1 participant