From c3f42f1d49b1e20504452bc6347c9e3ea8bd6568 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 11:50:58 +0200 Subject: [PATCH 01/19] vtxo: rename ExpiringState to UnilateralExitState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- arkrpc/indexer.pb.go | 12 ++++++------ arkrpc/indexer.proto | 6 +++--- daemonrpc/daemon.pb.go | 12 ++++++------ daemonrpc/daemon.proto | 6 +++--- darepod/rpc_server.go | 8 ++++---- vtxo/actor.go | 4 ++-- vtxo/actor_test.go | 2 +- vtxo/interfaces.go | 11 ++++++----- vtxo/states.go | 19 ++++++++++--------- vtxo/transitions.go | 22 +++++++++++----------- vtxo/transitions_test.go | 24 ++++++++++++------------ 11 files changed, 64 insertions(+), 62 deletions(-) diff --git a/arkrpc/indexer.pb.go b/arkrpc/indexer.pb.go index 9f9eff432..b9f4180b4 100644 --- a/arkrpc/indexer.pb.go +++ b/arkrpc/indexer.pb.go @@ -42,9 +42,9 @@ const ( // VTXO_STATUS_SPENT indicates the VTXO was spent via an out-of-round // transaction (terminal). VTXOStatus_VTXO_STATUS_SPENT VTXOStatus = 6 - // VTXO_STATUS_EXPIRING indicates the VTXO is close to expiry and has been - // escalated to chain resolution (terminal for the VTXO actor). - VTXOStatus_VTXO_STATUS_EXPIRING VTXOStatus = 7 + // VTXO_STATUS_UNILATERAL_EXIT indicates the VTXO has reached critical expiry + // and is being resolved on-chain (terminal for the VTXO actor). + VTXOStatus_VTXO_STATUS_UNILATERAL_EXIT VTXOStatus = 7 // VTXO_STATUS_FAILED indicates an unrecoverable error (terminal). VTXOStatus_VTXO_STATUS_FAILED VTXOStatus = 8 ) @@ -59,7 +59,7 @@ var ( 4: "VTXO_STATUS_FORFEITING", 5: "VTXO_STATUS_FORFEITED", 6: "VTXO_STATUS_SPENT", - 7: "VTXO_STATUS_EXPIRING", + 7: "VTXO_STATUS_UNILATERAL_EXIT", 8: "VTXO_STATUS_FAILED", } VTXOStatus_value = map[string]int32{ @@ -70,7 +70,7 @@ var ( "VTXO_STATUS_FORFEITING": 4, "VTXO_STATUS_FORFEITED": 5, "VTXO_STATUS_SPENT": 6, - "VTXO_STATUS_EXPIRING": 7, + "VTXO_STATUS_UNILATERAL_EXIT": 7, "VTXO_STATUS_FAILED": 8, } ) @@ -2109,7 +2109,7 @@ const file_indexer_proto_rawDesc = "" + "\x16VTXO_STATUS_FORFEITING\x10\x04\x12\x19\n" + "\x15VTXO_STATUS_FORFEITED\x10\x05\x12\x15\n" + "\x11VTXO_STATUS_SPENT\x10\x06\x12\x18\n" + - "\x14VTXO_STATUS_EXPIRING\x10\a\x12\x16\n" + + "\x14VTXO_STATUS_UNILATERAL_EXIT\x10\a\x12\x16\n" + "\x12VTXO_STATUS_FAILED\x10\b*\x91\x01\n" + "\rVTXOEventType\x12\x1f\n" + "\x1bVTXO_EVENT_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n" + diff --git a/arkrpc/indexer.proto b/arkrpc/indexer.proto index 1b2c5e37f..f045fea6d 100644 --- a/arkrpc/indexer.proto +++ b/arkrpc/indexer.proto @@ -217,9 +217,9 @@ enum VTXOStatus { // transaction (terminal). VTXO_STATUS_SPENT = 6; - // VTXO_STATUS_EXPIRING indicates the VTXO is close to expiry and has been - // escalated to chain resolution (terminal for the VTXO actor). - VTXO_STATUS_EXPIRING = 7; + // VTXO_STATUS_UNILATERAL_EXIT indicates the VTXO has reached critical expiry + // and is being resolved on-chain (terminal for the VTXO actor). + VTXO_STATUS_UNILATERAL_EXIT = 7; // VTXO_STATUS_FAILED indicates an unrecoverable error (terminal). VTXO_STATUS_FAILED = 8; diff --git a/daemonrpc/daemon.pb.go b/daemonrpc/daemon.pb.go index 0f3c8e358..a1c987b93 100644 --- a/daemonrpc/daemon.pb.go +++ b/daemonrpc/daemon.pb.go @@ -42,9 +42,9 @@ const ( // VTXO_STATUS_SPENT is terminal: the VTXO was spent via an // out-of-round transfer. VTXOStatus_VTXO_STATUS_SPENT VTXOStatus = 5 - // VTXO_STATUS_EXPIRING is terminal: the VTXO is being resolved - // on-chain via the unilateral exit path. - VTXOStatus_VTXO_STATUS_EXPIRING VTXOStatus = 6 + // VTXO_STATUS_UNILATERAL_EXIT is terminal: the VTXO has reached critical + // expiry and is being resolved on-chain. + VTXOStatus_VTXO_STATUS_UNILATERAL_EXIT VTXOStatus = 6 // VTXO_STATUS_FAILED is terminal: an unrecoverable error occurred. VTXOStatus_VTXO_STATUS_FAILED VTXOStatus = 7 ) @@ -58,7 +58,7 @@ var ( 3: "VTXO_STATUS_FORFEITING", 4: "VTXO_STATUS_FORFEITED", 5: "VTXO_STATUS_SPENT", - 6: "VTXO_STATUS_EXPIRING", + 6: "VTXO_STATUS_UNILATERAL_EXIT", 7: "VTXO_STATUS_FAILED", } VTXOStatus_value = map[string]int32{ @@ -68,7 +68,7 @@ var ( "VTXO_STATUS_FORFEITING": 3, "VTXO_STATUS_FORFEITED": 4, "VTXO_STATUS_SPENT": 5, - "VTXO_STATUS_EXPIRING": 6, + "VTXO_STATUS_UNILATERAL_EXIT": 6, "VTXO_STATUS_FAILED": 7, } ) @@ -2188,7 +2188,7 @@ const file_daemonrpc_daemon_proto_rawDesc = "" + "\x16VTXO_STATUS_FORFEITING\x10\x03\x12\x19\n" + "\x15VTXO_STATUS_FORFEITED\x10\x04\x12\x15\n" + "\x11VTXO_STATUS_SPENT\x10\x05\x12\x18\n" + - "\x14VTXO_STATUS_EXPIRING\x10\x06\x12\x16\n" + + "\x14VTXO_STATUS_UNILATERAL_EXIT\x10\x06\x12\x16\n" + "\x12VTXO_STATUS_FAILED\x10\a*\xd7\x03\n" + "\n" + "RoundState\x12\x17\n" + diff --git a/daemonrpc/daemon.proto b/daemonrpc/daemon.proto index 4c702e484..dc5441246 100644 --- a/daemonrpc/daemon.proto +++ b/daemonrpc/daemon.proto @@ -208,9 +208,9 @@ enum VTXOStatus { // out-of-round transfer. VTXO_STATUS_SPENT = 5; - // VTXO_STATUS_EXPIRING is terminal: the VTXO is being resolved - // on-chain via the unilateral exit path. - VTXO_STATUS_EXPIRING = 6; + // VTXO_STATUS_UNILATERAL_EXIT is terminal: the VTXO has reached critical + // expiry and is being resolved on-chain. + VTXO_STATUS_UNILATERAL_EXIT = 6; // VTXO_STATUS_FAILED is terminal: an unrecoverable error occurred. VTXO_STATUS_FAILED = 7; diff --git a/darepod/rpc_server.go b/darepod/rpc_server.go index 8f3a41730..81769b549 100644 --- a/darepod/rpc_server.go +++ b/darepod/rpc_server.go @@ -290,8 +290,8 @@ func protoStatusToDomain( case daemonrpc.VTXOStatus_VTXO_STATUS_SPENT: return vtxo.VTXOStatusSpent, nil - case daemonrpc.VTXOStatus_VTXO_STATUS_EXPIRING: - return vtxo.VTXOStatusExpiring, nil + case daemonrpc.VTXOStatus_VTXO_STATUS_UNILATERAL_EXIT: + return vtxo.VTXOStatusUnilateralExit, nil case daemonrpc.VTXOStatus_VTXO_STATUS_FAILED: return vtxo.VTXOStatusFailed, nil @@ -319,8 +319,8 @@ func vtxoStatusToProto(s vtxo.VTXOStatus) daemonrpc.VTXOStatus { case vtxo.VTXOStatusSpent: return daemonrpc.VTXOStatus_VTXO_STATUS_SPENT - case vtxo.VTXOStatusExpiring: - return daemonrpc.VTXOStatus_VTXO_STATUS_EXPIRING + case vtxo.VTXOStatusUnilateralExit: + return daemonrpc.VTXOStatus_VTXO_STATUS_UNILATERAL_EXIT case vtxo.VTXOStatusFailed: return daemonrpc.VTXOStatus_VTXO_STATUS_FAILED diff --git a/vtxo/actor.go b/vtxo/actor.go index 53e82c491..f865a1a5f 100644 --- a/vtxo/actor.go +++ b/vtxo/actor.go @@ -481,8 +481,8 @@ func statusToState( case VTXOStatusForfeited: return &ForfeitedState{VTXO: vtxo, NewRoundID: vtxo.RoundID} - case VTXOStatusExpiring: - return &ExpiringState{ + case VTXOStatusUnilateralExit: + return &UnilateralExitState{ VTXO: vtxo, Reason: "recovered from storage", } diff --git a/vtxo/actor_test.go b/vtxo/actor_test.go index 058e08272..713bf50c0 100644 --- a/vtxo/actor_test.go +++ b/vtxo/actor_test.go @@ -253,7 +253,7 @@ func TestProcessOutboxExpiringNotification(t *testing.T) { ChainParams: &chaincfg.RegressionNetParams, ChainResolver: chainResolver, }, - state: &ExpiringState{VTXO: vtxo}, + state: &UnilateralExitState{VTXO: vtxo}, env: h.env, } diff --git a/vtxo/interfaces.go b/vtxo/interfaces.go index c85e7ddd6..60fe0b7bc 100644 --- a/vtxo/interfaces.go +++ b/vtxo/interfaces.go @@ -229,9 +229,10 @@ const ( // (OOR) transaction (terminal). VTXOStatusSpent - // VTXOStatusExpiring indicates the VTXO is critically close to expiry - // and has been sent to the chain resolver (terminal for this actor). - VTXOStatusExpiring + // VTXOStatusUnilateralExit indicates the VTXO has reached critical + // expiry and been sent to the chain resolver for on-chain exit + // (terminal for this actor). + VTXOStatusUnilateralExit // VTXOStatusFailed indicates an unrecoverable error (terminal). VTXOStatusFailed @@ -250,8 +251,8 @@ func (s VTXOStatus) String() string { return "forfeited" case VTXOStatusSpent: return "spent" - case VTXOStatusExpiring: - return "expiring" + case VTXOStatusUnilateralExit: + return "unilateral_exit" case VTXOStatusFailed: return "failed" default: diff --git a/vtxo/states.go b/vtxo/states.go index 1a5c1cac8..f0a8534b6 100644 --- a/vtxo/states.go +++ b/vtxo/states.go @@ -128,27 +128,28 @@ func (s *ForfeitedState) IsTerminal() bool { func (s *ForfeitedState) vtxoStateSealed() {} -// ExpiringState is a terminal state indicating the VTXO is critically close to -// expiry and has been sent to the chain resolver for unilateral exit handling. -type ExpiringState struct { +// UnilateralExitState is a terminal state indicating the VTXO has reached +// critical expiry and has been sent to the chain resolver for unilateral +// on-chain exit handling. The chain resolver takes over from this point. +type UnilateralExitState struct { // VTXO is the descriptor for this VTXO. VTXO *Descriptor - // Reason explains why the VTXO is expiring. + // Reason explains why the VTXO is being unilaterally exited. Reason string } // String returns a human-readable state name. -func (s *ExpiringState) String() string { - return "Expiring" +func (s *UnilateralExitState) String() string { + return "UnilateralExit" } -// IsTerminal returns true since ExpiringState is a terminal state. -func (s *ExpiringState) IsTerminal() bool { +// IsTerminal returns true since UnilateralExitState is a terminal state. +func (s *UnilateralExitState) IsTerminal() bool { return true } -func (s *ExpiringState) vtxoStateSealed() {} +func (s *UnilateralExitState) vtxoStateSealed() {} // FailedState is a terminal state indicating an unrecoverable error occurred. type FailedState struct { diff --git a/vtxo/transitions.go b/vtxo/transitions.go index b11826a74..98000d209 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -104,17 +104,17 @@ func (s *LiveState) handleBlockEpoch( }, &VTXOStatusUpdate{ Outpoint: s.VTXO.Outpoint, - NewStatus: VTXOStatusExpiring, + NewStatus: VTXOStatusUnilateralExit, }, &VTXOTerminatedNotification{ VTXOOutpoint: s.VTXO.Outpoint, - FinalState: "Expiring", + FinalState: "UnilateralExit", Reason: "sent to chain resolver", }, } return &VTXOStateTransition{ - NextState: &ExpiringState{VTXO: s.VTXO, Reason: reason}, + NextState: &UnilateralExitState{VTXO: s.VTXO, Reason: reason}, NewEvents: fn.Some(VTXOEmittedEvent{Outbox: outbox}), }, nil @@ -335,17 +335,17 @@ func (s *RefreshRequestedState) ProcessEvent( }, &VTXOStatusUpdate{ Outpoint: s.VTXO.Outpoint, - NewStatus: VTXOStatusExpiring, + NewStatus: VTXOStatusUnilateralExit, }, &VTXOTerminatedNotification{ VTXOOutpoint: s.VTXO.Outpoint, - FinalState: "Expiring", + FinalState: "UnilateralExit", Reason: "refresh timeout", }, } return &VTXOStateTransition{ - NextState: &ExpiringState{ + NextState: &UnilateralExitState{ VTXO: s.VTXO, Reason: "critical expiry in refresh", }, @@ -503,17 +503,17 @@ func (s *ForfeitingState) ProcessEvent( }, &VTXOStatusUpdate{ Outpoint: s.VTXO.Outpoint, - NewStatus: VTXOStatusExpiring, + NewStatus: VTXOStatusUnilateralExit, }, &VTXOTerminatedNotification{ VTXOOutpoint: s.VTXO.Outpoint, - FinalState: "Expiring", + FinalState: "UnilateralExit", Reason: "forfeit timeout", }, } return &VTXOStateTransition{ - NextState: &ExpiringState{ + NextState: &UnilateralExitState{ VTXO: s.VTXO, Reason: "critical expiry in forfeit", }, @@ -562,9 +562,9 @@ func (s *ForfeitedState) ProcessEvent( }, nil } -// ProcessEvent for ExpiringState. This is a terminal state, so all events +// ProcessEvent for UnilateralExitState. This is a terminal state, so all events // result in staying in the same state. -func (s *ExpiringState) ProcessEvent( +func (s *UnilateralExitState) ProcessEvent( _ context.Context, _ VTXOEvent, _ *VTXOEnvironment, ) (*VTXOStateTransition, error) { diff --git a/vtxo/transitions_test.go b/vtxo/transitions_test.go index 1b0b967ae..5cecafe64 100644 --- a/vtxo/transitions_test.go +++ b/vtxo/transitions_test.go @@ -46,8 +46,8 @@ func TestStateProperties(t *testing.T) { isTerminal: true, }, { - name: "ExpiringState", - state: &ExpiringState{VTXO: vtxo}, + name: "UnilateralExitState", + state: &UnilateralExitState{VTXO: vtxo}, isTerminal: true, }, { @@ -131,7 +131,7 @@ func TestLiveStateBlockEpochNeedsRefresh(t *testing.T) { } // TestLiveStateBlockEpochCritical verifies that LiveState transitions to -// ExpiringState when critically close to expiry. +// UnilateralExitState when critically close to expiry. func TestLiveStateBlockEpochCritical(t *testing.T) { t.Parallel() @@ -159,13 +159,13 @@ func TestLiveStateBlockEpochCritical(t *testing.T) { // Setup mock for status update. h.store.On( "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, - VTXOStatusExpiring, + VTXOStatusUnilateralExit, ).Return(nil) _, err := h.sendEvent(evt) require.NoError(t, err) - assertState[*ExpiringState](h) + assertState[*UnilateralExitState](h) // Should emit ExpiringNotification (pointer type). assertOutboxContains[*ExpiringNotification](h) @@ -243,7 +243,7 @@ func TestForfeitRequestFromRefreshRequested(t *testing.T) { } // TestRefreshRequestedCriticalExpiry verifies that RefreshRequestedState -// transitions to ExpiringState if expiry becomes critical while waiting. +// transitions to UnilateralExitState if expiry becomes critical while waiting. func TestRefreshRequestedCriticalExpiry(t *testing.T) { t.Parallel() @@ -267,13 +267,13 @@ func TestRefreshRequestedCriticalExpiry(t *testing.T) { // Setup mock for status update. h.store.On( - "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, VTXOStatusExpiring, + "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, VTXOStatusUnilateralExit, ).Return(nil) _, err := h.sendEvent(evt) require.NoError(t, err) - assertState[*ExpiringState](h) + assertState[*UnilateralExitState](h) } // TestForfeitingStateConfirmed verifies that ForfeitingState transitions to @@ -323,7 +323,7 @@ func TestTerminalStatesSelfLoop(t *testing.T) { terminalStates := []VTXOState{ &ForfeitedState{VTXO: vtxo}, - &ExpiringState{VTXO: vtxo}, + &UnilateralExitState{VTXO: vtxo}, &FailedState{VTXO: vtxo, Reason: "test"}, } @@ -499,7 +499,7 @@ func TestForfeitRequestRealSigning(t *testing.T) { } // TestForfeitingStateCriticalExpiry verifies that ForfeitingState transitions -// to ExpiringState if critical expiry is reached while waiting for forfeit +// to UnilateralExitState if critical expiry is reached while waiting for forfeit // confirmation. func TestForfeitingStateCriticalExpiry(t *testing.T) { t.Parallel() @@ -529,13 +529,13 @@ func TestForfeitingStateCriticalExpiry(t *testing.T) { // Setup mock for status update. h.store.On( - "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, VTXOStatusExpiring, + "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, VTXOStatusUnilateralExit, ).Return(nil) _, err := h.sendEvent(evt) require.NoError(t, err) - assertState[*ExpiringState](h) + assertState[*UnilateralExitState](h) // Should emit ExpiringNotification and VTXOTerminatedNotification. assertOutboxContains[*ExpiringNotification](h) From 9731e0f7c36b00ef394141efa642f828c23d5eca Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 11:56:56 +0200 Subject: [PATCH 02/19] vtxo: replace RefreshRequestedState with PendingForfeitState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- arkrpc/indexer.pb.go | 12 ++++++------ arkrpc/indexer.proto | 6 +++--- daemonrpc/daemon.pb.go | 12 ++++++------ daemonrpc/daemon.proto | 6 +++--- darepod/rpc_server.go | 8 ++++---- db/vtxo_store_test.go | 10 +++++----- vtxo/actor.go | 4 ++-- vtxo/actor_test.go | 6 +++--- vtxo/interfaces.go | 19 +++++++++--------- vtxo/states.go | 25 ++++++++++++++---------- vtxo/transitions.go | 42 +++++++++++++++++++++------------------- vtxo/transitions_test.go | 22 ++++++++++----------- 12 files changed, 90 insertions(+), 82 deletions(-) diff --git a/arkrpc/indexer.pb.go b/arkrpc/indexer.pb.go index b9f4180b4..8021be391 100644 --- a/arkrpc/indexer.pb.go +++ b/arkrpc/indexer.pb.go @@ -31,9 +31,9 @@ const ( VTXOStatus_VTXO_STATUS_UNCONFIRMED VTXOStatus = 1 // VTXO_STATUS_LIVE indicates the VTXO is active and can be spent. VTXOStatus_VTXO_STATUS_LIVE VTXOStatus = 2 - // VTXO_STATUS_REFRESH_REQUESTED indicates a refresh has been requested - // but not yet completed via a new round. - VTXOStatus_VTXO_STATUS_REFRESH_REQUESTED VTXOStatus = 3 + // VTXO_STATUS_PENDING_FORFEIT indicates the VTXO has been committed + // to cooperative consumption and is awaiting forfeit details. + VTXOStatus_VTXO_STATUS_PENDING_FORFEIT VTXOStatus = 3 // VTXO_STATUS_FORFEITING indicates the VTXO is being forfeited in a // round. VTXOStatus_VTXO_STATUS_FORFEITING VTXOStatus = 4 @@ -55,7 +55,7 @@ var ( 0: "VTXO_STATUS_UNSPECIFIED", 1: "VTXO_STATUS_UNCONFIRMED", 2: "VTXO_STATUS_LIVE", - 3: "VTXO_STATUS_REFRESH_REQUESTED", + 3: "VTXO_STATUS_PENDING_FORFEIT", 4: "VTXO_STATUS_FORFEITING", 5: "VTXO_STATUS_FORFEITED", 6: "VTXO_STATUS_SPENT", @@ -66,7 +66,7 @@ var ( "VTXO_STATUS_UNSPECIFIED": 0, "VTXO_STATUS_UNCONFIRMED": 1, "VTXO_STATUS_LIVE": 2, - "VTXO_STATUS_REFRESH_REQUESTED": 3, + "VTXO_STATUS_PENDING_FORFEIT": 3, "VTXO_STATUS_FORFEITING": 4, "VTXO_STATUS_FORFEITED": 5, "VTXO_STATUS_SPENT": 6, @@ -2105,7 +2105,7 @@ const file_indexer_proto_rawDesc = "" + "\x17VTXO_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17VTXO_STATUS_UNCONFIRMED\x10\x01\x12\x14\n" + "\x10VTXO_STATUS_LIVE\x10\x02\x12!\n" + - "\x1dVTXO_STATUS_REFRESH_REQUESTED\x10\x03\x12\x1a\n" + + "\x1dVTXO_STATUS_PENDING_FORFEIT\x10\x03\x12\x1a\n" + "\x16VTXO_STATUS_FORFEITING\x10\x04\x12\x19\n" + "\x15VTXO_STATUS_FORFEITED\x10\x05\x12\x15\n" + "\x11VTXO_STATUS_SPENT\x10\x06\x12\x18\n" + diff --git a/arkrpc/indexer.proto b/arkrpc/indexer.proto index f045fea6d..d871a3dac 100644 --- a/arkrpc/indexer.proto +++ b/arkrpc/indexer.proto @@ -202,9 +202,9 @@ enum VTXOStatus { // VTXO_STATUS_LIVE indicates the VTXO is active and can be spent. VTXO_STATUS_LIVE = 2; - // VTXO_STATUS_REFRESH_REQUESTED indicates a refresh has been requested - // but not yet completed via a new round. - VTXO_STATUS_REFRESH_REQUESTED = 3; + // VTXO_STATUS_PENDING_FORFEIT indicates the VTXO has been committed + // to cooperative consumption and is awaiting forfeit details. + VTXO_STATUS_PENDING_FORFEIT = 3; // VTXO_STATUS_FORFEITING indicates the VTXO is being forfeited in a // round. diff --git a/daemonrpc/daemon.pb.go b/daemonrpc/daemon.pb.go index a1c987b93..863d30dc2 100644 --- a/daemonrpc/daemon.pb.go +++ b/daemonrpc/daemon.pb.go @@ -30,9 +30,9 @@ const ( VTXOStatus_VTXO_STATUS_UNSPECIFIED VTXOStatus = 0 // VTXO_STATUS_LIVE indicates the VTXO is active and spendable. VTXOStatus_VTXO_STATUS_LIVE VTXOStatus = 1 - // VTXO_STATUS_REFRESH_REQUESTED indicates a refresh has been queued - // but the forfeit transaction has not yet been signed. - VTXOStatus_VTXO_STATUS_REFRESH_REQUESTED VTXOStatus = 2 + // VTXO_STATUS_PENDING_FORFEIT indicates the VTXO has been committed + // to cooperative consumption and is awaiting forfeit details. + VTXOStatus_VTXO_STATUS_PENDING_FORFEIT VTXOStatus = 2 // VTXO_STATUS_FORFEITING indicates the forfeit transaction has been // signed and the VTXO is awaiting confirmation of the new round. VTXOStatus_VTXO_STATUS_FORFEITING VTXOStatus = 3 @@ -54,7 +54,7 @@ var ( VTXOStatus_name = map[int32]string{ 0: "VTXO_STATUS_UNSPECIFIED", 1: "VTXO_STATUS_LIVE", - 2: "VTXO_STATUS_REFRESH_REQUESTED", + 2: "VTXO_STATUS_PENDING_FORFEIT", 3: "VTXO_STATUS_FORFEITING", 4: "VTXO_STATUS_FORFEITED", 5: "VTXO_STATUS_SPENT", @@ -64,7 +64,7 @@ var ( VTXOStatus_value = map[string]int32{ "VTXO_STATUS_UNSPECIFIED": 0, "VTXO_STATUS_LIVE": 1, - "VTXO_STATUS_REFRESH_REQUESTED": 2, + "VTXO_STATUS_PENDING_FORFEIT": 2, "VTXO_STATUS_FORFEITING": 3, "VTXO_STATUS_FORFEITED": 4, "VTXO_STATUS_SPENT": 5, @@ -2184,7 +2184,7 @@ const file_daemonrpc_daemon_proto_rawDesc = "" + "VTXOStatus\x12\x1b\n" + "\x17VTXO_STATUS_UNSPECIFIED\x10\x00\x12\x14\n" + "\x10VTXO_STATUS_LIVE\x10\x01\x12!\n" + - "\x1dVTXO_STATUS_REFRESH_REQUESTED\x10\x02\x12\x1a\n" + + "\x1dVTXO_STATUS_PENDING_FORFEIT\x10\x02\x12\x1a\n" + "\x16VTXO_STATUS_FORFEITING\x10\x03\x12\x19\n" + "\x15VTXO_STATUS_FORFEITED\x10\x04\x12\x15\n" + "\x11VTXO_STATUS_SPENT\x10\x05\x12\x18\n" + diff --git a/daemonrpc/daemon.proto b/daemonrpc/daemon.proto index dc5441246..7b0ab7f88 100644 --- a/daemonrpc/daemon.proto +++ b/daemonrpc/daemon.proto @@ -192,9 +192,9 @@ enum VTXOStatus { // VTXO_STATUS_LIVE indicates the VTXO is active and spendable. VTXO_STATUS_LIVE = 1; - // VTXO_STATUS_REFRESH_REQUESTED indicates a refresh has been queued - // but the forfeit transaction has not yet been signed. - VTXO_STATUS_REFRESH_REQUESTED = 2; + // VTXO_STATUS_PENDING_FORFEIT indicates the VTXO has been committed + // to cooperative consumption and is awaiting forfeit details. + VTXO_STATUS_PENDING_FORFEIT = 2; // VTXO_STATUS_FORFEITING indicates the forfeit transaction has been // signed and the VTXO is awaiting confirmation of the new round. diff --git a/darepod/rpc_server.go b/darepod/rpc_server.go index 81769b549..d4bcce03e 100644 --- a/darepod/rpc_server.go +++ b/darepod/rpc_server.go @@ -278,8 +278,8 @@ func protoStatusToDomain( case daemonrpc.VTXOStatus_VTXO_STATUS_LIVE: return vtxo.VTXOStatusLive, nil - case daemonrpc.VTXOStatus_VTXO_STATUS_REFRESH_REQUESTED: - return vtxo.VTXOStatusRefreshRequested, nil + case daemonrpc.VTXOStatus_VTXO_STATUS_PENDING_FORFEIT: + return vtxo.VTXOStatusPendingForfeit, nil case daemonrpc.VTXOStatus_VTXO_STATUS_FORFEITING: return vtxo.VTXOStatusForfeiting, nil @@ -307,8 +307,8 @@ func vtxoStatusToProto(s vtxo.VTXOStatus) daemonrpc.VTXOStatus { case vtxo.VTXOStatusLive: return daemonrpc.VTXOStatus_VTXO_STATUS_LIVE - case vtxo.VTXOStatusRefreshRequested: - return daemonrpc.VTXOStatus_VTXO_STATUS_REFRESH_REQUESTED + case vtxo.VTXOStatusPendingForfeit: + return daemonrpc.VTXOStatus_VTXO_STATUS_PENDING_FORFEIT case vtxo.VTXOStatusForfeiting: return daemonrpc.VTXOStatus_VTXO_STATUS_FORFEITING diff --git a/db/vtxo_store_test.go b/db/vtxo_store_test.go index 913497a3d..079f14eda 100644 --- a/db/vtxo_store_test.go +++ b/db/vtxo_store_test.go @@ -224,7 +224,7 @@ func TestVTXOPersistenceStoreListLiveVTXOs(t *testing.T) { // Mark vtxo3 as RefreshRequested (non-terminal, should still be live). err = vtxoStore.UpdateVTXOStatus( - ctx, vtxo3.Outpoint, vtxo.VTXOStatusRefreshRequested, + ctx, vtxo3.Outpoint, vtxo.VTXOStatusPendingForfeit, ) require.NoError(t, err) @@ -262,13 +262,13 @@ func TestVTXOPersistenceStoreStatusTransitions(t *testing.T) { // Transition to RefreshRequested. err = vtxoStore.UpdateVTXOStatus( - ctx, desc.Outpoint, vtxo.VTXOStatusRefreshRequested, + ctx, desc.Outpoint, vtxo.VTXOStatusPendingForfeit, ) require.NoError(t, err) fetched, err = vtxoStore.GetVTXO(ctx, desc.Outpoint) require.NoError(t, err) - require.Equal(t, vtxo.VTXOStatusRefreshRequested, fetched.Status) + require.Equal(t, vtxo.VTXOStatusPendingForfeit, fetched.Status) // Transition to Forfeiting via MarkForfeiting. forfeitRoundID := testRoundIDDB("forfeit-round") @@ -528,7 +528,7 @@ func TestVTXOPersistenceStoreMultipleVTXOsLifecycle(t *testing.T) { // VTXO 0: stays live (no changes). // VTXO 1: goes to RefreshRequested (still live). err = vtxoStore.UpdateVTXOStatus( - ctx, vtxos[1].Outpoint, vtxo.VTXOStatusRefreshRequested, + ctx, vtxos[1].Outpoint, vtxo.VTXOStatusPendingForfeit, ) require.NoError(t, err) @@ -577,7 +577,7 @@ func TestVTXOPersistenceStoreMultipleVTXOsLifecycle(t *testing.T) { fetched1, err := vtxoStore.GetVTXO(ctx, vtxos[1].Outpoint) require.NoError(t, err) - require.Equal(t, vtxo.VTXOStatusRefreshRequested, fetched1.Status) + require.Equal(t, vtxo.VTXOStatusPendingForfeit, fetched1.Status) fetched2, err := vtxoStore.GetVTXO(ctx, vtxos[2].Outpoint) require.NoError(t, err) diff --git a/vtxo/actor.go b/vtxo/actor.go index f865a1a5f..f82d44124 100644 --- a/vtxo/actor.go +++ b/vtxo/actor.go @@ -455,8 +455,8 @@ func statusToState( LastCheckedHeight: vtxo.CreatedHeight, } - case VTXOStatusRefreshRequested: - return &RefreshRequestedState{VTXO: vtxo, RequestedAtHeight: 0} + case VTXOStatusPendingForfeit: + return &PendingForfeitState{VTXO: vtxo, RequestedAtHeight: 0} case VTXOStatusForfeiting: // Fetch the persisted forfeit tx for crash recovery. diff --git a/vtxo/actor_test.go b/vtxo/actor_test.go index 713bf50c0..79aebd574 100644 --- a/vtxo/actor_test.go +++ b/vtxo/actor_test.go @@ -131,13 +131,13 @@ func TestProcessOutboxStatusUpdate(t *testing.T) { h.store.On( "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, - VTXOStatusRefreshRequested, + VTXOStatusPendingForfeit, ).Return(nil) outbox := []VTXOOutMsg{ &VTXOStatusUpdate{ Outpoint: vtxo.Outpoint, - NewStatus: VTXOStatusRefreshRequested, + NewStatus: VTXOStatusPendingForfeit, }, } @@ -145,7 +145,7 @@ func TestProcessOutboxStatusUpdate(t *testing.T) { h.store.AssertCalled( t, "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, - VTXOStatusRefreshRequested, + VTXOStatusPendingForfeit, ) } diff --git a/vtxo/interfaces.go b/vtxo/interfaces.go index 60fe0b7bc..e59dc2609 100644 --- a/vtxo/interfaces.go +++ b/vtxo/interfaces.go @@ -92,7 +92,7 @@ var MessageSpec = struct { // crossed. // // Source: Chain source → VTXO Manager → VTXO Actor - // Handled in: LiveState, RefreshRequestedState, ForfeitingState + // Handled in: LiveState, PendingForfeitState, ForfeitingState BlockEpochEvent InboundEvent[*BlockEpochEvent] // ForfeitRequestEvent is received from the round actor when this VTXO @@ -100,7 +100,7 @@ var MessageSpec = struct { // the forfeit transaction and submit it back to the round actor. // // Source: Round Actor → VTXO Actor - // Handled in: LiveState, RefreshRequestedState + // Handled in: LiveState, PendingForfeitState ForfeitRequestEvent InboundEvent[*round.ForfeitRequestEvent] // ForfeitConfirmedEvent is received from the round actor when the new @@ -142,7 +142,7 @@ var MessageSpec = struct { // combines this with the operator signature and broadcasts. // // Destination: VTXO Actor → Round Actor - // Emitted from: LiveState, RefreshRequestedState (on ForfeitRequest) + // Emitted from: LiveState, PendingForfeitState (on ForfeitRequest) ForfeitSignatureSubmission OutboundMsg[*ForfeitSignatureSubmission] // ExpiringNotification is sent to the chain resolver when the VTXO @@ -150,7 +150,7 @@ var MessageSpec = struct { // terminal transition for this actor. // // Destination: VTXO Actor → Chain Resolver - // Emitted from: LiveState, RefreshRequestedState, ForfeitingState + // Emitted from: LiveState, PendingForfeitState, ForfeitingState // (on ExpiryStatusCritical) ExpiringNotification OutboundMsg[*ExpiringNotification] @@ -213,9 +213,10 @@ const ( // VTXOStatusLive indicates the VTXO is active and can be spent. VTXOStatusLive VTXOStatus = iota - // VTXOStatusRefreshRequested indicates a refresh has been requested but - // not yet completed via a new round. - VTXOStatusRefreshRequested + // VTXOStatusPendingForfeit indicates the VTXO has been committed to + // cooperative consumption and is awaiting forfeit details from the + // round actor. + VTXOStatusPendingForfeit // VTXOStatusForfeiting indicates the VTXO is being forfeited in a // round. @@ -243,8 +244,8 @@ func (s VTXOStatus) String() string { switch s { case VTXOStatusLive: return "live" - case VTXOStatusRefreshRequested: - return "refresh_requested" + case VTXOStatusPendingForfeit: + return "pending_forfeit" case VTXOStatusForfeiting: return "forfeiting" case VTXOStatusForfeited: diff --git a/vtxo/states.go b/vtxo/states.go index f0a8534b6..e03742b6e 100644 --- a/vtxo/states.go +++ b/vtxo/states.go @@ -43,28 +43,33 @@ func (s *LiveState) IsTerminal() bool { func (s *LiveState) vtxoStateSealed() {} -// RefreshRequestedState indicates a forfeit request has been sent to the round -// actor to refresh this VTXO. The VTXO is waiting for acknowledgment or a -// forfeit request to begin the batch swap process. -type RefreshRequestedState struct { +// PendingForfeitState indicates the VTXO has been committed to cooperative +// consumption (forfeit) and is awaiting concrete forfeit details from the +// round actor. This state is reached when the VTXO needs to be forfeited — +// whether due to approaching expiry, a leave request, or an in-round send — +// but the round actor has not yet supplied the connector outpoint and +// forfeit parameters needed to build the forfeit transaction. +type PendingForfeitState struct { // VTXO is the descriptor for this VTXO. VTXO *Descriptor - // RequestedAtHeight is the block height when the refresh was requested. + // RequestedAtHeight is the block height when the forfeit was + // requested. Zero if triggered manually rather than by expiry. RequestedAtHeight int32 } // String returns a human-readable state name. -func (s *RefreshRequestedState) String() string { - return "RefreshRequested" +func (s *PendingForfeitState) String() string { + return "PendingForfeit" } -// IsTerminal returns false since RefreshRequestedState is not terminal. -func (s *RefreshRequestedState) IsTerminal() bool { +// IsTerminal returns false since PendingForfeitState is not terminal. +func (s *PendingForfeitState) IsTerminal() bool { return false } -func (s *RefreshRequestedState) vtxoStateSealed() {} +// vtxoStateSealed marks this as implementing the sealed VTXOState interface. +func (s *PendingForfeitState) vtxoStateSealed() {} // ForfeitingState indicates the VTXO is being forfeited in a round. The VTXO // actor is waiting for the new commitment transaction to confirm. diff --git a/vtxo/transitions.go b/vtxo/transitions.go index 98000d209..dc21e27cb 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -77,12 +77,12 @@ func (s *LiveState) handleBlockEpoch( }, &VTXOStatusUpdate{ Outpoint: s.VTXO.Outpoint, - NewStatus: VTXOStatusRefreshRequested, + NewStatus: VTXOStatusPendingForfeit, }, } return &VTXOStateTransition{ - NextState: &RefreshRequestedState{ + NextState: &PendingForfeitState{ VTXO: s.VTXO, RequestedAtHeight: evt.Height, }, @@ -201,12 +201,12 @@ func (s *LiveState) handleTriggerRefresh( }, &VTXOStatusUpdate{ Outpoint: s.VTXO.Outpoint, - NewStatus: VTXOStatusRefreshRequested, + NewStatus: VTXOStatusPendingForfeit, }, } return &VTXOStateTransition{ - NextState: &RefreshRequestedState{ + NextState: &PendingForfeitState{ VTXO: s.VTXO, // Manual trigger, no height context. RequestedAtHeight: 0, @@ -216,7 +216,7 @@ func (s *LiveState) handleTriggerRefresh( } // handleTriggerLeave handles a leave (offboard) request from the wallet. This -// transitions the VTXO to RefreshRequestedState and emits a LeaveRequest to +// transitions the VTXO to PendingForfeitState and emits a LeaveRequest to // the round actor. The leave flow reuses the forfeit mechanism: the VTXO is // forfeited and the value goes to an on-chain output instead of a new VTXO. func (s *LiveState) handleTriggerLeave( @@ -229,14 +229,14 @@ func (s *LiveState) handleTriggerLeave( }, &VTXOStatusUpdate{ Outpoint: s.VTXO.Outpoint, - NewStatus: VTXOStatusRefreshRequested, + NewStatus: VTXOStatusPendingForfeit, }, } - // Reuse RefreshRequestedState since the behavior is identical: wait for + // Reuse PendingForfeitState since the behavior is identical: wait for // ForfeitRequestEvent from the round actor, then sign the forfeit tx. return &VTXOStateTransition{ - NextState: &RefreshRequestedState{ + NextState: &PendingForfeitState{ VTXO: s.VTXO, // Manual trigger, no height context. RequestedAtHeight: 0, @@ -311,15 +311,17 @@ func signForfeitVTXOInput(vtxo *Descriptor, evt *ForfeitRequestEvent, return schnorrSig, nil } -// ProcessEvent handles events in RefreshRequestedState. The VTXO is waiting -// for acknowledgment or a forfeit request from the round actor. -func (s *RefreshRequestedState) ProcessEvent( +// ProcessEvent handles events in PendingForfeitState. The VTXO has been +// committed to cooperative consumption and is waiting for the round actor +// to supply forfeit details (connector outpoint, pkscript, etc.). +func (s *PendingForfeitState) ProcessEvent( ctx context.Context, event VTXOEvent, env *VTXOEnvironment, ) (*VTXOStateTransition, error) { switch evt := event.(type) { case *BlockEpochEvent: - // Check if we've hit critical expiry while waiting for refresh. + // Check if we've hit critical expiry while waiting for + // forfeit details. expiryStatus := env.ExpiryConfig.CheckExpiry(s.VTXO, evt.Height) if expiryStatus == ExpiryStatusCritical || @@ -331,7 +333,7 @@ func (s *RefreshRequestedState) ProcessEvent( &ExpiringNotification{ VTXO: s.VTXO, BlocksRemaining: blocksRemaining, - Reason: "refresh timeout", + Reason: "forfeit timeout", }, &VTXOStatusUpdate{ Outpoint: s.VTXO.Outpoint, @@ -340,14 +342,14 @@ func (s *RefreshRequestedState) ProcessEvent( &VTXOTerminatedNotification{ VTXOOutpoint: s.VTXO.Outpoint, FinalState: "UnilateralExit", - Reason: "refresh timeout", + Reason: "forfeit timeout", }, } return &VTXOStateTransition{ NextState: &UnilateralExitState{ VTXO: s.VTXO, - Reason: "critical expiry in refresh", + Reason: "critical expiry pending forfeit", }, NewEvents: fn.Some(VTXOEmittedEvent{ Outbox: outbox, @@ -405,15 +407,15 @@ func (s *RefreshRequestedState) ProcessEvent( }, nil case *RefreshAcknowledgedEvent: - // Round actor acknowledged but no forfeit request yet. - // Stay in RefreshRequestedState. + // Round actor acknowledged but no forfeit details yet. + // Stay in PendingForfeitState. return &VTXOStateTransition{ NextState: s, }, nil case *ResumeVTXOEvent: - // On resume, stay in this state. The round actor should have - // persisted the refresh request. + // On resume, stay in this state. The round actor should + // re-send forfeit details when it resumes. return &VTXOStateTransition{ NextState: s, }, nil @@ -430,7 +432,7 @@ func (s *RefreshRequestedState) ProcessEvent( default: return nil, fmt.Errorf( - "refresh_requested: unexpected event: %T", event, + "pending_forfeit: unexpected event: %T", event, ) } } diff --git a/vtxo/transitions_test.go b/vtxo/transitions_test.go index 5cecafe64..aff23a8f2 100644 --- a/vtxo/transitions_test.go +++ b/vtxo/transitions_test.go @@ -31,8 +31,8 @@ func TestStateProperties(t *testing.T) { isTerminal: false, }, { - name: "RefreshRequestedState", - state: &RefreshRequestedState{VTXO: vtxo}, + name: "PendingForfeitState", + state: &PendingForfeitState{VTXO: vtxo}, isTerminal: false, }, { @@ -90,7 +90,7 @@ func TestLiveStateBlockEpochSafe(t *testing.T) { } // TestLiveStateBlockEpochNeedsRefresh verifies that LiveState transitions to -// RefreshRequestedState when approaching expiry threshold. +// PendingForfeitState when approaching expiry threshold. func TestLiveStateBlockEpochNeedsRefresh(t *testing.T) { t.Parallel() @@ -118,13 +118,13 @@ func TestLiveStateBlockEpochNeedsRefresh(t *testing.T) { // Setup mock for status update. h.store.On( "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, - VTXOStatusRefreshRequested, + VTXOStatusPendingForfeit, ).Return(nil) _, err := h.sendEvent(evt) require.NoError(t, err) - assertState[*RefreshRequestedState](h) + assertState[*PendingForfeitState](h) // Should emit ForfeitRequest. assertOutboxContains[*ForfeitRequest](h) @@ -207,16 +207,16 @@ func TestForfeitRequestFromLiveState(t *testing.T) { require.Equal(t, connectorOutpoint, state.ConnectorOutpoint) } -// TestForfeitRequestFromRefreshRequested verifies that RefreshRequestedState +// TestForfeitRequestFromPendingForfeit verifies that PendingForfeitState // transitions to ForfeitingState on ForfeitRequest. -func TestForfeitRequestFromRefreshRequested(t *testing.T) { +func TestForfeitRequestFromPendingForfeit(t *testing.T) { t.Parallel() h := newVTXOTestHarness(t) vtxo := h.newTestDescriptor() h.setupMockWalletForSigning() - h.withState(&RefreshRequestedState{ + h.withState(&PendingForfeitState{ VTXO: vtxo, RequestedAtHeight: 800, }) @@ -242,9 +242,9 @@ func TestForfeitRequestFromRefreshRequested(t *testing.T) { require.Equal(t, "round-456", state.NewRoundID) } -// TestRefreshRequestedCriticalExpiry verifies that RefreshRequestedState +// TestPendingForfeitCriticalExpiry verifies that PendingForfeitState // transitions to UnilateralExitState if expiry becomes critical while waiting. -func TestRefreshRequestedCriticalExpiry(t *testing.T) { +func TestPendingForfeitCriticalExpiry(t *testing.T) { t.Parallel() h := newVTXOTestHarness(t) @@ -257,7 +257,7 @@ func TestRefreshRequestedCriticalExpiry(t *testing.T) { TreeDepthMultiplier: 1, }) - h.withState(&RefreshRequestedState{ + h.withState(&PendingForfeitState{ VTXO: vtxo, RequestedAtHeight: 800, }) From aae932fe77f82de1623802460325c4e229127aa4 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 12:02:42 +0200 Subject: [PATCH 03/19] vtxo: remove business-intent distinction from VTXO FSM 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) --- vtxo/actor.go | 33 ------------------------- vtxo/events.go | 8 +++--- vtxo/outbox_messages.go | 11 --------- vtxo/transitions.go | 55 ++++++++++++----------------------------- 4 files changed, 20 insertions(+), 87 deletions(-) diff --git a/vtxo/actor.go b/vtxo/actor.go index f82d44124..9c7334c21 100644 --- a/vtxo/actor.go +++ b/vtxo/actor.go @@ -258,39 +258,6 @@ func (a *VTXOActor) processOutbox(ctx context.Context, outbox []VTXOOutMsg) { ) } - case *LeaveRequest: - // Route leave request to round actor. This tells the - // round to forfeit this VTXO and include a leave output - // in the batch transaction (no new VTXO is created). - if a.cfg.RoundActor != nil { - vtxo := a.cfg.VTXO - leaveReq := &round.LeaveVTXORequest{ - VTXOOutpoint: vtxo.Outpoint, - Amount: int64(vtxo.Amount), - Output: m.DestOutput, - } - err := a.cfg.RoundActor.Tell(ctx, leaveReq) - if err != nil { - a.logger(ctx).WarnS( - ctx, "Failed to send leave "+ - "request to round", - err, - slog.String( - "outpoint", - vtxo.Outpoint.String(), - ), - ) - } - - a.logger(ctx).InfoS( - ctx, "Sent leave request to round", - slog.String( - "outpoint", vtxo.Outpoint.String(), - ), - slog.Int64("amount", int64(vtxo.Amount)), - ) - } - case *ForfeitSignatureSubmission: // Route forfeit signature to round actor. if a.cfg.RoundActor != nil { diff --git a/vtxo/events.go b/vtxo/events.go index f65daca69..f0d03eae4 100644 --- a/vtxo/events.go +++ b/vtxo/events.go @@ -41,11 +41,11 @@ type ( // state. ResumeVTXOEvent = round.ResumeVTXOEvent - // TriggerRefreshEvent is sent to manually trigger a refresh request. + // TriggerRefreshEvent is sent to manually trigger cooperative + // forfeiture. TriggerRefreshEvent = round.TriggerRefreshEvent - // TriggerLeaveEvent is sent to manually trigger a leave (offboard) - // request. The VTXO will be forfeited and the value sent to the - // specified destination output. + // TriggerLeaveEvent is sent to manually trigger a leave + // (offboard). TriggerLeaveEvent = round.TriggerLeaveEvent ) diff --git a/vtxo/outbox_messages.go b/vtxo/outbox_messages.go index 414fbd5a7..d2fd34384 100644 --- a/vtxo/outbox_messages.go +++ b/vtxo/outbox_messages.go @@ -136,14 +136,3 @@ type VTXOTerminatedNotification struct { func (m *VTXOTerminatedNotification) vtxoOutMsgSealed() {} -// LeaveRequest is sent to the round actor when a VTXO is being offboarded. The -// round actor should queue this VTXO for forfeiture and include the destination -// output in the batch transaction. -type LeaveRequest struct { - actor.BaseMessage - - // DestOutput is the on-chain output where the funds will be sent. - DestOutput *wire.TxOut -} - -func (m *LeaveRequest) vtxoOutMsgSealed() {} diff --git a/vtxo/transitions.go b/vtxo/transitions.go index dc21e27cb..d85b537ba 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -25,10 +25,16 @@ func (s *LiveState) ProcessEvent( return s.handleForfeitRequest(ctx, evt, env) case *TriggerRefreshEvent: - return s.handleTriggerRefresh(ctx, evt, env) + // External forfeit trigger (refresh or leave). Both product + // intents map to the same lifecycle action: commit to + // cooperative consumption. + return s.handleExternalForfeitTrigger(ctx, env) case *TriggerLeaveEvent: - return s.handleTriggerLeave(ctx, evt, env) + // Leave is a product-level concept. From the VTXO FSM's + // perspective, a leave is identical to a refresh: both + // result in forfeiting this VTXO cooperatively. + return s.handleExternalForfeitTrigger(ctx, env) case *ResumeVTXOEvent: // On resume, stay in LiveState and re-check expiry on next @@ -188,11 +194,13 @@ func (s *LiveState) handleForfeitRequest( }, nil } -// handleTriggerRefresh handles a manual refresh request from the wallet. This -// bypasses the automatic expiry-based refresh and immediately transitions the -// VTXO to RefreshRequested state, emitting a ForfeitRequest to the round actor. -func (s *LiveState) handleTriggerRefresh( - _ context.Context, _ *TriggerRefreshEvent, _ *VTXOEnvironment, +// handleExternalForfeitTrigger handles an external trigger to forfeit this +// VTXO (from wallet or round actor). The VTXO doesn't distinguish between +// refresh and leave — both are cooperative consumption from the FSM's +// perspective. The caller (round/wallet) handles the product-level +// distinction. +func (s *LiveState) handleExternalForfeitTrigger( + _ context.Context, _ *VTXOEnvironment, ) (*VTXOStateTransition, error) { outbox := []VTXOOutMsg{ @@ -207,38 +215,7 @@ func (s *LiveState) handleTriggerRefresh( return &VTXOStateTransition{ NextState: &PendingForfeitState{ - VTXO: s.VTXO, - // Manual trigger, no height context. - RequestedAtHeight: 0, - }, - NewEvents: fn.Some(VTXOEmittedEvent{Outbox: outbox}), - }, nil -} - -// handleTriggerLeave handles a leave (offboard) request from the wallet. This -// transitions the VTXO to PendingForfeitState and emits a LeaveRequest to -// the round actor. The leave flow reuses the forfeit mechanism: the VTXO is -// forfeited and the value goes to an on-chain output instead of a new VTXO. -func (s *LiveState) handleTriggerLeave( - _ context.Context, evt *TriggerLeaveEvent, _ *VTXOEnvironment, -) (*VTXOStateTransition, error) { - - outbox := []VTXOOutMsg{ - &LeaveRequest{ - DestOutput: evt.DestOutput, - }, - &VTXOStatusUpdate{ - Outpoint: s.VTXO.Outpoint, - NewStatus: VTXOStatusPendingForfeit, - }, - } - - // Reuse PendingForfeitState since the behavior is identical: wait for - // ForfeitRequestEvent from the round actor, then sign the forfeit tx. - return &VTXOStateTransition{ - NextState: &PendingForfeitState{ - VTXO: s.VTXO, - // Manual trigger, no height context. + VTXO: s.VTXO, RequestedAtHeight: 0, }, NewEvents: fn.Some(VTXOEmittedEvent{Outbox: outbox}), From e7caa0641050793d0353f3f9a1dfeca6923d97f9 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 12:17:29 +0200 Subject: [PATCH 04/19] vtxo: route round-bound signals through manager 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 --- vtxo/actor.go | 145 +++++++++++++++---------------------------- vtxo/actor_test.go | 44 +++++++------ vtxo/harness_test.go | 45 ++------------ vtxo/manager.go | 23 ++++++- vtxo/messages.go | 31 +++++++++ 5 files changed, 131 insertions(+), 157 deletions(-) diff --git a/vtxo/actor.go b/vtxo/actor.go index 9c7334c21..817abd2a8 100644 --- a/vtxo/actor.go +++ b/vtxo/actor.go @@ -42,14 +42,12 @@ type VTXOActorConfig struct { // LoggerFromContext, or uses btclog.Disabled if no logger is found. Log fn.Option[btclog.Logger] - // RoundActor receives refresh requests and forfeit signatures from this - // VTXO actor. Uses actormsg.RoundReceivable to avoid import cycles. - RoundActor actor.TellOnlyRef[actormsg.RoundReceivable] - // ChainResolver receives expiring notifications for unilateral exit. ChainResolver actor.TellOnlyRef[ExpiringNotification] - // Manager receives termination notifications for cleanup. + // Manager receives relay messages and termination notifications. + // The VTXO actor routes round-bound signals through the manager + // rather than holding a direct round actor reference. Manager actor.TellOnlyRef[ManagerMsg] } @@ -89,6 +87,20 @@ func (a *VTXOActor) logger(ctx context.Context) btclog.Logger { return a.cfg.Log.UnwrapOr(build.LoggerFromContext(ctx)) } +// tellManager sends a message to the manager. All outbound signals from +// the VTXO actor are routed through this single point. +func (a *VTXOActor) tellManager(ctx context.Context, msg ManagerMsg) { + if a.cfg.Manager == nil { + return + } + + if err := a.cfg.Manager.Tell(ctx, msg); err != nil { + a.logger(ctx).WarnS(ctx, "Failed to tell manager", err, + slog.String("msg_type", fmt.Sprintf("%T", msg)), + slog.String("outpoint", a.cfg.VTXO.Outpoint.String())) + } +} + // Start initializes the actor and subscribes to block epochs. func (a *VTXOActor) Start(ctx context.Context, selfRef actor.TellOnlyRef[actormsg.VTXOActorMsg]) error { @@ -223,73 +235,39 @@ func (a *VTXOActor) processOutbox(ctx context.Context, outbox []VTXOOutMsg) { } case *ForfeitRequest: - // Route forfeit request to round actor. This tells - // the round to include this VTXO in the forfeit flow. - // The round FSM's RefreshVTXORequest handler creates - // the corresponding VTXORequest for the new output via - // buildVTXORequestFromRefresh, so we only need to send - // the refresh request here. - if a.cfg.RoundActor != nil { - vtxo := a.cfg.VTXO - - // Send the refresh request for forfeit flow. - // The FSM adds both the forfeit input and the - // new VTXO output from this single message. - refreshReq := &round.RefreshVTXORequest{ - VTXOOutpoint: m.VTXOOutpoint, - Amount: int64(vtxo.Amount), - NewVTXOKey: vtxo.ClientKey.PubKey, - PkScript: vtxo.PkScript, - OperatorKey: vtxo.OperatorKey, - Expiry: vtxo.RelativeExpiry, - SigningKey: vtxo.ClientKey, - } - if err := a.cfg.RoundActor.Tell( - ctx, refreshReq, - ); err != nil { - a.logger(ctx).WarnS( - ctx, "Failed to tell refresh", - err) - } - - a.logger(ctx).InfoS( - ctx, "Sent refresh request to round", - slog.String("outpoint", m.VTXOOutpoint.String()), - ) + // Relay forfeit request through the manager. The + // manager forwards it to the round actor. We build + // the round-specific message here since the VTXO + // actor has the descriptor data needed. + vtxo := a.cfg.VTXO + refreshReq := &round.RefreshVTXORequest{ + VTXOOutpoint: m.VTXOOutpoint, + Amount: int64(vtxo.Amount), + NewVTXOKey: vtxo.ClientKey.PubKey, + PkScript: vtxo.PkScript, + OperatorKey: vtxo.OperatorKey, + Expiry: vtxo.RelativeExpiry, + SigningKey: vtxo.ClientKey, } + a.tellManager(ctx, &RelayToRoundMsg{ + Payload: refreshReq, + }) case *ForfeitSignatureSubmission: - // Route forfeit signature to round actor. - if a.cfg.RoundActor != nil { - resp := &round.ForfeitSignatureResponse{ - VTXOOutpoint: m.VTXOOutpoint, - RoundID: m.RoundID, - ForfeitTx: m.ForfeitTx, - Signature: m.Signature, - } - err := a.cfg.RoundActor.Tell(ctx, resp) - if err != nil { - a.logger(ctx).WarnS( - ctx, - "Failed to send forfeit sig", - err, - slog.String( - "outpoint", - m.VTXOOutpoint.String(), - )) - } - a.logger(ctx).InfoS( - ctx, "Sent forfeit signature", - slog.String( - "outpoint", - m.VTXOOutpoint.String(), - ), - slog.String("round_id", m.RoundID), - ) + // Relay forfeit signature through the manager. + resp := &round.ForfeitSignatureResponse{ + VTXOOutpoint: m.VTXOOutpoint, + RoundID: m.RoundID, + ForfeitTx: m.ForfeitTx, + Signature: m.Signature, } + a.tellManager(ctx, &RelayToRoundMsg{ + Payload: resp, + }) case *ExpiringNotification: - // Route to chain resolver for unilateral exit handling. + // Route directly to chain resolver for unilateral + // exit handling. if a.cfg.ChainResolver != nil { err := a.cfg.ChainResolver.Tell(ctx, *m) if err != nil { @@ -302,40 +280,15 @@ func (a *VTXOActor) processOutbox(ctx context.Context, outbox []VTXOOutMsg) { m.VTXO.Outpoint.String(), )) } - a.logger(ctx).InfoS( - ctx, - "VTXO sent to chain resolver", - slog.String( - "outpoint", - m.VTXO.Outpoint.String(), - ), - slog.Int( - "blocks_remaining", - int(m.BlocksRemaining), - ), - ) } case *VTXOTerminatedNotification: // Notify manager to remove this actor from tracking. - if a.cfg.Manager != nil { - err := a.cfg.Manager.Tell( - ctx, &VTXOTerminatedMsg{ - Outpoint: m.VTXOOutpoint, - FinalState: m.FinalState, - Reason: m.Reason, - }) - if err != nil { - a.logger(ctx).WarnS( - ctx, - "Failed to notify manager", - err, - slog.String( - "outpoint", - m.VTXOOutpoint.String(), - )) - } - } + a.tellManager(ctx, &VTXOTerminatedMsg{ + Outpoint: m.VTXOOutpoint, + FinalState: m.FinalState, + Reason: m.Reason, + }) } } } diff --git a/vtxo/actor_test.go b/vtxo/actor_test.go index 79aebd574..ca8ec90bc 100644 --- a/vtxo/actor_test.go +++ b/vtxo/actor_test.go @@ -13,21 +13,21 @@ import ( ) // TestProcessOutboxForfeitSignature verifies that ForfeitSignatureSubmission -// messages in the outbox are routed to the round actor. +// messages are relayed through the manager to the round actor. func TestProcessOutboxForfeitSignature(t *testing.T) { t.Parallel() h := newVTXOTestHarness(t) vtxo := h.newTestDescriptor() - roundActor := newMockRoundActorRef(t) + manager := newMockManagerRef(t) actor := &VTXOActor{ cfg: &VTXOActorConfig{ VTXO: vtxo, Store: h.store, Wallet: h.wallet, ChainParams: &chaincfg.RegressionNetParams, - RoundActor: roundActor, + Manager: manager, }, state: &LiveState{VTXO: vtxo}, env: h.env, @@ -54,12 +54,16 @@ func TestProcessOutboxForfeitSignature(t *testing.T) { actor.processOutbox(h.ctx, outbox) - msgs := roundActor.getMessages() + msgs := manager.getMessages() require.Len(t, msgs, 1) - resp, ok := msgs[0].(*round.ForfeitSignatureResponse) + relayMsg, ok := msgs[0].(*RelayToRoundMsg) + require.True(t, ok, "expected RelayToRoundMsg, got %T", msgs[0]) + + resp, ok := relayMsg.Payload.(*round.ForfeitSignatureResponse) require.True( - t, ok, "expected ForfeitSignatureResponse, got %T", msgs[0], + t, ok, "expected ForfeitSignatureResponse, got %T", + relayMsg.Payload, ) require.Equal(t, vtxo.Outpoint, resp.VTXOOutpoint) require.Equal(t, "round-123", resp.RoundID) @@ -149,25 +153,23 @@ func TestProcessOutboxStatusUpdate(t *testing.T) { ) } -// TestProcessOutboxForfeitRequest verifies that ForfeitRequest messages in the -// outbox are routed to the round actor as a single RefreshVTXORequest with -// the correct fields populated. The round FSM handles creating the -// corresponding VTXORequest for the new output via buildVTXORequestFromRefresh, -// so only one message is sent. +// TestProcessOutboxForfeitRequest verifies that ForfeitRequest messages are +// relayed through the manager as a RelayToRoundMsg containing a +// RefreshVTXORequest with the correct fields. func TestProcessOutboxForfeitRequest(t *testing.T) { t.Parallel() h := newVTXOTestHarness(t) vtxo := h.newTestDescriptor() - roundActor := newMockRoundActorRef(t) + manager := newMockManagerRef(t) actor := &VTXOActor{ cfg: &VTXOActorConfig{ VTXO: vtxo, Store: h.store, Wallet: h.wallet, ChainParams: &chaincfg.RegressionNetParams, - RoundActor: roundActor, + Manager: manager, }, state: &LiveState{VTXO: vtxo}, env: h.env, @@ -181,13 +183,17 @@ func TestProcessOutboxForfeitRequest(t *testing.T) { actor.processOutbox(h.ctx, outbox) - // Should send a single RefreshVTXORequest. The round FSM creates the - // VTXORequest for the new output internally. - msgs := roundActor.getMessages() + msgs := manager.getMessages() require.Len(t, msgs, 1) - refreshReq, ok := msgs[0].(*round.RefreshVTXORequest) - require.True(t, ok, "expected RefreshVTXORequest, got %T", msgs[0]) + relayMsg, ok := msgs[0].(*RelayToRoundMsg) + require.True(t, ok, "expected RelayToRoundMsg, got %T", msgs[0]) + + refreshReq, ok := relayMsg.Payload.(*round.RefreshVTXORequest) + require.True( + t, ok, "expected RefreshVTXORequest, got %T", + relayMsg.Payload, + ) require.Equal(t, vtxo.Outpoint, refreshReq.VTXOOutpoint) require.Equal(t, int64(vtxo.Amount), refreshReq.Amount) require.Equal(t, vtxo.ClientKey.PubKey, refreshReq.NewVTXOKey) @@ -238,7 +244,7 @@ func TestProcessOutboxTerminatedNotification(t *testing.T) { } // TestProcessOutboxExpiringNotification verifies that ExpiringNotification -// messages are routed to the chain resolver. +// messages are routed directly to the chain resolver. func TestProcessOutboxExpiringNotification(t *testing.T) { t.Parallel() diff --git a/vtxo/harness_test.go b/vtxo/harness_test.go index f1bcf1e1c..5f95e3518 100644 --- a/vtxo/harness_test.go +++ b/vtxo/harness_test.go @@ -15,7 +15,6 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" - "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/lib/scripts" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" @@ -691,46 +690,6 @@ func assertOutboxContainsReal[T VTXOOutMsg](h *realVTXOSigningHarness) T { return zero } -// mockRoundActorRef captures messages sent to the round actor for test -// verification. Implements actor.TellOnlyRef[actormsg.RoundReceivable]. -type mockRoundActorRef struct { - t *testing.T - messages []actormsg.RoundReceivable - mu sync.Mutex -} - -func newMockRoundActorRef(t *testing.T) *mockRoundActorRef { - return &mockRoundActorRef{ - t: t, - messages: make([]actormsg.RoundReceivable, 0), - } -} - -func (m *mockRoundActorRef) ID() string { - return "mock-round-actor" -} - -func (m *mockRoundActorRef) Tell( - _ context.Context, msg actormsg.RoundReceivable, -) error { - - m.mu.Lock() - defer m.mu.Unlock() - m.messages = append(m.messages, msg) - - return nil -} - -func (m *mockRoundActorRef) getMessages() []actormsg.RoundReceivable { - m.mu.Lock() - defer m.mu.Unlock() - - result := make([]actormsg.RoundReceivable, len(m.messages)) - copy(result, m.messages) - - return result -} - // mockManagerRef captures messages sent to the manager for test verification. type mockManagerRef struct { t *testing.T @@ -774,6 +733,7 @@ type mockChainResolverRef struct { mu sync.Mutex } +// newMockChainResolverRef creates a new mock chain resolver ref. func newMockChainResolverRef(t *testing.T) *mockChainResolverRef { return &mockChainResolverRef{ t: t, @@ -781,10 +741,12 @@ func newMockChainResolverRef(t *testing.T) *mockChainResolverRef { } } +// ID returns the mock actor ID. func (m *mockChainResolverRef) ID() string { return "mock-chain-resolver" } +// Tell captures the message for test verification. func (m *mockChainResolverRef) Tell( _ context.Context, msg ExpiringNotification, ) error { @@ -796,6 +758,7 @@ func (m *mockChainResolverRef) Tell( return nil } +// getMessages returns all captured messages. func (m *mockChainResolverRef) getMessages() []ExpiringNotification { m.mu.Lock() defer m.mu.Unlock() diff --git a/vtxo/manager.go b/vtxo/manager.go index 71aa5767a..5c8a9640a 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -136,6 +136,9 @@ func (m *Manager) Receive(ctx context.Context, case *round.VTXOTerminatedMsg: return m.handleVTXOTerminated(ctx, req) + case *RelayToRoundMsg: + return m.handleRelayToRound(ctx, req) + case *GetActiveVTXOCountRequest: return fn.Ok[ManagerResp](&GetActiveVTXOCountResponse{ Count: len(m.actors), @@ -219,6 +222,25 @@ func (m *Manager) handleVTXOTerminated(ctx context.Context, return fn.Ok[ManagerResp](&VTXOTerminatedResp{}) } +// handleRelayToRound forwards a VTXO actor's message to the round actor. +// The VTXO actor pre-builds the round-specific message (RefreshVTXORequest +// or ForfeitSignatureResponse) and wraps it in RelayToRoundMsg. The manager +// just unwraps and forwards. +func (m *Manager) handleRelayToRound(ctx context.Context, + msg *RelayToRoundMsg) fn.Result[ManagerResp] { + + if err := m.cfg.RoundActor.Tell(ctx, msg.Payload); err != nil { + m.logger(ctx).WarnS(ctx, "Failed to relay to round", err, + slog.String("payload_type", fmt.Sprintf("%T", msg.Payload))) + + return fn.Err[ManagerResp]( + fmt.Errorf("relay to round: %w", err), + ) + } + + return fn.Ok[ManagerResp](&RelayToRoundResp{}) +} + // spawnVTXOActor creates a new VTXO FSM actor. func (m *Manager) spawnVTXOActor(ctx context.Context, vtxo *Descriptor) (VTXOActorRef, error) { @@ -234,7 +256,6 @@ func (m *Manager) spawnVTXOActor(ctx context.Context, ChainParams: m.cfg.ChainParams, ExpiryConfig: m.cfg.ExpiryConfig, Log: m.cfg.Log, - RoundActor: m.cfg.RoundActor, ChainResolver: m.cfg.ChainResolver, Manager: m.managerRef, } diff --git a/vtxo/messages.go b/vtxo/messages.go index a27f3bff1..ab4c0289d 100644 --- a/vtxo/messages.go +++ b/vtxo/messages.go @@ -53,3 +53,34 @@ type GetActiveVTXOCountResponse struct { } func (r *GetActiveVTXOCountResponse) managerRespSealed() {} + +// ============================================================================= +// Relay messages: VTXO actor → Manager → external actor +// ============================================================================= +// +// The VTXO actor routes all outbound signals through the manager rather than +// holding direct references to the round actor or chain resolver. These relay +// messages carry pre-built payloads that the manager unwraps and forwards. + +// RelayToRoundMsg wraps a message that the manager should relay to the round +// actor. The payload is already in the round-receivable format so the manager +// just forwards it without transformation. +type RelayToRoundMsg struct { + actor.BaseMessage + + // Payload is the round-receivable message to relay. + Payload actormsg.RoundReceivable +} + +// VTXOManagerMsg implements actormsg.VTXOManagerMsg marker interface. +func (m *RelayToRoundMsg) VTXOManagerMsg() {} + +// MessageType returns the message type for logging. +func (m *RelayToRoundMsg) MessageType() string { return "RelayToRoundMsg" } + +// RelayToRoundResp is the response for RelayToRoundMsg. +type RelayToRoundResp struct{} + +// managerRespSealed implements the ManagerResp sealed interface. +func (r *RelayToRoundResp) managerRespSealed() {} + From 30ed97d3e207728bd9e36a7ff9bda2fcd6041cfe Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 12:21:00 +0200 Subject: [PATCH 05/19] vtxo: add manager-driven expiry liveness tests 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 --- vtxo/actor_test.go | 74 ++++++++++++++++++++++++++++++++++++++++++++ vtxo/harness_test.go | 45 +++++++++++++++++++++++++++ vtxo/manager.go | 8 +++++ 3 files changed, 127 insertions(+) diff --git a/vtxo/actor_test.go b/vtxo/actor_test.go index ca8ec90bc..1d44094a5 100644 --- a/vtxo/actor_test.go +++ b/vtxo/actor_test.go @@ -402,3 +402,77 @@ func TestManagerGetActiveVTXOCount(t *testing.T) { require.True(t, ok, "expected GetActiveVTXOCountResponse, got %T", resp) require.Equal(t, 3, countResp.Count) } + +// TestManagerRelayToRound verifies the manager forwards RelayToRoundMsg +// payloads to the round actor. This is the liveness path: when a VTXO +// actor detects approaching expiry and emits a ForfeitRequest, the +// manager must relay it promptly without requiring wallet intervention. +func TestManagerRelayToRound(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + roundActor := newMockRoundActorRef(t) + mgr := &Manager{ + cfg: &ManagerConfig{ + RoundActor: roundActor, + }, + actors: make(map[wire.OutPoint]VTXOActorRef), + } + + // Simulate the payload a VTXO actor would build when + // ExpiryStatusNeedsRefresh is detected. + refreshReq := &round.RefreshVTXORequest{ + VTXOOutpoint: wire.OutPoint{Index: 42}, + Amount: 50000, + } + + result := mgr.Receive(ctx, &RelayToRoundMsg{Payload: refreshReq}) + _, err := result.Unpack() + require.NoError(t, err) + + msgs := roundActor.getMessages() + require.Len(t, msgs, 1) + + relayed, ok := msgs[0].(*round.RefreshVTXORequest) + require.True( + t, ok, "expected RefreshVTXORequest, got %T", msgs[0], + ) + require.Equal(t, wire.OutPoint{Index: 42}, relayed.VTXOOutpoint) + require.Equal(t, int64(50000), relayed.Amount) +} + +// TestManagerRelayForfeitSig verifies the manager forwards forfeit +// signature submissions to the round actor. +func TestManagerRelayForfeitSig(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + roundActor := newMockRoundActorRef(t) + mgr := &Manager{ + cfg: &ManagerConfig{ + RoundActor: roundActor, + }, + actors: make(map[wire.OutPoint]VTXOActorRef), + } + + forfeitResp := &round.ForfeitSignatureResponse{ + VTXOOutpoint: wire.OutPoint{Index: 7}, + RoundID: "round-abc", + } + + result := mgr.Receive(ctx, &RelayToRoundMsg{Payload: forfeitResp}) + _, err := result.Unpack() + require.NoError(t, err) + + msgs := roundActor.getMessages() + require.Len(t, msgs, 1) + + relayed, ok := msgs[0].(*round.ForfeitSignatureResponse) + require.True( + t, ok, "expected ForfeitSignatureResponse, got %T", msgs[0], + ) + require.Equal(t, wire.OutPoint{Index: 7}, relayed.VTXOOutpoint) + require.Equal(t, "round-abc", relayed.RoundID) +} diff --git a/vtxo/harness_test.go b/vtxo/harness_test.go index 5f95e3518..cadfcd3bb 100644 --- a/vtxo/harness_test.go +++ b/vtxo/harness_test.go @@ -15,6 +15,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/lib/scripts" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" @@ -690,6 +691,50 @@ func assertOutboxContainsReal[T VTXOOutMsg](h *realVTXOSigningHarness) T { return zero } +// mockRoundActorRef captures messages sent to the round actor for test +// verification. Used by manager relay tests. +type mockRoundActorRef struct { + t *testing.T + messages []actormsg.RoundReceivable + mu sync.Mutex +} + +// newMockRoundActorRef creates a new mock round actor ref. +func newMockRoundActorRef(t *testing.T) *mockRoundActorRef { + return &mockRoundActorRef{ + t: t, + messages: make([]actormsg.RoundReceivable, 0), + } +} + +// ID returns the mock actor ID. +func (m *mockRoundActorRef) ID() string { + return "mock-round-actor" +} + +// Tell captures the message for test verification. +func (m *mockRoundActorRef) Tell( + _ context.Context, msg actormsg.RoundReceivable, +) error { + + m.mu.Lock() + defer m.mu.Unlock() + m.messages = append(m.messages, msg) + + return nil +} + +// getMessages returns all captured messages. +func (m *mockRoundActorRef) getMessages() []actormsg.RoundReceivable { + m.mu.Lock() + defer m.mu.Unlock() + + result := make([]actormsg.RoundReceivable, len(m.messages)) + copy(result, m.messages) + + return result +} + // mockManagerRef captures messages sent to the manager for test verification. type mockManagerRef struct { t *testing.T diff --git a/vtxo/manager.go b/vtxo/manager.go index 5c8a9640a..7a4cc37c3 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -226,6 +226,14 @@ func (m *Manager) handleVTXOTerminated(ctx context.Context, // The VTXO actor pre-builds the round-specific message (RefreshVTXORequest // or ForfeitSignatureResponse) and wraps it in RelayToRoundMsg. The manager // just unwraps and forwards. +// +// Liveness guarantee: when a VTXO approaches expiry, the VTXO actor +// autonomously emits a ForfeitRequest (wrapped in RelayToRoundMsg) without +// requiring wallet input. The manager relays this immediately, ensuring +// cooperative action is always attempted before critical expiry. This +// default policy means safety does not depend on wallet reaction time. +// PR 2 may add reservation checks here, but the default must always be +// to relay forfeit requests promptly. func (m *Manager) handleRelayToRound(ctx context.Context, msg *RelayToRoundMsg) fn.Result[ManagerResp] { From e1b737f26a8313cfef8a72ff60fef22233c5adfc Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 12:25:37 +0200 Subject: [PATCH 06/19] vtxo: remove RefreshAcknowledgedEvent 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 --- round/actor.go | 20 ++------------------ round/vtxo_messages.go | 18 ------------------ vtxo/events.go | 4 ---- vtxo/transitions.go | 7 ------- 4 files changed, 2 insertions(+), 47 deletions(-) diff --git a/round/actor.go b/round/actor.go index ec81bd5f1..d903f7f51 100644 --- a/round/actor.go +++ b/round/actor.go @@ -268,8 +268,8 @@ type RoundClientConfig struct { VTXOManager actor.TellOnlyRef[actor.Message] // ActorSystem enables direct communication with VTXO actors via service - // keys. Used to send ForfeitRequestEvent, RefreshAcknowledgedEvent, and - // ForfeitConfirmedEvent to specific VTXO actors. + // keys. Used to send ForfeitRequestEvent and ForfeitConfirmedEvent to + // specific VTXO actors. ActorSystem *actor.ActorSystem // DisableJoinRequestAuth skips BIP-322 join authorization @@ -1767,22 +1767,6 @@ func (a *RoundClientActor) handleRefreshVTXORequest(ctx context.Context, slog.String("outpoint", req.VTXOOutpoint.String()), slog.Int64("amount", req.Amount)) - // Send acknowledgment back to the VTXO actor using Router pattern. - // The RoundID is empty since we haven't assigned it to a round yet. - if a.cfg.ActorSystem != nil { - serviceKey := actormsg.VTXOActorServiceKey(req.VTXOOutpoint) - err := serviceKey.Ref(a.cfg.ActorSystem).Tell( - ctx, &RefreshAcknowledgedEvent{ - RoundID: "", - }, - ) - if err != nil { - a.log.WarnS(ctx, "Failed to send refresh ack to VTXO actor", - err, - slog.String("outpoint", req.VTXOOutpoint.String())) - } - } - return fn.Ok[actormsg.RoundActorResp](nil) } diff --git a/round/vtxo_messages.go b/round/vtxo_messages.go index 0a71481a2..cdcb85d5a 100644 --- a/round/vtxo_messages.go +++ b/round/vtxo_messages.go @@ -87,24 +87,6 @@ func (e *ForfeitRequestEvent) MessageType() string { return "ForfeitRequestEvent" } -// RefreshAcknowledgedEvent is received when the round actor acknowledges a -// refresh request. This indicates the VTXO has been queued for inclusion in -// the next round but the forfeit request hasn't been sent yet. -type RefreshAcknowledgedEvent struct { - actor.BaseMessage - - // RoundID is the round where the refresh will be processed. - RoundID string -} - -// VTXOActorMsg implements actormsg.VTXOActorMsg marker interface. -func (e *RefreshAcknowledgedEvent) VTXOActorMsg() {} - -// MessageType returns the message type for logging. -func (e *RefreshAcknowledgedEvent) MessageType() string { - return "RefreshAcknowledgedEvent" -} - // ForfeitConfirmedEvent indicates the new commitment transaction has been // confirmed on-chain, meaning the forfeit is final. The old VTXO is now // permanently forfeited and the new VTXO is live. diff --git a/vtxo/events.go b/vtxo/events.go index f0d03eae4..5319b7d98 100644 --- a/vtxo/events.go +++ b/vtxo/events.go @@ -22,10 +22,6 @@ type ( // is being forfeited as part of a batch swap. ForfeitRequestEvent = round.ForfeitRequestEvent - // RefreshAcknowledgedEvent is received when the round actor - // acknowledges a refresh request. - RefreshAcknowledgedEvent = round.RefreshAcknowledgedEvent - // ForfeitConfirmedEvent indicates the new commitment transaction has // been confirmed on-chain. ForfeitConfirmedEvent = round.ForfeitConfirmedEvent diff --git a/vtxo/transitions.go b/vtxo/transitions.go index d85b537ba..4559c9fa0 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -383,13 +383,6 @@ func (s *PendingForfeitState) ProcessEvent( }), }, nil - case *RefreshAcknowledgedEvent: - // Round actor acknowledged but no forfeit details yet. - // Stay in PendingForfeitState. - return &VTXOStateTransition{ - NextState: s, - }, nil - case *ResumeVTXOEvent: // On resume, stay in this state. The round actor should // re-send forfeit details when it resumes. From 17d65f1c2f0615f136b6dc9634533298d5226643 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 12:30:03 +0200 Subject: [PATCH 07/19] vtxo: clean up stale comments and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- round/vtxo_messages.go | 8 ++++---- vtxo/interfaces.go | 18 ++++++++++-------- vtxo/manager.go | 6 +++--- vtxo/outbox_messages.go | 4 ++-- vtxo/states.go | 10 ++++------ vtxo/transitions.go | 6 +++--- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/round/vtxo_messages.go b/round/vtxo_messages.go index cdcb85d5a..3e35e6874 100644 --- a/round/vtxo_messages.go +++ b/round/vtxo_messages.go @@ -163,10 +163,10 @@ func (e *ResumeVTXOEvent) VTXOActorMsg() {} // MessageType returns the message type for logging. func (e *ResumeVTXOEvent) MessageType() string { return "ResumeVTXOEvent" } -// TriggerRefreshEvent is sent to a VTXO actor to manually trigger a refresh -// request. This bypasses the automatic expiry-based refresh and immediately -// transitions the VTXO to RefreshRequested state. Used by the wallet actor -// when the user explicitly requests a refresh. +// TriggerRefreshEvent is sent to a VTXO actor to manually trigger cooperative +// forfeiture. The VTXO transitions to PendingForfeitState and emits a +// ForfeitRequest. Used by the wallet actor when the user explicitly requests +// a refresh. type TriggerRefreshEvent struct { actor.BaseMessage diff --git a/vtxo/interfaces.go b/vtxo/interfaces.go index e59dc2609..a840b1ed1 100644 --- a/vtxo/interfaces.go +++ b/vtxo/interfaces.go @@ -87,9 +87,9 @@ var MessageSpec = struct { // ----------------------------------------------------------------- // BlockEpochEvent is received from the chain source (routed via VTXO - // manager) when a new block is connected. Triggers expiry status checks - // and may cause state transitions if refresh or critical thresholds are - // crossed. + // manager) when a new block is connected. Triggers expiry checks and + // may cause transitions to PendingForfeit or UnilateralExit if expiry + // thresholds are crossed. // // Source: Chain source → VTXO Manager → VTXO Actor // Handled in: LiveState, PendingForfeitState, ForfeitingState @@ -100,7 +100,8 @@ var MessageSpec = struct { // the forfeit transaction and submit it back to the round actor. // // Source: Round Actor → VTXO Actor - // Handled in: LiveState, PendingForfeitState + // Handled in: LiveState, PendingForfeitState (signs and transitions + // to Forfeiting) ForfeitRequestEvent InboundEvent[*round.ForfeitRequestEvent] // ForfeitConfirmedEvent is received from the round actor when the new @@ -129,12 +130,13 @@ var MessageSpec = struct { // OUTBOUND MESSAGES (FSM → external actors) // ----------------------------------------------------------------- - // ForfeitRequest is sent to the round actor when the VTXO's expiry - // status crosses the refresh threshold. Requests inclusion in the next - // batch swap to extend the VTXO's lifetime. + // ForfeitRequest is sent when the VTXO's expiry status crosses + // the pending-forfeit threshold, requesting cooperative forfeiture + // in the next batch swap. // // Destination: VTXO Actor → Round Actor - // Emitted from: LiveState (on ExpiryStatusNeedsRefresh) + // Emitted from: LiveState (on ExpiryStatusNeedsRefresh or external + // forfeit trigger) ForfeitRequest OutboundMsg[*ForfeitRequest] // ForfeitSignatureSubmission is sent to the round actor with the diff --git a/vtxo/manager.go b/vtxo/manager.go index 7a4cc37c3..b140cbf7f 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -40,9 +40,9 @@ type ManagerConfig struct { // LoggerFromContext, or uses btclog.Disabled if no logger is found. Log fn.Option[btclog.Logger] - // RoundActor receives refresh requests and forfeit signatures from VTXOs. - // Passed through to spawned VTXO actors. Uses actormsg.RoundReceivable to - // avoid import cycles. + // RoundActor receives forfeit requests and signatures relayed from VTXO + // actors through the manager. Uses actormsg.RoundReceivable to avoid + // import cycles. RoundActor actor.TellOnlyRef[actormsg.RoundReceivable] // ChainResolver receives expiring notifications for unilateral exit. diff --git a/vtxo/outbox_messages.go b/vtxo/outbox_messages.go index d2fd34384..4f7812520 100644 --- a/vtxo/outbox_messages.go +++ b/vtxo/outbox_messages.go @@ -42,8 +42,8 @@ func (u RefreshUrgency) String() string { } } -// ForfeitRequest is sent to the round actor when a VTXO needs to be forfeited -// as part of a refresh or leave operation. +// ForfeitRequest is sent when a VTXO needs to be cooperatively forfeited. +// Routed through the manager to the round actor. type ForfeitRequest struct { actor.BaseMessage diff --git a/vtxo/states.go b/vtxo/states.go index e03742b6e..36dee2f4b 100644 --- a/vtxo/states.go +++ b/vtxo/states.go @@ -105,16 +105,14 @@ func (s *ForfeitingState) IsTerminal() bool { func (s *ForfeitingState) vtxoStateSealed() {} -// ForfeitedState is a terminal state indicating the VTXO has been forfeited. -// The forfeit may have occurred as part of a batch swap (refresh into a new -// round) or a leave request (withdrawal to an on-chain address). +// ForfeitedState is a terminal state indicating the VTXO has been forfeited +// cooperatively. The round actor determines the disposition of the forfeited +// value (new VTXO in a fresh round, or an on-chain withdrawal output). type ForfeitedState struct { // VTXO is the descriptor for this VTXO. VTXO *Descriptor - // NewRoundID is the round where the forfeit was processed. For batch - // swaps this contains the replacement VTXO; for leave requests it's - // the round that processed the withdrawal. + // NewRoundID is the round where the forfeit was processed. NewRoundID string // CommitmentTxID is the new commitment transaction that was confirmed. diff --git a/vtxo/transitions.go b/vtxo/transitions.go index 4559c9fa0..2f36ea717 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -59,7 +59,7 @@ func (s *LiveState) ProcessEvent( } // handleBlockEpoch processes a new block notification and checks if the VTXO -// needs to be refreshed or escalated. +// needs to be forfeited cooperatively or escalated to unilateral exit. func (s *LiveState) handleBlockEpoch( _ context.Context, evt *BlockEpochEvent, env *VTXOEnvironment, ) (*VTXOStateTransition, error) { @@ -76,7 +76,7 @@ func (s *LiveState) handleBlockEpoch( }, nil case ExpiryStatusNeedsRefresh: - // Request refresh before expiry becomes critical. + // Request cooperative forfeit before expiry becomes critical. outbox := []VTXOOutMsg{ &ForfeitRequest{ VTXOOutpoint: s.VTXO.Outpoint, @@ -130,7 +130,7 @@ func (s *LiveState) handleBlockEpoch( return &VTXOStateTransition{ NextState: &FailedState{ VTXO: s.VTXO, - Reason: "batch expired before refresh", + Reason: "batch expired before cooperative forfeit", Recoverable: false, }, }, nil From 9190028e216c5763187d64266c2663562ad38ae6 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 13:50:27 +0200 Subject: [PATCH 08/19] multi: replace trigger events with PendingForfeitEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- round/actor.go | 114 +++++++++++++++++++++++++++++---------- round/vtxo_messages.go | 39 ++++---------- vtxo/events.go | 11 ++-- vtxo/interfaces.go | 13 ++++- vtxo/manager.go | 7 +-- vtxo/messages.go | 1 - vtxo/outbox_messages.go | 1 - vtxo/transitions.go | 88 +++++++++++++++--------------- vtxo/transitions_test.go | 46 ++++++++++++++-- wallet/wallet.go | 11 ++-- 10 files changed, 204 insertions(+), 127 deletions(-) diff --git a/round/actor.go b/round/actor.go index d903f7f51..75a62d56d 100644 --- a/round/actor.go +++ b/round/actor.go @@ -130,6 +130,21 @@ func buildVTXORequestFromRefresh( } } +// buildRefreshVTXORequest converts a persisted client VTXO into the round +// actor's refresh request shape so wallet-triggered refreshes can reuse the +// same round assembly path as expiry-driven requests. +func buildRefreshVTXORequest(vtxo *ClientVTXO) *RefreshVTXORequest { + return &RefreshVTXORequest{ + VTXOOutpoint: vtxo.Outpoint, + Amount: int64(vtxo.Amount), + NewVTXOKey: vtxo.ClientKey.PubKey, + PkScript: vtxo.PkScript, + OperatorKey: vtxo.OperatorKey, + Expiry: vtxo.Expiry, + SigningKey: vtxo.ClientKey, + } +} + // makeTimeoutID builds a composite timeout ID from round ID and phase. func makeTimeoutID(roundID RoundID, phase TimeoutPhase) timeout.ID { return timeout.ID(fmt.Sprintf("%s:%s", roundID.String(), phase)) @@ -268,8 +283,8 @@ type RoundClientConfig struct { VTXOManager actor.TellOnlyRef[actor.Message] // ActorSystem enables direct communication with VTXO actors via service - // keys. Used to send ForfeitRequestEvent and ForfeitConfirmedEvent to - // specific VTXO actors. + // keys. Used to send PendingForfeitEvent, ForfeitRequestEvent, and + // ForfeitConfirmedEvent to specific VTXO actors. ActorSystem *actor.ActorSystem // DisableJoinRequestAuth skips BIP-322 join authorization @@ -1847,9 +1862,9 @@ func (a *RoundClientActor) handleForfeitSignatureResponse(ctx context.Context, } // handleTriggerVTXORefresh processes a refresh trigger request from the wallet -// actor. For each target outpoint, we send TriggerRefreshEvent to the VTXO -// actor via its service key. The VTXO actor then emits RefreshVTXORequest back -// to us through its outbox. +// actor. The round actor owns the refresh intent composition: it loads the +// VTXO descriptor, queues the round intents, and then marks the VTXO pending +// cooperative consumption. func (a *RoundClientActor) handleTriggerVTXORefresh(ctx context.Context, cmd *actormsg.TriggerVTXORefreshMsg) fn.Result[actormsg.RoundActorResp] { @@ -1861,24 +1876,42 @@ func (a *RoundClientActor) handleTriggerVTXORefresh(ctx context.Context, triggeredCount := 0 for _, outpoint := range cmd.TargetOutpoints { + vtxo, err := a.cfg.VTXOStore.GetVTXO(ctx, outpoint) + if err != nil { + a.log.WarnS(ctx, + "Failed to load VTXO for refresh", + err, + slog.String("outpoint", outpoint.String())) + + continue + } + + result := a.handleRefreshVTXORequest( + ctx, buildRefreshVTXORequest(vtxo), + ) + if _, err := result.Unpack(); err != nil { + a.log.WarnS(ctx, + "Failed to queue refresh intents", + err, + slog.String("outpoint", outpoint.String())) + + continue + } + serviceKey := actormsg.VTXOActorServiceKey(outpoint) - err := serviceKey.Ref(a.cfg.ActorSystem).Tell( - ctx, &TriggerRefreshEvent{ - ForceRefresh: cmd.ForceRefresh, - }, + err = serviceKey.Ref(a.cfg.ActorSystem).Tell( + ctx, &PendingForfeitEvent{}, ) if err != nil { a.log.WarnS(ctx, - "Failed to send refresh trigger "+ - "to VTXO actor", + "Failed to mark VTXO pending forfeit", err, - slog.String( - "outpoint", - outpoint.String(), - )) + slog.String("outpoint", outpoint.String())) + + continue } - a.log.InfoS(ctx, "Sent refresh trigger to VTXO actor", + a.log.InfoS(ctx, "Queued refresh and marked VTXO pending", slog.String("outpoint", outpoint.String()), slog.Bool("force", cmd.ForceRefresh)) @@ -1892,9 +1925,9 @@ func (a *RoundClientActor) handleTriggerVTXORefresh(ctx context.Context, } // handleTriggerVTXOLeave processes a leave (offboard) trigger request from the -// wallet actor. For each target outpoint, we send TriggerLeaveEvent to the VTXO -// actor via its service key. The VTXO actor then emits LeaveVTXORequest back to -// us through its outbox. +// wallet actor. The round actor owns the leave intent composition: it queues +// the Forfeit+Leave intents, then marks the VTXO pending cooperative +// consumption without exposing leave semantics to the VTXO FSM. func (a *RoundClientActor) handleTriggerVTXOLeave(ctx context.Context, cmd *actormsg.TriggerVTXOLeaveMsg) fn.Result[actormsg.RoundActorResp] { @@ -1906,21 +1939,44 @@ func (a *RoundClientActor) handleTriggerVTXOLeave(ctx context.Context, triggeredCount := 0 for _, outpoint := range cmd.TargetOutpoints { + vtxo, err := a.cfg.VTXOStore.GetVTXO(ctx, outpoint) + if err != nil { + a.log.WarnS(ctx, + "Failed to load VTXO for leave", + err, + slog.String("outpoint", outpoint.String())) + + continue + } + + result := a.handleLeaveVTXORequest(ctx, &LeaveVTXORequest{ + VTXOOutpoint: outpoint, + Amount: int64(vtxo.Amount), + Output: cmd.DestOutput, + }) + if _, err := result.Unpack(); err != nil { + a.log.WarnS(ctx, + "Failed to queue leave intents", + err, + slog.String("outpoint", outpoint.String())) + + continue + } + serviceKey := actormsg.VTXOActorServiceKey(outpoint) - err := serviceKey.Ref(a.cfg.ActorSystem).Tell( - ctx, &TriggerLeaveEvent{ - DestOutput: cmd.DestOutput, - }, + err = serviceKey.Ref(a.cfg.ActorSystem).Tell( + ctx, &PendingForfeitEvent{}, ) if err != nil { - a.log.WarnS(ctx, "Failed to send leave trigger "+ - "to VTXO actor", err, - slog.String( - "outpoint", outpoint.String(), - )) + a.log.WarnS(ctx, + "Failed to mark VTXO pending forfeit", + err, + slog.String("outpoint", outpoint.String())) + + continue } - a.log.InfoS(ctx, "Sent leave trigger to VTXO actor", + a.log.InfoS(ctx, "Queued leave and marked VTXO pending", slog.String("outpoint", outpoint.String())) triggeredCount++ diff --git a/round/vtxo_messages.go b/round/vtxo_messages.go index 3e35e6874..23eb610da 100644 --- a/round/vtxo_messages.go +++ b/round/vtxo_messages.go @@ -163,44 +163,23 @@ func (e *ResumeVTXOEvent) VTXOActorMsg() {} // MessageType returns the message type for logging. func (e *ResumeVTXOEvent) MessageType() string { return "ResumeVTXOEvent" } -// TriggerRefreshEvent is sent to a VTXO actor to manually trigger cooperative -// forfeiture. The VTXO transitions to PendingForfeitState and emits a -// ForfeitRequest. Used by the wallet actor when the user explicitly requests -// a refresh. -type TriggerRefreshEvent struct { +// PendingForfeitEvent is sent to a VTXO actor when the round actor has +// accepted this VTXO for cooperative consumption in a pending round, but does +// not yet have the concrete connector/forfeit details needed for signing. +// This transitions the VTXO into PendingForfeitState without encoding whether +// the round intent is a refresh, leave, or another cooperative spend. +type PendingForfeitEvent struct { actor.BaseMessage - - // ForceRefresh indicates this is a user-initiated refresh that should - // proceed regardless of expiry status. - ForceRefresh bool } // VTXOActorMsg implements actormsg.VTXOActorMsg marker interface. -func (e *TriggerRefreshEvent) VTXOActorMsg() {} +func (e *PendingForfeitEvent) VTXOActorMsg() {} // MessageType returns the message type for logging. -func (e *TriggerRefreshEvent) MessageType() string { - return "TriggerRefreshEvent" -} - -// TriggerLeaveEvent is sent to a VTXO actor to manually trigger a leave -// (offboard) request. This transitions the VTXO to a state where it will be -// forfeited and the value sent to the specified destination output. Used by -// the wallet actor when the user explicitly requests to leave the Ark. -type TriggerLeaveEvent struct { - actor.BaseMessage - - // DestOutput is the on-chain destination output where the funds will - // be sent. This output will be included in the batch transaction. - DestOutput *wire.TxOut +func (e *PendingForfeitEvent) MessageType() string { + return "PendingForfeitEvent" } -// VTXOActorMsg implements actormsg.VTXOActorMsg marker interface. -func (e *TriggerLeaveEvent) VTXOActorMsg() {} - -// MessageType returns the message type for logging. -func (e *TriggerLeaveEvent) MessageType() string { return "TriggerLeaveEvent" } - // ============================================================================= // Messages TO VTXO Manager // ============================================================================= diff --git a/vtxo/events.go b/vtxo/events.go index 5319b7d98..948e8c7a6 100644 --- a/vtxo/events.go +++ b/vtxo/events.go @@ -37,11 +37,8 @@ type ( // state. ResumeVTXOEvent = round.ResumeVTXOEvent - // TriggerRefreshEvent is sent to manually trigger cooperative - // forfeiture. - TriggerRefreshEvent = round.TriggerRefreshEvent - - // TriggerLeaveEvent is sent to manually trigger a leave - // (offboard). - TriggerLeaveEvent = round.TriggerLeaveEvent + // PendingForfeitEvent is sent when the round actor has committed this + // VTXO to cooperative consumption and the VTXO should become + // unavailable for other uses while awaiting concrete forfeit details. + PendingForfeitEvent = round.PendingForfeitEvent ) diff --git a/vtxo/interfaces.go b/vtxo/interfaces.go index a840b1ed1..dbfeea9bd 100644 --- a/vtxo/interfaces.go +++ b/vtxo/interfaces.go @@ -62,6 +62,8 @@ type InternalEvent[E VTXOEvent] struct{} // // - BlockEpochEvent: From chain source (via VTXO manager), triggers expiry // checks on each new block. +// - PendingForfeitEvent: From round actor, commits the VTXO to cooperative +// consumption before concrete forfeit details are available. // - ForfeitRequestEvent: From round actor, initiates forfeit signing flow. // - ForfeitConfirmedEvent: From round actor, confirms forfeit completion. // - ResumeVTXOEvent: From VTXO manager, restores state after crash recovery. @@ -95,6 +97,14 @@ var MessageSpec = struct { // Handled in: LiveState, PendingForfeitState, ForfeitingState BlockEpochEvent InboundEvent[*BlockEpochEvent] + // PendingForfeitEvent is received from the round actor after it has + // accepted this VTXO into a pending cooperative-consumption round, but + // before it has concrete connector details to sign. + // + // Source: Round Actor → VTXO Actor + // Handled in: LiveState, PendingForfeitState + PendingForfeitEvent InboundEvent[*round.PendingForfeitEvent] + // ForfeitRequestEvent is received from the round actor when this VTXO // has been selected for inclusion in a batch swap. The FSM should sign // the forfeit transaction and submit it back to the round actor. @@ -135,8 +145,7 @@ var MessageSpec = struct { // in the next batch swap. // // Destination: VTXO Actor → Round Actor - // Emitted from: LiveState (on ExpiryStatusNeedsRefresh or external - // forfeit trigger) + // Emitted from: LiveState (on ExpiryStatusNeedsRefresh) ForfeitRequest OutboundMsg[*ForfeitRequest] // ForfeitSignatureSubmission is sent to the round actor with the diff --git a/vtxo/manager.go b/vtxo/manager.go index b140cbf7f..b49656cc5 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -223,9 +223,10 @@ func (m *Manager) handleVTXOTerminated(ctx context.Context, } // handleRelayToRound forwards a VTXO actor's message to the round actor. -// The VTXO actor pre-builds the round-specific message (RefreshVTXORequest -// or ForfeitSignatureResponse) and wraps it in RelayToRoundMsg. The manager -// just unwraps and forwards. +// The VTXO actor pre-builds the round-specific message +// (RefreshVTXORequest for the auto-expiry path, or +// ForfeitSignatureResponse during forfeit signing) and wraps it in +// RelayToRoundMsg. The manager just unwraps and forwards. // // Liveness guarantee: when a VTXO approaches expiry, the VTXO actor // autonomously emits a ForfeitRequest (wrapped in RelayToRoundMsg) without diff --git a/vtxo/messages.go b/vtxo/messages.go index ab4c0289d..4b31edbcb 100644 --- a/vtxo/messages.go +++ b/vtxo/messages.go @@ -83,4 +83,3 @@ type RelayToRoundResp struct{} // managerRespSealed implements the ManagerResp sealed interface. func (r *RelayToRoundResp) managerRespSealed() {} - diff --git a/vtxo/outbox_messages.go b/vtxo/outbox_messages.go index 4f7812520..ee884dd35 100644 --- a/vtxo/outbox_messages.go +++ b/vtxo/outbox_messages.go @@ -135,4 +135,3 @@ type VTXOTerminatedNotification struct { } func (m *VTXOTerminatedNotification) vtxoOutMsgSealed() {} - diff --git a/vtxo/transitions.go b/vtxo/transitions.go index 2f36ea717..76ce50bed 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -21,21 +21,12 @@ func (s *LiveState) ProcessEvent( case *BlockEpochEvent: return s.handleBlockEpoch(ctx, evt, env) + case *PendingForfeitEvent: + return s.handlePendingForfeit(ctx, env) + case *ForfeitRequestEvent: return s.handleForfeitRequest(ctx, evt, env) - case *TriggerRefreshEvent: - // External forfeit trigger (refresh or leave). Both product - // intents map to the same lifecycle action: commit to - // cooperative consumption. - return s.handleExternalForfeitTrigger(ctx, env) - - case *TriggerLeaveEvent: - // Leave is a product-level concept. From the VTXO FSM's - // perspective, a leave is identical to a refresh: both - // result in forfeiting this VTXO cooperatively. - return s.handleExternalForfeitTrigger(ctx, env) - case *ResumeVTXOEvent: // On resume, stay in LiveState and re-check expiry on next // block. @@ -58,6 +49,29 @@ func (s *LiveState) ProcessEvent( } } +// handlePendingForfeit commits this VTXO to cooperative consumption before the +// round has concrete connector details. The VTXO becomes unavailable for other +// operations while awaiting the later ForfeitRequestEvent. +func (s *LiveState) handlePendingForfeit( + _ context.Context, _ *VTXOEnvironment, +) (*VTXOStateTransition, error) { + + return &VTXOStateTransition{ + NextState: &PendingForfeitState{ + VTXO: s.VTXO, + RequestedAtHeight: 0, + }, + NewEvents: fn.Some(VTXOEmittedEvent{ + Outbox: []VTXOOutMsg{ + &VTXOStatusUpdate{ + Outpoint: s.VTXO.Outpoint, + NewStatus: VTXOStatusPendingForfeit, + }, + }, + }), + }, nil +} + // handleBlockEpoch processes a new block notification and checks if the VTXO // needs to be forfeited cooperatively or escalated to unilateral exit. func (s *LiveState) handleBlockEpoch( @@ -120,7 +134,10 @@ func (s *LiveState) handleBlockEpoch( } return &VTXOStateTransition{ - NextState: &UnilateralExitState{VTXO: s.VTXO, Reason: reason}, + NextState: &UnilateralExitState{ + VTXO: s.VTXO, + Reason: reason, + }, NewEvents: fn.Some(VTXOEmittedEvent{Outbox: outbox}), }, nil @@ -129,8 +146,9 @@ func (s *LiveState) handleBlockEpoch( // works correctly. return &VTXOStateTransition{ NextState: &FailedState{ - VTXO: s.VTXO, - Reason: "batch expired before cooperative forfeit", + VTXO: s.VTXO, + Reason: "batch expired before " + + "cooperative forfeit", Recoverable: false, }, }, nil @@ -194,34 +212,6 @@ func (s *LiveState) handleForfeitRequest( }, nil } -// handleExternalForfeitTrigger handles an external trigger to forfeit this -// VTXO (from wallet or round actor). The VTXO doesn't distinguish between -// refresh and leave — both are cooperative consumption from the FSM's -// perspective. The caller (round/wallet) handles the product-level -// distinction. -func (s *LiveState) handleExternalForfeitTrigger( - _ context.Context, _ *VTXOEnvironment, -) (*VTXOStateTransition, error) { - - outbox := []VTXOOutMsg{ - &ForfeitRequest{ - VTXOOutpoint: s.VTXO.Outpoint, - }, - &VTXOStatusUpdate{ - Outpoint: s.VTXO.Outpoint, - NewStatus: VTXOStatusPendingForfeit, - }, - } - - return &VTXOStateTransition{ - NextState: &PendingForfeitState{ - VTXO: s.VTXO, - RequestedAtHeight: 0, - }, - NewEvents: fn.Some(VTXOEmittedEvent{Outbox: outbox}), - }, nil -} - // signForfeitVTXOInput produces the client's schnorr signature for the VTXO // input of a forfeit transaction. The VTXO uses a tapscript with a 2-of-2 // collaborative spend path, so both client and operator signatures are needed. @@ -325,8 +315,9 @@ func (s *PendingForfeitState) ProcessEvent( return &VTXOStateTransition{ NextState: &UnilateralExitState{ - VTXO: s.VTXO, - Reason: "critical expiry pending forfeit", + VTXO: s.VTXO, + Reason: "critical expiry pending " + + "forfeit", }, NewEvents: fn.Some(VTXOEmittedEvent{ Outbox: outbox, @@ -383,6 +374,13 @@ func (s *PendingForfeitState) ProcessEvent( }), }, nil + case *PendingForfeitEvent: + // Duplicate commit while already pending is harmless. The + // round may re-issue this after restart or replay. + return &VTXOStateTransition{ + NextState: s, + }, nil + case *ResumeVTXOEvent: // On resume, stay in this state. The round actor should // re-send forfeit details when it resumes. diff --git a/vtxo/transitions_test.go b/vtxo/transitions_test.go index aff23a8f2..b9b321b08 100644 --- a/vtxo/transitions_test.go +++ b/vtxo/transitions_test.go @@ -130,6 +130,38 @@ func TestLiveStateBlockEpochNeedsRefresh(t *testing.T) { assertOutboxContains[*ForfeitRequest](h) } +// TestPendingForfeitEventFromLiveState verifies that a round-driven pending +// forfeit commit moves the VTXO into PendingForfeitState without emitting a +// round request back out. The round actor already owns the intent package in +// this path, so the VTXO only needs to update its availability state. +func TestPendingForfeitEventFromLiveState(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + + h.withState(&LiveState{ + VTXO: vtxo, + LastCheckedHeight: 100, + }) + + h.store.On( + "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, + VTXOStatusPendingForfeit, + ).Return(nil) + + _, err := h.sendEvent(&round.PendingForfeitEvent{}) + require.NoError(t, err) + + assertState[*PendingForfeitState](h) + require.Len(t, h.outboxMessages, 1) + _, ok := h.outboxMessages[0].(*VTXOStatusUpdate) + require.True( + t, ok, "expected VTXOStatusUpdate, got %T", + h.outboxMessages[0], + ) +} + // TestLiveStateBlockEpochCritical verifies that LiveState transitions to // UnilateralExitState when critically close to expiry. func TestLiveStateBlockEpochCritical(t *testing.T) { @@ -267,7 +299,10 @@ func TestPendingForfeitCriticalExpiry(t *testing.T) { // Setup mock for status update. h.store.On( - "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, VTXOStatusUnilateralExit, + "UpdateVTXOStatus", + h.ctx, + vtxo.Outpoint, + VTXOStatusUnilateralExit, ).Return(nil) _, err := h.sendEvent(evt) @@ -499,8 +534,8 @@ func TestForfeitRequestRealSigning(t *testing.T) { } // TestForfeitingStateCriticalExpiry verifies that ForfeitingState transitions -// to UnilateralExitState if critical expiry is reached while waiting for forfeit -// confirmation. +// to UnilateralExitState if critical expiry is reached while waiting for +// forfeit confirmation. func TestForfeitingStateCriticalExpiry(t *testing.T) { t.Parallel() @@ -529,7 +564,10 @@ func TestForfeitingStateCriticalExpiry(t *testing.T) { // Setup mock for status update. h.store.On( - "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, VTXOStatusUnilateralExit, + "UpdateVTXOStatus", + h.ctx, + vtxo.Outpoint, + VTXOStatusUnilateralExit, ).Return(nil) _, err := h.sendEvent(evt) diff --git a/wallet/wallet.go b/wallet/wallet.go index a4e34f6c4..319fb89a0 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -645,8 +645,9 @@ func (a *Ark) handleRefreshVTXOs(ctx context.Context, slog.Int("target_count", len(req.TargetOutpoints)), slog.Bool("force_refresh", req.ForceRefresh)) - // Forward to round actor via service key lookup. The round actor looks - // up VTXO actors by service key and sends TriggerRefreshEvent to each. + // Forward to the round actor. The round actor now owns refresh + // intent composition and will mark the target VTXOs pending + // cooperative consumption itself. if a.actorSystem != nil { serviceKey := actormsg.RoundActorServiceKey() roundRef := serviceKey.Ref(a.actorSystem) @@ -689,9 +690,9 @@ func (a *Ark) handleLeaveVTXOs(ctx context.Context, slog.Int("target_count", len(req.TargetOutpoints)), ) - // Forward to round actor via service key lookup. The round actor - // looks up VTXO actors by service key and sends TriggerLeaveEvent - // to each. + // Forward to the round actor. The round actor now owns leave + // intent composition and will mark the target VTXOs pending + // cooperative consumption itself. if a.actorSystem != nil { serviceKey := actormsg.RoundActorServiceKey() roundRef := serviceKey.Ref(a.actorSystem) From 6b2f6c11e8a4efde9476e40b49c1acdce67f635b Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 13:52:44 +0200 Subject: [PATCH 09/19] round: add RegisterIntentRequest entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- round/actor.go | 106 +++++++++++++++++++++++++ round/actor_test.go | 184 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) diff --git a/round/actor.go b/round/actor.go index 75a62d56d..c4a1d74b4 100644 --- a/round/actor.go +++ b/round/actor.go @@ -114,6 +114,30 @@ func (e *LeaveVTXORequest) MessageType() string { return "LeaveVTXORequest" } +// RegisterIntentRequest is the primary entry point for registering a +// pre-composed intent package with the round actor. The caller (typically +// the wallet) builds the full IntentPackage containing forfeits, VTXO +// requests, and/or leave requests. The round actor validates and registers +// it with the FSM, then notifies affected VTXO actors. +// +// This replaces the older pattern where the round actor loaded VTXO +// descriptors and composed intents itself (via TriggerVTXORefreshMsg / +// TriggerVTXOLeaveMsg). +type RegisterIntentRequest struct { + actor.BaseMessage + + // Package is the fully composed round intent bundle. + Package *IntentPackage +} + +// RoundReceivable implements actormsg.RoundReceivable marker interface. +func (e *RegisterIntentRequest) RoundReceivable() {} + +// MessageType returns the message type for logging. +func (e *RegisterIntentRequest) MessageType() string { + return "RegisterIntentRequest" +} + // buildVTXORequestFromRefresh constructs a types.VTXORequest from a // RefreshVTXORequest. The refresh request contains all info needed to create // the new VTXO output in the round. @@ -811,6 +835,9 @@ func (a *RoundClientActor) Receive(ctx context.Context, case *TimeoutMsg: return a.handleTimeout(ctx, m) + case *RegisterIntentRequest: + return a.handleRegisterIntent(ctx, m) + case *RefreshVTXORequest: return a.handleRefreshVTXORequest(ctx, m) @@ -1747,6 +1774,11 @@ func (a *RoundClientActor) processConfirmationRequest( // The VTXO is approaching expiry and needs to be included in the next batch // swap round. The actor translates the request into a single IntentPackage // containing one forfeit input and one new VTXO output. +// +// NOTE: Unlike handleRegisterIntent, no PendingForfeitEvent is sent back to +// the VTXO actor here. The VTXO actor has already self-transitioned to +// PendingForfeitState before sending this relay message, so the notification +// would be a no-op. func (a *RoundClientActor) handleRefreshVTXORequest(ctx context.Context, req *RefreshVTXORequest) fn.Result[actormsg.RoundActorResp] { @@ -1824,6 +1856,80 @@ func (a *RoundClientActor) handleLeaveVTXORequest(ctx context.Context, return fn.Ok[actormsg.RoundActorResp](nil) } +// handleRegisterIntent processes a pre-composed intent package from the +// wallet. The wallet has already loaded VTXO descriptors and built the full +// IntentPackage. The round actor registers it with the FSM and notifies +// affected VTXO actors. +// +// If FSM registration succeeds but a PendingForfeitEvent notification fails +// for one VTXO, the handler logs the failure and continues notifying the +// remaining VTXOs. The round FSM already has the intent, so the forfeit +// will proceed when the round advances. The missed VTXO will receive the +// concrete ForfeitRequestEvent later and transition directly from Live to +// Forfeiting via the existing fast path. +func (a *RoundClientActor) handleRegisterIntent(ctx context.Context, + req *RegisterIntentRequest) fn.Result[actormsg.RoundActorResp] { + + if req.Package == nil || req.Package.isEmpty() { + return fn.Err[actormsg.RoundActorResp]( + fmt.Errorf("empty intent package"), + ) + } + + // Find a pending round or create one if none exists. + roundFSM := a.findPendingRound() + if roundFSM == nil { + var err error + roundFSM, err = a.createNewRound(ctx) + if err != nil { + return fn.Err[actormsg.RoundActorResp](fmt.Errorf( + "failed to create round for intent: %w", err, + )) + } + } + + // Feed the pre-composed package to the FSM. + err := a.askEventAndProcessOutbox(ctx, roundFSM, req.Package) + if err != nil { + return fn.Err[actormsg.RoundActorResp](fmt.Errorf( + "FSM error processing intent package: %w", err, + )) + } + + // Notify each forfeited VTXO that it is now pending cooperative + // consumption. This is done after FSM registration succeeds so we + // never mark a VTXO pending for a round that rejected the intent. + if a.cfg.ActorSystem != nil { + for _, forfeit := range req.Package.Forfeits { + if forfeit.VTXOOutpoint == nil { + continue + } + + outpoint := *forfeit.VTXOOutpoint + serviceKey := actormsg.VTXOActorServiceKey(outpoint) + err := serviceKey.Ref(a.cfg.ActorSystem).Tell( + ctx, &PendingForfeitEvent{}, + ) + if err != nil { + a.log.WarnS(ctx, + "Failed to notify VTXO of pending "+ + "forfeit", err, + slog.String( + "outpoint", + outpoint.String(), + )) + } + } + } + + a.log.InfoS(ctx, "Registered intent package", + slog.Int("forfeits", len(req.Package.Forfeits)), + slog.Int("vtxos", len(req.Package.VTXOs)), + slog.Int("leaves", len(req.Package.Leaves))) + + return fn.Ok[actormsg.RoundActorResp](nil) +} + // handleForfeitSignatureResponse processes a forfeit signature from a VTXO // actor. The VTXO actor has signed the forfeit transaction as part of a batch // swap round. The signature is forwarded to the round's FSM for tracking. diff --git a/round/actor_test.go b/round/actor_test.go index 5cfed04eb..a6054161a 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -1940,3 +1940,187 @@ func TestActorIntentMapping(t *testing.T) { ) }) } + +// TestHandleRegisterIntent verifies that RegisterIntentRequest registers a +// pre-composed intent package with the round FSM without the round actor +// performing any intent composition. +func TestHandleRegisterIntent(t *testing.T) { + t.Parallel() + + t.Run("registers_refresh_intent_package", func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + + err := h.start() + require.NoError(t, err) + + // Build a refresh-style intent package: one forfeit + one + // new VTXO. This is what the wallet would compose. + vtxoOutpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte("register-refresh")), + Index: 0, + } + req := &RegisterIntentRequest{ + Package: &IntentPackage{Intents: Intents{ + Forfeits: []types.ForfeitRequest{{ + VTXOOutpoint: &vtxoOutpoint, + }}, + VTXOs: []types.VTXORequest{{ + Amount: 50000, + PkScript: []byte{0x51, 0x20}, + ClientKey: h.clientPubKey, + OperatorKey: h.operatorPubKey, + Expiry: 144, + }}, + }}, + } + + result := h.receive(req) + require.True(t, result.IsOk(), + "expected Ok, got: %v", result.Err()) + + // Verify FSM has the intent registered. + states := h.queryState() + tempState, exists := h.findTempState(states) + require.True(t, exists, + "expected temp-keyed FSM state") + + assembly, ok := tempState.State.(*PendingRoundAssembly) + require.True(t, ok, + "expected PendingRoundAssembly, got %T", + tempState.State) + + require.Len(t, assembly.Forfeits, 1) + require.Equal( + t, vtxoOutpoint, + *assembly.Forfeits[0].VTXOOutpoint, + ) + require.Len(t, assembly.VTXOs, 1) + }) + + t.Run("registers_leave_intent_package", func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + + err := h.start() + require.NoError(t, err) + + // Build a leave-style intent package: one forfeit + one + // leave output. + vtxoOutpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte("register-leave")), + Index: 0, + } + leaveOutput := &wire.TxOut{ + Value: 60000, + PkScript: []byte{0x00, 0x14, 0x01, 0x02}, + } + req := &RegisterIntentRequest{ + Package: &IntentPackage{Intents: Intents{ + Forfeits: []types.ForfeitRequest{{ + VTXOOutpoint: &vtxoOutpoint, + }}, + Leaves: []*types.LeaveRequest{{ + Output: leaveOutput, + }}, + }}, + } + + result := h.receive(req) + require.True(t, result.IsOk(), + "expected Ok, got: %v", result.Err()) + + states := h.queryState() + tempState, exists := h.findTempState(states) + require.True(t, exists) + + assembly, ok := tempState.State.(*PendingRoundAssembly) + require.True(t, ok) + + require.Len(t, assembly.Forfeits, 1) + require.Len(t, assembly.Leaves, 1) + require.Equal( + t, leaveOutput.Value, + assembly.Leaves[0].Output.Value, + ) + require.Empty(t, assembly.VTXOs) + }) + + t.Run("rejects_empty_package", func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + + err := h.start() + require.NoError(t, err) + + // Nil package. + result := h.receive(&RegisterIntentRequest{ + Package: nil, + }) + require.True(t, result.IsErr()) + + // Empty package. + result = h.receive(&RegisterIntentRequest{ + Package: &IntentPackage{}, + }) + require.True(t, result.IsErr()) + }) + + t.Run("accumulates_with_existing_intents", func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + + err := h.start() + require.NoError(t, err) + + // First: add a boarding intent via the existing path. + intent := h.newTestBoardingIntent() + h.sendWalletConfirmation(intent) + h.assertFSMState("PendingRoundAssembly") + + // Second: register a refresh intent package. + vtxoOutpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte("accum-refresh")), + Index: 0, + } + req := &RegisterIntentRequest{ + Package: &IntentPackage{Intents: Intents{ + Forfeits: []types.ForfeitRequest{{ + VTXOOutpoint: &vtxoOutpoint, + }}, + VTXOs: []types.VTXORequest{{ + Amount: 40000, + PkScript: []byte{0x51, 0x20}, + ClientKey: h.clientPubKey, + OperatorKey: h.operatorPubKey, + Expiry: 144, + }}, + }}, + } + + result := h.receive(req) + require.True(t, result.IsOk(), + "expected Ok, got: %v", result.Err()) + + // Verify both boarding and refresh intents are present. + states := h.queryState() + tempState, exists := h.findTempState(states) + require.True(t, exists) + + assembly, ok := tempState.State.(*PendingRoundAssembly) + require.True(t, ok) + + require.Len(t, assembly.Boarding, 1) + require.Len(t, assembly.Forfeits, 1) + require.Len(t, assembly.VTXOs, 1, + "1 from register intent") + }) +} From 7142bcd173bdaa37907e1e74e9644a9d28f5d7b3 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 14:13:23 +0200 Subject: [PATCH 10/19] multi: switch wallet refresh flow to RegisterIntentMsg 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). --- darepod/server.go | 26 +++++++- lib/actormsg/interfaces.go | 29 +++++++++ round/actor.go | 12 ++++ systest/helpers.go | 2 +- wallet/interfaces.go | 49 +++++++++++++++ wallet/wallet.go | 122 ++++++++++++++++++++++++++++--------- wallet/wallet_test.go | 20 +++--- 7 files changed, 217 insertions(+), 43 deletions(-) diff --git a/darepod/server.go b/darepod/server.go index b6b423a36..f54b304f8 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -15,6 +15,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/darepo-client/arkrpc" "github.com/lightninglabs/darepo-client/baselib/actor" @@ -1450,9 +1451,30 @@ func (s *Server) initWalletActor(ctx context.Context, boardingBackend = w.BoardingBackend() } + // Adapt the VTXO persistence store to the wallet's VTXOReader + // interface. The wallet uses this to load VTXO descriptors when + // building intent packages for round registration. + vtxoReader := wallet.VTXOReaderFunc(func(ctx context.Context, + op wire.OutPoint) (*wallet.VTXODescriptor, error) { + + desc, err := s.vtxoStore.GetVTXO(ctx, op) + if err != nil { + return nil, err + } + + return &wallet.VTXODescriptor{ + Outpoint: desc.Outpoint, + Amount: desc.Amount, + PkScript: desc.PkScript, + Expiry: desc.RelativeExpiry, + ClientKey: desc.ClientKey, + OperatorKey: desc.OperatorKey, + }, nil + }) + walletActor := wallet.NewArk( - boardingBackend, boardingStore, chainSourceRef, - s.actorSystem, log, + boardingBackend, boardingStore, vtxoReader, + chainSourceRef, s.actorSystem, log, ) walletKey := actor.NewServiceKey[ wallet.WalletMsg, wallet.WalletResp, diff --git a/lib/actormsg/interfaces.go b/lib/actormsg/interfaces.go index 1dbb51f4b..2db327e87 100644 --- a/lib/actormsg/interfaces.go +++ b/lib/actormsg/interfaces.go @@ -4,6 +4,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/lib/types" ) // VTXOActorMsg is the message type for VTXO actors. Messages sent TO VTXO @@ -44,6 +45,34 @@ type VTXOManagerMsg interface { VTXOManagerMsg() } +// RegisterIntentMsg is sent from the wallet actor to the round actor to +// register a pre-composed intent package. The wallet builds the full set of +// forfeits, VTXO requests, and leave requests; the round actor validates +// and registers it with the FSM. +// +// Defined in actormsg to avoid the wallet→round import cycle. +type RegisterIntentMsg struct { + actor.BaseMessage + + // Forfeits contains the VTXOs being forfeited as inputs. + Forfeits []types.ForfeitRequest + + // VTXOs is the templates for the VTXO(s) requested in the round. + VTXOs []types.VTXORequest + + // Leaves contains the leave requests for VTXOs being exited to + // on-chain outputs. + Leaves []*types.LeaveRequest +} + +// RoundReceivable implements the RoundReceivable marker interface. +func (m *RegisterIntentMsg) RoundReceivable() {} + +// MessageType returns the message type for logging. +func (m *RegisterIntentMsg) MessageType() string { + return "RegisterIntentMsg" +} + // TriggerVTXORefreshMsg is sent from the wallet actor to the round actor to // request refresh of specific VTXOs. Defined in actormsg to avoid import cycle // between wallet and round packages. diff --git a/round/actor.go b/round/actor.go index c4a1d74b4..3eedb1cf4 100644 --- a/round/actor.go +++ b/round/actor.go @@ -838,6 +838,18 @@ func (a *RoundClientActor) Receive(ctx context.Context, case *RegisterIntentRequest: return a.handleRegisterIntent(ctx, m) + // NOTE: RegisterIntentMsg mirrors the fields of IntentPackage. + // If either type gains new fields, this adapter must be updated + // to carry them through. + case *actormsg.RegisterIntentMsg: + return a.handleRegisterIntent(ctx, &RegisterIntentRequest{ + Package: &IntentPackage{Intents: Intents{ + Forfeits: m.Forfeits, + VTXOs: m.VTXOs, + Leaves: m.Leaves, + }}, + }) + case *RefreshVTXORequest: return a.handleRefreshVTXORequest(ctx, m) diff --git a/systest/helpers.go b/systest/helpers.go index 0175afaa4..459b5c614 100644 --- a/systest/helpers.go +++ b/systest/helpers.go @@ -112,7 +112,7 @@ func NewBoardingWalletFixture(t *testing.T) *BoardingWalletFixture { chainSourceRef := h.NewChainSourceActor() backend := h.NewBoardingBackend() walletActor := wallet.NewArk( - backend, h.BoardingStore(), chainSourceRef, + backend, h.BoardingStore(), nil, chainSourceRef, h.ActorSystem(), h.SubLogger(wallet.Subsystem), ) diff --git a/wallet/interfaces.go b/wallet/interfaces.go index c0dd15c5e..ec79f9f41 100644 --- a/wallet/interfaces.go +++ b/wallet/interfaces.go @@ -13,6 +13,55 @@ import ( "github.com/lightningnetwork/lnd/keychain" ) +// VTXODescriptor contains the VTXO information needed by the wallet to build +// intent packages for round registration. This is a wallet-level view that +// avoids importing the vtxo package (which would cause an import cycle). +type VTXODescriptor struct { + // Outpoint identifies the VTXO's location in the virtual transaction + // tree. + Outpoint wire.OutPoint + + // Amount is the value of this VTXO in satoshis. + Amount btcutil.Amount + + // PkScript is the output script for this VTXO. + PkScript []byte + + // Expiry is the CSV delay for the unilateral exit path. This + // corresponds to vtxo.Descriptor.RelativeExpiry. + Expiry uint32 + + // ClientKey is the client's key descriptor for this VTXO. + ClientKey keychain.KeyDescriptor + + // OperatorKey is the operator's public key for collaborative spends. + OperatorKey *btcec.PublicKey +} + +// VTXOReader provides read-only access to VTXO descriptors. The wallet uses +// this to load VTXO data when building intent packages for round registration. +// Implementors convert from their internal VTXO representation (e.g., +// vtxo.Descriptor) to the wallet-level VTXODescriptor. +type VTXOReader interface { + // GetVTXO retrieves a VTXO descriptor by its outpoint. Returns an + // error if the VTXO is not found. + GetVTXO(ctx context.Context, + outpoint wire.OutPoint) (*VTXODescriptor, error) +} + +// VTXOReaderFunc is an adapter to allow the use of ordinary functions as +// VTXOReader. If f is a function with the appropriate signature, +// VTXOReaderFunc(f) is a VTXOReader that calls f. +type VTXOReaderFunc func(ctx context.Context, + outpoint wire.OutPoint) (*VTXODescriptor, error) + +// GetVTXO calls f(ctx, outpoint). +func (f VTXOReaderFunc) GetVTXO(ctx context.Context, + outpoint wire.OutPoint) (*VTXODescriptor, error) { + + return f(ctx, outpoint) +} + // Ask documents a request-response message pair. The Req type is sent via // actor.Ask, and the Resp type is returned. This is used purely for // documentation to provide a quick reference of available operations. diff --git a/wallet/wallet.go b/wallet/wallet.go index 319fb89a0..0ae0d3e2d 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -17,6 +17,7 @@ import ( "github.com/lightninglabs/darepo-client/chainsource" "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/lightninglabs/darepo-client/lib/types" fn "github.com/lightningnetwork/lnd/fn/v2" ) @@ -68,10 +69,14 @@ type Ark struct { // store persists boarding addresses and intents to the database. store BoardingStore + // vtxoReader provides read-only access to VTXO descriptors. The wallet + // uses this to load VTXO data when building intent packages for round + // registration (refresh and leave flows). + vtxoReader VTXOReader + // actorSystem is the actor system context for looking up actors by - // service key. Used to find the round actor when forwarding refresh - // requests. This avoids circular dependencies since we look up the - // actor on-demand rather than holding a reference. + // service key. Used to find the round actor when forwarding intent + // registration messages. actorSystem actor.SystemContext // chainSource provides block epoch notifications for polling. @@ -109,10 +114,11 @@ type Ark struct { // NewArk creates a new Ark wallet actor. The logger is optional and falls back // to the global package logger when nil is passed. // -// The actorSystem parameter enables refresh request forwarding to the round -// actor via service key lookup. This avoids circular dependencies since we -// look up the actor on-demand using the well-known RoundActorServiceKey. +// The vtxoReader provides read-only VTXO descriptor access so the wallet can +// compose intent packages for round registration. The actorSystem enables +// round actor lookup via service key. func NewArk(backend BoardingBackend, store BoardingStore, + vtxoReader VTXOReader, chainSource actor.ActorRef[chainsource.ChainSourceMsg, chainsource.ChainSourceResp], actorSystem actor.SystemContext, actorLog btclog.Logger) *Ark { @@ -128,6 +134,7 @@ func NewArk(backend BoardingBackend, store BoardingStore, return &Ark{ backend: backend, store: store, + vtxoReader: vtxoReader, chainSource: chainSource, actorSystem: actorSystem, notifiers: make(map[string]notifierInfo), @@ -635,9 +642,14 @@ func (a *Ark) sendBacklog(ctx context.Context, slog.Int("events_sent", len(intents))) } -// handleRefreshVTXOs processes a request to refresh VTXOs. This forwards the -// request to the round actor which coordinates with VTXO actors to initiate -// the refresh flow. +// handleRefreshVTXOs processes a request to refresh VTXOs. The wallet loads +// each VTXO descriptor, builds a forfeit + VTXO request pair, and sends the +// composed intent package to the round actor via RegisterIntentMsg. The round +// actor validates, registers with the FSM, and notifies VTXO actors. +// +// If some VTXOs fail to load but at least one succeeds, the successful +// forfeits are still submitted to the round (partial participation). The +// caller should check Errors in the response to detect partial failures. func (a *Ark) handleRefreshVTXOs(ctx context.Context, req *RefreshVTXOsRequest) fn.Result[WalletResp] { @@ -645,36 +657,86 @@ func (a *Ark) handleRefreshVTXOs(ctx context.Context, slog.Int("target_count", len(req.TargetOutpoints)), slog.Bool("force_refresh", req.ForceRefresh)) - // Forward to the round actor. The round actor now owns refresh - // intent composition and will mark the target VTXOs pending - // cooperative consumption itself. - if a.actorSystem != nil { - serviceKey := actormsg.RoundActorServiceKey() - roundRef := serviceKey.Ref(a.actorSystem) + if a.actorSystem == nil { + a.logger(ctx).WarnS( + ctx, "No actor system for refresh", nil, + ) - err := roundRef.Tell(ctx, &actormsg.TriggerVTXORefreshMsg{ - TargetOutpoints: req.TargetOutpoints, - ForceRefresh: req.ForceRefresh, + return fn.Ok[WalletResp](&RefreshVTXOsResponse{ + Errors: make(map[wire.OutPoint]error), }) + } + + // Build intent package by loading each VTXO descriptor and + // constructing the corresponding forfeit + VTXO request pair. + var ( + forfeits []types.ForfeitRequest + vtxos []types.VTXORequest + errors = make(map[wire.OutPoint]error) + ) + + for _, outpoint := range req.TargetOutpoints { + vtxo, err := a.vtxoReader.GetVTXO(ctx, outpoint) if err != nil { a.logger(ctx).WarnS(ctx, - "Failed to forward refresh to "+ - "round actor", - err) + "Failed to load VTXO for refresh", + err, + slog.String("outpoint", + outpoint.String())) + + errors[outpoint] = err + + continue } - a.logger(ctx).DebugS( - ctx, "Forwarded refresh to round actor", - ) - } else { - a.logger(ctx).WarnS( - ctx, "No actor system for refresh", nil, - ) + // Each refresh produces a forfeit of the old VTXO and a + // request for a new VTXO with the same parameters. + op := vtxo.Outpoint + forfeits = append(forfeits, types.ForfeitRequest{ + VTXOOutpoint: &op, + }) + vtxos = append(vtxos, types.VTXORequest{ + Amount: vtxo.Amount, + PkScript: vtxo.PkScript, + Expiry: vtxo.Expiry, + ClientKey: vtxo.ClientKey.PubKey, + OperatorKey: vtxo.OperatorKey, + SigningKey: vtxo.ClientKey, + }) + } + + // Send the composed intent package to the round actor and await + // the result. Using Ask (not Tell) so we surface FSM rejection + // or validation failures back to the caller. + if len(forfeits) > 0 { + serviceKey := actormsg.RoundActorServiceKey() + roundRef := serviceKey.Ref(a.actorSystem) + + future := roundRef.Ask(ctx, &actormsg.RegisterIntentMsg{ + Forfeits: forfeits, + VTXOs: vtxos, + }) + result := future.Await(ctx) + if result.IsErr() { + a.logger(ctx).WarnS(ctx, + "Round rejected refresh intent", + result.Err()) + + return fn.Err[WalletResp](fmt.Errorf( + "round rejected refresh intent: %w", + result.Err(), + )) + } } + a.logger(ctx).InfoS(ctx, "Registered refresh intent package", + slog.Int("forfeits", len(forfeits)), + slog.Int("vtxos", len(vtxos)), + slog.Int("errors", len(errors))) + resp := &RefreshVTXOsResponse{ - RefreshingCount: len(req.TargetOutpoints), - Errors: make(map[wire.OutPoint]error), + RefreshingCount: len(forfeits), + Errors: errors, } return fn.Ok[WalletResp](resp) diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index 8c908df00..dd601e3a8 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -288,7 +288,7 @@ func TestCreateBoardingAddress(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, nil, + backend, store, nil, chainSource, nil, btclog.Disabled, ) @@ -327,7 +327,7 @@ func TestRegisterNotifier(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, nil, + backend, store, nil, chainSource, nil, btclog.Disabled, ) @@ -473,7 +473,7 @@ func TestProcessNewUtxo(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, nil, + backend, store, nil, chainSource, nil, btclog.Disabled, ) @@ -630,7 +630,7 @@ func TestProcessUtxoMinConfFiltering(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, nil, + backend, store, nil, chainSource, nil, btclog.Disabled, ) @@ -766,7 +766,7 @@ func TestGetActiveBoardingAddresses(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, nil, + backend, store, nil, chainSource, nil, btclog.Disabled, ) @@ -808,7 +808,7 @@ func TestGetBoardingBalance(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, nil, + backend, store, nil, chainSource, nil, btclog.Disabled, ) @@ -917,7 +917,7 @@ func TestSendBacklog(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, nil, + backend, store, nil, chainSource, nil, btclog.Disabled, ) @@ -960,7 +960,7 @@ func TestSendBacklog(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, nil, + backend, store, nil, chainSource, nil, btclog.Disabled, ) @@ -1009,7 +1009,7 @@ func TestSendBacklog(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, nil, + backend, store, nil, chainSource, nil, btclog.Disabled, ) @@ -1078,7 +1078,7 @@ func TestSendBacklog(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, nil, + backend, store, nil, chainSource, nil, btclog.Disabled, ) From 53172b8f55c599734db2a866cbeff6e63a0a13dd Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 14:14:52 +0200 Subject: [PATCH 11/19] wallet: switch leave flow to RegisterIntentMsg 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. --- wallet/wallet.go | 134 +++++++++++++++++++++++++++++++++++------- wallet/wallet_test.go | 63 ++++++++++++++++++++ 2 files changed, 176 insertions(+), 21 deletions(-) diff --git a/wallet/wallet.go b/wallet/wallet.go index 0ae0d3e2d..99ab94c3a 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -149,6 +149,19 @@ func (a *Ark) logger(ctx context.Context) btclog.Logger { return a.actorLog.UnwrapOr(build.LoggerFromContext(ctx)) } +// allTargetErrors builds a per-outpoint error map for operations that fail +// before the wallet can inspect individual VTXOs. +func allTargetErrors(targets []wire.OutPoint, + err error) map[wire.OutPoint]error { + + errors := make(map[wire.OutPoint]error, len(targets)) + for _, outpoint := range targets { + errors[outpoint] = err + } + + return errors +} + // Start initializes the actor and subscribes to block epochs. The selfRef // parameter is the actor's own reference, used to receive block epoch // notifications from the chainsource actor. @@ -667,6 +680,15 @@ func (a *Ark) handleRefreshVTXOs(ctx context.Context, }) } + if a.vtxoReader == nil { + err := fmt.Errorf("VTXO reader not configured") + a.logger(ctx).WarnS(ctx, "No VTXO reader for refresh", err) + + return fn.Ok[WalletResp](&RefreshVTXOsResponse{ + Errors: allTargetErrors(req.TargetOutpoints, err), + }) + } + // Build intent package by loading each VTXO descriptor and // constructing the corresponding forfeit + VTXO request pair. var ( @@ -701,7 +723,7 @@ func (a *Ark) handleRefreshVTXOs(ctx context.Context, Expiry: vtxo.Expiry, ClientKey: vtxo.ClientKey.PubKey, OperatorKey: vtxo.OperatorKey, - SigningKey: vtxo.ClientKey, + SigningKey: vtxo.ClientKey, }) } @@ -742,38 +764,108 @@ func (a *Ark) handleRefreshVTXOs(ctx context.Context, return fn.Ok[WalletResp](resp) } -// handleLeaveVTXOs processes a leave (offboard) request by forwarding it to the -// round actor. The round actor coordinates with VTXO actors to initiate the -// forfeit flow, resulting in an on-chain output in the batch transaction. +// handleLeaveVTXOs processes a leave (offboard) request. The wallet loads each +// VTXO descriptor, builds a forfeit + leave request pair, and sends the +// composed intent package to the round actor via RegisterIntentMsg. The round +// actor validates, registers with the FSM, and notifies VTXO actors. +// +// If some VTXOs fail to load but at least one succeeds, the successful +// forfeits are still submitted (partial participation). The caller should +// check Errors in the response to detect partial failures. +// +// All VTXOs in a single request produce individual LeaveRequest objects +// pointing to the same DestOutput. The server is expected to create a +// separate on-chain output for each leave, not aggregate them. func (a *Ark) handleLeaveVTXOs(ctx context.Context, req *LeaveVTXOsRequest) fn.Result[WalletResp] { a.logger(ctx).InfoS(ctx, "Received VTXO leave request", - slog.Int("target_count", len(req.TargetOutpoints)), + slog.Int("target_count", len(req.TargetOutpoints))) + + if a.actorSystem == nil { + a.logger(ctx).WarnS( + ctx, "No actor system for leave", nil, + ) + + return fn.Ok[WalletResp](&LeaveVTXOsResponse{ + Errors: make(map[wire.OutPoint]error), + }) + } + + if a.vtxoReader == nil { + err := fmt.Errorf("VTXO reader not configured") + a.logger(ctx).WarnS(ctx, "No VTXO reader for leave", err) + + return fn.Ok[WalletResp](&LeaveVTXOsResponse{ + Errors: allTargetErrors(req.TargetOutpoints, err), + }) + } + + // Build intent package by loading each VTXO descriptor and + // constructing the corresponding forfeit + leave request pair. + var ( + forfeits []types.ForfeitRequest + leaves []*types.LeaveRequest + errors = make(map[wire.OutPoint]error) ) - // Forward to the round actor. The round actor now owns leave - // intent composition and will mark the target VTXOs pending - // cooperative consumption itself. - if a.actorSystem != nil { + for _, outpoint := range req.TargetOutpoints { + _, err := a.vtxoReader.GetVTXO(ctx, outpoint) + if err != nil { + a.logger(ctx).WarnS(ctx, + "Failed to load VTXO for leave", + err, + slog.String("outpoint", + outpoint.String())) + + errors[outpoint] = err + + continue + } + + // Each leave produces a forfeit of the old VTXO and a + // leave request with the destination output. + op := outpoint + forfeits = append(forfeits, types.ForfeitRequest{ + VTXOOutpoint: &op, + }) + leaves = append(leaves, &types.LeaveRequest{ + Output: req.DestOutput, + }) + } + + // Send the composed intent package to the round actor and await + // the result. Using Ask (not Tell) so we surface FSM rejection + // or validation failures back to the caller. + if len(forfeits) > 0 { serviceKey := actormsg.RoundActorServiceKey() roundRef := serviceKey.Ref(a.actorSystem) - if err := roundRef.Tell(ctx, &actormsg.TriggerVTXOLeaveMsg{ - TargetOutpoints: req.TargetOutpoints, - DestOutput: req.DestOutput, - }); err != nil { - a.logger(ctx).WarnS(ctx, "Failed to forward leave to "+ - "round actor", err) + + future := roundRef.Ask(ctx, &actormsg.RegisterIntentMsg{ + Forfeits: forfeits, + Leaves: leaves, + }) + result := future.Await(ctx) + if result.IsErr() { + a.logger(ctx).WarnS(ctx, + "Round rejected leave intent", + result.Err()) + + return fn.Err[WalletResp](fmt.Errorf( + "round rejected leave intent: %w", + result.Err(), + )) } - } else { - a.logger(ctx).WarnS( - ctx, "No actor system for leave", nil, - ) } + a.logger(ctx).InfoS(ctx, "Registered leave intent package", + slog.Int("forfeits", len(forfeits)), + slog.Int("leaves", len(leaves)), + slog.Int("errors", len(errors))) + resp := &LeaveVTXOsResponse{ - LeavingCount: len(req.TargetOutpoints), - Errors: make(map[wire.OutPoint]error), + LeavingCount: len(forfeits), + Errors: errors, } return fn.Ok[WalletResp](resp) diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index dd601e3a8..068ac21be 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -1121,3 +1121,66 @@ func TestSendBacklog(t *testing.T) { store.AssertExpectations(t) }) } + +// TestIntentCompositionRequiresVTXOReader verifies that refresh and leave +// return per-outpoint errors instead of panicking when the wallet is created +// without a VTXOReader. +func TestIntentCompositionRequiresVTXOReader(t *testing.T) { + t.Parallel() + + system := actor.NewActorSystem() + defer func() { + err := system.Shutdown(t.Context()) + require.NoError(t, err) + }() + + backend := &MockBoardingBackend{} + store := &MockBoardingStore{} + + var chainSource actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ] + walletActor := NewArk( + backend, store, nil, chainSource, system, btclog.Disabled, + ) + + refreshOutpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte("refresh-without-reader")), + Index: 0, + } + refreshResult := walletActor.handleRefreshVTXOs(t.Context(), + &RefreshVTXOsRequest{ + TargetOutpoints: []wire.OutPoint{refreshOutpoint}, + }, + ) + refreshRespVal, err := refreshResult.Unpack() + require.NoError(t, err) + + refreshResp, ok := refreshRespVal.(*RefreshVTXOsResponse) + require.True(t, ok, "unexpected response type: %T", refreshRespVal) + require.Zero(t, refreshResp.RefreshingCount) + require.ErrorContains(t, refreshResp.Errors[refreshOutpoint], + "VTXO reader not configured") + + leaveOutpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte("leave-without-reader")), + Index: 1, + } + leaveResult := walletActor.handleLeaveVTXOs(t.Context(), + &LeaveVTXOsRequest{ + TargetOutpoints: []wire.OutPoint{leaveOutpoint}, + DestOutput: &wire.TxOut{ + Value: 1234, + PkScript: []byte{0x00, 0x14, 0x01, 0x02}, + }, + }, + ) + leaveRespVal, err := leaveResult.Unpack() + require.NoError(t, err) + + leaveResp, ok := leaveRespVal.(*LeaveVTXOsResponse) + require.True(t, ok, "unexpected response type: %T", leaveRespVal) + require.Zero(t, leaveResp.LeavingCount) + require.ErrorContains(t, leaveResp.Errors[leaveOutpoint], + "VTXO reader not configured") +} From 11e9bc174dcd8d51ce2752c8572b23ef8f07ad69 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 14:19:00 +0200 Subject: [PATCH 12/19] multi: remove trigger messages and obsolete round handlers 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. --- lib/actormsg/interfaces.go | 45 ----------- round/actor.go | 152 ------------------------------------- 2 files changed, 197 deletions(-) diff --git a/lib/actormsg/interfaces.go b/lib/actormsg/interfaces.go index 2db327e87..2823f11e1 100644 --- a/lib/actormsg/interfaces.go +++ b/lib/actormsg/interfaces.go @@ -2,7 +2,6 @@ package actormsg import ( "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/lib/types" ) @@ -73,50 +72,6 @@ func (m *RegisterIntentMsg) MessageType() string { return "RegisterIntentMsg" } -// TriggerVTXORefreshMsg is sent from the wallet actor to the round actor to -// request refresh of specific VTXOs. Defined in actormsg to avoid import cycle -// between wallet and round packages. -type TriggerVTXORefreshMsg struct { - actor.BaseMessage - - // TargetOutpoints specifies which VTXOs to refresh. - TargetOutpoints []wire.OutPoint - - // ForceRefresh indicates this is a user-initiated refresh that should - // proceed regardless of expiry status. - ForceRefresh bool -} - -// RoundReceivable implements the RoundReceivable marker interface. -func (m *TriggerVTXORefreshMsg) RoundReceivable() {} - -// MessageType returns the message type for logging. -func (m *TriggerVTXORefreshMsg) MessageType() string { - return "TriggerVTXORefreshMsg" -} - -// TriggerVTXOLeaveMsg is sent from the wallet actor to the round actor to -// request leave (offboard) of specific VTXOs. The VTXOs will be forfeited and -// their value sent to the specified destination output. -type TriggerVTXOLeaveMsg struct { - actor.BaseMessage - - // TargetOutpoints specifies which VTXOs to leave (offboard). - TargetOutpoints []wire.OutPoint - - // DestOutput is the on-chain destination output where the funds will - // be sent. This output will be included in the batch transaction. - DestOutput *wire.TxOut -} - -// RoundReceivable implements the RoundReceivable marker interface. -func (m *TriggerVTXOLeaveMsg) RoundReceivable() {} - -// MessageType returns the message type for logging. -func (m *TriggerVTXOLeaveMsg) MessageType() string { - return "TriggerVTXOLeaveMsg" -} - // TriggerBoardMsg is sent from the wallet actor to the round actor // to trigger boarding of confirmed UTXOs into the next round. The // wallet computes the VTXO output amounts after deducting operator diff --git a/round/actor.go b/round/actor.go index 3eedb1cf4..be652776c 100644 --- a/round/actor.go +++ b/round/actor.go @@ -119,10 +119,6 @@ func (e *LeaveVTXORequest) MessageType() string { // the wallet) builds the full IntentPackage containing forfeits, VTXO // requests, and/or leave requests. The round actor validates and registers // it with the FSM, then notifies affected VTXO actors. -// -// This replaces the older pattern where the round actor loaded VTXO -// descriptors and composed intents itself (via TriggerVTXORefreshMsg / -// TriggerVTXOLeaveMsg). type RegisterIntentRequest struct { actor.BaseMessage @@ -154,21 +150,6 @@ func buildVTXORequestFromRefresh( } } -// buildRefreshVTXORequest converts a persisted client VTXO into the round -// actor's refresh request shape so wallet-triggered refreshes can reuse the -// same round assembly path as expiry-driven requests. -func buildRefreshVTXORequest(vtxo *ClientVTXO) *RefreshVTXORequest { - return &RefreshVTXORequest{ - VTXOOutpoint: vtxo.Outpoint, - Amount: int64(vtxo.Amount), - NewVTXOKey: vtxo.ClientKey.PubKey, - PkScript: vtxo.PkScript, - OperatorKey: vtxo.OperatorKey, - Expiry: vtxo.Expiry, - SigningKey: vtxo.ClientKey, - } -} - // makeTimeoutID builds a composite timeout ID from round ID and phase. func makeTimeoutID(roundID RoundID, phase TimeoutPhase) timeout.ID { return timeout.ID(fmt.Sprintf("%s:%s", roundID.String(), phase)) @@ -859,12 +840,6 @@ func (a *RoundClientActor) Receive(ctx context.Context, case *ForfeitSignatureResponse: return a.handleForfeitSignatureResponse(ctx, m) - case *actormsg.TriggerVTXORefreshMsg: - return a.handleTriggerVTXORefresh(ctx, m) - - case *actormsg.TriggerVTXOLeaveMsg: - return a.handleTriggerVTXOLeave(ctx, m) - case *actormsg.TriggerBoardMsg: return a.handleTriggerBoard(ctx, m) @@ -1979,133 +1954,6 @@ func (a *RoundClientActor) handleForfeitSignatureResponse(ctx context.Context, return fn.Ok[actormsg.RoundActorResp](nil) } -// handleTriggerVTXORefresh processes a refresh trigger request from the wallet -// actor. The round actor owns the refresh intent composition: it loads the -// VTXO descriptor, queues the round intents, and then marks the VTXO pending -// cooperative consumption. -func (a *RoundClientActor) handleTriggerVTXORefresh(ctx context.Context, - cmd *actormsg.TriggerVTXORefreshMsg) fn.Result[actormsg.RoundActorResp] { - - if a.cfg.ActorSystem == nil { - return fn.Err[actormsg.RoundActorResp](fmt.Errorf( - "ActorSystem not configured, cannot trigger VTXO refresh", - )) - } - - triggeredCount := 0 - for _, outpoint := range cmd.TargetOutpoints { - vtxo, err := a.cfg.VTXOStore.GetVTXO(ctx, outpoint) - if err != nil { - a.log.WarnS(ctx, - "Failed to load VTXO for refresh", - err, - slog.String("outpoint", outpoint.String())) - - continue - } - - result := a.handleRefreshVTXORequest( - ctx, buildRefreshVTXORequest(vtxo), - ) - if _, err := result.Unpack(); err != nil { - a.log.WarnS(ctx, - "Failed to queue refresh intents", - err, - slog.String("outpoint", outpoint.String())) - - continue - } - - serviceKey := actormsg.VTXOActorServiceKey(outpoint) - err = serviceKey.Ref(a.cfg.ActorSystem).Tell( - ctx, &PendingForfeitEvent{}, - ) - if err != nil { - a.log.WarnS(ctx, - "Failed to mark VTXO pending forfeit", - err, - slog.String("outpoint", outpoint.String())) - - continue - } - - a.log.InfoS(ctx, "Queued refresh and marked VTXO pending", - slog.String("outpoint", outpoint.String()), - slog.Bool("force", cmd.ForceRefresh)) - - triggeredCount++ - } - - a.log.InfoS(ctx, "Triggered VTXO refresh", - slog.Int("count", triggeredCount)) - - return fn.Ok[actormsg.RoundActorResp](nil) -} - -// handleTriggerVTXOLeave processes a leave (offboard) trigger request from the -// wallet actor. The round actor owns the leave intent composition: it queues -// the Forfeit+Leave intents, then marks the VTXO pending cooperative -// consumption without exposing leave semantics to the VTXO FSM. -func (a *RoundClientActor) handleTriggerVTXOLeave(ctx context.Context, - cmd *actormsg.TriggerVTXOLeaveMsg) fn.Result[actormsg.RoundActorResp] { - - if a.cfg.ActorSystem == nil { - return fn.Err[actormsg.RoundActorResp](fmt.Errorf( - "ActorSystem not configured, cannot trigger VTXO leave", - )) - } - - triggeredCount := 0 - for _, outpoint := range cmd.TargetOutpoints { - vtxo, err := a.cfg.VTXOStore.GetVTXO(ctx, outpoint) - if err != nil { - a.log.WarnS(ctx, - "Failed to load VTXO for leave", - err, - slog.String("outpoint", outpoint.String())) - - continue - } - - result := a.handleLeaveVTXORequest(ctx, &LeaveVTXORequest{ - VTXOOutpoint: outpoint, - Amount: int64(vtxo.Amount), - Output: cmd.DestOutput, - }) - if _, err := result.Unpack(); err != nil { - a.log.WarnS(ctx, - "Failed to queue leave intents", - err, - slog.String("outpoint", outpoint.String())) - - continue - } - - serviceKey := actormsg.VTXOActorServiceKey(outpoint) - err = serviceKey.Ref(a.cfg.ActorSystem).Tell( - ctx, &PendingForfeitEvent{}, - ) - if err != nil { - a.log.WarnS(ctx, - "Failed to mark VTXO pending forfeit", - err, - slog.String("outpoint", outpoint.String())) - - continue - } - - a.log.InfoS(ctx, "Queued leave and marked VTXO pending", - slog.String("outpoint", outpoint.String())) - - triggeredCount++ - } - - a.log.InfoS(ctx, "Triggered VTXO leave", - slog.Int("count", triggeredCount)) - - return fn.Ok[actormsg.RoundActorResp](nil) -} - // handleTriggerBoard processes a board request forwarded from the wallet actor. // It registers the VTXO output amounts into a round FSM and then triggers // RegistrationRequested to kick off the round join flow. This combines the From b555f2f0f1ded00b0e075b7b93e1fce6b9e092b5 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 14:23:22 +0200 Subject: [PATCH 13/19] round: remove LeaveVTXORequest and 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. --- round/README.md | 50 ++++++++++++++-------------- round/actor.go | 79 ++------------------------------------------- round/actor_test.go | 41 ++++++++++++++--------- 3 files changed, 54 insertions(+), 116 deletions(-) diff --git a/round/README.md b/round/README.md index ce4be8488..b54f0f308 100644 --- a/round/README.md +++ b/round/README.md @@ -74,16 +74,15 @@ four independent pools: - **Boarding** — on-chain inputs to spend (from confirmed boarding UTXOs) - **VTXOs** — requested output amounts for new virtual UTXOs -- **Forfeits** — VTXOs to be rolled into a new tree (from VTXO actors whose - VTXOs are approaching expiry) -- **Leaves** — VTXOs to be exited on-chain (from VTXO actors or the wallet - when the user wants to offboard) +- **Forfeits** — VTXOs to be rolled into a new tree +- **Leaves** — VTXOs to be exited on-chain -The actor translates external messages — `RefreshVTXORequest`, -`LeaveVTXORequest`, wallet confirmations, and VTXO requests — into an -`IntentPackage` before sending it to the FSM. This keeps input validation -and intent construction in the actor layer, while the FSM focuses purely -on pool accumulation and state management. +The actor translates external messages — wallet-composed intent packages, +boarding confirmations, VTXO requests, and the auto-expiry +`RefreshVTXORequest` path from VTXO actors — into an `IntentPackage` +before sending it to the FSM. This keeps round registration in the actor +layer, while the FSM focuses purely on pool accumulation and state +management. This separation supports fan-in (multiple inputs funding one output) and fan-out (one input creating multiple outputs) scenarios, as well as mixed @@ -288,10 +287,11 @@ sequenceDiagram Note over A: User specifies VTXO amounts A->>C: IntentPackage(vtxos) opt Refresh / Leave + W->>A: RegisterIntentMsg(forfeit + vtxo/leave) + A->>C: IntentPackage(forfeit + vtxo/leave) + Note over V,A: Auto-expiry path: V->>A: RefreshVTXORequest A->>C: IntentPackage(forfeit + vtxo) - V->>A: LeaveVTXORequest - A->>C: IntentPackage(forfeit + leave) end Note over C,B: Round Registration Phase @@ -433,14 +433,16 @@ differ in how intents are assembled and how old VTXOs are handled. ### Refresh -When a VTXO approaches its CSV expiry, the VTXO actor sends a -`RefreshVTXORequest` to the round actor (either automatically or via manual -`TriggerRefreshEvent`). The round actor translates these into an -`IntentPackage` with a forfeit request and corresponding VTXO output, which -the FSM accumulates in `PendingRoundAssembly.Forfeits` and -`PendingRoundAssembly.VTXOs` alongside any boarding intents. During -registration, refresh requests are included in the `JoinRoundRequest` as -forfeit outpoints with corresponding new VTXO outputs. +Manual refresh now starts in the wallet: the wallet loads the target VTXOs, +builds a forfeit plus replacement-VTXO `IntentPackage`, and sends it to the +round actor via `RegisterIntentMsg`. The round actor registers that package +with the FSM and marks the participating VTXOs as `PendingForfeit`. + +For the auto-expiry path, a VTXO actor may still emit +`RefreshVTXORequest`. The round actor converts that request into the same +forfeit-plus-VTXO `IntentPackage`, which the FSM accumulates in +`PendingRoundAssembly.Forfeits` and `PendingRoundAssembly.VTXOs` alongside +any boarding intents. After the operator signs the VTXO tree, the FSM enters ForfeitSignaturesCollecting and sends `ForfeitRequestToVTXO` to each VTXO @@ -450,17 +452,17 @@ and returns a `ForfeitSignatureResponse`. This ensures atomic replacement: the old VTXO becomes unspendable only when the new commitment transaction confirms. -On the VTXO actor side, the flow progresses through `RefreshRequestedState` → +On the VTXO actor side, the flow progresses through `PendingForfeitState` → `ForfeitingState` → `ForfeitedState` as the forfeit is requested, signed, and confirmed. ### Leave Leave follows the same mechanics as refresh, but the output is an on-chain -destination rather than a new VTXO. The user (or wallet) sends a -`LeaveVTXORequest`, accumulated in `PendingRoundAssembly.Leaves`. During -commitment transaction validation, the FSM verifies all leave outputs appear -in the transaction via `validateLeaveOutputs()`. +destination rather than a new VTXO. The wallet builds a forfeit-plus-leave +`IntentPackage` and sends it to the round actor via `RegisterIntentMsg`. +During commitment transaction validation, the FSM verifies all leave outputs +appear in the transaction via `validateLeaveOutputs()`. ### Mixed Rounds diff --git a/round/actor.go b/round/actor.go index be652776c..5479632c4 100644 --- a/round/actor.go +++ b/round/actor.go @@ -80,40 +80,6 @@ func (e *RefreshVTXORequest) MessageType() string { return "RefreshVTXORequest" } -// LeaveVTXORequest is sent from a VTXO actor (or wallet) when the user wants -// to exit the Ark by forfeiting a VTXO and receiving an on-chain output. This -// is similar to RefreshVTXORequest except the output is on-chain rather than a -// new VTXO. -// -// The leave flow uses the same forfeit mechanism as refresh: the old VTXO is -// forfeited via a connector output, and the leave output is included directly -// in the batch transaction. -// -// NOTE: This type is an actor message (RoundReceivable), not an FSM event. -// The actor translates it into an IntentPackage{Forfeits: [1], Leaves: [1]}. -type LeaveVTXORequest struct { - actor.BaseMessage - - // VTXOOutpoint identifies the VTXO to forfeit. - VTXOOutpoint wire.OutPoint - - // Amount is the VTXO value in satoshis. - Amount int64 - - // Output is the on-chain destination output that will be included in - // the batch transaction. This contains the value and pkScript for the - // leave output. - Output *wire.TxOut -} - -// RoundReceivable implements actormsg.RoundReceivable marker interface. -func (e *LeaveVTXORequest) RoundReceivable() {} - -// MessageType returns the message type for logging. -func (e *LeaveVTXORequest) MessageType() string { - return "LeaveVTXORequest" -} - // RegisterIntentRequest is the primary entry point for registering a // pre-composed intent package with the round actor. The caller (typically // the wallet) builds the full IntentPackage containing forfeits, VTXO @@ -834,9 +800,6 @@ func (a *RoundClientActor) Receive(ctx context.Context, case *RefreshVTXORequest: return a.handleRefreshVTXORequest(ctx, m) - case *LeaveVTXORequest: - return a.handleLeaveVTXORequest(ctx, m) - case *ForfeitSignatureResponse: return a.handleForfeitSignatureResponse(ctx, m) @@ -1576,7 +1539,7 @@ func (a *RoundClientActor) processOutbox(ctx context.Context, m.RoundID.WhenSome(func(id RoundID) { roundIDStr = id.String() }) - a.log.WarnS(ctx, "Round failed", nil, + a.log.WarnS(ctx, "Round failed", m.OriginalError, slog.String("round_id", roundIDStr), slog.String("reason", m.Reason), slog.Bool("recoverable", m.Recoverable)) @@ -1785,6 +1748,7 @@ func (a *RoundClientActor) handleRefreshVTXORequest(ctx context.Context, pkg := &IntentPackage{Intents: Intents{ Forfeits: []types.ForfeitRequest{{ VTXOOutpoint: &req.VTXOOutpoint, + Amount: btcutil.Amount(req.Amount), }}, VTXOs: []types.VTXORequest{ buildVTXORequestFromRefresh(req), @@ -1804,45 +1768,6 @@ func (a *RoundClientActor) handleRefreshVTXORequest(ctx context.Context, return fn.Ok[actormsg.RoundActorResp](nil) } -// handleLeaveVTXORequest processes a leave (offboard) request from a VTXO -// actor. The actor translates the request into a single IntentPackage -// containing one forfeit input and one leave output. -func (a *RoundClientActor) handleLeaveVTXORequest(ctx context.Context, - req *LeaveVTXORequest) fn.Result[actormsg.RoundActorResp] { - - // Find a pending round or create one if none exists. - roundFSM := a.findPendingRound() - if roundFSM == nil { - var err error - roundFSM, err = a.createNewRound(ctx) - if err != nil { - return fn.Err[actormsg.RoundActorResp](fmt.Errorf( - "failed to create round for leave: %w", err, - )) - } - } - - // Bundle the forfeit input and leave output atomically. - pkg := &IntentPackage{Intents: Intents{ - Forfeits: []types.ForfeitRequest{{ - VTXOOutpoint: &req.VTXOOutpoint, - }}, - Leaves: []*types.LeaveRequest{{Output: req.Output}}, - }} - err := a.askEventAndProcessOutbox(ctx, roundFSM, pkg) - if err != nil { - return fn.Err[actormsg.RoundActorResp](fmt.Errorf( - "FSM error processing leave package: %w", err, - )) - } - - a.log.InfoS(ctx, "Queued VTXO for leave", - slog.String("outpoint", req.VTXOOutpoint.String()), - slog.Int64("amount", req.Amount)) - - return fn.Ok[actormsg.RoundActorResp](nil) -} - // handleRegisterIntent processes a pre-composed intent package from the // wallet. The wallet has already loaded VTXO descriptors and built the full // IntentPackage. The round actor registers it with the FSM and notifies diff --git a/round/actor_test.go b/round/actor_test.go index a6054161a..4e45864fc 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -1795,10 +1795,15 @@ func TestActorIntentMapping(t *testing.T) { Value: 60000, PkScript: []byte{0x00, 0x14, 0x01, 0x02}, } - leaveReq := &LeaveVTXORequest{ - VTXOOutpoint: vtxoOutpoint, - Amount: 60000, - Output: leaveOutput, + leaveReq := &RegisterIntentRequest{ + Package: &IntentPackage{Intents: Intents{ + Forfeits: []types.ForfeitRequest{{ + VTXOOutpoint: &vtxoOutpoint, + }}, + Leaves: []*types.LeaveRequest{{ + Output: leaveOutput, + }}, + }}, } result := h.receive(leaveReq) @@ -1866,17 +1871,23 @@ func TestActorIntentMapping(t *testing.T) { result := h.receive(refreshReq) require.True(t, result.IsOk()) - // 4. Leave request. - leaveReq := &LeaveVTXORequest{ - VTXOOutpoint: wire.OutPoint{ - Hash: chainhash.HashH([]byte("leave")), - Index: 0, - }, - Amount: 20000, - Output: &wire.TxOut{ - Value: 20000, - PkScript: []byte{0x00, 0x14}, - }, + // 4. Leave request via RegisterIntentRequest. + leaveOutpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte("leave")), + Index: 0, + } + leaveReq := &RegisterIntentRequest{ + Package: &IntentPackage{Intents: Intents{ + Forfeits: []types.ForfeitRequest{{ + VTXOOutpoint: &leaveOutpoint, + }}, + Leaves: []*types.LeaveRequest{{ + Output: &wire.TxOut{ + Value: 20000, + PkScript: []byte{0x00, 0x14}, + }, + }}, + }}, } result = h.receive(leaveReq) require.True(t, result.IsOk()) From 0c43aaa3a72054665e9522baf2bd8a2a87e7c9e0 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 15:34:31 +0200 Subject: [PATCH 14/19] multi: carry local forfeit amounts into round intents 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. --- lib/types/boarding.go | 6 ++++ round/actor_test.go | 9 ++++++ round/join_auth.go | 32 +++++++++++++++------ round/join_auth_test.go | 63 +++++++++++++++++++++++++++++++++++++++++ wallet/wallet.go | 4 ++- 5 files changed, 105 insertions(+), 9 deletions(-) diff --git a/lib/types/boarding.go b/lib/types/boarding.go index ccbb8d0bb..b215fc6ef 100644 --- a/lib/types/boarding.go +++ b/lib/types/boarding.go @@ -95,6 +95,12 @@ type LeaveRequest struct { type ForfeitRequest struct { // VTXOOutpoint is the outpoint of the VTXO to forfeit. VTXOOutpoint *wire.OutPoint + + // Amount is the local value of the forfeited VTXO in satoshis. This + // is used by the client when validating a round before registration. + // It is not part of the join-round wire encoding, where the outpoint + // remains the source of truth. + Amount btcutil.Amount } type VTXORequest struct { diff --git a/round/actor_test.go b/round/actor_test.go index 4e45864fc..09d0ae9a3 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -1230,6 +1230,10 @@ func TestHandleRefreshVTXORequest(t *testing.T) { t, vtxoOutpoint, *assembly.Forfeits[0].VTXOOutpoint, ) + require.Equal( + t, btcutil.Amount(refreshReq.Amount), + assembly.Forfeits[0].Amount, + ) // Verify the VTXO output request is tracked. require.Len(t, assembly.VTXOs, 1) @@ -1977,6 +1981,7 @@ func TestHandleRegisterIntent(t *testing.T) { Package: &IntentPackage{Intents: Intents{ Forfeits: []types.ForfeitRequest{{ VTXOOutpoint: &vtxoOutpoint, + Amount: 50000, }}, VTXOs: []types.VTXORequest{{ Amount: 50000, @@ -2008,6 +2013,10 @@ func TestHandleRegisterIntent(t *testing.T) { t, vtxoOutpoint, *assembly.Forfeits[0].VTXOOutpoint, ) + require.Equal( + t, btcutil.Amount(50000), + assembly.Forfeits[0].Amount, + ) require.Len(t, assembly.VTXOs, 1) }) diff --git a/round/join_auth.go b/round/join_auth.go index eabc31f04..5323dd7f7 100644 --- a/round/join_auth.go +++ b/round/join_auth.go @@ -89,10 +89,18 @@ func deriveJoinAuthIdentifierKey(ctx context.Context, // sortedForfeitRequests sorts forfeit requests by outpoint (txid bytes // then output index) so the resulting list is deterministic. Returns an -// error if any request has a nil VTXOOutpoint. +// error if any request has a nil VTXOOutpoint. The embedded Amount field +// is preserved so callers can use the fast-path in +// computeTotalForfeitAmount without a store lookup. func sortedForfeitRequests( forfeits []types.ForfeitRequest) ([]*types.ForfeitRequest, error) { + // Index amounts by outpoint so we can carry them through + // the sort. + amountByOP := make( + map[wire.OutPoint]btcutil.Amount, len(forfeits), + ) + // Collect and sort the outpoints, validating that none are nil. outpoints := make([]wire.OutPoint, 0, len(forfeits)) for i := 0; i < len(forfeits); i++ { @@ -100,13 +108,15 @@ func sortedForfeitRequests( return nil, fmt.Errorf("forfeit request %d "+ "has nil outpoint", i) } - outpoints = append( - outpoints, *forfeits[i].VTXOOutpoint, - ) + + op := *forfeits[i].VTXOOutpoint + outpoints = append(outpoints, op) + amountByOP[op] = forfeits[i].Amount } sortOutPoints(outpoints) - // Build the sorted result with pointer-to-outpoint fields. + // Build the sorted result, preserving both the outpoint + // pointer and the embedded Amount. requests := make( []*types.ForfeitRequest, 0, len(outpoints), ) @@ -115,6 +125,7 @@ func sortedForfeitRequests( requests = append( requests, &types.ForfeitRequest{ VTXOOutpoint: &op, + Amount: amountByOP[op], }, ) } @@ -138,15 +149,20 @@ func sortOutPoints(outpoints []wire.OutPoint) { } // computeTotalForfeitAmount looks up each forfeited VTXO's value from -// the VTXOStore and returns the sum. This replaces the old approach of -// carrying the amount inside ForfeitIntent — the outpoint is sufficient -// and the store is the source of truth. +// the VTXOStore and returns the sum. If the caller already populated the +// local Amount field, we use that directly and avoid the store lookup. func computeTotalForfeitAmount(ctx context.Context, store VTXOStore, forfeits []types.ForfeitRequest) (btcutil.Amount, error) { var total btcutil.Amount for i := 0; i < len(forfeits); i++ { + if forfeits[i].Amount != 0 { + total += forfeits[i].Amount + + continue + } + vtxo, err := store.GetVTXO( ctx, *forfeits[i].VTXOOutpoint, ) diff --git a/round/join_auth_test.go b/round/join_auth_test.go index c2a7d316f..136b67d24 100644 --- a/round/join_auth_test.go +++ b/round/join_auth_test.go @@ -638,6 +638,69 @@ func TestBuildJoinRoundAuthForfeitOnly(t *testing.T) { f.verifyAuth(t, auth, proofPrevOuts) } +// TestComputeTotalForfeitAmountUsesEmbeddedAmount verifies that the round can +// sum forfeits without consulting the VTXO store when the caller already +// supplied local amount metadata. +func TestComputeTotalForfeitAmountUsesEmbeddedAmount(t *testing.T) { + t.Parallel() + + total, err := computeTotalForfeitAmount( + t.Context(), + nil, + []types.ForfeitRequest{{ + VTXOOutpoint: &wire.OutPoint{}, + Amount: 123, + }, { + VTXOOutpoint: &wire.OutPoint{ + Index: 1, + }, + Amount: 456, + }}, + ) + require.NoError(t, err) + require.Equal(t, btcutil.Amount(579), total) +} + +// TestSortedForfeitRequestsPreservesAmount verifies that the Amount +// field survives the sort so the embedded-amount fast-path in +// computeTotalForfeitAmount works on sorted output. +func TestSortedForfeitRequestsPreservesAmount(t *testing.T) { + t.Parallel() + + // Two forfeits with amounts, in reverse outpoint order. + forfeits := []types.ForfeitRequest{ + { + VTXOOutpoint: &wire.OutPoint{ + Hash: chainhash.Hash{0xff}, + Index: 1, + }, + Amount: 300, + }, + { + VTXOOutpoint: &wire.OutPoint{ + Hash: chainhash.Hash{0x01}, + Index: 0, + }, + Amount: 700, + }, + } + + sorted, err := sortedForfeitRequests(forfeits) + require.NoError(t, err) + require.Len(t, sorted, 2) + + // After sorting, the 0x01 outpoint should come first. + require.Equal(t, + chainhash.Hash{0x01}, sorted[0].VTXOOutpoint.Hash, + ) + require.Equal(t, btcutil.Amount(700), sorted[0].Amount) + + require.Equal(t, + chainhash.Hash{0xff}, sorted[1].VTXOOutpoint.Hash, + ) + require.Equal(t, btcutil.Amount(300), sorted[1].Amount) +} + // TestBuildJoinRoundAuthRejectsNoInputs verifies that // buildJoinRoundAuth returns an error when no proof-of-funds inputs // are provided. diff --git a/wallet/wallet.go b/wallet/wallet.go index 99ab94c3a..7e90bbad1 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -716,6 +716,7 @@ func (a *Ark) handleRefreshVTXOs(ctx context.Context, op := vtxo.Outpoint forfeits = append(forfeits, types.ForfeitRequest{ VTXOOutpoint: &op, + Amount: vtxo.Amount, }) vtxos = append(vtxos, types.VTXORequest{ Amount: vtxo.Amount, @@ -810,7 +811,7 @@ func (a *Ark) handleLeaveVTXOs(ctx context.Context, ) for _, outpoint := range req.TargetOutpoints { - _, err := a.vtxoReader.GetVTXO(ctx, outpoint) + vtxo, err := a.vtxoReader.GetVTXO(ctx, outpoint) if err != nil { a.logger(ctx).WarnS(ctx, "Failed to load VTXO for leave", @@ -828,6 +829,7 @@ func (a *Ark) handleLeaveVTXOs(ctx context.Context, op := outpoint forfeits = append(forfeits, types.ForfeitRequest{ VTXOOutpoint: &op, + Amount: vtxo.Amount, }) leaves = append(leaves, &types.LeaveRequest{ Output: req.DestOutput, From 01ad4cb1032bd3b0676c65398ccf450bedc0801c Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 12 Mar 2026 17:00:04 +0200 Subject: [PATCH 15/19] round: detach local persistence from actor request contexts --- round/actor.go | 5 ++++- round/transitions.go | 31 ++++++++++++++++++++++++------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/round/actor.go b/round/actor.go index 5479632c4..a833dde06 100644 --- a/round/actor.go +++ b/round/actor.go @@ -1811,7 +1811,10 @@ func (a *RoundClientActor) handleRegisterIntent(ctx context.Context, // Notify each forfeited VTXO that it is now pending cooperative // consumption. This is done after FSM registration succeeds so we // never mark a VTXO pending for a round that rejected the intent. + // We use a detached context so that notifications are not abandoned + // if the caller's request context expires after FSM registration. if a.cfg.ActorSystem != nil { + tellCtx := context.WithoutCancel(ctx) for _, forfeit := range req.Package.Forfeits { if forfeit.VTXOOutpoint == nil { continue @@ -1820,7 +1823,7 @@ func (a *RoundClientActor) handleRegisterIntent(ctx context.Context, outpoint := *forfeit.VTXOOutpoint serviceKey := actormsg.VTXOActorServiceKey(outpoint) err := serviceKey.Ref(a.cfg.ActorSystem).Tell( - ctx, &PendingForfeitEvent{}, + tellCtx, &PendingForfeitEvent{}, ) if err != nil { a.log.WarnS(ctx, diff --git a/round/transitions.go b/round/transitions.go index 83a320170..dbc732be4 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -261,6 +261,12 @@ func (s *PendingRoundAssembly) ProcessEvent(ctx context.Context, slog.Int("boarding_intent_count", len(s.Boarding)), slog.Int("vtxo_intent_count", len(s.VTXOs))) + // Registration may outlive the triggering actor request, so use + // a detached context for local store and wallet operations. We + // still use the original actor context later when emitting the + // outbox. + opCtx := context.WithoutCancel(ctx) + // Calculate total input amount from all boarding intents. var totalInput btcutil.Amount for _, boarding := range s.Boarding { @@ -275,7 +281,7 @@ func (s *PendingRoundAssembly) ProcessEvent(ctx context.Context, // Include all forfeited VTXO amounts as inputs. forfeitAmt, err := computeTotalForfeitAmount( - ctx, env.VTXOStore, s.Forfeits, + opCtx, env.VTXOStore, s.Forfeits, ) if err != nil { return failWithNotification( @@ -395,7 +401,7 @@ func (s *PendingRoundAssembly) ProcessEvent(ctx context.Context, // Derive a fresh identifier key for the join-request // authorization challenge. identifierKeyDesc, err := deriveJoinAuthIdentifierKey( - ctx, env.Wallet, + opCtx, env.Wallet, ) if err != nil { return failWithNotification( @@ -411,7 +417,7 @@ func (s *PendingRoundAssembly) ProcessEvent(ctx context.Context, var joinAuth *types.JoinRoundAuth if !env.DisableJoinRequestAuth { auth, err := buildJoinRoundAuth( - ctx, env, identifierKeyDesc, intent, vtxoReqs, + opCtx, env, identifierKeyDesc, intent, vtxoReqs, forfeitReqs, leaveReqs, ) if err != nil { @@ -1134,7 +1140,11 @@ func (s *ForfeitSignaturesCollectingState) ProcessEvent( ForfeitedVTXOs: forfeitedVTXOs, } - err = env.RoundStore.CommitState(ctx, round, nextState) + // Checkpointing may outlive the triggering actor request, so + // use a detached context for the local store write. + opCtx := context.WithoutCancel(ctx) + + err = env.RoundStore.CommitState(opCtx, round, nextState) if err != nil { return nil, fmt.Errorf("failed to commit round "+ "state: %w", err) @@ -1485,7 +1495,11 @@ func (s *PartialSigsSentState) ProcessEvent( ClientTrees: s.ClientTrees, InputSigs: boardingInputSigs, } - err = env.RoundStore.CommitState(ctx, round, nextState) + // Checkpointing may outlive the triggering actor request, so + // use a detached context for the local store write. + opCtx := context.WithoutCancel(ctx) + + err = env.RoundStore.CommitState(opCtx, round, nextState) if err != nil { return nil, fmt.Errorf("failed to commit round "+ "state: %w", err) @@ -1659,8 +1673,11 @@ func (s *InputSigSentState) ProcessEvent( slog.Int("vtxo_count", len(vtxos))) // Persist VTXOs with their extracted tree paths for future - // spending. - if err := env.VTXOStore.SaveVTXOs(ctx, vtxos); err != nil { + // spending. Confirmation handling may outlive the triggering + // actor request, so use a detached context for the store write. + opCtx := context.WithoutCancel(ctx) + + if err := env.VTXOStore.SaveVTXOs(opCtx, vtxos); err != nil { return nil, fmt.Errorf("failed to save VTXOs: %w", err) } From 696ae8bbc2afc4706d3c6eeca16343f87ef2927a Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 12 Mar 2026 17:00:14 +0200 Subject: [PATCH 16/19] multi: regenerate proto stubs and fix mailbox interface 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. --- arkrpc/indexer.pb.go | 30 ++-- arkrpc/indexer.proto | 4 +- daemonrpc/daemon.pb.go | 278 +++++++++++++++--------------- daemonrpc/daemon_grpc.pb.go | 6 +- daemonrpc/daemon_mailboxrpc.pb.go | 70 ++++++++ darepod/rpc_server.go | 19 ++ darepod/server.go | 3 +- 7 files changed, 250 insertions(+), 160 deletions(-) diff --git a/arkrpc/indexer.pb.go b/arkrpc/indexer.pb.go index 8021be391..2e77eea34 100644 --- a/arkrpc/indexer.pb.go +++ b/arkrpc/indexer.pb.go @@ -42,8 +42,8 @@ const ( // VTXO_STATUS_SPENT indicates the VTXO was spent via an out-of-round // transaction (terminal). VTXOStatus_VTXO_STATUS_SPENT VTXOStatus = 6 - // VTXO_STATUS_UNILATERAL_EXIT indicates the VTXO has reached critical expiry - // and is being resolved on-chain (terminal for the VTXO actor). + // VTXO_STATUS_UNILATERAL_EXIT indicates the VTXO has reached critical + // expiry and is being resolved on-chain (terminal for the VTXO actor). VTXOStatus_VTXO_STATUS_UNILATERAL_EXIT VTXOStatus = 7 // VTXO_STATUS_FAILED indicates an unrecoverable error (terminal). VTXOStatus_VTXO_STATUS_FAILED VTXOStatus = 8 @@ -63,15 +63,15 @@ var ( 8: "VTXO_STATUS_FAILED", } VTXOStatus_value = map[string]int32{ - "VTXO_STATUS_UNSPECIFIED": 0, - "VTXO_STATUS_UNCONFIRMED": 1, - "VTXO_STATUS_LIVE": 2, + "VTXO_STATUS_UNSPECIFIED": 0, + "VTXO_STATUS_UNCONFIRMED": 1, + "VTXO_STATUS_LIVE": 2, "VTXO_STATUS_PENDING_FORFEIT": 3, - "VTXO_STATUS_FORFEITING": 4, - "VTXO_STATUS_FORFEITED": 5, - "VTXO_STATUS_SPENT": 6, - "VTXO_STATUS_UNILATERAL_EXIT": 7, - "VTXO_STATUS_FAILED": 8, + "VTXO_STATUS_FORFEITING": 4, + "VTXO_STATUS_FORFEITED": 5, + "VTXO_STATUS_SPENT": 6, + "VTXO_STATUS_UNILATERAL_EXIT": 7, + "VTXO_STATUS_FAILED": 8, } ) @@ -2099,17 +2099,17 @@ const file_indexer_proto_rawDesc = "" + "\bevent_id\x18\x01 \x01(\x04R\aeventId\x12)\n" + "\x04type\x18\x02 \x01(\x0e2\x15.arkrpc.VTXOEventTypeR\x04type\x12,\n" + "\boutpoint\x18\x03 \x01(\v2\x10.arkrpc.OutPointR\boutpoint\x12*\n" + - "\x06status\x18\x04 \x01(\x0e2\x12.arkrpc.VTXOStatusR\x06status*\xff\x01\n" + + "\x06status\x18\x04 \x01(\x0e2\x12.arkrpc.VTXOStatusR\x06status*\x84\x02\n" + "\n" + "VTXOStatus\x12\x1b\n" + "\x17VTXO_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17VTXO_STATUS_UNCONFIRMED\x10\x01\x12\x14\n" + - "\x10VTXO_STATUS_LIVE\x10\x02\x12!\n" + - "\x1dVTXO_STATUS_PENDING_FORFEIT\x10\x03\x12\x1a\n" + + "\x10VTXO_STATUS_LIVE\x10\x02\x12\x1f\n" + + "\x1bVTXO_STATUS_PENDING_FORFEIT\x10\x03\x12\x1a\n" + "\x16VTXO_STATUS_FORFEITING\x10\x04\x12\x19\n" + "\x15VTXO_STATUS_FORFEITED\x10\x05\x12\x15\n" + - "\x11VTXO_STATUS_SPENT\x10\x06\x12\x18\n" + - "\x14VTXO_STATUS_UNILATERAL_EXIT\x10\a\x12\x16\n" + + "\x11VTXO_STATUS_SPENT\x10\x06\x12\x1f\n" + + "\x1bVTXO_STATUS_UNILATERAL_EXIT\x10\a\x12\x16\n" + "\x12VTXO_STATUS_FAILED\x10\b*\x91\x01\n" + "\rVTXOEventType\x12\x1f\n" + "\x1bVTXO_EVENT_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n" + diff --git a/arkrpc/indexer.proto b/arkrpc/indexer.proto index d871a3dac..410587209 100644 --- a/arkrpc/indexer.proto +++ b/arkrpc/indexer.proto @@ -217,8 +217,8 @@ enum VTXOStatus { // transaction (terminal). VTXO_STATUS_SPENT = 6; - // VTXO_STATUS_UNILATERAL_EXIT indicates the VTXO has reached critical expiry - // and is being resolved on-chain (terminal for the VTXO actor). + // VTXO_STATUS_UNILATERAL_EXIT indicates the VTXO has reached critical + // expiry and is being resolved on-chain (terminal for the VTXO actor). VTXO_STATUS_UNILATERAL_EXIT = 7; // VTXO_STATUS_FAILED indicates an unrecoverable error (terminal). diff --git a/daemonrpc/daemon.pb.go b/daemonrpc/daemon.pb.go index 863d30dc2..3853618bc 100644 --- a/daemonrpc/daemon.pb.go +++ b/daemonrpc/daemon.pb.go @@ -1,8 +1,8 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v5.28.0 -// source: daemonrpc/daemon.proto +// protoc v3.21.12 +// source: daemon.proto package daemonrpc @@ -62,14 +62,14 @@ var ( 7: "VTXO_STATUS_FAILED", } VTXOStatus_value = map[string]int32{ - "VTXO_STATUS_UNSPECIFIED": 0, - "VTXO_STATUS_LIVE": 1, + "VTXO_STATUS_UNSPECIFIED": 0, + "VTXO_STATUS_LIVE": 1, "VTXO_STATUS_PENDING_FORFEIT": 2, - "VTXO_STATUS_FORFEITING": 3, - "VTXO_STATUS_FORFEITED": 4, - "VTXO_STATUS_SPENT": 5, - "VTXO_STATUS_UNILATERAL_EXIT": 6, - "VTXO_STATUS_FAILED": 7, + "VTXO_STATUS_FORFEITING": 3, + "VTXO_STATUS_FORFEITED": 4, + "VTXO_STATUS_SPENT": 5, + "VTXO_STATUS_UNILATERAL_EXIT": 6, + "VTXO_STATUS_FAILED": 7, } ) @@ -84,11 +84,11 @@ func (x VTXOStatus) String() string { } func (VTXOStatus) Descriptor() protoreflect.EnumDescriptor { - return file_daemonrpc_daemon_proto_enumTypes[0].Descriptor() + return file_daemon_proto_enumTypes[0].Descriptor() } func (VTXOStatus) Type() protoreflect.EnumType { - return &file_daemonrpc_daemon_proto_enumTypes[0] + return &file_daemon_proto_enumTypes[0] } func (x VTXOStatus) Number() protoreflect.EnumNumber { @@ -97,7 +97,7 @@ func (x VTXOStatus) Number() protoreflect.EnumNumber { // Deprecated: Use VTXOStatus.Descriptor instead. func (VTXOStatus) EnumDescriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{0} + return file_daemon_proto_rawDescGZIP(), []int{0} } // RoundState represents the lifecycle state of a client's round FSM. @@ -197,11 +197,11 @@ func (x RoundState) String() string { } func (RoundState) Descriptor() protoreflect.EnumDescriptor { - return file_daemonrpc_daemon_proto_enumTypes[1].Descriptor() + return file_daemon_proto_enumTypes[1].Descriptor() } func (RoundState) Type() protoreflect.EnumType { - return &file_daemonrpc_daemon_proto_enumTypes[1] + return &file_daemon_proto_enumTypes[1] } func (x RoundState) Number() protoreflect.EnumNumber { @@ -210,7 +210,7 @@ func (x RoundState) Number() protoreflect.EnumNumber { // Deprecated: Use RoundState.Descriptor instead. func (RoundState) EnumDescriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{1} + return file_daemon_proto_rawDescGZIP(), []int{1} } type GetInfoRequest struct { @@ -221,7 +221,7 @@ type GetInfoRequest struct { func (x *GetInfoRequest) Reset() { *x = GetInfoRequest{} - mi := &file_daemonrpc_daemon_proto_msgTypes[0] + mi := &file_daemon_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -233,7 +233,7 @@ func (x *GetInfoRequest) String() string { func (*GetInfoRequest) ProtoMessage() {} func (x *GetInfoRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[0] + mi := &file_daemon_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -246,7 +246,7 @@ func (x *GetInfoRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetInfoRequest.ProtoReflect.Descriptor instead. func (*GetInfoRequest) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{0} + return file_daemon_proto_rawDescGZIP(), []int{0} } type GetInfoResponse struct { @@ -284,7 +284,7 @@ type GetInfoResponse struct { func (x *GetInfoResponse) Reset() { *x = GetInfoResponse{} - mi := &file_daemonrpc_daemon_proto_msgTypes[1] + mi := &file_daemon_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -296,7 +296,7 @@ func (x *GetInfoResponse) String() string { func (*GetInfoResponse) ProtoMessage() {} func (x *GetInfoResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[1] + mi := &file_daemon_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -309,7 +309,7 @@ func (x *GetInfoResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetInfoResponse.ProtoReflect.Descriptor instead. func (*GetInfoResponse) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{1} + return file_daemon_proto_rawDescGZIP(), []int{1} } func (x *GetInfoResponse) GetVersion() string { @@ -394,7 +394,7 @@ type GenSeedRequest struct { func (x *GenSeedRequest) Reset() { *x = GenSeedRequest{} - mi := &file_daemonrpc_daemon_proto_msgTypes[2] + mi := &file_daemon_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -406,7 +406,7 @@ func (x *GenSeedRequest) String() string { func (*GenSeedRequest) ProtoMessage() {} func (x *GenSeedRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[2] + mi := &file_daemon_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -419,7 +419,7 @@ func (x *GenSeedRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GenSeedRequest.ProtoReflect.Descriptor instead. func (*GenSeedRequest) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{2} + return file_daemon_proto_rawDescGZIP(), []int{2} } func (x *GenSeedRequest) GetSeedPassphrase() []byte { @@ -443,7 +443,7 @@ type GenSeedResponse struct { func (x *GenSeedResponse) Reset() { *x = GenSeedResponse{} - mi := &file_daemonrpc_daemon_proto_msgTypes[3] + mi := &file_daemon_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -455,7 +455,7 @@ func (x *GenSeedResponse) String() string { func (*GenSeedResponse) ProtoMessage() {} func (x *GenSeedResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[3] + mi := &file_daemon_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -468,7 +468,7 @@ func (x *GenSeedResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GenSeedResponse.ProtoReflect.Descriptor instead. func (*GenSeedResponse) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{3} + return file_daemon_proto_rawDescGZIP(), []int{3} } func (x *GenSeedResponse) GetMnemonic() []string { @@ -503,7 +503,7 @@ type InitWalletRequest struct { func (x *InitWalletRequest) Reset() { *x = InitWalletRequest{} - mi := &file_daemonrpc_daemon_proto_msgTypes[4] + mi := &file_daemon_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -515,7 +515,7 @@ func (x *InitWalletRequest) String() string { func (*InitWalletRequest) ProtoMessage() {} func (x *InitWalletRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[4] + mi := &file_daemon_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -528,7 +528,7 @@ func (x *InitWalletRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InitWalletRequest.ProtoReflect.Descriptor instead. func (*InitWalletRequest) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{4} + return file_daemon_proto_rawDescGZIP(), []int{4} } func (x *InitWalletRequest) GetMnemonic() []string { @@ -563,7 +563,7 @@ type InitWalletResponse struct { func (x *InitWalletResponse) Reset() { *x = InitWalletResponse{} - mi := &file_daemonrpc_daemon_proto_msgTypes[5] + mi := &file_daemon_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -575,7 +575,7 @@ func (x *InitWalletResponse) String() string { func (*InitWalletResponse) ProtoMessage() {} func (x *InitWalletResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[5] + mi := &file_daemon_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -588,7 +588,7 @@ func (x *InitWalletResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InitWalletResponse.ProtoReflect.Descriptor instead. func (*InitWalletResponse) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{5} + return file_daemon_proto_rawDescGZIP(), []int{5} } func (x *InitWalletResponse) GetIdentityPubkey() string { @@ -609,7 +609,7 @@ type UnlockWalletRequest struct { func (x *UnlockWalletRequest) Reset() { *x = UnlockWalletRequest{} - mi := &file_daemonrpc_daemon_proto_msgTypes[6] + mi := &file_daemon_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -621,7 +621,7 @@ func (x *UnlockWalletRequest) String() string { func (*UnlockWalletRequest) ProtoMessage() {} func (x *UnlockWalletRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[6] + mi := &file_daemon_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -634,7 +634,7 @@ func (x *UnlockWalletRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UnlockWalletRequest.ProtoReflect.Descriptor instead. func (*UnlockWalletRequest) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{6} + return file_daemon_proto_rawDescGZIP(), []int{6} } func (x *UnlockWalletRequest) GetWalletPassword() []byte { @@ -655,7 +655,7 @@ type UnlockWalletResponse struct { func (x *UnlockWalletResponse) Reset() { *x = UnlockWalletResponse{} - mi := &file_daemonrpc_daemon_proto_msgTypes[7] + mi := &file_daemon_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -667,7 +667,7 @@ func (x *UnlockWalletResponse) String() string { func (*UnlockWalletResponse) ProtoMessage() {} func (x *UnlockWalletResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[7] + mi := &file_daemon_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -680,7 +680,7 @@ func (x *UnlockWalletResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UnlockWalletResponse.ProtoReflect.Descriptor instead. func (*UnlockWalletResponse) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{7} + return file_daemon_proto_rawDescGZIP(), []int{7} } func (x *UnlockWalletResponse) GetIdentityPubkey() string { @@ -698,7 +698,7 @@ type GetBalanceRequest struct { func (x *GetBalanceRequest) Reset() { *x = GetBalanceRequest{} - mi := &file_daemonrpc_daemon_proto_msgTypes[8] + mi := &file_daemon_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -710,7 +710,7 @@ func (x *GetBalanceRequest) String() string { func (*GetBalanceRequest) ProtoMessage() {} func (x *GetBalanceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[8] + mi := &file_daemon_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -723,7 +723,7 @@ func (x *GetBalanceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBalanceRequest.ProtoReflect.Descriptor instead. func (*GetBalanceRequest) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{8} + return file_daemon_proto_rawDescGZIP(), []int{8} } type GetBalanceResponse struct { @@ -746,7 +746,7 @@ type GetBalanceResponse struct { func (x *GetBalanceResponse) Reset() { *x = GetBalanceResponse{} - mi := &file_daemonrpc_daemon_proto_msgTypes[9] + mi := &file_daemon_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -758,7 +758,7 @@ func (x *GetBalanceResponse) String() string { func (*GetBalanceResponse) ProtoMessage() {} func (x *GetBalanceResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[9] + mi := &file_daemon_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -771,7 +771,7 @@ func (x *GetBalanceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBalanceResponse.ProtoReflect.Descriptor instead. func (*GetBalanceResponse) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{9} + return file_daemon_proto_rawDescGZIP(), []int{9} } func (x *GetBalanceResponse) GetBoardingConfirmedSat() int64 { @@ -831,7 +831,7 @@ type VTXO struct { func (x *VTXO) Reset() { *x = VTXO{} - mi := &file_daemonrpc_daemon_proto_msgTypes[10] + mi := &file_daemon_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -843,7 +843,7 @@ func (x *VTXO) String() string { func (*VTXO) ProtoMessage() {} func (x *VTXO) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[10] + mi := &file_daemon_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -856,7 +856,7 @@ func (x *VTXO) ProtoReflect() protoreflect.Message { // Deprecated: Use VTXO.ProtoReflect.Descriptor instead. func (*VTXO) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{10} + return file_daemon_proto_rawDescGZIP(), []int{10} } func (x *VTXO) GetOutpoint() string { @@ -935,7 +935,7 @@ type ListVTXOsRequest struct { func (x *ListVTXOsRequest) Reset() { *x = ListVTXOsRequest{} - mi := &file_daemonrpc_daemon_proto_msgTypes[11] + mi := &file_daemon_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -947,7 +947,7 @@ func (x *ListVTXOsRequest) String() string { func (*ListVTXOsRequest) ProtoMessage() {} func (x *ListVTXOsRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[11] + mi := &file_daemon_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -960,7 +960,7 @@ func (x *ListVTXOsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListVTXOsRequest.ProtoReflect.Descriptor instead. func (*ListVTXOsRequest) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{11} + return file_daemon_proto_rawDescGZIP(), []int{11} } func (x *ListVTXOsRequest) GetStatusFilter() VTXOStatus { @@ -987,7 +987,7 @@ type ListVTXOsResponse struct { func (x *ListVTXOsResponse) Reset() { *x = ListVTXOsResponse{} - mi := &file_daemonrpc_daemon_proto_msgTypes[12] + mi := &file_daemon_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -999,7 +999,7 @@ func (x *ListVTXOsResponse) String() string { func (*ListVTXOsResponse) ProtoMessage() {} func (x *ListVTXOsResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[12] + mi := &file_daemon_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1012,7 +1012,7 @@ func (x *ListVTXOsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListVTXOsResponse.ProtoReflect.Descriptor instead. func (*ListVTXOsResponse) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{12} + return file_daemon_proto_rawDescGZIP(), []int{12} } func (x *ListVTXOsResponse) GetVtxos() []*VTXO { @@ -1030,7 +1030,7 @@ type NewAddressRequest struct { func (x *NewAddressRequest) Reset() { *x = NewAddressRequest{} - mi := &file_daemonrpc_daemon_proto_msgTypes[13] + mi := &file_daemon_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1042,7 +1042,7 @@ func (x *NewAddressRequest) String() string { func (*NewAddressRequest) ProtoMessage() {} func (x *NewAddressRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[13] + mi := &file_daemon_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1055,7 +1055,7 @@ func (x *NewAddressRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use NewAddressRequest.ProtoReflect.Descriptor instead. func (*NewAddressRequest) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{13} + return file_daemon_proto_rawDescGZIP(), []int{13} } type NewAddressResponse struct { @@ -1068,7 +1068,7 @@ type NewAddressResponse struct { func (x *NewAddressResponse) Reset() { *x = NewAddressResponse{} - mi := &file_daemonrpc_daemon_proto_msgTypes[14] + mi := &file_daemon_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1080,7 +1080,7 @@ func (x *NewAddressResponse) String() string { func (*NewAddressResponse) ProtoMessage() {} func (x *NewAddressResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[14] + mi := &file_daemon_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1093,7 +1093,7 @@ func (x *NewAddressResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use NewAddressResponse.ProtoReflect.Descriptor instead. func (*NewAddressResponse) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{14} + return file_daemon_proto_rawDescGZIP(), []int{14} } func (x *NewAddressResponse) GetAddress() string { @@ -1120,7 +1120,7 @@ type Output struct { func (x *Output) Reset() { *x = Output{} - mi := &file_daemonrpc_daemon_proto_msgTypes[15] + mi := &file_daemon_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1132,7 +1132,7 @@ func (x *Output) String() string { func (*Output) ProtoMessage() {} func (x *Output) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[15] + mi := &file_daemon_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1145,7 +1145,7 @@ func (x *Output) ProtoReflect() protoreflect.Message { // Deprecated: Use Output.ProtoReflect.Descriptor instead. func (*Output) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{15} + return file_daemon_proto_rawDescGZIP(), []int{15} } func (x *Output) GetDestination() isOutput_Destination { @@ -1229,7 +1229,7 @@ type SendVTXORequest struct { func (x *SendVTXORequest) Reset() { *x = SendVTXORequest{} - mi := &file_daemonrpc_daemon_proto_msgTypes[16] + mi := &file_daemon_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1241,7 +1241,7 @@ func (x *SendVTXORequest) String() string { func (*SendVTXORequest) ProtoMessage() {} func (x *SendVTXORequest) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[16] + mi := &file_daemon_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1254,7 +1254,7 @@ func (x *SendVTXORequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendVTXORequest.ProtoReflect.Descriptor instead. func (*SendVTXORequest) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{16} + return file_daemon_proto_rawDescGZIP(), []int{16} } func (x *SendVTXORequest) GetRecipients() []*Output { @@ -1288,7 +1288,7 @@ type SendVTXOResponse struct { func (x *SendVTXOResponse) Reset() { *x = SendVTXOResponse{} - mi := &file_daemonrpc_daemon_proto_msgTypes[17] + mi := &file_daemon_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1300,7 +1300,7 @@ func (x *SendVTXOResponse) String() string { func (*SendVTXOResponse) ProtoMessage() {} func (x *SendVTXOResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[17] + mi := &file_daemon_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1313,7 +1313,7 @@ func (x *SendVTXOResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendVTXOResponse.ProtoReflect.Descriptor instead. func (*SendVTXOResponse) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{17} + return file_daemon_proto_rawDescGZIP(), []int{17} } func (x *SendVTXOResponse) GetStatus() string { @@ -1349,7 +1349,7 @@ type SendOORRequest struct { func (x *SendOORRequest) Reset() { *x = SendOORRequest{} - mi := &file_daemonrpc_daemon_proto_msgTypes[18] + mi := &file_daemon_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1361,7 +1361,7 @@ func (x *SendOORRequest) String() string { func (*SendOORRequest) ProtoMessage() {} func (x *SendOORRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[18] + mi := &file_daemon_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1374,7 +1374,7 @@ func (x *SendOORRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendOORRequest.ProtoReflect.Descriptor instead. func (*SendOORRequest) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{18} + return file_daemon_proto_rawDescGZIP(), []int{18} } func (x *SendOORRequest) GetRecipient() *Output { @@ -1404,7 +1404,7 @@ type SendOORResponse struct { func (x *SendOORResponse) Reset() { *x = SendOORResponse{} - mi := &file_daemonrpc_daemon_proto_msgTypes[19] + mi := &file_daemon_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1416,7 +1416,7 @@ func (x *SendOORResponse) String() string { func (*SendOORResponse) ProtoMessage() {} func (x *SendOORResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[19] + mi := &file_daemon_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1429,7 +1429,7 @@ func (x *SendOORResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendOORResponse.ProtoReflect.Descriptor instead. func (*SendOORResponse) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{19} + return file_daemon_proto_rawDescGZIP(), []int{19} } func (x *SendOORResponse) GetStatus() string { @@ -1459,7 +1459,7 @@ type OutpointSelection struct { func (x *OutpointSelection) Reset() { *x = OutpointSelection{} - mi := &file_daemonrpc_daemon_proto_msgTypes[20] + mi := &file_daemon_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1471,7 +1471,7 @@ func (x *OutpointSelection) String() string { func (*OutpointSelection) ProtoMessage() {} func (x *OutpointSelection) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[20] + mi := &file_daemon_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1484,7 +1484,7 @@ func (x *OutpointSelection) ProtoReflect() protoreflect.Message { // Deprecated: Use OutpointSelection.ProtoReflect.Descriptor instead. func (*OutpointSelection) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{20} + return file_daemon_proto_rawDescGZIP(), []int{20} } func (x *OutpointSelection) GetOutpoints() []string { @@ -1509,7 +1509,7 @@ type RefreshVTXOsRequest struct { func (x *RefreshVTXOsRequest) Reset() { *x = RefreshVTXOsRequest{} - mi := &file_daemonrpc_daemon_proto_msgTypes[21] + mi := &file_daemon_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1521,7 +1521,7 @@ func (x *RefreshVTXOsRequest) String() string { func (*RefreshVTXOsRequest) ProtoMessage() {} func (x *RefreshVTXOsRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[21] + mi := &file_daemon_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1534,7 +1534,7 @@ func (x *RefreshVTXOsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RefreshVTXOsRequest.ProtoReflect.Descriptor instead. func (*RefreshVTXOsRequest) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{21} + return file_daemon_proto_rawDescGZIP(), []int{21} } func (x *RefreshVTXOsRequest) GetSelection() isRefreshVTXOsRequest_Selection { @@ -1601,7 +1601,7 @@ type RefreshVTXOsResponse struct { func (x *RefreshVTXOsResponse) Reset() { *x = RefreshVTXOsResponse{} - mi := &file_daemonrpc_daemon_proto_msgTypes[22] + mi := &file_daemon_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1613,7 +1613,7 @@ func (x *RefreshVTXOsResponse) String() string { func (*RefreshVTXOsResponse) ProtoMessage() {} func (x *RefreshVTXOsResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[22] + mi := &file_daemon_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1626,7 +1626,7 @@ func (x *RefreshVTXOsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RefreshVTXOsResponse.ProtoReflect.Descriptor instead. func (*RefreshVTXOsResponse) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{22} + return file_daemon_proto_rawDescGZIP(), []int{22} } func (x *RefreshVTXOsResponse) GetQueuedOutpoints() []string { @@ -1651,7 +1651,7 @@ type BoardRequest struct { func (x *BoardRequest) Reset() { *x = BoardRequest{} - mi := &file_daemonrpc_daemon_proto_msgTypes[23] + mi := &file_daemon_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1663,7 +1663,7 @@ func (x *BoardRequest) String() string { func (*BoardRequest) ProtoMessage() {} func (x *BoardRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[23] + mi := &file_daemon_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1676,7 +1676,7 @@ func (x *BoardRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BoardRequest.ProtoReflect.Descriptor instead. func (*BoardRequest) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{23} + return file_daemon_proto_rawDescGZIP(), []int{23} } type BoardResponse struct { @@ -1690,7 +1690,7 @@ type BoardResponse struct { func (x *BoardResponse) Reset() { *x = BoardResponse{} - mi := &file_daemonrpc_daemon_proto_msgTypes[24] + mi := &file_daemon_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1702,7 +1702,7 @@ func (x *BoardResponse) String() string { func (*BoardResponse) ProtoMessage() {} func (x *BoardResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[24] + mi := &file_daemon_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1715,7 +1715,7 @@ func (x *BoardResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BoardResponse.ProtoReflect.Descriptor instead. func (*BoardResponse) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{24} + return file_daemon_proto_rawDescGZIP(), []int{24} } func (x *BoardResponse) GetStatus() string { @@ -1738,7 +1738,7 @@ type RoundVTXOInfo struct { func (x *RoundVTXOInfo) Reset() { *x = RoundVTXOInfo{} - mi := &file_daemonrpc_daemon_proto_msgTypes[25] + mi := &file_daemon_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1750,7 +1750,7 @@ func (x *RoundVTXOInfo) String() string { func (*RoundVTXOInfo) ProtoMessage() {} func (x *RoundVTXOInfo) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[25] + mi := &file_daemon_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1763,7 +1763,7 @@ func (x *RoundVTXOInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use RoundVTXOInfo.ProtoReflect.Descriptor instead. func (*RoundVTXOInfo) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{25} + return file_daemon_proto_rawDescGZIP(), []int{25} } func (x *RoundVTXOInfo) GetOutpoint() string { @@ -1802,7 +1802,7 @@ type RoundInfo struct { func (x *RoundInfo) Reset() { *x = RoundInfo{} - mi := &file_daemonrpc_daemon_proto_msgTypes[26] + mi := &file_daemon_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1814,7 +1814,7 @@ func (x *RoundInfo) String() string { func (*RoundInfo) ProtoMessage() {} func (x *RoundInfo) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[26] + mi := &file_daemon_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1827,7 +1827,7 @@ func (x *RoundInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use RoundInfo.ProtoReflect.Descriptor instead. func (*RoundInfo) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{26} + return file_daemon_proto_rawDescGZIP(), []int{26} } func (x *RoundInfo) GetRoundId() string { @@ -1879,7 +1879,7 @@ type ListRoundsRequest struct { func (x *ListRoundsRequest) Reset() { *x = ListRoundsRequest{} - mi := &file_daemonrpc_daemon_proto_msgTypes[27] + mi := &file_daemon_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1891,7 +1891,7 @@ func (x *ListRoundsRequest) String() string { func (*ListRoundsRequest) ProtoMessage() {} func (x *ListRoundsRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[27] + mi := &file_daemon_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1904,7 +1904,7 @@ func (x *ListRoundsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRoundsRequest.ProtoReflect.Descriptor instead. func (*ListRoundsRequest) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{27} + return file_daemon_proto_rawDescGZIP(), []int{27} } func (x *ListRoundsRequest) GetPageSize() int32 { @@ -1941,7 +1941,7 @@ type ListRoundsResponse struct { func (x *ListRoundsResponse) Reset() { *x = ListRoundsResponse{} - mi := &file_daemonrpc_daemon_proto_msgTypes[28] + mi := &file_daemon_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1953,7 +1953,7 @@ func (x *ListRoundsResponse) String() string { func (*ListRoundsResponse) ProtoMessage() {} func (x *ListRoundsResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[28] + mi := &file_daemon_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1966,7 +1966,7 @@ func (x *ListRoundsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRoundsResponse.ProtoReflect.Descriptor instead. func (*ListRoundsResponse) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{28} + return file_daemon_proto_rawDescGZIP(), []int{28} } func (x *ListRoundsResponse) GetRounds() []*RoundInfo { @@ -1991,7 +1991,7 @@ type WatchRoundsRequest struct { func (x *WatchRoundsRequest) Reset() { *x = WatchRoundsRequest{} - mi := &file_daemonrpc_daemon_proto_msgTypes[29] + mi := &file_daemon_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2003,7 +2003,7 @@ func (x *WatchRoundsRequest) String() string { func (*WatchRoundsRequest) ProtoMessage() {} func (x *WatchRoundsRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[29] + mi := &file_daemon_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2016,7 +2016,7 @@ func (x *WatchRoundsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchRoundsRequest.ProtoReflect.Descriptor instead. func (*WatchRoundsRequest) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{29} + return file_daemon_proto_rawDescGZIP(), []int{29} } type WatchRoundsResponse struct { @@ -2030,7 +2030,7 @@ type WatchRoundsResponse struct { func (x *WatchRoundsResponse) Reset() { *x = WatchRoundsResponse{} - mi := &file_daemonrpc_daemon_proto_msgTypes[30] + mi := &file_daemon_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2042,7 +2042,7 @@ func (x *WatchRoundsResponse) String() string { func (*WatchRoundsResponse) ProtoMessage() {} func (x *WatchRoundsResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemonrpc_daemon_proto_msgTypes[30] + mi := &file_daemon_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2055,7 +2055,7 @@ func (x *WatchRoundsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchRoundsResponse.ProtoReflect.Descriptor instead. func (*WatchRoundsResponse) Descriptor() ([]byte, []int) { - return file_daemonrpc_daemon_proto_rawDescGZIP(), []int{30} + return file_daemon_proto_rawDescGZIP(), []int{30} } func (x *WatchRoundsResponse) GetRound() *RoundInfo { @@ -2065,11 +2065,11 @@ func (x *WatchRoundsResponse) GetRound() *RoundInfo { return nil } -var File_daemonrpc_daemon_proto protoreflect.FileDescriptor +var File_daemon_proto protoreflect.FileDescriptor -const file_daemonrpc_daemon_proto_rawDesc = "" + +const file_daemon_proto_rawDesc = "" + "\n" + - "\x16daemonrpc/daemon.proto\x12\tdaemonrpc\"\x10\n" + + "\fdaemon.proto\x12\tdaemonrpc\"\x10\n" + "\x0eGetInfoRequest\"\xe5\x02\n" + "\x0fGetInfoResponse\x12\x18\n" + "\aversion\x18\x01 \x01(\tR\aversion\x12\x16\n" + @@ -2179,16 +2179,16 @@ const file_daemonrpc_daemon_proto_rawDesc = "" + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\x14\n" + "\x12WatchRoundsRequest\"A\n" + "\x13WatchRoundsResponse\x12*\n" + - "\x05round\x18\x01 \x01(\v2\x14.daemonrpc.RoundInfoR\x05round*\xe2\x01\n" + + "\x05round\x18\x01 \x01(\v2\x14.daemonrpc.RoundInfoR\x05round*\xe7\x01\n" + "\n" + "VTXOStatus\x12\x1b\n" + "\x17VTXO_STATUS_UNSPECIFIED\x10\x00\x12\x14\n" + - "\x10VTXO_STATUS_LIVE\x10\x01\x12!\n" + - "\x1dVTXO_STATUS_PENDING_FORFEIT\x10\x02\x12\x1a\n" + + "\x10VTXO_STATUS_LIVE\x10\x01\x12\x1f\n" + + "\x1bVTXO_STATUS_PENDING_FORFEIT\x10\x02\x12\x1a\n" + "\x16VTXO_STATUS_FORFEITING\x10\x03\x12\x19\n" + "\x15VTXO_STATUS_FORFEITED\x10\x04\x12\x15\n" + - "\x11VTXO_STATUS_SPENT\x10\x05\x12\x18\n" + - "\x14VTXO_STATUS_UNILATERAL_EXIT\x10\x06\x12\x16\n" + + "\x11VTXO_STATUS_SPENT\x10\x05\x12\x1f\n" + + "\x1bVTXO_STATUS_UNILATERAL_EXIT\x10\x06\x12\x16\n" + "\x12VTXO_STATUS_FAILED\x10\a*\xd7\x03\n" + "\n" + "RoundState\x12\x17\n" + @@ -2228,20 +2228,20 @@ const file_daemonrpc_daemon_proto_rawDesc = "" + "\vWatchRounds\x12\x1d.daemonrpc.WatchRoundsRequest\x1a\x1e.daemonrpc.WatchRoundsResponse0\x01B2Z0github.com/lightninglabs/darepo-client/daemonrpcb\x06proto3" var ( - file_daemonrpc_daemon_proto_rawDescOnce sync.Once - file_daemonrpc_daemon_proto_rawDescData []byte + file_daemon_proto_rawDescOnce sync.Once + file_daemon_proto_rawDescData []byte ) -func file_daemonrpc_daemon_proto_rawDescGZIP() []byte { - file_daemonrpc_daemon_proto_rawDescOnce.Do(func() { - file_daemonrpc_daemon_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_daemonrpc_daemon_proto_rawDesc), len(file_daemonrpc_daemon_proto_rawDesc))) +func file_daemon_proto_rawDescGZIP() []byte { + file_daemon_proto_rawDescOnce.Do(func() { + file_daemon_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc))) }) - return file_daemonrpc_daemon_proto_rawDescData + return file_daemon_proto_rawDescData } -var file_daemonrpc_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_daemonrpc_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 31) -var file_daemonrpc_daemon_proto_goTypes = []any{ +var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 31) +var file_daemon_proto_goTypes = []any{ (VTXOStatus)(0), // 0: daemonrpc.VTXOStatus (RoundState)(0), // 1: daemonrpc.RoundState (*GetInfoRequest)(nil), // 2: daemonrpc.GetInfoRequest @@ -2276,7 +2276,7 @@ var file_daemonrpc_daemon_proto_goTypes = []any{ (*WatchRoundsRequest)(nil), // 31: daemonrpc.WatchRoundsRequest (*WatchRoundsResponse)(nil), // 32: daemonrpc.WatchRoundsResponse } -var file_daemonrpc_daemon_proto_depIdxs = []int32{ +var file_daemon_proto_depIdxs = []int32{ 0, // 0: daemonrpc.VTXO.status:type_name -> daemonrpc.VTXOStatus 0, // 1: daemonrpc.ListVTXOsRequest.status_filter:type_name -> daemonrpc.VTXOStatus 12, // 2: daemonrpc.ListVTXOsResponse.vtxos:type_name -> daemonrpc.VTXO @@ -2320,17 +2320,17 @@ var file_daemonrpc_daemon_proto_depIdxs = []int32{ 0, // [0:10] is the sub-list for field type_name } -func init() { file_daemonrpc_daemon_proto_init() } -func file_daemonrpc_daemon_proto_init() { - if File_daemonrpc_daemon_proto != nil { +func init() { file_daemon_proto_init() } +func file_daemon_proto_init() { + if File_daemon_proto != nil { return } - file_daemonrpc_daemon_proto_msgTypes[15].OneofWrappers = []any{ + file_daemon_proto_msgTypes[15].OneofWrappers = []any{ (*Output_Address)(nil), (*Output_Pubkey)(nil), (*Output_PkScript)(nil), } - file_daemonrpc_daemon_proto_msgTypes[21].OneofWrappers = []any{ + file_daemon_proto_msgTypes[21].OneofWrappers = []any{ (*RefreshVTXOsRequest_Outpoints)(nil), (*RefreshVTXOsRequest_All)(nil), } @@ -2338,18 +2338,18 @@ func file_daemonrpc_daemon_proto_init() { out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemonrpc_daemon_proto_rawDesc), len(file_daemonrpc_daemon_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), NumEnums: 2, NumMessages: 31, NumExtensions: 0, NumServices: 1, }, - GoTypes: file_daemonrpc_daemon_proto_goTypes, - DependencyIndexes: file_daemonrpc_daemon_proto_depIdxs, - EnumInfos: file_daemonrpc_daemon_proto_enumTypes, - MessageInfos: file_daemonrpc_daemon_proto_msgTypes, + GoTypes: file_daemon_proto_goTypes, + DependencyIndexes: file_daemon_proto_depIdxs, + EnumInfos: file_daemon_proto_enumTypes, + MessageInfos: file_daemon_proto_msgTypes, }.Build() - File_daemonrpc_daemon_proto = out.File - file_daemonrpc_daemon_proto_goTypes = nil - file_daemonrpc_daemon_proto_depIdxs = nil + File_daemon_proto = out.File + file_daemon_proto_goTypes = nil + file_daemon_proto_depIdxs = nil } diff --git a/daemonrpc/daemon_grpc.pb.go b/daemonrpc/daemon_grpc.pb.go index 6cf3248d3..5e0f74df7 100644 --- a/daemonrpc/daemon_grpc.pb.go +++ b/daemonrpc/daemon_grpc.pb.go @@ -1,8 +1,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v5.28.0 -// source: daemonrpc/daemon.proto +// - protoc v3.21.12 +// source: daemon.proto package daemonrpc @@ -655,5 +655,5 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ ServerStreams: true, }, }, - Metadata: "daemonrpc/daemon.proto", + Metadata: "daemon.proto", } diff --git a/daemonrpc/daemon_mailboxrpc.pb.go b/daemonrpc/daemon_mailboxrpc.pb.go index b3b186cc3..4a2cde742 100644 --- a/daemonrpc/daemon_mailboxrpc.pb.go +++ b/daemonrpc/daemon_mailboxrpc.pb.go @@ -46,6 +46,10 @@ type DaemonServiceMailboxServer interface { RefreshVTXOs(ctx context.Context, req *RefreshVTXOsRequest) (*RefreshVTXOsResponse, error) // Board handles Board. Board(ctx context.Context, req *BoardRequest) (*BoardResponse, error) + // ListRounds handles ListRounds. + ListRounds(ctx context.Context, req *ListRoundsRequest) (*ListRoundsResponse, error) + // WatchRounds handles WatchRounds. + WatchRounds(ctx context.Context, req *WatchRoundsRequest) (*WatchRoundsResponse, error) } // RegisterDaemonServiceMailboxServer registers handlers for DaemonService. @@ -160,6 +164,26 @@ func RegisterDaemonServiceMailboxServer(r rpc.Router, impl DaemonServiceMailboxS return impl.Board(ctx, req) }) + r.Handle("daemonrpc.DaemonService", "ListRounds", func() proto.Message { + return &ListRoundsRequest{} + }, func(ctx context.Context, msg proto.Message) (proto.Message, error) { + req, ok := msg.(*ListRoundsRequest) + if !ok { + return nil, fmt.Errorf("unexpected request type: %T", msg) + } + + return impl.ListRounds(ctx, req) + }) + r.Handle("daemonrpc.DaemonService", "WatchRounds", func() proto.Message { + return &WatchRoundsRequest{} + }, func(ctx context.Context, msg proto.Message) (proto.Message, error) { + req, ok := msg.(*WatchRoundsRequest) + if !ok { + return nil, fmt.Errorf("unexpected request type: %T", msg) + } + + return impl.WatchRounds(ctx, req) + }) } // GetInfo calls the GetInfo RPC. @@ -414,3 +438,49 @@ func (c *DaemonServiceMailboxClient) Board(ctx context.Context, req *BoardReques return resp, nil } + +// ListRounds calls the ListRounds RPC. +func (c *DaemonServiceMailboxClient) ListRounds(ctx context.Context, req *ListRoundsRequest, opts ...rpc.RPCOptions) (*ListRoundsResponse, error) { + var opt rpc.RPCOptions + if len(opts) > 0 { + opt = opts[0] + } + + result, err := c.C.SendRPC(ctx, rpc.ServiceMethod{ + Service: "daemonrpc.DaemonService", + Method: "ListRounds", + }, req, opt) + if err != nil { + return nil, err + } + + resp := new(ListRoundsResponse) + if err := c.C.AwaitRPC(ctx, result.CorrelationID, resp); err != nil { + return nil, err + } + + return resp, nil +} + +// WatchRounds calls the WatchRounds RPC. +func (c *DaemonServiceMailboxClient) WatchRounds(ctx context.Context, req *WatchRoundsRequest, opts ...rpc.RPCOptions) (*WatchRoundsResponse, error) { + var opt rpc.RPCOptions + if len(opts) > 0 { + opt = opts[0] + } + + result, err := c.C.SendRPC(ctx, rpc.ServiceMethod{ + Service: "daemonrpc.DaemonService", + Method: "WatchRounds", + }, req, opt) + if err != nil { + return nil, err + } + + resp := new(WatchRoundsResponse) + if err := c.C.AwaitRPC(ctx, result.CorrelationID, resp); err != nil { + return nil, err + } + + return resp, nil +} diff --git a/darepod/rpc_server.go b/darepod/rpc_server.go index d4bcce03e..48066cd81 100644 --- a/darepod/rpc_server.go +++ b/darepod/rpc_server.go @@ -1200,3 +1200,22 @@ func (r *RPCServer) WatchRounds( } } } + +// rpcMailboxAdapter wraps RPCServer to satisfy +// DaemonServiceMailboxServer. The mailbox transport is unary +// request/response, so server-streaming RPCs like WatchRounds +// return an error indicating they are unsupported over mailbox. +type rpcMailboxAdapter struct { + *RPCServer +} + +// WatchRounds is unsupported over the mailbox transport because it +// is a server-streaming RPC. Callers should use the gRPC transport +// for streaming endpoints. +func (a *rpcMailboxAdapter) WatchRounds(_ context.Context, + _ *daemonrpc.WatchRoundsRequest) ( + *daemonrpc.WatchRoundsResponse, error) { + + return nil, fmt.Errorf("WatchRounds is a server-streaming " + + "RPC and is not supported over mailbox transport") +} diff --git a/darepod/server.go b/darepod/server.go index f54b304f8..fc022c120 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -398,7 +398,8 @@ func (s *Server) run(ctx context.Context, // handler serves both transports. s.mailboxMux = mailboxrpc.NewServeMux() daemonrpc.RegisterDaemonServiceMailboxServer( - s.mailboxMux, s.rpcServer, + s.mailboxMux, + &rpcMailboxAdapter{RPCServer: s.rpcServer}, ) lis, err := net.Listen("tcp", s.cfg.RPC.ListenAddr) From 083e68e082f2a76e28d43013355f608dd37cbb6b Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 12 Mar 2026 21:44:00 -0500 Subject: [PATCH 17/19] round: use findAssemblingRound for intent registration and add VTXO dedup 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. --- round/actor.go | 16 +++-- round/actor_harness_test.go | 35 ++++++++++ round/actor_test.go | 126 ++++++++++++++++++++++++++++++++++++ round/transitions.go | 20 +++++- 4 files changed, 192 insertions(+), 5 deletions(-) diff --git a/round/actor.go b/round/actor.go index a833dde06..53e0859d8 100644 --- a/round/actor.go +++ b/round/actor.go @@ -1732,8 +1732,12 @@ func (a *RoundClientActor) processConfirmationRequest( func (a *RoundClientActor) handleRefreshVTXORequest(ctx context.Context, req *RefreshVTXORequest) fn.Result[actormsg.RoundActorResp] { - // Find a pending round or create one if none exists. - roundFSM := a.findPendingRound() + // Find an assembling round (Idle or PendingRoundAssembly) or create + // one. We must not use findPendingRound here because it matches by + // temp-key status, which includes rounds in RegistrationSentState. + // Feeding an IntentPackage to RegistrationSentState would self-loop + // silently, discarding the intent. + roundFSM := a.findAssemblingRound() if roundFSM == nil { var err error roundFSM, err = a.createNewRound(ctx) @@ -1788,8 +1792,12 @@ func (a *RoundClientActor) handleRegisterIntent(ctx context.Context, ) } - // Find a pending round or create one if none exists. - roundFSM := a.findPendingRound() + // Find an assembling round (Idle or PendingRoundAssembly) or create + // one. We must not use findPendingRound here because it matches by + // temp-key status, which includes rounds in RegistrationSentState. + // Feeding an IntentPackage to RegistrationSentState would self-loop + // silently, discarding the intent. + roundFSM := a.findAssemblingRound() if roundFSM == nil { var err error roundFSM, err = a.createNewRound(ctx) diff --git a/round/actor_harness_test.go b/round/actor_harness_test.go index ac5b17a72..bf2ce6d82 100644 --- a/round/actor_harness_test.go +++ b/round/actor_harness_test.go @@ -1001,6 +1001,41 @@ func (h *actorTestHarness) setupRoundInForfeitCollectingState(roundID RoundID) { } } +// setupRoundInRegistrationSentState creates a round FSM in +// RegistrationSentState with a temp key and adds it to the actor's rounds map. +// This simulates a round that has been sent to the server but not yet assigned +// a RoundID. +func (h *actorTestHarness) setupRoundInRegistrationSentState() TempRoundKey { + h.t.Helper() + + tempKey, err := NewTempRoundKey() + require.NoError(h.t, err) + + initialState := &RegistrationSentState{ + Intents: Intents{}, + } + + errReporter := newContextErrorReporter( + h.ctx, tempKey.LogPrefix(), + ) + fsmCfg := ClientStateMachineCfg{ + Logger: h.actor.log.WithPrefix(tempKey.LogPrefix()), + ErrorReporter: errReporter, + InitialState: initialState, + Env: h.actor.env, + } + newFSM := protofsm.NewStateMachine(fsmCfg) + newFSM.Start(h.ctx) + + keyStr := RoundKeyStr(tempKey.KeyString()) + h.actor.rounds[keyStr] = &RoundFSM{ + FSM: &newFSM, + Key: tempKey, + } + + return tempKey +} + // setupMockRoundStoreForRecovery configures the RoundStore mock to return // active rounds for recovery on Start(), using PartialSigsSentState which is // stable and won't immediately transition on recovery. diff --git a/round/actor_test.go b/round/actor_test.go index 09d0ae9a3..6bd4d177f 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -2143,4 +2143,130 @@ func TestHandleRegisterIntent(t *testing.T) { require.Len(t, assembly.VTXOs, 1, "1 from register intent") }) + + t.Run("intent_creates_new_round_when_existing_in_registration_sent", + func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + + err := h.start() + require.NoError(t, err) + + // Place a round in RegistrationSentState with a + // temp key. Before the fix, findPendingRound would + // match this round and the IntentPackage would be + // silently self-looped. + h.setupRoundInRegistrationSentState() + + // Now register an intent. With findAssemblingRound, + // this should create a new round rather than + // matching the RegistrationSent one. + vtxoOutpoint := wire.OutPoint{ + Hash: chainhash.HashH( + []byte("new-round"), + ), + Index: 0, + } + req := &RegisterIntentRequest{ + Package: &IntentPackage{Intents: Intents{ + Forfeits: []types.ForfeitRequest{{ + VTXOOutpoint: &vtxoOutpoint, + }}, + VTXOs: []types.VTXORequest{{ + Amount: 40000, + PkScript: []byte{0x51, 0x20}, + ClientKey: h.clientPubKey, + OperatorKey: h.operatorPubKey, + Expiry: 144, + }}, + }}, + } + + result := h.receive(req) + require.True( + t, result.IsOk(), + "expected Ok, got: %v", result.Err(), + ) + + // We should now have two rounds: one in + // RegistrationSent and a new one in + // PendingRoundAssembly with our intent. + states := h.queryState() + require.Len(t, states, 2, + "should have 2 rounds") + + var foundAssembly bool + for _, info := range states { + assembly, ok := info.State.(*PendingRoundAssembly) + if !ok { + continue + } + + foundAssembly = true + require.Len( + t, assembly.Forfeits, 1, + "intent should be in new round", + ) + } + require.True(t, foundAssembly, + "should have a PendingRoundAssembly round") + }, + ) + + t.Run("duplicate_vtxo_pkscript_deduplicated", func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + + err := h.start() + require.NoError(t, err) + + pkScript := []byte{0x51, 0x20, 0x01, 0x02} + + // Send two intent packages with VTXOs sharing the same + // PkScript. The second should be deduplicated. + for i := 0; i < 2; i++ { + outpoint := wire.OutPoint{ + Hash: chainhash.HashH( + []byte(fmt.Sprintf("dup-vtxo-%d", i)), + ), + Index: 0, + } + req := &RegisterIntentRequest{ + Package: &IntentPackage{Intents: Intents{ + Forfeits: []types.ForfeitRequest{{ + VTXOOutpoint: &outpoint, + }}, + VTXOs: []types.VTXORequest{{ + Amount: 40000, + PkScript: pkScript, + ClientKey: h.clientPubKey, + OperatorKey: h.operatorPubKey, + Expiry: 144, + }}, + }}, + } + + result := h.receive(req) + require.True(t, result.IsOk()) + } + + states := h.queryState() + tempState, exists := h.findTempState(states) + require.True(t, exists) + + assembly, ok := tempState.State.(*PendingRoundAssembly) + require.True(t, ok) + + // Forfeits should have 2 (different outpoints). + require.Len(t, assembly.Forfeits, 2) + + // VTXOs should be deduplicated to 1 (same PkScript). + require.Len(t, assembly.VTXOs, 1, + "duplicate VTXOs by PkScript should be "+ + "deduplicated") + }) } diff --git a/round/transitions.go b/round/transitions.go index dbc732be4..e360cf25c 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -238,8 +238,26 @@ func (s *PendingRoundAssembly) ProcessEvent(ctx context.Context, ) } + // Deduplicate VTXO requests by PkScript. Two refresh + // paths (wallet-driven and auto-expiry) could race to + // create an output for the same VTXO. Duplicate outputs + // would inflate totalOutput and cause the balance check + // to fail, locking the VTXO in PendingForfeitState. + vtxoScriptSeen := fn.NewSet[string]() + for _, v := range s.VTXOs { + vtxoScriptSeen.Add(string(v.PkScript)) + } + updatedVTXOs := slices.Clone(s.VTXOs) - updatedVTXOs = append(updatedVTXOs, evt.VTXOs...) + for _, newVTXO := range evt.VTXOs { + key := string(newVTXO.PkScript) + if vtxoScriptSeen.Contains(key) { + continue + } + + vtxoScriptSeen.Add(key) + updatedVTXOs = append(updatedVTXOs, newVTXO) + } updatedLeaves := slices.Clone(s.Leaves) updatedLeaves = append(updatedLeaves, evt.Leaves...) From 71ec7c699009708cce0a350f34ab02aeb017e0f6 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 12 Mar 2026 21:44:07 -0500 Subject: [PATCH 18/19] round: validate forfeit amounts against VTXOStore instead of trusting 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. --- round/interfaces.go | 6 ++++-- round/join_auth.go | 41 +++++++++++++++++++++++++-------------- round/join_auth_test.go | 43 +++++++++++++++++++++++++++++++++++------ 3 files changed, 68 insertions(+), 22 deletions(-) diff --git a/round/interfaces.go b/round/interfaces.go index 35f61adac..078caeafc 100644 --- a/round/interfaces.go +++ b/round/interfaces.go @@ -230,8 +230,10 @@ type Intents struct { Leaves []*types.LeaveRequest // Forfeits contains the VTXOs being forfeited as inputs. Each entry - // carries only the outpoint; the VTXO amount is looked up from - // VTXOStore at registration time. + // carries the outpoint and an optional embedded Amount hint. The + // canonical amount is always looked up from VTXOStore at registration + // time; the embedded Amount is only used as a fallback when no store + // is available (e.g., test environments). Forfeits []types.ForfeitRequest } diff --git a/round/join_auth.go b/round/join_auth.go index 5323dd7f7..6cd066279 100644 --- a/round/join_auth.go +++ b/round/join_auth.go @@ -89,9 +89,7 @@ func deriveJoinAuthIdentifierKey(ctx context.Context, // sortedForfeitRequests sorts forfeit requests by outpoint (txid bytes // then output index) so the resulting list is deterministic. Returns an -// error if any request has a nil VTXOOutpoint. The embedded Amount field -// is preserved so callers can use the fast-path in -// computeTotalForfeitAmount without a store lookup. +// error if any request has a nil VTXOOutpoint. func sortedForfeitRequests( forfeits []types.ForfeitRequest) ([]*types.ForfeitRequest, error) { @@ -149,30 +147,45 @@ func sortOutPoints(outpoints []wire.OutPoint) { } // computeTotalForfeitAmount looks up each forfeited VTXO's value from -// the VTXOStore and returns the sum. If the caller already populated the -// local Amount field, we use that directly and avoid the store lookup. +// the VTXOStore and returns the sum. The VTXOStore is always used as +// the canonical source of truth to prevent callers from inflating the +// forfeit total via the embedded Amount field. If the store is nil (test +// harness), the embedded Amount is used as a fallback. func computeTotalForfeitAmount(ctx context.Context, store VTXOStore, forfeits []types.ForfeitRequest) (btcutil.Amount, error) { var total btcutil.Amount for i := 0; i < len(forfeits); i++ { + // When a store is available, always use it as the source + // of truth for the VTXO amount. + if store != nil { + vtxo, err := store.GetVTXO( + ctx, *forfeits[i].VTXOOutpoint, + ) + if err != nil { + return 0, fmt.Errorf( + "forfeit amount lookup %s: %w", + forfeits[i].VTXOOutpoint, err, + ) + } + total += vtxo.Amount + + continue + } + + // Fallback to the embedded amount when no store is + // available (e.g., test environments). if forfeits[i].Amount != 0 { total += forfeits[i].Amount continue } - vtxo, err := store.GetVTXO( - ctx, *forfeits[i].VTXOOutpoint, + return 0, fmt.Errorf( + "no store and no embedded amount for %s", + forfeits[i].VTXOOutpoint, ) - if err != nil { - return 0, fmt.Errorf( - "forfeit amount lookup %s: %w", - forfeits[i].VTXOOutpoint, err, - ) - } - total += vtxo.Amount } return total, nil diff --git a/round/join_auth_test.go b/round/join_auth_test.go index 136b67d24..db7016fac 100644 --- a/round/join_auth_test.go +++ b/round/join_auth_test.go @@ -638,10 +638,9 @@ func TestBuildJoinRoundAuthForfeitOnly(t *testing.T) { f.verifyAuth(t, auth, proofPrevOuts) } -// TestComputeTotalForfeitAmountUsesEmbeddedAmount verifies that the round can -// sum forfeits without consulting the VTXO store when the caller already -// supplied local amount metadata. -func TestComputeTotalForfeitAmountUsesEmbeddedAmount(t *testing.T) { +// TestComputeTotalForfeitAmountFallsBackToEmbedded verifies that the embedded +// Amount is used as a fallback when no VTXOStore is available (nil store). +func TestComputeTotalForfeitAmountFallsBackToEmbedded(t *testing.T) { t.Parallel() total, err := computeTotalForfeitAmount( @@ -661,9 +660,41 @@ func TestComputeTotalForfeitAmountUsesEmbeddedAmount(t *testing.T) { require.Equal(t, btcutil.Amount(579), total) } +// TestComputeTotalForfeitAmountStoreOverridesEmbedded verifies that +// when a VTXOStore is provided, the store amount is used even if the +// embedded Amount field is set. This prevents callers from inflating +// the forfeit total. +func TestComputeTotalForfeitAmountStoreOverridesEmbedded(t *testing.T) { + t.Parallel() + + outpoint1 := wire.OutPoint{Hash: chainhash.Hash{0x01}} + outpoint2 := wire.OutPoint{Hash: chainhash.Hash{0x02}, Index: 1} + + store := &MockVTXOStore{} + store.On("GetVTXO", mock.Anything, outpoint1).Return( + &ClientVTXO{Amount: 100}, nil, + ) + store.On("GetVTXO", mock.Anything, outpoint2).Return( + &ClientVTXO{Amount: 200}, nil, + ) + + // The embedded amounts (999, 888) should be ignored when a store + // is available. The store values (100, 200) should be used. + total, err := computeTotalForfeitAmount( + t.Context(), + store, + []types.ForfeitRequest{ + {VTXOOutpoint: &outpoint1, Amount: 999}, + {VTXOOutpoint: &outpoint2, Amount: 888}, + }, + ) + require.NoError(t, err) + require.Equal(t, btcutil.Amount(300), total, + "store amounts (100+200) should override embedded (999+888)") +} + // TestSortedForfeitRequestsPreservesAmount verifies that the Amount -// field survives the sort so the embedded-amount fast-path in -// computeTotalForfeitAmount works on sorted output. +// field survives the sort. func TestSortedForfeitRequestsPreservesAmount(t *testing.T) { t.Parallel() From a421b045948f073f89746130df0ea010d7732dc5 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 12 Mar 2026 21:46:05 -0500 Subject: [PATCH 19/19] docs: update per-package docs for VTXO FSM refactor 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. --- ARCHITECTURE.md | 6 ++++-- round/AGENTS.md | 9 ++++++--- round/CLAUDE.md | 9 ++++++--- round/actor_test.go | 3 ++- vtxo/AGENTS.md | 18 +++++++++--------- vtxo/CLAUDE.md | 18 +++++++++--------- wallet/AGENTS.md | 9 +++++---- wallet/CLAUDE.md | 9 +++++---- 8 files changed, 46 insertions(+), 35 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c975af5a2..6e7440315 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -139,8 +139,10 @@ InputSigSent → Confirmed → Idle ### VTXO FSM ``` -Live → RefreshRequested → Forfeiting → Forfeited -Live → Expiring → (sweep or refresh) +Live → PendingForfeit → Forfeiting → Forfeited +Live → Forfeiting → Forfeited (fast path: ForfeitRequestEvent in LiveState) +PendingForfeit → UnilateralExit (critical expiry while pending) +Live → UnilateralExit (critical expiry) Live → Spent Any → Failed ``` diff --git a/round/AGENTS.md b/round/AGENTS.md index 26a61d5fd..cff1b4537 100644 --- a/round/AGENTS.md +++ b/round/AGENTS.md @@ -13,6 +13,9 @@ protocols with MuSig2 signing ceremonies. - `ClientOutMsg` — Outbound messages (JoinRoundRequest, SubmitNoncesRequest, SubmitPartialSigRequest, VTXOCreatedNotification). - `ClientEnvironment` — FSM environment providing storage access (boarding intents, round checkpoints, VTXO store). - `BoardingIntent` — Represents a funded on-chain input to include in a round. +- `Intents` — Pools of boarding, VTXO, forfeit, and leave requests accumulated before registration. +- `IntentPackage` — FSM event wrapping `Intents` for atomic delivery to the round FSM. +- `RegisterIntentRequest` — Actor message carrying a pre-composed `IntentPackage` from the wallet. ## Relationships @@ -20,14 +23,14 @@ protocols with MuSig2 signing ceremonies. - **Depended on by**: `vtxo` (forfeit coordination), `db` (round persistence), `darepod` (wiring). - **Sends**: - → `serverconn`: `JoinRoundRequest`, `SubmitNoncesRequest`, `SubmitPartialSigRequest`, `SubmitForfeitSigRequest`, `SubmitVTXOForfeitSigsToServer` - - → `vtxo`: `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `RefreshAcknowledgedEvent`, `BlockEpochEvent` + - → `vtxo`: `PendingForfeitEvent`, `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `BlockEpochEvent` - → `vtxo` manager: `VTXOCreatedNotification` - → `wallet`: `RegisterConfirmationNotifierRequest` - → `timeout`: `ScheduleTimeoutRequest`, `CancelTimeoutRequest` - **Receives**: - ← `serverconn`: `CommitmentTxBuilt`, `NoncesAggregated`, `OperatorSigned`, `RoundJoined`, `BoardingFailed` - - ← `vtxo`: `RefreshVTXORequest`, `LeaveVTXORequest`, `ForfeitSignatureSubmission` - - ← `wallet` (via `lib/actormsg`): `TriggerBoardMsg` (combines VTXO registration + registration trigger) + - ← `vtxo`: `RefreshVTXORequest`, `ForfeitSignatureSubmission` + - ← `wallet` (via `lib/actormsg`): `RegisterIntentMsg` (pre-composed intent package), `TriggerBoardMsg` (VTXO registration + registration trigger) - ← `wallet`: `BoardingUtxoConfirmedEvent` - ← `timeout`: `TimeoutMsg` - ← `chainsource`: `ConfirmationEvent` diff --git a/round/CLAUDE.md b/round/CLAUDE.md index 26a61d5fd..cff1b4537 100644 --- a/round/CLAUDE.md +++ b/round/CLAUDE.md @@ -13,6 +13,9 @@ protocols with MuSig2 signing ceremonies. - `ClientOutMsg` — Outbound messages (JoinRoundRequest, SubmitNoncesRequest, SubmitPartialSigRequest, VTXOCreatedNotification). - `ClientEnvironment` — FSM environment providing storage access (boarding intents, round checkpoints, VTXO store). - `BoardingIntent` — Represents a funded on-chain input to include in a round. +- `Intents` — Pools of boarding, VTXO, forfeit, and leave requests accumulated before registration. +- `IntentPackage` — FSM event wrapping `Intents` for atomic delivery to the round FSM. +- `RegisterIntentRequest` — Actor message carrying a pre-composed `IntentPackage` from the wallet. ## Relationships @@ -20,14 +23,14 @@ protocols with MuSig2 signing ceremonies. - **Depended on by**: `vtxo` (forfeit coordination), `db` (round persistence), `darepod` (wiring). - **Sends**: - → `serverconn`: `JoinRoundRequest`, `SubmitNoncesRequest`, `SubmitPartialSigRequest`, `SubmitForfeitSigRequest`, `SubmitVTXOForfeitSigsToServer` - - → `vtxo`: `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `RefreshAcknowledgedEvent`, `BlockEpochEvent` + - → `vtxo`: `PendingForfeitEvent`, `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `BlockEpochEvent` - → `vtxo` manager: `VTXOCreatedNotification` - → `wallet`: `RegisterConfirmationNotifierRequest` - → `timeout`: `ScheduleTimeoutRequest`, `CancelTimeoutRequest` - **Receives**: - ← `serverconn`: `CommitmentTxBuilt`, `NoncesAggregated`, `OperatorSigned`, `RoundJoined`, `BoardingFailed` - - ← `vtxo`: `RefreshVTXORequest`, `LeaveVTXORequest`, `ForfeitSignatureSubmission` - - ← `wallet` (via `lib/actormsg`): `TriggerBoardMsg` (combines VTXO registration + registration trigger) + - ← `vtxo`: `RefreshVTXORequest`, `ForfeitSignatureSubmission` + - ← `wallet` (via `lib/actormsg`): `RegisterIntentMsg` (pre-composed intent package), `TriggerBoardMsg` (VTXO registration + registration trigger) - ← `wallet`: `BoardingUtxoConfirmedEvent` - ← `timeout`: `TimeoutMsg` - ← `chainsource`: `ConfirmationEvent` diff --git a/round/actor_test.go b/round/actor_test.go index 6bd4d177f..d764b8c7f 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -2199,7 +2199,8 @@ func TestHandleRegisterIntent(t *testing.T) { var foundAssembly bool for _, info := range states { - assembly, ok := info.State.(*PendingRoundAssembly) + s := info.State + assembly, ok := s.(*PendingRoundAssembly) if !ok { continue } diff --git a/vtxo/AGENTS.md b/vtxo/AGENTS.md index e97b27ff9..c13432af2 100644 --- a/vtxo/AGENTS.md +++ b/vtxo/AGENTS.md @@ -3,28 +3,28 @@ ## Purpose Per-VTXO lifecycle management using a state machine that monitors expiry, -coordinates refresh (forfeit + new issuance), and tracks cooperative and -unilateral spending paths. +coordinates forfeit signing, and tracks cooperative and unilateral exit paths. +The VTXO FSM models lifecycle phases only (not business intent like +refresh vs leave); intent composition is handled by the wallet. ## Key Types -- `VTXOState` — Sealed interface for all states (Live, RefreshRequested, Forfeiting, Forfeited, Expiring, Failed). +- `VTXOState` — Sealed interface for all states (Live, PendingForfeit, Forfeiting, Forfeited, UnilateralExit, Failed). - `Descriptor` — Complete VTXO metadata: outpoint, amount, taproot key, CSV expiry, tree path to root. - `Manager` — Actor managing per-VTXO FSM instances and their lifecycle. -- `VTXOEvent` — Inbound events (BlockEpochEvent, ForfeitRequest, ForfeitConfirmed, ResumeVTXOEvent). -- `VTXOOutMsg` — Outbound messages (ForfeitRequest, ExpiringNotify, StatusUpdate, Terminated). +- `VTXOEvent` — Inbound events (BlockEpochEvent, PendingForfeitEvent, ForfeitRequestEvent, ForfeitConfirmedEvent, ResumeVTXOEvent). +- `VTXOOutMsg` — Outbound messages (ForfeitRequest, ForfeitSignatureSubmission, ExpiringNotification, VTXOStatusUpdate, VTXOTerminatedNotification). ## Relationships - **Depends on**: `baselib/protofsm` (FSM engine), `lib/tree` (tree paths). - **Depended on by**: `round` (triggers forfeit requests), `oor` (incoming VTXOs), `db` (persistence), `darepod` (wiring). - **Sends**: - - → `round`: `ForfeitRequest`, `ForfeitSignatureSubmission` + - → `round` (via manager relay): `RefreshVTXORequest` (auto-expiry path), `ForfeitSignatureSubmission` - → `db` (via outbox): `VTXOStatusUpdate` - - → `vtxo` manager: `VTXOTerminatedMsg` + - → `vtxo` manager: `VTXOTerminatedNotification`, `RelayToRoundMsg` - **Receives**: - - ← `round`: `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `RefreshAcknowledgedEvent`, `BlockEpochEvent` - - ← `wallet`: `TriggerRefreshEvent`, `TriggerLeaveEvent` + - ← `round`: `PendingForfeitEvent`, `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `BlockEpochEvent` - ← `chainsource` (via Manager): `BlockEpochEvent` - ← API: `ResumeVTXOEvent` diff --git a/vtxo/CLAUDE.md b/vtxo/CLAUDE.md index e97b27ff9..c13432af2 100644 --- a/vtxo/CLAUDE.md +++ b/vtxo/CLAUDE.md @@ -3,28 +3,28 @@ ## Purpose Per-VTXO lifecycle management using a state machine that monitors expiry, -coordinates refresh (forfeit + new issuance), and tracks cooperative and -unilateral spending paths. +coordinates forfeit signing, and tracks cooperative and unilateral exit paths. +The VTXO FSM models lifecycle phases only (not business intent like +refresh vs leave); intent composition is handled by the wallet. ## Key Types -- `VTXOState` — Sealed interface for all states (Live, RefreshRequested, Forfeiting, Forfeited, Expiring, Failed). +- `VTXOState` — Sealed interface for all states (Live, PendingForfeit, Forfeiting, Forfeited, UnilateralExit, Failed). - `Descriptor` — Complete VTXO metadata: outpoint, amount, taproot key, CSV expiry, tree path to root. - `Manager` — Actor managing per-VTXO FSM instances and their lifecycle. -- `VTXOEvent` — Inbound events (BlockEpochEvent, ForfeitRequest, ForfeitConfirmed, ResumeVTXOEvent). -- `VTXOOutMsg` — Outbound messages (ForfeitRequest, ExpiringNotify, StatusUpdate, Terminated). +- `VTXOEvent` — Inbound events (BlockEpochEvent, PendingForfeitEvent, ForfeitRequestEvent, ForfeitConfirmedEvent, ResumeVTXOEvent). +- `VTXOOutMsg` — Outbound messages (ForfeitRequest, ForfeitSignatureSubmission, ExpiringNotification, VTXOStatusUpdate, VTXOTerminatedNotification). ## Relationships - **Depends on**: `baselib/protofsm` (FSM engine), `lib/tree` (tree paths). - **Depended on by**: `round` (triggers forfeit requests), `oor` (incoming VTXOs), `db` (persistence), `darepod` (wiring). - **Sends**: - - → `round`: `ForfeitRequest`, `ForfeitSignatureSubmission` + - → `round` (via manager relay): `RefreshVTXORequest` (auto-expiry path), `ForfeitSignatureSubmission` - → `db` (via outbox): `VTXOStatusUpdate` - - → `vtxo` manager: `VTXOTerminatedMsg` + - → `vtxo` manager: `VTXOTerminatedNotification`, `RelayToRoundMsg` - **Receives**: - - ← `round`: `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `RefreshAcknowledgedEvent`, `BlockEpochEvent` - - ← `wallet`: `TriggerRefreshEvent`, `TriggerLeaveEvent` + - ← `round`: `PendingForfeitEvent`, `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `BlockEpochEvent` - ← `chainsource` (via Manager): `BlockEpochEvent` - ← API: `ResumeVTXOEvent` diff --git a/wallet/AGENTS.md b/wallet/AGENTS.md index c6fa5dc4c..6e78b2c3b 100644 --- a/wallet/AGENTS.md +++ b/wallet/AGENTS.md @@ -3,8 +3,10 @@ ## Purpose Manages on-chain boarding addresses (2-of-2 multisig with operator + CSV -timeout) and monitors for confirmed boarding UTXOs, notifying the round actor -when new boarding opportunities are available. +timeout), monitors for confirmed boarding UTXOs, and composes intent packages +for round participation (refresh, leave). The wallet owns intent composition: +it loads VTXO descriptors, builds forfeit+output pairings, and sends +pre-composed `RegisterIntentMsg` to the round actor. ## Key Types @@ -22,8 +24,7 @@ when new boarding opportunities are available. - **Depended on by**: `round` (boarding intents), `db` (persistence), `darepod` (wiring). - **Sends**: - → `round` (via registered notifier): `BoardingUtxoConfirmedEvent` - - → `round` (via `lib/actormsg`): `TriggerBoardMsg` (VTXO amounts for boarding) - - → `vtxo`: `TriggerRefreshEvent`, `TriggerLeaveEvent` + - → `round` (via `lib/actormsg`): `RegisterIntentMsg` (pre-composed intent package with forfeits + VTXOs/leaves), `TriggerBoardMsg` (VTXO amounts for boarding) - **Receives**: - ← `chainsource`: `BlockEpochNotification` (triggers UTXO polling) - ← `round`: `RegisterConfirmationNotifierRequest` diff --git a/wallet/CLAUDE.md b/wallet/CLAUDE.md index c6fa5dc4c..6e78b2c3b 100644 --- a/wallet/CLAUDE.md +++ b/wallet/CLAUDE.md @@ -3,8 +3,10 @@ ## Purpose Manages on-chain boarding addresses (2-of-2 multisig with operator + CSV -timeout) and monitors for confirmed boarding UTXOs, notifying the round actor -when new boarding opportunities are available. +timeout), monitors for confirmed boarding UTXOs, and composes intent packages +for round participation (refresh, leave). The wallet owns intent composition: +it loads VTXO descriptors, builds forfeit+output pairings, and sends +pre-composed `RegisterIntentMsg` to the round actor. ## Key Types @@ -22,8 +24,7 @@ when new boarding opportunities are available. - **Depended on by**: `round` (boarding intents), `db` (persistence), `darepod` (wiring). - **Sends**: - → `round` (via registered notifier): `BoardingUtxoConfirmedEvent` - - → `round` (via `lib/actormsg`): `TriggerBoardMsg` (VTXO amounts for boarding) - - → `vtxo`: `TriggerRefreshEvent`, `TriggerLeaveEvent` + - → `round` (via `lib/actormsg`): `RegisterIntentMsg` (pre-composed intent package with forfeits + VTXOs/leaves), `TriggerBoardMsg` (VTXO amounts for boarding) - **Receives**: - ← `chainsource`: `BlockEpochNotification` (triggers UTXO polling) - ← `round`: `RegisterConfirmationNotifierRequest`