Skip to content

vtxo+round+wallet: purify VTXO FSM and move intent composition to wallet - #172

Merged
Roasbeef merged 19 commits into
mainfrom
vtxo-fsm-refactor
Mar 13, 2026
Merged

vtxo+round+wallet: purify VTXO FSM and move intent composition to wallet#172
Roasbeef merged 19 commits into
mainfrom
vtxo-fsm-refactor

Conversation

@ellemouton

@ellemouton ellemouton commented Mar 11, 2026

Copy link
Copy Markdown
Member

Refactor groundwork for VTXO coin selection (#150).

This PR simplifies the boundary between vtxo, round, and wallet
so upcoming work on FSM-based locking (#168) and directed in-round
sends can build on a cleaner ownership model.

At a high level:

  • The VTXO FSM now models lifecycle only, not business intent.
  • The wallet now owns intent composition.
  • The round actor now validates and registers pre-composed intents
    instead of assembling them itself.

No protocol change is intended here. This is a structural refactor that
removes overlapping responsibilities, dead message paths, and context
lifetime bugs.

Why

Before this change, intent ownership was split across multiple layers:

  • The wallet triggered refresh/leave indirectly.
  • VTXO actors translated those triggers into round-specific messages.
  • The round actor still had to reconstruct intent packages.

That made the architecture harder to extend for coin selection and
locking, because the system did not have a single clear owner for
"which VTXOs are being consumed, and for what purpose".

This PR makes that ownership explicit:

  • wallet decides what intent package to register.
  • round drives protocol execution for that package.
  • vtxo tracks availability and lifecycle while remaining agnostic to
    whether a cooperative consume is a refresh, leave, or later another
    flow.

What changed

1. VTXO FSM now models lifecycle, not business intent

Refresh and leave were previously represented as separate concepts in
the VTXO FSM even though, from the VTXO actor's perspective, both are
just cooperative forfeits.

This PR simplifies that model:

  • RefreshRequestedState -> PendingForfeitState
  • ExpiringState -> UnilateralExitState
  • TriggerRefreshEvent and TriggerLeaveEvent collapse into
    PendingForfeitEvent
  • RefreshAcknowledgedEvent is removed as dead/no-op state machinery

This leaves the VTXO FSM focused on lifecycle transitions:

  • live
  • pending cooperative consume
  • forfeiting
  • forfeited
  • unilateral exit
  • failed

2. Round-bound VTXO signals now route through the manager

VTXO actors no longer hold a direct round actor reference.

Instead:

  • VTXO actors emit relay messages to the VTXO manager
  • the manager forwards round-bound payloads
  • round-to-VTXO notifications still use actor lookup by service key

This keeps VTXO actors simpler and makes outbound coordination flow
through one place.

3. Wallet now owns intent composition

The biggest architectural change is that the wallet now builds the full
intent package for refresh and leave flows.

That means:

  • the wallet loads VTXO descriptors itself
  • the wallet composes forfeits plus replacement VTXOs or leave outputs
  • the round actor receives a pre-composed package via
    RegisterIntentRequest / RegisterIntentMsg
  • the round actor validates and registers that package with the FSM

The round actor no longer needs separate wallet-trigger paths for
refresh/leave intent assembly.

4. Forfeit amounts are carried locally in intents

Forfeit amounts are now carried in the local intent data used during
registration.

This removes fragile registration-time store lookups and makes amount
validation more direct when building the round request.

The amount remains local bookkeeping, not a wire-level protocol change.

5. Persistence work no longer depends on request-context lifetime

Several local persistence operations now use context.WithoutCancel
when the work must survive beyond the actor request that triggered it.

This applies to operations like:

  • round checkpoint persistence
  • confirmed VTXO persistence
  • local registration-time work that should not be abandoned because the
    triggering request context expired

Diagrams

Ownership after this PR

wallet
  -> load VTXO data
  -> compose IntentPackage
  -> register with round

round
  -> validate/register package
  -> drive round FSM
  -> notify VTXO actors when cooperative consume becomes concrete

vtxo
  -> track lifecycle / availability
  -> sign forfeits when requested
  -> escalate to unilateral exit if expiry becomes critical

VTXO lifecycle before vs after

Before, the FSM encoded refresh/leave intent explicitly.
After, it models only the lifecycle of cooperative consumption.

Before:
Live
  -> RefreshRequested
  -> Forfeiting
  -> Forfeited

Live
  -> Expiring
  -> Failed

After:
Live
  -> PendingForfeit
  -> Forfeiting
  -> Forfeited

Live
  -> UnilateralExit
  -> Failed

Result

After this PR:

  • VTXO actors no longer care why they are being cooperatively consumed
  • the wallet is the single place where round intents are composed
  • the round actor has a narrower, cleaner responsibility
  • the codebase is in a better position for VTXO locking and coin
    selection work

Test plan

  • make unit pkg=vtxo
  • make unit pkg=round
  • make unit pkg=wallet
  • make build
  • make lint

@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 significantly refactors the VTXO lifecycle management by clearly separating responsibilities across the VTXO actor, round actor, and wallet. The VTXO actor is now a lean state machine focused solely on coin lifecycle and forfeit execution. The wallet assumes the role of composing user-driven intents for VTXO operations, while the round actor's responsibility is narrowed to validating and registering these pre-composed intent packages. This architectural change reduces coupling, improves extensibility, and lays the groundwork for future features like coin selection and in-round sends.

Highlights

  • VTXO FSM Refactor: Renamed VTXO FSM states: ExpiringState is now UnilateralExitState, and RefreshRequestedState is now PendingForfeitState. This clarifies the VTXO actor's role to pure lifecycle management, removing product-level business intent.
  • Decoupled VTXO Actor Outbound Signaling: The VTXO actor no longer holds direct references to the RoundActor or ChainResolver. All outbound signals, such as ForfeitRequest and ForfeitSignatureSubmission, are now routed through the VTXOManager via a new RelayToRoundMsg.
  • Centralized Wallet Intent Composition: The wallet now owns the composition of VTXO intents (refresh and leave). It uses a new VTXOReader interface to load VTXO descriptors and constructs a complete RegisterIntentMsg package, which is then sent to the round actor.
  • Simplified Round Actor API: The round actor's API for intent registration has been streamlined. It now accepts a single RegisterIntentRequest (or actormsg.RegisterIntentMsg) containing a pre-composed intent package, removing the need for TriggerRefreshEvent, TriggerLeaveEvent, RefreshAcknowledgedEvent, and LeaveVTXORequest.
  • Improved Context Handling for Persistence: Persistence operations within the round actor's state transitions now utilize context.WithoutCancel. This ensures that critical store writes can complete independently of the original actor request's context lifecycle.

🧠 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/server.go
    • Added wire package import.
    • Modified initWalletActor to adapt the VTXO persistence store to the new wallet.VTXOReader interface and pass it to the wallet actor.
  • docs/vtxo_three_pr_execplan.md
    • Added a new document detailing the three-PR plan for VTXO refactor, reservation safety, and in-round sends, with this PR being the first phase.
  • lib/actormsg/interfaces.go
    • Removed TriggerVTXORefreshMsg and TriggerVTXOLeaveMsg.
    • Introduced RegisterIntentMsg to encapsulate forfeit, VTXO, and leave requests for wallet-to-round communication.
    • Updated import from wire to lib/types.
  • lib/types/boarding.go
    • Added an Amount field to ForfeitRequest to carry local value information, reducing the need for store lookups.
  • round/README.md
    • Updated the description of the round actor's role to reflect its new focus on accepting wallet-composed intent packages.
    • Revised the refresh and leave sections to detail the wallet-driven intent composition and the use of RegisterIntentMsg.
  • round/actor.go
    • Removed the LeaveVTXORequest struct.
    • Introduced RegisterIntentRequest as the primary entry point for pre-composed intent packages.
    • Modified the Receive method to handle RegisterIntentRequest and actormsg.RegisterIntentMsg.
    • Removed handlers for LeaveVTXORequest, actormsg.TriggerVTXORefreshMsg, and actormsg.TriggerVTXOLeaveMsg.
    • Updated handleRefreshVTXORequest to include the VTXO Amount in the ForfeitRequest and removed the RefreshAcknowledgedEvent notification.
    • Added handleRegisterIntent to process the new RegisterIntentRequest, notify VTXO actors with PendingForfeitEvent, and log the registered package.
    • Updated RoundClientConfig comments to reflect the new PendingForfeitEvent.
    • Modified Round failed log message to include the original error.
  • round/actor_test.go
    • Updated TestHandleRefreshVTXORequest to assert the Amount field in ForfeitRequest.
    • Modified TestActorIntentMapping to use RegisterIntentRequest for the leave flow.
    • Added TestHandleRegisterIntent to verify the new intent registration process for refresh and leave, including error handling for empty packages.
  • round/join_auth.go
    • Modified computeTotalForfeitAmount to prioritize the embedded Amount in ForfeitRequest before performing a store lookup.
    • Updated context usage for computeTotalForfeitAmount, deriveJoinAuthIdentifierKey, and buildJoinRoundAuth to use a detached context (opCtx).
  • round/join_auth_test.go
    • Added TestComputeTotalForfeitAmountUsesEmbeddedAmount to validate the new Amount field usage in forfeit amount calculation.
  • round/transitions.go
    • Implemented context.WithoutCancel for persistence operations within PendingRoundAssembly.ProcessEvent to ensure they complete reliably.
    • Applied context.WithoutCancel to store writes in ForfeitSignaturesCollectingState.ProcessEvent, PartialSigsSentState.ProcessEvent, and InputSigSentState.ProcessEvent.
  • round/vtxo_messages.go
    • Removed RefreshAcknowledgedEvent, TriggerRefreshEvent, and TriggerLeaveEvent.
    • Introduced PendingForfeitEvent to signal cooperative consumption to VTXO actors.
  • systest/helpers.go
    • Updated NewArk calls in test setup to pass nil for the newly introduced vtxoReader parameter.
  • vtxo/actor.go
    • Removed RoundActor from VTXOActorConfig.
    • Added a tellManager helper function to centralize outbound message routing.
    • Modified processOutbox to relay ForfeitRequest and ForfeitSignatureSubmission messages through the VTXOManager using RelayToRoundMsg.
    • Removed handling for LeaveRequest.
    • Updated statusToState mappings for renamed VTXO statuses.
  • vtxo/actor_test.go
    • Updated tests to reflect the new routing of forfeit signatures and requests through the manager.
    • Adjusted status checks to use VTXOStatusPendingForfeit and VTXOStatusUnilateralExit.
    • Added TestManagerRelayToRound and TestManagerRelayForfeitSig to verify the manager's message relaying functionality.
  • vtxo/events.go
    • Removed aliases for RefreshAcknowledgedEvent, TriggerRefreshEvent, and TriggerLeaveEvent.
    • Added an alias for PendingForfeitEvent.
  • vtxo/harness_test.go
    • Updated comments for mockRoundActorRef to clarify its use in manager relay tests.
  • vtxo/interfaces.go
    • Updated MessageSpec to reflect the introduction of PendingForfeitEvent and changes in ForfeitRequest and ExpiringNotification handling.
    • Renamed VTXOStatusRefreshRequested to VTXOStatusPendingForfeit and VTXOStatusExpiring to VTXOStatusUnilateralExit, providing backward-compatible aliases.
  • vtxo/manager.go
    • Updated ManagerConfig comments regarding the RoundActor's role.
    • Added handleRelayToRound to process RelayToRoundMsg and forward its payload to the RoundActor.
    • Removed RoundActor from the spawnVTXOActor configuration.
  • vtxo/messages.go
    • Added RelayToRoundMsg and RelayToRoundResp for relaying messages from VTXO actors to the round actor via the manager.
  • vtxo/outbox_messages.go
    • Updated the comment for ForfeitRequest to reflect its routing through the manager.
    • Removed the LeaveRequest struct.
  • vtxo/states.go
    • Renamed RefreshRequestedState to PendingForfeitState and ExpiringState to UnilateralExitState, with updated documentation for clarity.
  • vtxo/transitions.go
    • Removed handleTriggerRefresh and handleTriggerLeave functions.
    • Added handlePendingForfeit to LiveState to transition to PendingForfeitState upon receiving a PendingForfeitEvent.
    • Updated LiveState.handleBlockEpoch to transition to PendingForfeitState or UnilateralExitState based on expiry status.
    • Modified PendingForfeitState.ProcessEvent (formerly RefreshRequestedState) to handle PendingForfeitEvent and transition to UnilateralExitState on critical expiry.
    • Updated ForfeitingState.ProcessEvent to transition to UnilateralExitState on critical expiry.
  • vtxo/transitions_test.go
    • Updated tests to reflect the renamed states and the new handling of PendingForfeitEvent.
    • Adjusted test assertions for state transitions to match the new state names.
  • wallet/interfaces.go
    • Introduced VTXODescriptor, VTXOReader interface, and VTXOReaderFunc adapter to enable the wallet to load VTXO data without direct vtxo package imports.
  • wallet/wallet.go
    • Added a vtxoReader field to the Ark struct.
    • Updated the NewArk constructor to accept a VTXOReader parameter.
    • Added an allTargetErrors helper function.
    • Rewrote handleRefreshVTXOs and handleLeaveVTXOs to use the vtxoReader for VTXO data, build actormsg.RegisterIntentMsg packages, and Ask the round actor for registration.
  • wallet/wallet_test.go
    • Updated NewArk calls in existing tests to pass nil for the new vtxoReader parameter.
    • Added TestIntentCompositionRequiresVTXOReader to verify proper error handling when the vtxoReader is not configured during intent composition.
Activity
  • The author, ellemouton, has completed Phase A and Phase B of the VTXO refactor plan, as detailed in the newly added docs/vtxo_three_pr_execplan.md.
  • During development, code review identified and addressed three issues: leave metadata loss (deferred by design, handled by wallet intent composition), relay error propagation (fixed), and stale comments (fixed). These fixes were squashed into appropriate commits via autosquash rebase, indicating a thorough and iterative development process.
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.

@ellemouton
ellemouton force-pushed the vtxo-fsm-refactor branch 2 times, most recently from 8c450da to 699bb06 Compare March 11, 2026 13:52

@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 comprehensive three-PR execution plan document for refactoring VTXO management, reservation safety, and in-round sends. The code changes primarily focus on the first phase of this plan, which involves refactoring the VTXO actor's state machine and routing. Key changes include moving intent composition logic from the round actor to the wallet, introducing a new RegisterIntentMsg for the wallet to send pre-composed intent packages to the round actor, and routing VTXO actor outbound signals (like forfeit requests and signatures) through the VTXO manager instead of directly to the round actor. The VTXO actor's states RefreshRequestedState and ExpiringState have been renamed to PendingForfeitState and UnilateralExitState respectively, and related trigger messages have been removed. Additionally, a VTXOReader interface is introduced in the wallet to allow it to load VTXO descriptors for intent package creation, and ForfeitRequest now includes an Amount field to optimize total forfeit amount computation. A review comment highlights a documentation issue where hardcoded local file paths in the execution plan make links unusable for other developers.

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

docs/vtxo_three_pr_execplan.md (7)

medium

This design document contains a hardcoded local file path, which makes the link unusable for other developers. It would be better to use a relative path if the file is within the repository.

A similar hardcoded path is present on line 1017 in a cd command example, which should also be generalized for other developers.

@ellemouton
ellemouton force-pushed the vtxo-fsm-refactor branch 2 times, most recently from 4e5d307 to 6be4ecc Compare March 11, 2026 14:17
@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Mar 11, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 3m 37s —— View job


Code Review

  • Gather context and understand the request
  • Read core changed files (vtxo, round, wallet, actormsg)
  • Analyze architectural changes
  • Review error handling and edge cases
  • Check code style and conventions
  • Post comprehensive review feedback

This is a well-structured, clearly motivated refactor. The core idea—separating lifecycle concerns (VTXO FSM) from business intent composition (wallet)—is sound, and the code executes on it cleanly. Below are the findings from a full read of the changed files.

Correctness Issues

1. sortedForfeitRequests silently drops Amount fieldround/join_auth.go:109-122

When building the sorted output, only VTXOOutpoint is carried over; Amount is zeroed:

requests = append(requests, &types.ForfeitRequest{
    VTXOOutpoint: &op,
    // Amount is NOT copied
})

In the current call graph this is safe — computeTotalForfeitAmount is called at transitions.go:283 on the pre-sorted s.Forfeits, and the buildJoinRoundAuth code fetches amounts from the VTXOStore anyway. But the invariant is fragile: if a future caller passes the sorted output to computeTotalForfeitAmount, the Amount != 0 fast-path is silently bypassed and the store is consulted with potentially stale data. This should be defended against with either:

  • preserving Amount in sortedForfeitRequests (trivial fix), or
  • a comment documenting the invariant.

Fix this →

2. Asymmetric VTXO state-transition responsibility is undocumented at the seamround/actor.go:1711, round/actor.go:1765

The two intent paths have fundamentally different ownership of the PendingForfeitState transition:

  • Auto-expiry path (handleRefreshVTXORequest): the VTXO actor self-transitions to PendingForfeitState before the relay message reaches the round actor. No PendingForfeitEvent is sent back.
  • Wallet-initiated path (handleRegisterIntent): the VTXO actor is still in LiveState when the round gets the package. The round actor sends PendingForfeitEvent after FSM registration succeeds.

This asymmetry is correct (handled gracefully — idempotent PendingForfeitEvent in PendingForfeitState is a no-op) but it is not documented at either entry point. A developer reading handleRefreshVTXORequest would reasonably ask "why doesn't this also notify the VTXO actor?" — and the answer ("because it already notified itself") belongs in the function comment.

Design / Behavioral Observations

3. handleLeaveVTXOs fans N VTXOs to one destination outputwallet/wallet.go:818-821

All VTXOs in a single LeaveVTXOsRequest receive individual LeaveRequest objects all pointing to the same req.DestOutput. This means N separate connector-output leaves are created for one on-chain output. The value consolidation semantics should be documented on LeaveVTXOsRequest or handleLeaveVTXOs — specifically whether the server is expected to aggregate them into one output or create N separate outputs. If N outputs are created, a caller who sends 3 VTXOs expecting one merged output will be surprised.

4. Partial-success silent promotion in handleRefreshVTXOs / handleLeaveVTXOswallet/wallet.go:727-746

When some VTXOs fail to load (errors populated) but at least one succeeds, the successful forfeits are still submitted to the round. The caller gets RefreshingCount > 0 and errors != nil. This is probably intentional (partial participation is better than none for the liveness path), but it should be documented in the function comment. A caller who doesn't check the Errors map may believe all VTXOs were enrolled.

Nits / Minor Issues

5. VTXODescriptor.Expiry vs vtxo.Descriptor.RelativeExpirywallet/interfaces.go:31, darepod/server.go:1423

The wallet-level descriptor renames RelativeExpiry → Expiry. The adapter in server.go correctly maps desc.RelativeExpiry → Expiry. This is reasonable since wallet shouldn't need the "Relative" qualifier. Worth a brief comment in VTXODescriptor.Expiry's GoDoc noting it corresponds to vtxo.Descriptor.RelativeExpiry for cross-package clarity.

6. handleRegisterIntent uses request ctx for Tell notificationsround/actor.go:1805

The PendingForfeitEvent notifications in the loop at line 1805 use ctx from the actor request scope. If the round actor's request context is cancelled immediately after FSM registration (e.g., timeout), the tells may fail. The comment at lines 1759-1764 handles this correctly ("The missed VTXO will receive the concrete ForfeitRequestEvent later"), but consider using context.WithoutCancel(ctx) here for consistency with the persistence operations in transitions.go and to make the intent explicit.

7. RegisterIntentMsg adapter in Receive reconstructs IntentPackage fields directlyround/actor.go:789-794

case *actormsg.RegisterIntentMsg:
    return a.handleRegisterIntent(ctx, &RegisterIntentRequest{
        Package: &IntentPackage{Intents: Intents{
            Forfeits: m.Forfeits,
            VTXOs:    m.VTXOs,
            Leaves:   m.Leaves,
        }},
    })

If RegisterIntentMsg ever gains new fields (e.g., metadata, priority), this adapter will silently drop them. A compile-time exhaustiveness check or at minimum a comment linking the two types together would make maintenance safer.

What's Well Done

  • The FSM renaming (RefreshRequested → PendingForfeit, Expiring → UnilateralExit) is accurate and unambiguous. The "lifecycle-only, no business intent" principle is consistently applied.
  • The idempotent handling of duplicate PendingForfeitEvent in PendingForfeitState is correct and well-commented.
  • context.WithoutCancel for persistence in transitions.go is a genuine correctness improvement — store writes will no longer be abandoned if the actor request context expires mid-write.
  • The VTXOReader / VTXOReaderFunc abstraction cleanly breaks the wallet→vtxo import cycle without leaking vtxo internals.
  • computeTotalForfeitAmount's embedded-Amount fast-path and the new test TestComputeTotalForfeitAmountUsesEmbeddedAmount are a good defense against fragile store lookups.
  • tellManager as a single outbound routing point in the VTXO actor (vtxo/actor.go:92) is a clean centralization.

@ellemouton

Copy link
Copy Markdown
Member Author

@claude review again

@claude

claude Bot commented Mar 11, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 2m 56s —— View job


Code Review

  • Gather context
  • Read core changed files (vtxo, round, wallet, actormsg)
  • Analyze architectural changes and correctness
  • Review error handling and edge cases
  • Check code style and conventions
  • Post review feedback

All seven findings from the previous review have been resolved. Noting them explicitly and then adding fresh observations.

Previously Raised Issues — All Resolved

# Finding Resolution
1 sortedForfeitRequests silently dropped Amount Fixed: amountByOP map now preserves Amount through sort (join_auth.go:98-131)
2 Asymmetric PendingForfeitEvent ownership undocumented Fixed: comment at actor.go:1719-1722 explains the auto-expiry path self-transitions before the relay
3 N VTXOs → one DestOutput semantics undocumented Fixed: documented in handleLeaveVTXOs function comment (wallet.go:773-776)
4 Partial-success silent promotion undocumented Fixed: documented in both handleRefreshVTXOs and handleLeaveVTXOs comments
5 VTXODescriptor.Expiry field rename undocumented Fixed: GoDoc at interfaces.go:31 now says "corresponds to vtxo.Descriptor.RelativeExpiry"
6 PendingForfeitEvent notifications used request ctx Fixed: context.WithoutCancel(ctx) now used at actor.go:1808
7 RegisterIntentMsg adapter silently drops new fields Fixed: comment at actor.go:788-790 warns future maintainers

Fresh Observations

1. Forfeit/leave positional pairing is an implicit contractwallet/wallet.go:825-833

Each forfeits[i] is conceptually paired with leaves[i] (one VTXO forfeited per leave). But types.LeaveRequest has no VTXOOutpoint field to make this explicit, and there's no len(forfeits) == len(leaves) assertion at any layer. The function comment documents this correctly, but if future code modifies only one slice (e.g., deduplication, reordering), the pairing silently breaks. This is a latent risk for the next PR that touches leave logic.

2. handleRelayToRound error return is structurally unreachablevtxo/manager.go:238-251

Manager.Receive returns fn.Err when Tell to the round actor fails. But VTXO actors reach the manager via Tell (fire-and-forget), so no caller ever awaits the result. The error is correctly logged at line 242, making it observable. The fn.Err return value is unreachable in practice — not a bug, but the function signature implies the caller can observe the error when it cannot. A comment noting "error is logged but not propagated to the VTXO actor (fire-and-forget semantics)" would prevent confusion.

3. Inconsistency: all-fail vs partial-fail return conventionswallet/wallet.go:731-750

When the vtxoReader is nil, both handleRefreshVTXOs and handleLeaveVTXOs return Ok with all outpoints mapped to an error. When len(forfeits) == 0 (all VTXOs failed to load individually), the function also returns Ok with RefreshingCount: 0 and all outpoints in Errors. But when the round actor rejects the package, the function returns fn.Err. The asymmetry between "all load failures" (Ok + Errors map) and "round rejection" (Err) could surprise callers. This is not a bug since the comment documents partial participation, but it warrants a note in the caller documentation that Ok does not imply any VTXO was enrolled.

4. handleRegisterIntent sends Tell to VTXO actors using actorSystem lookupround/actor.go:1807-1828

If a VTXO actor has terminated between FSM registration and the notification loop (race between handleVTXOTerminated in the manager and this Tell), serviceKey.Ref(a.cfg.ActorSystem).Tell(...) would fail. The failure is logged as a WarnS and the handler continues, which is correct. The existing comment at line 1767-1772 explains this case well ("The missed VTXO will receive the concrete ForfeitRequestEvent later"). No action needed — just confirming the reasoning is sound.

What's Well Done

The resolutions to all prior findings are clean and surgical — no unnecessary changes leaked in. Specifically:

  • context.WithoutCancel for persistence writes in transitions.go and for VTXO notifications in handleRegisterIntent is consistently applied.
  • The VTXOReader / VTXOReaderFunc abstraction breaks the wallet→vtxo import cycle elegantly and is documented clearly.
  • The idempotent PendingForfeitEvent in PendingForfeitState (transitions.go:377-382) is a solid defensive choice that handles replay and restart safely.
  • tellManager as the single outbound point (vtxo/actor.go:92-102) makes the relay path easy to trace and test.
  • The new test TestComputeTotalForfeitAmountUsesEmbeddedAmount directly validates the embedded-amount fast-path, giving confidence the store is bypassed when data is present.

LGTM with the minor observations above (none are blockers).

Rename the terminal expiry state to better reflect its
semantics: the VTXO has reached critical expiry and is
being handed off to the chain resolver for unilateral
on-chain exit. This is a pure rename with no behavioral
changes.

- ExpiringState → UnilateralExitState
- VTXOStatusExpiring → VTXOStatusUnilateralExit
Rename the pre-forfeit waiting state to reflect its actual
semantics: the VTXO is committed to cooperative consumption
and is awaiting concrete forfeit details from the round
actor. The old name implied this state was specific to
the "refresh" product concept, but it is equally reachable
via leave requests and in-round sends.

- RefreshRequestedState → PendingForfeitState
- VTXOStatusRefreshRequested → VTXOStatusPendingForfeit
- Update test names and transition comments
The VTXO actor no longer distinguishes between refresh and
leave as separate product concepts. Both TriggerRefreshEvent
and TriggerLeaveEvent now map to the same lifecycle action:
commit to cooperative consumption via PendingForfeitState.

- Collapse handleTriggerRefresh and handleTriggerLeave into
  a single handleExternalForfeitTrigger method
- Remove LeaveRequest outbox message (leave vs refresh is a
  round/wallet concern, not a VTXO lifecycle concept)
Remove the direct RoundActor reference from VTXOActorConfig.
The VTXO actor now routes ForfeitRequest and
ForfeitSignatureSubmission through the manager via
RelayToRoundMsg. The manager unwraps and forwards to the
round actor.

ChainResolver remains as a direct reference on the VTXO
actor since it is not yet wired up and keeping it direct
is simpler for now.

- Add RelayToRoundMsg manager message type
- Add tellManager helper on VTXOActor for consolidated
  outbound routing
- Add handleRelayToRound handler on Manager
- Remove RoundActor from VTXOActorConfig
- Update tests to verify relay through manager
Document and test the liveness guarantee: when a VTXO
approaches expiry, the VTXO actor autonomously emits a
ForfeitRequest through the manager without requiring wallet
intervention. The manager relays it promptly to the round
actor, ensuring cooperative action is always attempted
before critical expiry.

- Add liveness policy comment on handleRelayToRound
- Add TestManagerRelayToRound proving forfeit requests
  reach the round actor via the manager relay path
- Add TestManagerRelayForfeitSig for signature submission
Remove the RefreshAcknowledgedEvent from the VTXO FSM and
the round actor. This event was a no-op acknowledgment
sent from the round actor back to the VTXO actor after
queuing a refresh, adding complexity without value. The
VTXO actor in PendingForfeitState simply waits for the
ForfeitRequestEvent with concrete forfeit details.

- Remove RefreshAcknowledgedEvent type definition from
  round/vtxo_messages.go
- Remove sending code from round/actor.go
- Remove handler from PendingForfeitState.ProcessEvent
- Remove type alias from vtxo/events.go
Update comments throughout the VTXO package to reflect the
refactored architecture. The VTXO FSM no longer distinguishes
between refresh and leave as separate concepts — both are
cooperative forfeiture from the FSM's perspective.

- Replace "refresh" terminology with "cooperative forfeit"
  in state transition comments
- Remove product-level leave/refresh distinction from
  ForfeitedState documentation
- Update MessageSpec comments for PendingForfeitState
- Update ForfeitRequest and manager comment wording
Replace TriggerRefreshEvent and TriggerLeaveEvent with a single
PendingForfeitEvent that carries no business intent. The round
actor now owns intent composition: it loads VTXO descriptors,
builds the IntentPackage (refresh or leave), feeds the FSM, and
sends PendingForfeitEvent to mark each VTXO as pending
cooperative consumption.

The VTXO FSM no longer handles TriggerRefreshEvent or
TriggerLeaveEvent. It accepts PendingForfeitEvent to transition
Live → PendingForfeit, with a no-op handler for duplicate
PendingForfeitEvent in PendingForfeitState.

This completes the Phase A goal: the VTXO actor speaks only
lifecycle, not business intent. Phase B will move intent
composition from the round actor to the wallet.
Add RegisterIntentRequest as the primary entry point for
registering pre-composed intent packages with the round actor.
The caller (wallet) builds the full IntentPackage and the round
actor validates, registers it with the FSM, and notifies
affected VTXO actors with PendingForfeitEvent.

The handler derives forfeited outpoints from the package rather
than accepting them separately, and only sends PendingForfeitEvent
after FSM registration succeeds. If a VTXO notification fails,
the handler logs and continues — the missed VTXO will receive
the concrete ForfeitRequestEvent later via the fast path.

The existing RefreshVTXORequest and LeaveVTXORequest paths
remain active until callers are switched to
RegisterIntentRequest in subsequent commits.
Move intent composition for VTXO refresh from the round actor to the
wallet. The wallet now loads VTXO descriptors via a VTXOReader
interface, builds forfeit + VTXO request pairs, and sends a
RegisterIntentMsg to the round actor. The round actor converts this
to its internal RegisterIntentRequest and handles FSM registration
and VTXO actor notification.

This is the key boundary shift: the wallet owns intent composition,
the round only registers and validates. The leave flow still uses the
old TriggerVTXOLeaveMsg path (next commit).
Mirror the refresh flow change: the wallet now loads VTXO descriptors,
builds forfeit + leave request pairs, and sends a RegisterIntentMsg
to the round actor instead of TriggerVTXOLeaveMsg. Both wallet flows
now use the same intent registration path.
Remove TriggerVTXORefreshMsg, TriggerVTXOLeaveMsg from actormsg and
their corresponding round actor handlers (handleTriggerVTXORefresh,
handleTriggerVTXOLeave). Also remove buildRefreshVTXORequest helper
whose only caller was the deleted refresh handler.

The wallet now sends RegisterIntentMsg directly, making these trigger
messages and their round-side intent composition logic dead code.
Remove LeaveVTXORequest type, its Receive case, and the
handleLeaveVTXORequest handler. No external callers send this message
since the wallet now uses RegisterIntentMsg for leaves. Also remove
buildRefreshVTXORequest whose only caller was deleted in the previous
commit.

Update tests to use RegisterIntentRequest with forfeit + leave pairs
instead of the removed LeaveVTXORequest.
Keep local amount metadata alongside forfeits when the wallet
or the auto-expiry refresh path builds a round intent.

The new RegisterIntent flow validates forfeited input value
during round registration. Relying on store lookups there
made the refresh systest fragile because the short-lived
actor request context could be canceled before those reads
finished.

Using locally available amounts keeps validation
deterministic and makes the refresh and leave paths
independent from a store round-trip.
@ellemouton
ellemouton force-pushed the vtxo-fsm-refactor branch 2 times, most recently from 61f031b to b3ef24e Compare March 12, 2026 11:53
@ellemouton
ellemouton force-pushed the vtxo-fsm-refactor branch 3 times, most recently from 61f031b to a15ea97 Compare March 12, 2026 15:01
Run `make rpc` to regenerate protobuf Go stubs so the raw descriptor
bytes match the renamed VTXO_STATUS_PENDING_FORFEIT and
VTXO_STATUS_UNILATERAL_EXIT enum values. The previous manual sed
renames updated Go-level maps but left the binary-encoded
FileDescriptorProto with stale length prefixes, causing a panic at
init time.

The regeneration also adds ListRounds and WatchRounds to the
mailbox server interface. Since WatchRounds is a server-streaming
RPC incompatible with the unary mailbox transport, introduce an
rpcMailboxAdapter that wraps RPCServer and returns an error for
WatchRounds over mailbox.
@ellemouton
ellemouton requested a review from Roasbeef March 12, 2026 18:48
…edup

Replace findPendingRound with findAssemblingRound in handleRegisterIntent
and handleRefreshVTXORequest. findPendingRound matches by temp-key status
which includes rounds in RegistrationSentState. Feeding an IntentPackage
to RegistrationSentState causes a silent self-loop, discarding the intent
without returning an error. findAssemblingRound correctly filters by FSM
state (Idle or PendingRoundAssembly).

Also add PkScript-based deduplication for VTXO requests in
PendingRoundAssembly.ProcessEvent. Two refresh paths (wallet-driven and
auto-expiry) could race to create output requests for the same VTXO. The
forfeit pool already deduplicates by outpoint, but duplicate VTXO outputs
would inflate totalOutput and cause the balance check to fail.
… embedded

Change computeTotalForfeitAmount to always look up the canonical VTXO
amount from VTXOStore when a store is available. Previously, a non-zero
embedded Amount field on ForfeitRequest would skip the store lookup
entirely, allowing a buggy or compromised caller to inflate the forfeit
total. The embedded Amount is now only used as a fallback when no store
is provided (test environments).

Also update the Forfeits field comment in Intents to reflect that the
store is the canonical source of truth, and remove the stale fast-path
reference from the sortedForfeitRequests comment.

@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.

Solid refactor

Found a few things locally and regenerated the docs, will tack on as extra commits.

Comment thread round/actor.go Outdated
ctx, &TriggerRefreshEvent{
ForceRefresh: cmd.ForceRefresh,
},
err = serviceKey.Ref(a.cfg.ActorSystem).Tell(

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 the round actor speaks directly to the vtxo actors, but the vtxo actor relays through the round actor?

Why not make this symmetric? On either end of the relationship. One other thing to think about here is: which of these actors will be made durable? Right now we have plas to make the round actor durable, so it can survive restarts when receiving messages from the wallet/server.

Comment thread round/actor.go
Comment thread round/actor.go
case *ForfeitSignatureResponse:
return a.handleForfeitSignatureResponse(ctx, m)

case *actormsg.TriggerVTXORefreshMsg:

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.

👍 for removing these, was working in this area earlier in the week and was a bit confused re why we had to very similar messages sent into the actor.

@Roasbeef

Copy link
Copy Markdown
Member

Review Fixes

Pushed three commits addressing findings from code review:

083e68e0 — round: use findAssemblingRound for intent registration and add VTXO dedup

handleRegisterIntent and handleRefreshVTXORequest were using findPendingRound() which matches by temp-key status. This includes rounds in RegistrationSentState — feeding an IntentPackage to that state causes a silent self-loop, discarding the intent without error. Switched both to findAssemblingRound() which correctly filters by FSM state (Idle or PendingRoundAssembly).

Also added PkScript-based deduplication for VTXO requests in PendingRoundAssembly.ProcessEvent. The forfeit pool already deduplicates by outpoint, but duplicate VTXO output requests (from wallet + auto-expiry racing) would inflate totalOutput and cause the balance check to fail.

Tests: intent_creates_new_round_when_existing_in_registration_sent, duplicate_vtxo_pkscript_deduplicated.

71ec7c69 — round: validate forfeit amounts against VTXOStore instead of trusting embedded

computeTotalForfeitAmount was trusting the embedded Amount field on ForfeitRequest and skipping the VTXOStore lookup when it was non-zero. Changed to always use VTXOStore as the canonical source of truth when a store is available. The embedded amount is now only a fallback for test environments (nil store).

Updated the stale Forfeits field comment in Intents and the sortedForfeitRequests comment.

Test: TestComputeTotalForfeitAmountStoreOverridesEmbedded — verifies store amounts (100+200=300) override inflated embedded amounts (999+888).

a459522d — docs: update per-package docs for VTXO FSM refactor

Updated round/CLAUDE.md, vtxo/CLAUDE.md, wallet/CLAUDE.md, and ARCHITECTURE.md with new state names (PendingForfeit, UnilateralExit), message types (PendingForfeitEvent, RegisterIntentMsg), and the wallet's intent composition ownership.

Note on proto stubs

make rpc produces the same output already on the branch — the Docker container uses protoc v3.21.12. The version difference vs main (v5.28.0) is because main was generated with a local install rather than the Docker container. No action needed.

Update round, vtxo, and wallet CLAUDE.md/AGENTS.md to reflect the new
message types and FSM states from the VTXO FSM purification:

- round: Add PendingForfeitEvent, RegisterIntentMsg, IntentPackage to
  message flows. Remove RefreshAcknowledgedEvent and LeaveVTXORequest.
- vtxo: Rename states (PendingForfeit, UnilateralExit). Remove
  TriggerRefreshEvent/TriggerLeaveEvent from receives. Add
  PendingForfeitEvent, RelayToRoundMsg.
- wallet: Document intent composition responsibility. Replace
  TriggerRefreshEvent/TriggerLeaveEvent sends with RegisterIntentMsg.
- ARCHITECTURE.md: Update VTXO FSM state diagram with new state names
  and the fast-path ForfeitRequestEvent transition.
@Roasbeef
Roasbeef force-pushed the vtxo-fsm-refactor branch from a459522 to a421b04 Compare March 13, 2026 03:03
@Roasbeef
Roasbeef merged commit 99652d4 into main Mar 13, 2026
16 checks passed
@ellemouton
ellemouton deleted the vtxo-fsm-refactor branch March 13, 2026 06:56
ellemouton added a commit that referenced this pull request Mar 17, 2026
Update the client submodule pointer to include the merged VTXO FSM
refactor (PR #172). This replaces RefreshRequestedState with
PendingForfeitState, renames ExpiringState to UnilateralExitState,
removes trigger messages, and routes all round-bound signals through
the VTXO manager.

Fix systest compilation: wire a VTXOReader adapter (closure over
vtxoStore) into wallet.NewArk so the wallet can load VTXO descriptors
for intent composition, and replace the removed
VTXOStatusRefreshRequested constant with VTXOStatusPendingForfeit.
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.

2 participants