feat: unilateral exit support#39
Conversation
🔍 Arkana PR Review —
|
| 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
🔍 Arkana PR Review —
|
🔍 Arkana Review —
|
Review — New Commit
|
|
@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? |
|
Three questions, addressing each: 1. No on-chain funds for CPFP
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 This is a correctness concern: a user with no on-chain fee funds could silently fail their exit. 2. Confirmed UTXOs only
But it's only a documentation contract — there's no validation that 3. Sequential confirmation between tree txs Yes, 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 |
|
@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 |
|
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:
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 How to provide on-chain funds: Callers must implement 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: |
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
bdaec07 to
9d03cc9
Compare
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.
There was a problem hiding this comment.
🔍 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: truefilter inPostBatchVtxoPollingHandler(poll all wallet contracts), or - (b) Have
SimpleIntentSchedulerderive 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:
-
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 defaultingIFeeWalletto required). -
Confirmed UTXO enforcement:
IFeeWallet.SelectFeeUtxoAsyncdocuments "confirmed" but doesn't enforce it. An implementor returning an unconfirmed UTXO will causesubmitpackageto 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:
SettleAVtxoAsynchelper 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)]withCancellationTokenthreading through all async calls. Proper. - Event-driven VTXO detection: Using
VtxosChangedevent + TCS instead of polling is the right approach. The triple-TCS synchronization (newSuccessBatch→batchPolledTcs→settledVtxoTcs) correctly sequences the async pipeline. - Diagnostic output:
TestContext.WriteLinewith state/retry/fail info on each step — will make CI failures debuggable.
Minor
UnilateralExitTests.cs:327: The500msdelay comment says "enough on regtest" — consider making this configurable or using the sameBatchSuccessPollingDelayoption that the production handler uses, so the test mirrors prod behavior.ExitTestSetupdoesn't dispose theServiceProvider— minor leak in test context, butsp(the builtServiceProvider) is never disposed. TheIAsyncDisposable[]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 🔍
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.
There was a problem hiding this comment.
🔍 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:
- Tree-tx PSBTs from
GetVirtualTxslackFinalScriptWitness→ empty witnesses → mempool rejection - 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.
There was a problem hiding this comment.
🔍 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:94—new WitScript(new[] { sig.ToBytes() }, true)ChainSwapMusigSession.cs:104,212—new 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:
- The TRUC issue was already fixed elsewhere (if so, remove the stale XML doc), or
- 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)
There was a problem hiding this comment.
🔍 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:94—new WitScript(new[] { sig.ToBytes() }, true)ChainSwapMusigSession.cs:104,212—new 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:
- The TRUC issue was already fixed elsewhere (if so, remove the stale XML doc), or
- 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)
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.
|
Pushed two commits to address the rebase + opt-in storage concern.
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.
There was a problem hiding this comment.
🔍 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:
-
Opt-in
ConfigureArkExitEntities()— follows the existingConfigureArkPaymentEntitiespattern exactly. Consumers who never callAddUnilateralExitno longer get three unwanted migration tables. Clean. -
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 toStartExitAsync. 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.
…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
Summary
New files (36 changed, +2577 lines)
Abstractions:
IOnchainBroadcaster,IVirtualTxStorage,IExitSessionStorage,ExitSession,VirtualTx,VtxoBranchCore Services:
VirtualTxService,UnilateralExitService,ExitWatchtowerService,P2ACpfpBuilderTransport:
GetVtxoChainAsync,GetVirtualTxsAsync,GetVtxoTreeAsync(gRPC + REST)Storage:
VirtualTxEntity,VtxoBranchEntity,ExitSessionEntity(EF Core)Tests: 12 new unit tests for VirtualTxService and P2ACpfpBuilder
Test plan
dotnet test NArk.Tests)dotnet build NArk.Core— 0 errorsdotnet build NArk.Storage.EfCore— 0 errors