Skip to content

unroller: client-side VTXO unilateral exit via on-chain tree unrolling - #183

Closed
ellemouton wants to merge 11 commits into
mainfrom
unilateral-exit-unroll
Closed

unroller: client-side VTXO unilateral exit via on-chain tree unrolling#183
ellemouton wants to merge 11 commits into
mainfrom
unilateral-exit-unroll

Conversation

@ellemouton

@ellemouton ellemouton commented Mar 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements client-side unilateral exit (on-chain tree unrolling) for VTXOs. When a client needs to exit the Ark protocol — either voluntarily or because a VTXO is approaching expiry — this broadcasts the presigned VTXO transaction tree on-chain, waits for the CSV delay, and sweeps the funds back to the client's on-chain wallet.

Key changes

  • chainsource/chainbackends: SubmitPackage interface — adds atomic parent+child package submission for V3 transactions with ephemeral anchors (PR #98 by @sputn1ck)
  • round: signature propagation + verification — ensures extracted client trees receive aggregated MuSig2 signatures and verifies them cryptographically via VerifySigned() (PR #98 by @sputn1ck)
  • db: persistent SQLite UnrollStore — migration 000006, sqlc-generated queries via BatchedRoundStore + ExecTx, crash-safe persistence of unroll state (status, level, confirmation heights, fee rate, broadcast height)
  • unroller: VTXO tree unroller actor — BFS level-by-level broadcast with CPFP package relay, confirmation tracking, CSV delay monitoring, automatic VTXO sweep after CSV, fee bumping via CPFP child replacement, crash recovery with state re-derivation from persistent store (PR #98 by @sputn1ck)
  • lwwallet: SubmitPackage via esplora — enables tree unrolling in lwwallet mode using the esplora /txs/package HTTP API with automatic CPFP child construction from wallet UTXOs (no bitcoind dependency)
  • darepod: unroller wiring + chain resolver — integrates unroller into daemon startup with SQLite store, bridges vtxo.ExpiringNotificationunroller.UnrollRequest for automatic expiry-triggered exit
  • daemonrpc/CLI: ExitVTXO + FundingAddress RPCsdarepocli wallet exit <outpoint> for voluntary exit with VTXO status validation, darepocli wallet funding-address for on-chain fee UTXO funding, schema registry updated

Architecture

darepocli wallet exit <outpoint>
        ↓
    ExitVTXO RPC (validates VTXO is live)
        ↓
    UnrollerActor.UnrollRequest
        ↓
    extractLevelOrder (BFS via lib/tree/queue, 10k node bound)
        ↓
    broadcastLevel (V3 parent + CPFP child via SubmitPackage)
        ↓
    registerConfirmation → handleConfirmation → next level
        ↓
    All levels confirmed → AwaitingCSV → BlockEpochEvent
        ↓
    CSV satisfied → sweepVTXO (timeout path spend to wallet)
        ↓
    Complete (funds in on-chain wallet)

Fee bumping (when broadcasts don't confirm within BumpAfterBlocks):

BlockEpochEvent (stale broadcast detected)
        ↓
    feeBumpLevel
        ↓
    WalletKit mode: rebuild explicit CPFP child with bumped fee rate (2x)
    lwwallet mode: resubmit with Child=nil (chain backend auto-builds fresh child)
        ↓
    V3 package RBF replaces old child if total fee is higher

Tested on regtest

Full end-to-end with mempool/electrs (esplora /txs/package):

  1. Fund boarding → join round → get VTXO (99,999,000 sats)
  2. darepocli wallet funding-address → fund wallet UTXO for CPFP fees
  3. darepocli wallet exit <outpoint> → tree broadcast via esplora package submission
  4. Mine → tree levels confirmed → CSV wait (144 blocks)
  5. Mine 144 blocks → CSV satisfied → sweep broadcast (99,998,856 sats to wallet)
  6. Mine → sweep confirmed on-chain

Companion server PR

lightninglabs/darepo#161 — adds 8 system tests exercising the unroller with real LND + bitcoind backends, including restart recovery and CSV completion tests.

Known limitations / follow-up work

  • OOR VTXOs: detected and rejected with clear error — checkpoint chain unrolling needs separate implementation (#198)
  • Re-org awareness: unroller does not yet handle chain reorganizations that invalidate confirmed levels (#207)
  • Tree-based grouping: multiple VTXOs in the same tree are unrolled independently — could share common ancestor broadcasts as a follow-up optimization

Credits

Core unroller design and SubmitPackage infrastructure ported from PR #98 by @sputn1ck. Commits attributed accordingly.

Test plan

  • go build ./cmd/darepod/ ./cmd/darepocli/ — both binaries compile
  • make fmt-check — formatting clean
  • make lint — linter clean
  • make sqlc-check — SQL models up to date
  • Unit tests: unroller (BFS, status, actor requests, OOR guard, fee bump, CSV completion, sweep) — 24 tests
  • Unit tests: lwwallet CPFP (anchor detection, UTXO selection, fee estimation) — 8 tests
  • Unit tests: SQLite UnrollStore (CRUD, status filtering, persistence) — 9 tests
  • End-to-end regtest (boarding → round → exit → CSV wait → sweep → funds in wallet)
  • Server systests: 8 tests including restart recovery, CSV completion (darepo#161)

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a critical feature enabling clients to unilaterally reclaim their Virtual Transaction Outputs (VTXOs) by broadcasting the associated transaction trees directly onto the Bitcoin blockchain. This mechanism ensures that users can exit the Ark protocol either by choice or necessity (e.g., VTXO expiry), providing a robust and trust-minimized way to make their funds spendable on-chain after a specified time delay. The changes involve significant updates to transaction broadcasting, wallet integration, and daemon logic to support this complex multi-transaction process.

Highlights

  • Client-Side Unilateral Exit (VTXO Unrolling): Implemented a mechanism for clients to unilaterally exit the Ark protocol by broadcasting their presigned VTXO transaction tree on-chain. This allows VTXOs to become spendable after their CSV delay, either voluntarily or due to approaching expiry.
  • Atomic Package Submission for V3 Transactions: Introduced a SubmitPackage interface and functionality across chainsource and chainbackends to atomically submit parent+child transaction packages. This is crucial for V3 transactions with ephemeral anchors, which cannot be broadcast individually.
  • VTXO Tree Unroller Actor: Developed a dedicated unroller actor responsible for managing the unrolling process. It performs a BFS level-by-level broadcast, handles CPFP package relay, tracks confirmations, monitors CSV delays, and supports crash recovery.
  • LWWALLET CPFP Support: Enhanced lwwallet to support tree unrolling by automatically building CPFP child transactions from wallet UTXOs and directly calling bitcoind's submitpackage RPC when WalletKit is unavailable.
  • Daemon Integration and RPCs: Integrated the unroller into the darepod daemon startup, bridging vtxo.ExpiringNotification to unroller.UnrollRequest for automatic expiry-triggered exits. New daemonrpc and CLI commands (ExitVTXO and FundingAddress) were added for manual exit and fee funding.
  • Signature Propagation Fix: Ensured that extracted client trees correctly receive aggregated MuSig2 signatures, which is a prerequisite for ToSignedTx() during the unrolling process.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • chainbackends/lnd.go
    • Imported btcjson package.
    • Added PackageSubmitter interface for atomic parent+child transaction submission.
    • Included packageSubmitter field in LNDBackend struct.
    • Implemented SubmitPackage method in LNDBackend to utilize the PackageSubmitter.
  • chainbackends/lndclient_adapters.go
    • Added PackageSubmitter field to LNDBackendFromLndClientConfig.
    • Assigned the PackageSubmitter from config to the LNDBackend instance.
  • chainsource/backend.go
    • Added SubmitPackage method to the ChainBackend interface for atomic transaction package submission.
  • chainsource/chainsource.go
    • Added a new case to the Receive method to handle SubmitPackageRequest.
    • Implemented handleSubmitPackage to process atomic package submission requests by delegating to the backend.
  • chainsource/chainsource_errors_test.go
    • Added SubmitPackage method to the errorBackend for testing purposes.
  • chainsource/chainsource_test.go
    • Added SubmitPackage method to the mockBackend as a no-op.
  • chainsource/messages.go
    • Defined SubmitPackageRequest struct for requesting atomic package submission.
    • Defined SubmitPackageResponse struct to indicate successful package submission.
  • cmd/darepocli/darepoclicommands/cmd_exit.go
    • Added newWalletFundingAddressCmd to create a CLI command for generating a P2TR funding address.
    • Added newWalletExitCmd to create a CLI command for initiating unilateral VTXO exits.
    • Implemented walletExit function to execute the ExitVTXO RPC and handle outpoint parsing and validation.
  • cmd/darepocli/darepoclicommands/cmd_wallet.go
    • Integrated newWalletFundingAddressCmd and newWalletExitCmd into the main wallet CLI commands.
  • cmd/darepod/main.go
    • Added a new flag wallet.bitcoindrpcurl to configure the bitcoind JSON-RPC URL for submitpackage.
  • daemonrpc/daemon.proto
    • Added FundingAddress RPC service for generating a P2TR address.
    • Added ExitVTXO RPC service for initiating unilateral VTXO exits.
    • Defined FundingAddressRequest and FundingAddressResponse messages.
    • Defined ExitVTXORequest and ExitVTXOResponse messages.
  • daemonrpc/daemon_grpc.pb.go
    • Updated gRPC service definitions to include FundingAddress and ExitVTXO full method names.
    • Added client interface methods for FundingAddress and ExitVTXO.
    • Added server interface methods for FundingAddress and ExitVTXO.
    • Implemented handler functions for FundingAddress and ExitVTXO RPCs.
    • Updated DaemonService_ServiceDesc to register the new RPC methods.
  • daemonrpc/exit_vtxo.go
    • Added hand-written Go types for FundingAddressRequest, FundingAddressResponse, ExitVTXORequest, and ExitVTXOResponse.
  • darepod/chain_resolver.go
    • Added chainResolverAdapter struct to bridge vtxo.ExpiringNotification to unroller.UnrollRequest.
    • Implemented newChainResolverAdapter to create new adapter instances.
    • Implemented ID method for actor identification.
    • Implemented Tell method to convert and forward expiry notifications as unroll requests.
  • darepod/config.go
    • Added BitcoindRPCURL field to WalletConfig for bitcoind RPC endpoint configuration.
  • darepod/rpc_exit.go
    • Implemented FundingAddress RPC handler to generate a new P2TR address from the wallet.
    • Implemented ExitVTXO RPC handler to parse outpoints and send an UnrollRequest to the unroller actor.
  • darepod/server.go
    • Imported unroller and vtxo packages.
    • Added unrollerRef field to the Server struct to hold a reference to the unroller actor.
    • Updated startLwwallet to pass BitcoindRPCURL to the lwwallet configuration.
    • Updated initChainBackend to pass BitcoindRPCURL to NewChainBackend.
    • Added initUnrollerActor function to create, register, and start the unroller actor.
    • Updated startWalletDependentActors to initialize the unroller actor.
    • Updated initRoundActor to create and register the VTXO manager actor, and to build a chainResolver for the unroller.
  • darepod/unroll_store_mem.go
    • Added memUnrollStore struct as an in-memory implementation of unroller.UnrollStore.
    • Implemented newMemUnrollStore to create new in-memory store instances.
    • Implemented GetVTXO to retrieve VTXO descriptors.
    • Implemented SaveUnrollState and UpdateUnrollState for persisting unroll states.
    • Implemented GetUnrollState and ListActiveUnrolls for retrieving unroll states.
    • Implemented DeleteUnrollState to remove completed unroll records.
  • lwwallet/chain_backend.go
    • Imported additional packages for transaction handling and signing.
    • Defined CPFPWallet interface for wallet operations needed for CPFP child construction.
    • Added bitcoindRPCURL and wallet fields to ChainBackend.
    • Updated NewChainBackend to accept an optional bitcoindRPCURL.
    • Added SetWallet method to attach a wallet for automatic CPFP child construction.
    • Implemented SubmitPackage to submit parent+child transaction packages, including auto-CPFP child construction if needed.
    • Added buildCPFPChild to construct and sign CPFP child transactions.
    • Added selectFeeUTXO to find suitable wallet UTXOs for fee payment.
    • Added estimateWeightCB to compute transaction weight.
    • Added bitcoindSubmitPackage to send serialized transaction hex to bitcoind's submitpackage RPC.
  • lwwallet/config.go
    • Added BitcoindRPCURL field to Config for bitcoind JSON-RPC endpoint.
  • lwwallet/wallet.go
    • Passed cfg.BitcoindRPCURL to NewChainBackend during initialization.
    • Wired the newly created wallet into the chain backend using chainBackend.SetWallet.
  • round/transitions.go
    • Added logic within PartialSigsSentState.ProcessEvent to propagate validated aggregated signatures to client sub-trees, ensuring they contain valid signatures for unilateral exit.
  • unroller/actor.go
    • Added UnrollerConfig struct for unroller actor configuration.
    • Defined UnrollerActor struct to manage VTXO tree unrolling state.
    • Implemented NewUnrollerActor to create new actor instances.
    • Implemented Start method to initialize the actor and recover in-progress unrolls.
    • Implemented OnStop for cleanup.
    • Implemented Receive method to process incoming unroller messages.
    • Added resumeUnroll to continue interrupted unrolling processes.
    • Added indexUnrollTxids and cleanupUnrollTxids for managing transaction ID lookups.
    • Added findPkScriptForTxid to retrieve pkScript for a given transaction ID.
    • Implemented handleGetUnrollStatus to return the current status of an unroll.
  • unroller/bitcoind_rpc.go
    • Added bitcoindSendRaw function to broadcast transactions directly via bitcoind's sendrawtransaction RPC with maxfeerate=0.
  • unroller/cpfp.go
    • Defined WalletKit interface for wallet operations required for CPFP child construction.
    • Defined feeUTXO struct to represent a selected UTXO for fee payment.
    • Implemented selectFeeUTXO to find a suitable confirmed wallet UTXO.
    • Implemented buildCPFPChild to construct an unsigned V3 CPFP child transaction.
    • Implemented signCPFPChild to sign the CPFP child transaction using LND's PSBT flow.
    • Implemented estimateWeight to compute transaction weight for fee calculation.
  • unroller/messages.go
    • Defined UnrollerMsg sealed interface.
    • Defined UnrollRequest message to initiate VTXO unrolling.
    • Defined ConfirmationEvent message to notify about transaction confirmations.
    • Defined BlockEpochEvent message to notify about new blocks.
    • Defined GetUnrollStatusRequest message to query unroll status.
    • Defined UnrollerResp sealed interface.
    • Defined UnrollStartedResp message to acknowledge unroll initiation.
    • Defined UnrollStatusResp message to return current unroll status.
  • unroller/state.go
    • Defined UnrollStatus enum to represent the current phase of unrolling.
    • Defined UnrollState struct to track the progress of a VTXO tree unroll.
    • Defined LevelTxids struct to group transactions by tree level.
    • Defined ConfirmationInfo struct to record transaction confirmation details.
    • Implemented extractLevelOrder to perform a BFS traversal of the VTXO tree and group transactions by level.
  • unroller/store.go
    • Defined UnrollStore interface for persistence of unroll state, including methods for VTXO retrieval, saving, updating, getting, listing, and deleting unroll states.
  • unroller/unroll.go
    • Implemented handleUnrollRequest to initiate the unrolling process for target VTXOs.
    • Implemented broadcastLevel to broadcast transactions at a specific tree level, using 1P1C package relay with CPFP when WalletKit is available.
    • Implemented broadcastLevelDirect as a fallback for lwwallet mode, using auto-CPFP via the chain backend.
    • Implemented getFeeRate to query the current fee rate estimate.
    • Implemented getBestHeight to query the current best block height.
    • Implemented failUnroll to transition an unroll to a failed state and clean up.
    • Implemented registerConfirmation to subscribe to confirmation events for broadcast transactions.
    • Implemented subscribeBlockEpochs to subscribe to block epoch events for CSV tracking.
    • Implemented handleConfirmation to process confirmation events and advance the unroll state.
    • Implemented isLevelConfirmed to check if all transactions at a given level are confirmed.
    • Implemented handleBlockEpoch to track block height and check for CSV delay satisfaction.
    • Implemented getLevelConfirmHeight to get the confirmation height of a transaction level.
    • Implemented handleAllLevelsComplete to transition to the CSV wait state once all levels are confirmed.
    • Implemented handleCSVComplete to mark an unroll as complete after CSV delay is satisfied.
Activity
  • The pull request implements client-side unilateral exit for VTXOs, allowing them to be spent on-chain after a CSV delay.
  • It introduces atomic parent+child package submission for V3 transactions with ephemeral anchors, a critical component for unrolling.
  • A new unroller actor has been added to manage the BFS level-by-level broadcast, confirmation tracking, and crash recovery.
  • The lwwallet now supports SubmitPackage with auto-CPFP, enabling tree unrolling in this mode.
  • The darepod daemon integrates the unroller and includes a chain resolver to trigger unrolls automatically upon VTXO expiry.
  • New ExitVTXO and FundingAddress RPCs and CLI commands have been added for user interaction.
  • The implementation has been tested on regtest, covering boarding, round participation, VTXO acquisition, funding for CPFP fees, and successful unilateral exit.
  • Known limitations include lack of support for Out-of-Round (OOR) VTXO unrolling, no automatic VTXO sweep after unroll, and the use of an in-memory UnrollStore (state lost on restart).
  • The PR requires bitcoind v29+ for ephemeral anchor support in submitpackage.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a significant new feature: client-side unilateral exit for VTXOs via on-chain tree unrolling. The implementation is comprehensive, touching many parts of the system from the chain backend and source, to new RPCs and CLI commands, and a new unroller actor to manage the process. The architecture is well-thought-out, with good separation of concerns.

I've identified a few issues, including a critical bug in the ExitVTXO RPC handler that causes it to only process one outpoint while reporting success for all, a high-severity bug in the CPFP logic that could lead to a loss of funds, and a potential panic during crash recovery. I've provided detailed comments and suggestions for these issues. Overall, this is a solid implementation of a complex feature.

Comment thread darepod/rpc_exit.go
Comment thread unroller/actor.go
Comment thread unroller/cpfp.go
Comment thread daemonrpc/exit_vtxo.go Outdated
}

func (m *ExitVTXORequest) Reset() {}
func (m *ExitVTXORequest) String() string { return m.Outpoints[0] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The String() method for ExitVTXORequest will panic if m.Outpoints is empty. While callers might check for an empty slice, String() methods should generally be safe from causing panics.

Suggested change
func (m *ExitVTXORequest) String() string { return m.Outpoints[0] }
func (m *ExitVTXORequest) String() string {
if len(m.Outpoints) == 0 {
return ""
}
return m.Outpoints[0]
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Fixed — exit_vtxo.go is now deleted entirely. Types are proper protoc-generated code via make rpc. The generated String() uses protobuf reflection and is safe.

Comment thread darepod/server.go Outdated
Comment on lines +1541 to +1545
// lwwallet doesn't expose lndclient.WalletKit, so
// WalletKit stays nil. The unroller falls back to
// direct broadcast (no CPFP), which works on regtest.
log.WarnS(ctx, "Unroller using direct broadcast "+
"(no CPFP) in lwwallet mode", nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The comment here is misleading. It states that the unroller falls back to "direct broadcast (no CPFP)" in lwwallet mode. However, the implementation in lwwallet/chain_backend.go does perform CPFP by automatically constructing a child transaction. The comment should be updated to reflect the actual behavior.

Suggested change
// lwwallet doesn't expose lndclient.WalletKit, so
// WalletKit stays nil. The unroller falls back to
// direct broadcast (no CPFP), which works on regtest.
log.WarnS(ctx, "Unroller using direct broadcast "+
"(no CPFP) in lwwallet mode", nil)
// lwwallet doesn't expose lndclient.WalletKit, so
// WalletKit stays nil. The unroller falls back to the
// chain backend's automatic CPFP child construction.
log.WarnS(ctx, "Unroller using chain backend's auto-CPFP "+
"in lwwallet mode", nil)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Fixed — comment now reads "chain backend's auto-CPFP" instead of "direct broadcast (no CPFP)".

Comment thread unroller/unroll.go Outdated
@ellemouton
ellemouton force-pushed the unilateral-exit-unroll branch 2 times, most recently from 7d50817 to 5cc25a9 Compare March 16, 2026 16:11
Comment thread lwwallet/chain_backend.go
@@ -1,19 +1,28 @@
package lwwallet

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think this commit is what we want as we don't want the lwwallet to require a bitcoind connection. afaik esplora has submitpackage available?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah cool - will update today. this was v1 just getting local stuff working :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Done — refactored to use esplora /txs/package endpoint. No bitcoind dependency. BitcoindRPCURL config removed entirely. Tested end-to-end on regtest with mempool/electrs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Thanks for the reference — used the same pattern. EsploraClient.SubmitPackage now POSTs to /txs/package with JSON array of hex txs, matching the ark-og SDK approach.

@sputn1ck

sputn1ck commented Mar 16, 2026

Copy link
Copy Markdown
Member

I think a topic for discussion should be if we want to auto unroll from the client side (e.g. on vtxo expiry). I'm on the no side, as this occurs cost to the client and the operator itself.

@ellemouton
ellemouton force-pushed the unilateral-exit-unroll branch 7 times, most recently from d374d8f to 9fdb823 Compare March 17, 2026 13:40
@ellemouton

Copy link
Copy Markdown
Contributor Author

I think a topic for discussion should be if we want to auto unroll from the client side (e.g. on vtxo expiry). I'm on the no side, as this occurs cost to the client and the operator itself.

yeah - i think we can remove the ability later though if we agree? just added it so we can test nicely & deterministically. just during this "push until all basic flows are in place"

@Roasbeef Roasbeef left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should make a tracking issue to make this re-org aware.

Comment thread round/transitions.go
// persisted client trees contain valid signatures for
// unilateral exit (unrolling).
for _, clientTree := range s.ClientTrees {
if err := clientTree.SubmitTreeSigs(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this a bug fix? Should we have a test for this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ah I see, we need them for unrolling.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Agreed a focused unit test would be good — will track as follow-up.

CREATE TABLE IF NOT EXISTS unrolls (
vtxo_outpoint_hash BLOB NOT NULL,
vtxo_outpoint_index INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 0,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Enum table for status?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Other tables in the project (vtxos, rounds) use raw ints for status. Added a status values comment in the migration instead. Happy to add an enum table if preferred.

vtxo_outpoint_hash BLOB NOT NULL,
vtxo_outpoint_index INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 0,
current_level INTEGER NOT NULL DEFAULT 0,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Final level also relevant?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Derived from the VTXO tree on load via extractLevelOrder. Not persisting avoids staleness — the tree is the source of truth.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Good point — for single-commitment trees the level count is immutable and re-derived on load. But with cross-batch ancestry (#199), the tree could span multiple commitment txs and the depth at unroll time may differ from what we re-derive later. Added a TODO to persist total_levels as a sanity check. Tracking alongside #199 since it only matters once we support multi-commitment unrolling.

vtxo_outpoint_index INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 0,
current_level INTEGER NOT NULL DEFAULT 0,
leaf_confirm_height INTEGER NOT NULL DEFAULT 0,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Doesn't default zero defeat the purpose of not null?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Removed all DEFAULT 0 from the migration. Code always provides values explicitly.

@@ -0,0 +1,14 @@
-- Unroll store: tracks in-progress VTXO tree unrolls so that the unroller
-- can resume broadcasting from where it left off after daemon restart.
CREATE TABLE IF NOT EXISTS unrolls (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Txid's of the unroll aren't relevant? Or we already store that in the vtxt tree (tlv encoded rn?).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Txids are derived from the tree nodes (TLV-encoded in the VTXO store). BroadcastTxids are now rebuilt on restart from LevelOrder (see fix for the "not derived on restart" comment).


// newWalletFundingAddressCmd creates the wallet funding-address
// subcommand for generating a plain on-chain P2TR address.
func newWalletFundingAddressCmd() *cobra.Command {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should also update the schema command as well. i thought i had a ci check for that but maybe it's not working for some reason

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Added wallet.funding-address and wallet.exit entries to schema_registry.go.

Comment thread unroller/unroll.go

txid := levelTxids.Txids[i]

signedTx, err := node.ToSignedTx()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Where does the fee bump actually take place?

Also I think we can use lnd's fee function here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Fee bumping now properly rebuilds an explicit CPFP child in WalletKit mode with a bumped fee rate (2x multiplier via FeeMultiplier). In lwwallet mode, Child=nil lets the chain backend auto-build a fresh child. Added clarifying comment explaining the V3 package RBF mechanism.

Comment thread db/unroll_store.go
// etc.) in the `unrolls` table.
//
// Complex derived fields (LevelOrder, BroadcastTxids, ConfirmedTxids)
// are NOT persisted. They are re-derived from the VTXO tree on load

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Don't see these derived on restart.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Start() now fetches the VTXO descriptor, re-derives LevelOrder via extractLevelOrder, and rebuilds BroadcastTxids from all levels 0..CurrentLevel.

Comment thread unroller/state.go

// CurrentFeeRate is the fee rate (sat/vB) used in the most
// recent broadcast. Used to calculate the next bump rate.
CurrentFeeRate int64

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Doesn't look like we persist this and last broadcast height.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Added last_broadcast_height and current_fee_rate columns to migration, sqlc queries, and store save/update/load paths.

Comment thread unroller/unroll.go Outdated
// new package if the total fee is higher.
submitReq := &chainsource.SubmitPackageRequest{
Parents: []*wire.MsgTx{signedTx},
Child: nil,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So this is bumping by RBF'ing the parent?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

~cc Not RBF on the parent — it's V3 package RBF via a replacement CPFP child. The same presigned parent is resubmitted with a new child that pays a higher total package fee. Added comment clarifying this.

The LND TLS cert and macaroon paths are needed by consumers
that configure arkd to connect to the harness-managed LND
instance. Expose them via read-only accessor methods.
Add atomic parent+child transaction package submission support to the
ChainBackend interface. Required for broadcasting V3 transactions with
ephemeral anchors via bitcoind's submitpackage RPC.

- Add SubmitPackage method to ChainBackend interface
- Add SubmitPackageRequest/Response message types
- Add handleSubmitPackage handler in ChainSourceActor
- Update test mocks to satisfy interface
Add PackageSubmitter interface and SubmitPackage implementation to the
LND chain backend for atomic parent+child transaction package submission.
Validates per-transaction results and provides detailed error diagnostics.
sputn1ck and others added 5 commits March 20, 2026 11:06
After SubmitTreeSigs applies aggregated MuSig2 signatures to
VTXOTreePaths, propagate them to each ClientTree and verify
cryptographic validity via VerifySigned(). ClientTrees are separate
node copies from ExtractPathForCoSigners that don't share memory
with VTXOTreePaths.

Uses failWithNotification to emit RoundFailedNotification on signature
propagation or verification failure, ensuring wallet/manager actors
are notified.
Add SQLite persistence for in-progress VTXO tree unrolls so they
survive daemon restart. The unrolls table tracks status, current
level, leaf confirmation height, error message, and retry count.
Complex derived fields (LevelOrder, BroadcastTxids, ConfirmedTxids)
are re-derived from the VTXO tree on load via extractLevelOrder.

- Add migration 000006_unroll_store
- Add sqlc query definitions and generated code
- Implement UnrollPersistenceStore with direct SQL queries
- Delegate GetVTXO to VTXOPersistenceStore for tree path access
- Add 9 unit tests covering CRUD, status filtering, and persistence
- Bump LatestMigrationVersion to 6
Add the unroller package for on-chain broadcasting of presigned VTXO
transaction trees during unilateral exit. The UnrollerActor coordinates
level-by-level tree broadcast with V3 package relay, tracks confirmation
progress, and monitors CSV delay completion.

Key components:
- UnrollerActor with crash recovery via persistent store
- broadcastLevel: CPFP package relay with fee rate cap (500 sat/vB)
- broadcastLevelDirect: fallback via chain backend auto-CPFP
- findAnchorOutput: P2A script scan (not positional assumption)
- extractLevelOrder: BFS traversal with sanity checks
- Per-level UTXO tracking to prevent double-spend
- Tell error logging for confirmation/block registrations
- OOR VTXO guard with clear error message
- Unit tests for BFS, status, actor request handling

Based on the unroller design by sputnik (PR #98).
Add SubmitPackage support to the lwwallet/esplora chain backend using
the esplora /txs/package HTTP API. When a nil child is provided and
a CPFPWallet is configured, automatically constructs a CPFP child:

- Scans for P2A anchor via scripts.AnchorPkScript
- Estimates fee with 500 sat/vB cap and 1 sat/vB fallback
- Selects smallest sufficient wallet UTXO with exclude set
- Builds V3 child with anchor + wallet UTXO inputs
- Signs via ComputeInputScript with proper PrevOutputFetcher
- Rejects if total fee exceeds VTXO value (sanity check)
- Thread-safe wallet access via mutex

Also adds CPFPWallet interface, SetWallet lifecycle method, and
EsploraClient.SubmitPackage for /txs/package endpoint.
Integrate the unroller into the daemon startup sequence with
persistent SQLite storage and chain resolver for automatic
expiry-triggered exit.

- Add initUnrollerActor using SQLite-backed UnrollPersistenceStore
- Create chainResolverAdapter bridging vtxo.ExpiringNotification
  to unroller.UnrollRequest via outpoint mapping
- Wire ChainResolver into vtxo.ManagerConfig in initVTXOManager
- Unit tests for chain resolver adapter
@ellemouton
ellemouton force-pushed the unilateral-exit-unroll branch 2 times, most recently from 7c0ecbb to 020c4e5 Compare March 20, 2026 09:44
Add two new RPCs to the daemon service:

- ExitVTXO: initiates unilateral exit for specified VTXOs by sending
  UnrollRequest to the unroller actor. Validates VTXO status (must be
  live) before initiating. Guards FundingAddress against panic in LND
  mode.

- FundingAddress: returns a plain BIP-86 P2TR address from the
  internal btcwallet for fee-funding UTXOs (lwwallet mode only).

CLI commands:
- darepocli wallet exit [outpoint...] — trigger unilateral exit
- darepocli wallet funding-address — get on-chain fee funding address

Includes protoc-generated code for both RPCs.
When tree transactions don't confirm within BumpAfterBlocks (default
6 blocks), the unroller automatically resubmits the package with a
fresh CPFP child. The chain backend builds a new child using the
current fee estimate, which V3 package RBF accepts if the total fee
is higher than the previous child.

- Add feeBumpLevel method: resubmits current level's parent txs
  with Child: nil so chain backend auto-constructs higher-fee child
- Monitor Broadcasting status in handleBlockEpoch for bump triggers
- Subscribe to block epochs during broadcasting (not just CSV wait)
- Track LastBroadcastHeight and CurrentFeeRate in UnrollState
- Max 10 bump retries before permanent failure
- Best-effort: individual bump errors are logged, not fatal
- Resume bump monitoring after crash recovery
After the unroller completes tree unrolling and the CSV delay is
satisfied, automatically construct and broadcast a sweep transaction
that spends the VTXO via the timeout script path back to the user's
on-chain wallet.

The sweep transaction:
- Version 2 (no anchor needed, pays its own fees)
- Input: VTXO outpoint with sequence = CSV delay
- Witness: <sig> <timeout_script> <control_block>
- Output: fresh wallet P2TR address minus estimated fee

This completes the full unilateral exit flow per ARK-05:
  Board → Round → VTXO created
  → Exit triggered (voluntary or expiry)
  → Tree unrolled on-chain (CPFP + fee bumping)
  → CSV delay satisfied
  → VTXO swept to wallet ← NEW

Wiring:
- LND mode: Signer from lndbackend.ClientWallet, SweepAddress
  from lndSvc.WalletKit.NextAddr
- lwwallet mode: Signer from btcwallet, SweepAddress from
  lwwallet.NewAddress

Sweep failures are logged but do not fail the unroll — the VTXO
remains on-chain and can be swept manually.
@ellemouton
ellemouton force-pushed the unilateral-exit-unroll branch from 020c4e5 to e4d6e7f Compare March 20, 2026 10:04
ellemouton pushed a commit that referenced this pull request Mar 26, 2026
…sts-part-2

Part 2: real-daemon integration tests (durability + indexer)
@litbot-9000

Copy link
Copy Markdown
Collaborator

@sputn1ck: review reminder
@bhandras: review reminder
@ellemouton, remember to re-request review from reviewers when ready

@ellemouton

Copy link
Copy Markdown
Contributor Author

replaced by #235

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.

5 participants