Skip to content

vtxo+wallet: implement VTXO coin selection with atomic locking - #168

Closed
ellemouton wants to merge 7 commits into
mainfrom
vtxo-coin-select
Closed

vtxo+wallet: implement VTXO coin selection with atomic locking#168
ellemouton wants to merge 7 commits into
mainfrom
vtxo-coin-select

Conversation

@ellemouton

@ellemouton ellemouton commented Mar 10, 2026

Copy link
Copy Markdown
Member

VTXO coin selection + locking for transfers

Closes #150

Implements full VTXO coin selection with in-memory locking to prevent
double-spends across concurrent OOR sends and in-round directed
transfers. The wallet actor selects VTXOs using a largest-first
strategy and delegates lock management to the VTXO manager via the
actor system's service key pattern.

Architecture

Locking lives in the VTXO manager rather than the wallet because:

  • The manager owns VTXO lifecycle (spawn, terminate, status tracking)
  • Auto-unlock on termination is a natural fit
  • Actor serialization (Receive) eliminates explicit synchronization
  • The wallet avoids the wallet → vtxo → round → wallet import cycle
    by discovering the manager through the well-known
    VTXOManagerServiceKey

Select & Lock Flow

Selection and locking happen in a single atomic manager message
(SelectAndLockVTXOsRequest) — no race window between list and lock.

┌────────┐  SelectAndLockVTXOsRequest(target)
│ Caller │─────────────────────────────────────►┌────────────────┐
│(RPC/   │                                      │  Wallet Actor  │
│ Round) │                                      └───────┬────────┘
└────────┘                                              │
            ① Ask: SelectAndLockVTXOsRequest            │
               (service key lookup)                     ▼
                                          ┌─────────────────────────┐
                                          │      VTXO Manager       │
                                          │  (single Receive call)  │
                                          │                         │
                                          │  ListLiveVTXOs()        │
                                          │  filter locked outpoints│
                                          │  selectCoinsLargestFirst│
                                          │  lockedOutpoints.Add()  │
                                          └─────────────────────────┘
            ② SelectAndLockVTXOsResponse                │
               { Selected, TotalSelected } ◄────────────┘
                        │
                        ▼
            ③ Wallet adapts to SelectedVTXO list
               and returns to caller

Unlock Flow (failure / cancellation)

┌────────┐  UnlockVTXOsRequest(outpoints)
│ Caller │─────────────────────────────────────►┌────────────────┐
└────────┘                                      │  Wallet Actor  │
                                                └───────┬────────┘
            Forwards to VTXO manager via service key    │
                                                        ▼
                                                ┌───────────────┐
            lockedOutpoints.Remove(op) ─────────│ VTXO Manager  │
                                                └───────────────┘
            VTXOs available for next selection

Auto-Unlock on Termination

  VTXO actor terminates (spent / forfeited / expired)
              │
              ▼
  VTXOTerminatedMsg ────────────────────────────┌───────────────┐
                                                │ VTXO Manager  │
  handleVTXOTerminated:                         │               │
    delete from actors map                      │  auto-unlock  │
    lockedOutpoints.Remove(op) ◄────────────────└───────────────┘

Double-Spend Prevention

Locked VTXOs are invisible to subsequent selections. Two concurrent
callers requesting 50k each will never receive the same VTXO:

Caller A: SelectAndLock(50k) → gets vtxo1(60k), locks it
Caller B: SelectAndLock(50k) → vtxo1 filtered out, gets vtxo2(55k)

All access is serialized through the manager's Receive — no explicit
mutex needed.

Design Notes

  • Locks are transient (in-memory fn.Set): cleared on daemon
    restart. After restart there are no in-flight transfers, so nothing
    needs to stay locked.
  • Largest-first selection minimizes input count. The pure function
    sorts a copy to avoid mutating the caller's slice.
  • ManagerMsg / ManagerResp are type aliases (not embedding
    interfaces) so Go generics treat them as identical to
    actormsg.VTXOManagerMsg / actormsg.VTXOManagerResp — required for
    RegisterWithSystem to accept the service key's type parameters.
  • TTL-based lock expiry (mentioned in issue) is deferred: auto-unlock
    on termination + transient locks cover the practical cases.

Define message types for VTXO locking in the actormsg package so both
the wallet and vtxo packages can use them without import cycles. This
includes ListAvailableVTXOs, LockVTXOs, and UnlockVTXOs request/response
pairs, plus a VTXOManagerResp marker interface and a well-known service
key for looking up the VTXO manager actor.
@ellemouton
ellemouton changed the base branch from master to main March 10, 2026 13:30
@ellemouton
ellemouton force-pushed the vtxo-coin-select branch 2 times, most recently from b17fbe3 to 77ea637 Compare March 10, 2026 13:50
Add an in-memory locked outpoints set to the VTXO manager and wire up
three new message handlers: ListAvailableVTXOs (returns live minus
locked), LockVTXOs, and UnlockVTXOs. These enable the wallet actor
to perform coin selection against available VTXOs and atomically lock
selected ones for OOR transfers.

The ManagerResp interface is updated to embed actormsg.VTXOManagerResp
so response types defined in actormsg can satisfy it without import
cycles. Terminated VTXOs are also automatically unlocked.
Add SelectAndLockVTXOs and UnlockVTXOs handlers to the wallet actor.
The wallet queries the VTXO manager for available (live, unlocked)
VTXOs via service key lookup, runs largest-first coin selection to
cover the target amount, then asks the manager to lock the selected
outpoints. Unlock forwards directly to the manager.

The coin selection algorithm sorts VTXOs by descending amount and
greedily picks until the target is met, minimizing input count.
Add tests for the selectCoinsLargestFirst pure function covering exact
match, multiple inputs, all inputs needed, insufficient balance, empty
input, PkScript preservation, and input slice immutability.

Add tests for the VTXO manager lock/unlock handlers covering list
available with exclusion, lock counting, unlock, unlock nonexistent,
automatic unlock on termination, field mapping, and backward
compatibility of the GetActiveVTXOCount message.
Update comments on SelectAndLockVTXOsRequest, SelectedVTXO,
UnlockVTXOsRequest, and the RPC unlockVTXOs helper to reflect that
VTXO coin selection and locking applies to both OOR transfers and
in-round directed sends, not just OOR.
Wire the VTXO manager into the daemon's actor system so it is
discoverable via the well-known VTXOManagerServiceKey. The new
initVTXOManager method creates the manager with all required
dependencies (store, wallet signer, chain source, round actor),
registers it under the service key, starts it to recover persisted
VTXOs, and returns a MapInputRef adapter for the round actor's
VTXOManager field.

The vtxo.ManagerMsg and ManagerResp types are converted from
embedding interfaces to type aliases for actormsg.VTXOManagerMsg
and actormsg.VTXOManagerResp respectively. This is required because
Go generics treat interface embedding as a distinct type, so
RegisterWithSystem would fail with a type mismatch when using the
actormsg-typed service key.
Add integration tests in internal/coinselect that exercise the full
coin selection flow: wallet actor → service key lookup → VTXO manager
Ask → coin selection → lock → response. This covers the orchestration
glue that unit tests in wallet/ and vtxo/ cannot reach due to the
import cycle (wallet → vtxo → round → wallet).

Tests cover:
- Basic select and lock via real actor system
- Locked VTXOs excluded from subsequent selections
- Insufficient balance after locking
- Unlock restores availability for re-selection
- Clear error when no actor system is configured

The stubVTXOStore uses a ready flag so Manager.Start sees an empty
store (avoiding VTXO actor spawning), then the test VTXOs become
visible for coin selection queries.
@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 enhancement to the system's VTXO handling by implementing a robust and secure coin selection and locking mechanism. The primary goal is to ensure that VTXOs are atomically selected and locked, effectively preventing race conditions and double-spending in a concurrent environment. By centralizing lock management within the VTXO manager, the system gains improved architectural clarity and leverages the benefits of the actor model for reliable state management.

Highlights

  • Atomic VTXO Coin Selection and Locking: Implemented a comprehensive VTXO coin selection mechanism that includes atomic locking of selected VTXOs to prevent double-spending across concurrent operations like OOR sends and in-round directed transfers.
  • VTXO Manager for Lock Management: Moved VTXO lock management from the wallet to the VTXO manager. This architectural change leverages the actor system for serialization, prevents import cycles, and allows for automatic unlocking upon VTXO termination.
  • Largest-First Coin Selection Strategy: The coin selection process now employs a largest-first strategy, which aims to minimize the number of inputs used for a given target amount.
  • Transient In-Memory Locks: Locks are transient and stored in-memory using an fn.Set, ensuring they are cleared on daemon restart, which aligns with the state of in-flight transfers.
  • Actor Message Type Aliases: Introduced type aliases for ManagerMsg and ManagerResp to ensure compatibility with Go generics and the RegisterWithSystem function for service key registration.

🧠 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
  • darepod/rpc_server.go
    • Updated comments for the unlockVTXOs function to reflect its broader application for both OOR and in-round transfers.
  • darepod/server.go
    • Imported the vtxo package.
    • Initialized and registered the VTXO manager actor, integrating it with the round actor configuration.
    • Added a new initVTXOManager function responsible for creating, registering, and starting the VTXO manager.
  • internal/coinselect/coinselect_integration_test.go
    • Added a new file containing integration tests for the VTXO coin selection and locking functionality.
  • lib/actormsg/interfaces.go
    • Added the VTXOManagerResp interface.
    • Introduced new message types: AvailableVTXO, ListAvailableVTXOsRequest, ListAvailableVTXOsResponse, SelectAndLockVTXOsRequest, SelectAndLockVTXOsResponse, LockVTXOsRequest, LockVTXOsResponse, UnlockVTXOsRequest, and UnlockVTXOsResponse.
  • lib/actormsg/service_keys.go
    • Added the VTXOManagerServiceKeyName constant.
    • Added the VTXOManagerServiceKey function for looking up the VTXO manager actor.
  • vtxo/manager.go
    • Imported the sort package.
    • Added a lockedOutpoints set to the Manager struct to track reserved VTXOs.
    • Initialized the lockedOutpoints set in the NewManager function.
    • Added handlers for ListAvailableVTXOsRequest, SelectAndLockVTXOsRequest, LockVTXOsRequest, and UnlockVTXOsRequest.
    • Modified handleVTXOTerminated to automatically remove terminated VTXOs from the lockedOutpoints set.
    • Implemented handleListAvailable, handleSelectAndLockVTXOs, handleLockVTXOs, handleUnlockVTXOs, listAvailableVTXOs, and selectCoinsLargestFirst functions.
  • vtxo/manager_lock_test.go
    • Added a new file containing unit tests specifically for the VTXO manager's locking functionality.
  • vtxo/messages.go
    • Changed ManagerMsg and ManagerResp to type aliases for actormsg.VTXOManagerMsg and actormsg.VTXOManagerResp respectively.
    • Updated VTXOCreatedResp, VTXOTerminatedResp, and GetActiveVTXOCountResponse to implement the VTXOManagerResp interface.
  • wallet/messages.go
    • Updated comments for SelectAndLockVTXOsRequest and UnlockVTXOsRequest to explicitly include in-round transfers.
    • Modified the comment for SelectedVTXO.
  • wallet/wallet.go
    • Added handlers for SelectAndLockVTXOsRequest and UnlockVTXOsRequest to the Receive method.
    • Implemented handleSelectAndLockVTXOs and handleUnlockVTXOs functions, which forward requests to the VTXO manager.
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 implements atomic VTXO coin selection and locking within the VTXO manager, which is a crucial step for preventing double-spends during concurrent transfers. The architecture is well-designed, leveraging the actor model and service keys to decouple the wallet and VTXO manager, avoiding import cycles. The changes are extensive, touching actor wiring, message definitions, and adding new logic to the VTXO manager. The accompanying integration and unit tests are thorough.

My review has identified a couple of areas for improvement:

  • There is a duplicated and unused coin selection function in the wallet actor that should be removed to eliminate dead code.
  • A minor bug was found in one of the new test files where function return values were handled incorrectly.

Overall, this is a great contribution that significantly enhances the wallet's capabilities.

I am having trouble creating individual review comments. Click here to see my feedback.

wallet/wallet.go (831-835)

high

This selectCoinsLargestFirst function seems to be a duplicate of the one in vtxo/manager.go and appears to be unused. The coin selection logic is now handled entirely by the VTXO manager, as seen in vtxo/manager.go:handleSelectAndLockVTXOs. To avoid code duplication and reduce confusion, this function should be removed. The corresponding test file wallet/coin_selection_test.go should also be removed.

vtxo/manager_lock_test.go (49-50)

medium

The return values from result.Unpack() are (ManagerResp, error), but they are being assigned to resp, ok. The second return value is an error, not a boolean. This should be resp, err := result.Unpack() followed by require.NoError(t, err).

        resp, err := result.Unpack()
        require.NoError(t, err)

vtxo/manager_lock_test.go (65-66)

medium

Similar to the comment above, the return values from result.Unpack() are (ManagerResp, error), but they are being assigned to resp, ok. The second return value is an error, not a boolean. This should be resp, err = result.Unpack() followed by require.NoError(t, err).

        resp, err = result.Unpack()
        require.NoError(t, err)

@ellemouton
ellemouton requested a review from Roasbeef March 10, 2026 17:19
Comment thread vtxo/manager.go
// handleListAvailable returns all live VTXOs that are not currently locked.
// This queries the store for live VTXOs and filters out any that appear in
// the locked outpoints set.
func (m *Manager) handleListAvailable(

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 add tests for these new methods.

Also do the vtxos themselves need to know that they're locked?

Comment thread wallet/wallet.go
mgrKey := actormsg.VTXOManagerServiceKey()
mgrRef := mgrKey.Ref(a.actorSystem)

future := mgrRef.Ask(ctx, &actormsg.SelectAndLockVTXOsRequest{

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 could prob make a helper function (type param'd w/ the message type) for the 10 or so lines below this (actor call boiler plate).

Comment thread wallet/wallet.go
mgrKey := actormsg.VTXOManagerServiceKey()
mgrRef := mgrKey.Ref(a.actorSystem)

future := mgrRef.Ask(

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.

Same here re the boiler plate: req, await, call, type cast.

Comment thread darepod/server.go
// find it via service key lookup for lock/unlock operations.
func (s *Server) initVTXOManager(ctx context.Context,
clientWallet vtxo.VTXOWallet,
chainSourceRef actor.ActorRef[

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.

Non-blocking, but can add some type aliases to the respective packages to cut down on the type decl length here.

@@ -0,0 +1,332 @@
package coinselect

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.

Any reason to put put these in systest? I guess it doesn't necessarily need access to any of the backends?

Comment thread wallet/wallet.go

resp, err := result.Unpack()
if err != nil {
return fn.Err[WalletResp](

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.

These errors paths should unlock the locked vtxos. Also we may want to consider a sort of auto unlock timeout here as well.

Also I think the vtxo actors themelves need to also know about this locking, as right now the path for refresh is wallet -> round <-> vtxo. Otherwise we could try an oor, but then it races with a refresh attempt.

Comment thread vtxo/manager.go

var locked int
for _, op := range req.Outpoints {
if !m.lockedOutpoints.Contains(op) {

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.

Only add them if they're actually vtxo outpoints? The manager has a map of all the actual outpoints.

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.

darepod: implement full VTXO coin selection + locking for OOR sends

2 participants