From eba860c790eb4b721c81f4fdb1f248792ba38129 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 2 Apr 2026 07:34:13 +0200 Subject: [PATCH 1/6] multi: add ForceUnrollEvent to VTXO lifecycle Add ForceUnrollEvent so manual unroll requests route through the VTXO actor's FSM rather than bypassing it with direct DB writes. LiveState handles ForceUnrollEvent by transitioning to UnilateralExitState and emitting ExpiringNotification through the chain resolver seam, converging manual and automatic triggers. Add ForceUnrollRequest/Response to the VTXO manager admission types and TestLiveStateForceUnroll unit test. --- lib/actormsg/vtxo_admission.go | 37 +++++++++ vtxo/chain_resolver.go | 77 +++++++++++++++++ vtxo/events.go | 20 +++++ vtxo/manager.go | 83 ++++++++++++++++++ vtxo/messages.go | 7 ++ vtxo/transitions.go | 148 +++++++++++++++++++++++++++++++++ vtxo/transitions_test.go | 93 +++++++++++++++++++++ 7 files changed, 465 insertions(+) create mode 100644 vtxo/chain_resolver.go diff --git a/lib/actormsg/vtxo_admission.go b/lib/actormsg/vtxo_admission.go index 95c251410..f25901131 100644 --- a/lib/actormsg/vtxo_admission.go +++ b/lib/actormsg/vtxo_admission.go @@ -215,3 +215,40 @@ type SelectAndReserveForfeitResponse struct { // VTXOManagerResp implements the VTXOManagerResp marker interface. func (r *SelectAndReserveForfeitResponse) VTXOManagerResp() {} + +// ForceUnrollRequest asks the VTXO manager to transition a specific VTXO +// into UnilateralExitState and trigger unroll through the chain resolver +// seam. This converges manual and automatic unroll on the same code path. +type ForceUnrollRequest struct { + actor.BaseMessage + + // Outpoint identifies the VTXO to force-unroll. + Outpoint wire.OutPoint + + // Reason explains why the unroll was requested. + Reason string +} + +// VTXOManagerMsg implements VTXOManagerMsg marker interface. +func (m *ForceUnrollRequest) VTXOManagerMsg() {} + +// MessageType returns the message type for logging. +func (m *ForceUnrollRequest) MessageType() string { + return "ForceUnrollRequest" +} + +// ForceUnrollResponse confirms that the VTXO was transitioned to +// UnilateralExitState and the chain resolver was notified. +type ForceUnrollResponse struct { + // Accepted is true if the VTXO was successfully transitioned into + // UnilateralExitState by this request. + Accepted bool + + // Reason carries a human-readable explanation when Accepted is + // false (e.g. "no such vtxo", "already terminal"). Empty when + // Accepted is true. + Reason string +} + +// VTXOManagerResp implements the VTXOManagerResp marker interface. +func (r *ForceUnrollResponse) VTXOManagerResp() {} diff --git a/vtxo/chain_resolver.go b/vtxo/chain_resolver.go new file mode 100644 index 000000000..c639105eb --- /dev/null +++ b/vtxo/chain_resolver.go @@ -0,0 +1,77 @@ +package vtxo + +import ( + "context" + "sync" + + "github.com/lightninglabs/darepo-client/baselib/actor" +) + +// LazyChainResolver is a forwarding TellOnlyRef for ExpiringNotification +// that allows the real target to be set after the VTXO manager is already +// running. This breaks the init-order dependency between the VTXO manager +// and the unroll subsystem. +// +// Notifications received before the target is wired are buffered and +// replayed when Set() is called. This prevents critical-expiry +// notifications from being dropped during the brief init window. +type LazyChainResolver struct { + mu sync.Mutex + target actor.TellOnlyRef[ExpiringNotification] + buffered []bufferedNotification +} + +type bufferedNotification struct { + msg ExpiringNotification +} + +// NewLazyChainResolver creates a new lazy chain resolver with no target. +// Call Set() to wire the real destination once the unroll subsystem is +// initialized. +func NewLazyChainResolver() *LazyChainResolver { + return &LazyChainResolver{} +} + +// Set stores the real chain resolver target and replays any buffered +// notifications. Safe to call once from the daemon init path. +func (l *LazyChainResolver) Set( + ref actor.TellOnlyRef[ExpiringNotification]) { + + l.mu.Lock() + l.target = ref + pending := l.buffered + l.buffered = nil + l.mu.Unlock() + + for _, p := range pending { + // Best-effort replay; errors are non-fatal since the + // job will be picked up on restart if delivery fails. + _ = ref.Tell(context.Background(), p.msg) + } +} + +// ID implements actor.BaseActorRef. +func (l *LazyChainResolver) ID() string { + return "lazy-chain-resolver" +} + +// Tell forwards the message to the real target. If the target has not +// been set yet, the notification is buffered for replay when Set() is +// called. +func (l *LazyChainResolver) Tell(ctx context.Context, + msg ExpiringNotification) error { + + l.mu.Lock() + t := l.target + if t == nil { + l.buffered = append(l.buffered, bufferedNotification{ + msg: msg, + }) + l.mu.Unlock() + + return nil + } + l.mu.Unlock() + + return t.Tell(ctx, msg) +} diff --git a/vtxo/events.go b/vtxo/events.go index 2e052bb3d..7f6fe2e93 100644 --- a/vtxo/events.go +++ b/vtxo/events.go @@ -1,6 +1,7 @@ package vtxo import ( + "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/round" ) @@ -57,3 +58,22 @@ type ( // LiveState. ForfeitReleasedEvent = round.ForfeitReleasedEvent ) + +// ForceUnrollEvent is sent to a VTXO actor when a manual unilateral +// exit is requested via the Unroll RPC. The VTXO actor transitions to +// UnilateralExitState and emits ExpiringNotification through the chain +// resolver seam, converging with the automatic critical-expiry path. +type ForceUnrollEvent struct { + actor.BaseMessage + + // Reason explains why the manual unroll was requested. + Reason string +} + +// VTXOActorMsg implements actormsg.VTXOActorMsg marker interface. +func (e *ForceUnrollEvent) VTXOActorMsg() {} + +// MessageType returns the message type for logging. +func (e *ForceUnrollEvent) MessageType() string { + return "ForceUnrollEvent" +} diff --git a/vtxo/manager.go b/vtxo/manager.go index 5bdcf5a49..daab8e72f 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -176,6 +176,9 @@ func (m *Manager) Receive(ctx context.Context, Count: len(m.actors), }) + case *ForceUnrollRequest: + return m.handleForceUnroll(ctx, req) + default: return fn.Err[ManagerResp]( fmt.Errorf("unknown message: %T", msg), @@ -280,6 +283,86 @@ func (m *Manager) handleVTXOsMaterialized(ctx context.Context, return fn.Ok[ManagerResp](&VTXOsMaterializedResp{}) } +// handleForceUnroll transitions a VTXO into UnilateralExitState via the +// VTXO actor's FSM, then lets the outbox handler emit +// ExpiringNotification through the chain resolver seam. This ensures +// manual and automatic unroll converge on the same ownership/state +// transition path. The actor is driven via Ask (not Tell) so the caller +// can distinguish "accepted and transitioning", "already terminal", and +// "no such vtxo" rather than observing a uniform Accepted:true even when +// the FSM silently self-looped on a terminal state. +func (m *Manager) handleForceUnroll(ctx context.Context, + req *ForceUnrollRequest) fn.Result[ManagerResp] { + + actorRef, ok := m.actors[req.Outpoint] + if !ok { + // The VTXO actor is already gone (likely already terminal + // and cleaned up via handleVTXOTerminated). Report a + // specific reason so the caller can tell this apart from + // "event accepted but actor self-looped". + return fn.Ok[ManagerResp](&ForceUnrollResponse{ + Accepted: false, + Reason: "no such vtxo", + }) + } + + reason := req.Reason + if reason == "" { + reason = "manual unroll" + } + + resp, err := actorRef.Ask(ctx, &ForceUnrollEvent{ + Reason: reason, + }).Await(ctx).Unpack() + if err != nil { + return fn.Err[ManagerResp](fmt.Errorf( + "ask force-unroll: %w", err, + )) + } + + actorResp, ok := resp.(VTXOActorResponse) + if !ok { + return fn.Err[ManagerResp](fmt.Errorf( + "unexpected force-unroll response type: %T", resp, + )) + } + + // Terminal states self-loop on ForceUnrollEvent. Detect the + // PriorState == NewState case on a terminal state and report a + // clear Reason so the caller sees a no-op explicitly rather than + // Accepted:true on work that was never scheduled. + priorTerminal := actorResp.PriorState != nil && + actorResp.PriorState.IsTerminal() + newTerminal := actorResp.NewState != nil && + actorResp.NewState.IsTerminal() + + if priorTerminal && newTerminal { + m.logger(ctx).InfoS(ctx, "Force-unroll no-op on terminal VTXO", + slog.String("outpoint", req.Outpoint.String()), + slog.String("state", fmt.Sprintf( + "%T", actorResp.NewState, + )), + ) + + return fn.Ok[ManagerResp](&ForceUnrollResponse{ + Accepted: false, + Reason: "already terminal", + }) + } + + m.logger(ctx).InfoS(ctx, "Force-unroll accepted by VTXO actor", + slog.String("outpoint", req.Outpoint.String()), + slog.String("reason", reason), + slog.String("new_state", fmt.Sprintf( + "%T", actorResp.NewState, + )), + ) + + return fn.Ok[ManagerResp](&ForceUnrollResponse{ + Accepted: true, + }) +} + // handleVTXOTerminated removes a VTXO actor from tracking when it reaches // a terminal state (Forfeited, Failed, etc.). func (m *Manager) handleVTXOTerminated(ctx context.Context, diff --git a/vtxo/messages.go b/vtxo/messages.go index db3506dc7..cffff93d1 100644 --- a/vtxo/messages.go +++ b/vtxo/messages.go @@ -166,3 +166,10 @@ type SelectAndReserveForfeitRequest = actormsg.SelectAndReserveForfeitRequest // SelectAndReserveForfeitResponse is an alias for the canonical type in // actormsg. type SelectAndReserveForfeitResponse = actormsg.SelectAndReserveForfeitResponse + +// ForceUnrollRequest asks the manager to transition a VTXO into +// UnilateralExitState and trigger unroll through the chain resolver. +type ForceUnrollRequest = actormsg.ForceUnrollRequest + +// ForceUnrollResponse confirms the force-unroll request was accepted. +type ForceUnrollResponse = actormsg.ForceUnrollResponse diff --git a/vtxo/transitions.go b/vtxo/transitions.go index dc5d82fba..02b38168e 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -38,6 +38,9 @@ func (s *LiveState) ProcessEvent( NextState: s, }, nil + case *ForceUnrollEvent: + return s.handleForceUnroll(ctx, evt) + case *VTXOFailedEvent: return &VTXOStateTransition{ NextState: &FailedState{ @@ -99,6 +102,43 @@ func (s *LiveState) handleSpendReserve( }, nil } +// handleForceUnroll processes a manual unroll request. It produces the same +// transition as critical expiry, converging both manual and automatic paths +// on the same chain resolver seam. +func (s *LiveState) handleForceUnroll(_ context.Context, + evt *ForceUnrollEvent) (*VTXOStateTransition, error) { + + reason := evt.Reason + if reason == "" { + reason = "manual unroll" + } + + outbox := []VTXOOutMsg{ + &ExpiringNotification{ + VTXO: s.VTXO, + BlocksRemaining: 0, + Reason: reason, + }, + &VTXOStatusUpdate{ + Outpoint: s.VTXO.Outpoint, + NewStatus: VTXOStatusUnilateralExit, + }, + &VTXOTerminatedNotification{ + VTXOOutpoint: s.VTXO.Outpoint, + FinalState: "UnilateralExit", + Reason: reason, + }, + } + + return &VTXOStateTransition{ + NextState: &UnilateralExitState{ + VTXO: s.VTXO, + Reason: reason, + }, + NewEvents: fn.Some(VTXOEmittedEvent{Outbox: outbox}), + }, 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( @@ -399,6 +439,42 @@ func (s *PendingForfeitState) ProcessEvent( NextState: s, }, nil + case *ForceUnrollEvent: + // Client requested unilateral exit while forfeit is + // still pending. Transition to exit — the on-chain + // recovery path doesn't depend on the forfeit. + reason := evt.Reason + if reason == "" { + reason = "manual unroll (pending forfeit)" + } + + outbox := []VTXOOutMsg{ + &ExpiringNotification{ + VTXO: s.VTXO, + BlocksRemaining: 0, + Reason: reason, + }, + &VTXOStatusUpdate{ + Outpoint: s.VTXO.Outpoint, + NewStatus: VTXOStatusUnilateralExit, + }, + &VTXOTerminatedNotification{ + VTXOOutpoint: s.VTXO.Outpoint, + FinalState: "UnilateralExit", + Reason: reason, + }, + } + + return &VTXOStateTransition{ + NextState: &UnilateralExitState{ + VTXO: s.VTXO, + Reason: reason, + }, + NewEvents: fn.Some(VTXOEmittedEvent{ + Outbox: outbox, + }), + }, nil + case *ForfeitRequestEvent: // Round actor is ready for forfeit. Build and sign the forfeit // tx to transfer this VTXO to the new round. @@ -615,6 +691,41 @@ func (s *ForfeitingState) ProcessEvent( NextState: s, }, nil + case *ForceUnrollEvent: + // Client requested unilateral exit while a forfeit is + // mid-flight. The on-chain recovery path doesn't depend + // on the forfeit signature landing, so we escalate to + // UnilateralExitState immediately. + reason := evt.Reason + if reason == "" { + reason = "manual unroll (forfeiting)" + } + + outbox := []VTXOOutMsg{ + &ExpiringNotification{ + VTXO: s.VTXO, + BlocksRemaining: 0, + Reason: reason, + }, + &VTXOStatusUpdate{ + Outpoint: s.VTXO.Outpoint, + NewStatus: VTXOStatusUnilateralExit, + }, + &VTXOTerminatedNotification{ + VTXOOutpoint: s.VTXO.Outpoint, + FinalState: "UnilateralExit", + Reason: reason, + }, + } + + return &VTXOStateTransition{ + NextState: &UnilateralExitState{ + VTXO: s.VTXO, + Reason: reason, + }, + NewEvents: fn.Some(VTXOEmittedEvent{Outbox: outbox}), + }, nil + case *VTXOFailedEvent: return &VTXOStateTransition{ NextState: &FailedState{ @@ -749,6 +860,43 @@ func (s *SpendingState) ProcessEvent( NextState: s, }, nil + case *ForceUnrollEvent: + // Client requested unilateral exit while an OOR spend is + // in flight. The on-chain recovery path supersedes the + // OOR claim, so we escalate to UnilateralExitState using + // the same outbox shape as the critical-expiry branch + // above to converge manual and automatic exits on a + // single chain resolver seam. + reason := evt.Reason + if reason == "" { + reason = "manual unroll (spending)" + } + + outbox := []VTXOOutMsg{ + &ExpiringNotification{ + VTXO: s.VTXO, + BlocksRemaining: 0, + Reason: reason, + }, + &VTXOStatusUpdate{ + Outpoint: s.VTXO.Outpoint, + NewStatus: VTXOStatusUnilateralExit, + }, + &VTXOTerminatedNotification{ + VTXOOutpoint: s.VTXO.Outpoint, + FinalState: "UnilateralExit", + Reason: reason, + }, + } + + return &VTXOStateTransition{ + NextState: &UnilateralExitState{ + VTXO: s.VTXO, + Reason: reason, + }, + NewEvents: fn.Some(VTXOEmittedEvent{Outbox: outbox}), + }, nil + case *VTXOFailedEvent: return &VTXOStateTransition{ NextState: &FailedState{ diff --git a/vtxo/transitions_test.go b/vtxo/transitions_test.go index 7d6430475..de150b20a 100644 --- a/vtxo/transitions_test.go +++ b/vtxo/transitions_test.go @@ -213,6 +213,38 @@ func TestLiveStateBlockEpochCritical(t *testing.T) { assertOutboxContains[*ExpiringNotification](h) } +// TestLiveStateForceUnroll verifies that LiveState transitions to +// UnilateralExitState on ForceUnrollEvent, emitting the same outbox as +// the critical expiry path. +func TestLiveStateForceUnroll(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = 10000 + vtxo.CreatedHeight = 100 + + h.withState(&LiveState{ + VTXO: vtxo, + LastCheckedHeight: 100, + }) + + h.store.On( + "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, + VTXOStatusUnilateralExit, + ).Return(nil) + + _, err := h.sendEvent(&ForceUnrollEvent{ + Reason: "manual unroll", + }) + require.NoError(t, err) + + assertState[*UnilateralExitState](h) + assertOutboxContains[*ExpiringNotification](h) + assertOutboxContains[*VTXOStatusUpdate](h) + assertOutboxContains[*VTXOTerminatedNotification](h) +} + // TestForfeitRequestFromLiveState verifies that LiveState transitions to // ForfeitingState on ForfeitRequest from round actor. func TestForfeitRequestFromLiveState(t *testing.T) { @@ -1069,6 +1101,67 @@ func TestSpendingStateFailedEvent(t *testing.T) { require.Equal(t, "test failure", state.Reason) } +// TestSpendingStateForceUnroll verifies that SpendingState escalates to +// UnilateralExitState on ForceUnrollEvent, emitting the same outbox shape +// as the critical-expiry branch so manual and automatic exits converge on +// a single chain resolver seam. +func TestSpendingStateForceUnroll(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + + h.withState(&SpendingState{ + VTXO: vtxo, + LastCheckedHeight: 200, + }) + + h.store.On( + "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, + VTXOStatusUnilateralExit, + ).Return(nil) + + _, err := h.sendEvent(&ForceUnrollEvent{ + Reason: "manual unroll", + }) + require.NoError(t, err) + + assertState[*UnilateralExitState](h) + assertOutboxContains[*ExpiringNotification](h) + assertOutboxContains[*VTXOStatusUpdate](h) + assertOutboxContains[*VTXOTerminatedNotification](h) +} + +// TestForfeitingStateForceUnroll verifies that ForfeitingState escalates to +// UnilateralExitState on ForceUnrollEvent so an in-flight forfeit does not +// swallow a manual unroll request. +func TestForfeitingStateForceUnroll(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + + h.withState(&ForfeitingState{ + VTXO: vtxo, + NewRoundID: "round-123", + }) + + h.store.On( + "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, + VTXOStatusUnilateralExit, + ).Return(nil) + + _, err := h.sendEvent(&ForceUnrollEvent{ + Reason: "manual unroll", + }) + require.NoError(t, err) + + assertState[*UnilateralExitState](h) + assertOutboxContains[*ExpiringNotification](h) + assertOutboxContains[*VTXOStatusUpdate](h) + assertOutboxContains[*VTXOTerminatedNotification](h) +} + // TestForfeitSignatureValidity verifies that forfeit signatures produced by // the VTXO FSM can actually spend the VTXO output when combined with the // operator's signature. From 4007ace2d65f7b894b5edca03ee23b1145001045 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 2 Apr 2026 07:34:22 +0200 Subject: [PATCH 2/6] daemonrpc: add Unroll and GetUnrollStatus RPCs Add Unroll RPC for triggering unilateral exit by outpoint and GetUnrollStatus RPC for querying job progress. Add UnrollJobStatus enum and request/response messages for both endpoints. --- daemonrpc/daemon.pb.go | 530 ++++++++++++++++++++++++------ daemonrpc/daemon.proto | 76 +++++ daemonrpc/daemon_grpc.pb.go | 88 +++++ daemonrpc/daemon_mailboxrpc.pb.go | 70 ++++ 4 files changed, 663 insertions(+), 101 deletions(-) diff --git a/daemonrpc/daemon.pb.go b/daemonrpc/daemon.pb.go index 6b49b99ad..ee4a5f901 100644 --- a/daemonrpc/daemon.pb.go +++ b/daemonrpc/daemon.pb.go @@ -218,6 +218,80 @@ func (RoundState) EnumDescriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{1} } +// UnrollJobStatus represents the high-level phase of an unroll job. +type UnrollJobStatus int32 + +const ( + UnrollJobStatus_UNROLL_JOB_STATUS_UNSPECIFIED UnrollJobStatus = 0 + // UNROLL_JOB_STATUS_PENDING indicates the job has been created but + // has not yet started materializing recovery transactions. + UnrollJobStatus_UNROLL_JOB_STATUS_PENDING UnrollJobStatus = 1 + // UNROLL_JOB_STATUS_MATERIALIZING indicates recovery transactions + // are being broadcast and confirmed on-chain. + UnrollJobStatus_UNROLL_JOB_STATUS_MATERIALIZING UnrollJobStatus = 2 + // UNROLL_JOB_STATUS_CSV_PENDING indicates all recovery transactions + // are confirmed and the job is waiting for the CSV delay to expire. + UnrollJobStatus_UNROLL_JOB_STATUS_CSV_PENDING UnrollJobStatus = 3 + // UNROLL_JOB_STATUS_SWEEPING indicates the CSV delay has expired + // and the sweep transaction is being broadcast/confirmed. + UnrollJobStatus_UNROLL_JOB_STATUS_SWEEPING UnrollJobStatus = 4 + // UNROLL_JOB_STATUS_COMPLETED is terminal: the sweep confirmed and + // the funds are in the on-chain wallet. + UnrollJobStatus_UNROLL_JOB_STATUS_COMPLETED UnrollJobStatus = 5 + // UNROLL_JOB_STATUS_FAILED is terminal: the unroll encountered an + // unrecoverable error. + UnrollJobStatus_UNROLL_JOB_STATUS_FAILED UnrollJobStatus = 6 +) + +// Enum value maps for UnrollJobStatus. +var ( + UnrollJobStatus_name = map[int32]string{ + 0: "UNROLL_JOB_STATUS_UNSPECIFIED", + 1: "UNROLL_JOB_STATUS_PENDING", + 2: "UNROLL_JOB_STATUS_MATERIALIZING", + 3: "UNROLL_JOB_STATUS_CSV_PENDING", + 4: "UNROLL_JOB_STATUS_SWEEPING", + 5: "UNROLL_JOB_STATUS_COMPLETED", + 6: "UNROLL_JOB_STATUS_FAILED", + } + UnrollJobStatus_value = map[string]int32{ + "UNROLL_JOB_STATUS_UNSPECIFIED": 0, + "UNROLL_JOB_STATUS_PENDING": 1, + "UNROLL_JOB_STATUS_MATERIALIZING": 2, + "UNROLL_JOB_STATUS_CSV_PENDING": 3, + "UNROLL_JOB_STATUS_SWEEPING": 4, + "UNROLL_JOB_STATUS_COMPLETED": 5, + "UNROLL_JOB_STATUS_FAILED": 6, + } +) + +func (x UnrollJobStatus) Enum() *UnrollJobStatus { + p := new(UnrollJobStatus) + *p = x + return p +} + +func (x UnrollJobStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (UnrollJobStatus) Descriptor() protoreflect.EnumDescriptor { + return file_daemon_proto_enumTypes[2].Descriptor() +} + +func (UnrollJobStatus) Type() protoreflect.EnumType { + return &file_daemon_proto_enumTypes[2] +} + +func (x UnrollJobStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use UnrollJobStatus.Descriptor instead. +func (UnrollJobStatus) EnumDescriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{2} +} + type GetInfoRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -3012,6 +3086,226 @@ func (x *GetFeeHistoryResponse) GetTotalFeesPaidSat() int64 { return 0 } +type UnrollRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // outpoint is the VTXO outpoint to unilaterally exit, formatted as + // "txid:index". + Outpoint string `protobuf:"bytes,1,opt,name=outpoint,proto3" json:"outpoint,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnrollRequest) Reset() { + *x = UnrollRequest{} + mi := &file_daemon_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnrollRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnrollRequest) ProtoMessage() {} + +func (x *UnrollRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[43] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnrollRequest.ProtoReflect.Descriptor instead. +func (*UnrollRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{43} +} + +func (x *UnrollRequest) GetOutpoint() string { + if x != nil { + return x.Outpoint + } + return "" +} + +type UnrollResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // created indicates whether a new unroll job was spawned. False if + // an existing job already covers this target. + Created bool `protobuf:"varint,1,opt,name=created,proto3" json:"created,omitempty"` + // actor_id is the identifier of the durable unroll job actor. + ActorId string `protobuf:"bytes,2,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnrollResponse) Reset() { + *x = UnrollResponse{} + mi := &file_daemon_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnrollResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnrollResponse) ProtoMessage() {} + +func (x *UnrollResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[44] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnrollResponse.ProtoReflect.Descriptor instead. +func (*UnrollResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{44} +} + +func (x *UnrollResponse) GetCreated() bool { + if x != nil { + return x.Created + } + return false +} + +func (x *UnrollResponse) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +type GetUnrollStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // outpoint is the VTXO outpoint to query, formatted as "txid:index". + Outpoint string `protobuf:"bytes,1,opt,name=outpoint,proto3" json:"outpoint,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetUnrollStatusRequest) Reset() { + *x = GetUnrollStatusRequest{} + mi := &file_daemon_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetUnrollStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetUnrollStatusRequest) ProtoMessage() {} + +func (x *GetUnrollStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[45] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetUnrollStatusRequest.ProtoReflect.Descriptor instead. +func (*GetUnrollStatusRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{45} +} + +func (x *GetUnrollStatusRequest) GetOutpoint() string { + if x != nil { + return x.Outpoint + } + return "" +} + +type GetUnrollStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // found is true if an unroll job exists for the requested outpoint. + Found bool `protobuf:"varint,1,opt,name=found,proto3" json:"found,omitempty"` + // status is the current high-level phase of the unroll job. + Status UnrollJobStatus `protobuf:"varint,2,opt,name=status,proto3,enum=daemonrpc.UnrollJobStatus" json:"status,omitempty"` + // sweep_txid is the txid of the sweep transaction, set once the + // sweep has been broadcast. + SweepTxid string `protobuf:"bytes,3,opt,name=sweep_txid,json=sweepTxid,proto3" json:"sweep_txid,omitempty"` + // last_error contains the failure reason if the job is in FAILED + // status. + LastError string `protobuf:"bytes,4,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetUnrollStatusResponse) Reset() { + *x = GetUnrollStatusResponse{} + mi := &file_daemon_proto_msgTypes[46] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetUnrollStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetUnrollStatusResponse) ProtoMessage() {} + +func (x *GetUnrollStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[46] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetUnrollStatusResponse.ProtoReflect.Descriptor instead. +func (*GetUnrollStatusResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{46} +} + +func (x *GetUnrollStatusResponse) GetFound() bool { + if x != nil { + return x.Found + } + return false +} + +func (x *GetUnrollStatusResponse) GetStatus() UnrollJobStatus { + if x != nil { + return x.Status + } + return UnrollJobStatus_UNROLL_JOB_STATUS_UNSPECIFIED +} + +func (x *GetUnrollStatusResponse) GetSweepTxid() string { + if x != nil { + return x.SweepTxid + } + return "" +} + +func (x *GetUnrollStatusResponse) GetLastError() string { + if x != nil { + return x.LastError + } + return "" +} + var File_daemon_proto protoreflect.FileDescriptor const file_daemon_proto_rawDesc = "" + @@ -3197,7 +3491,21 @@ const file_daemon_proto_rawDesc = "" + "session_id\x18\t \x01(\fR\tsessionId\"|\n" + "\x15GetFeeHistoryResponse\x124\n" + "\aentries\x18\x01 \x03(\v2\x1a.daemonrpc.FeeHistoryEntryR\aentries\x12-\n" + - "\x13total_fees_paid_sat\x18\x02 \x01(\x03R\x10totalFeesPaidSat*\x81\x02\n" + + "\x13total_fees_paid_sat\x18\x02 \x01(\x03R\x10totalFeesPaidSat\"+\n" + + "\rUnrollRequest\x12\x1a\n" + + "\boutpoint\x18\x01 \x01(\tR\boutpoint\"E\n" + + "\x0eUnrollResponse\x12\x18\n" + + "\acreated\x18\x01 \x01(\bR\acreated\x12\x19\n" + + "\bactor_id\x18\x02 \x01(\tR\aactorId\"4\n" + + "\x16GetUnrollStatusRequest\x12\x1a\n" + + "\boutpoint\x18\x01 \x01(\tR\boutpoint\"\xa1\x01\n" + + "\x17GetUnrollStatusResponse\x12\x14\n" + + "\x05found\x18\x01 \x01(\bR\x05found\x122\n" + + "\x06status\x18\x02 \x01(\x0e2\x1a.daemonrpc.UnrollJobStatusR\x06status\x12\x1d\n" + + "\n" + + "sweep_txid\x18\x03 \x01(\tR\tsweepTxid\x12\x1d\n" + + "\n" + + "last_error\x18\x04 \x01(\tR\tlastError*\x81\x02\n" + "\n" + "VTXOStatus\x12\x1b\n" + "\x17VTXO_STATUS_UNSPECIFIED\x10\x00\x12\x14\n" + @@ -3226,7 +3534,15 @@ const file_daemon_proto_rawDesc = "" + "\x1aROUND_STATE_INPUT_SIG_SENT\x10\v\x12\x19\n" + "\x15ROUND_STATE_CONFIRMED\x10\f\x12\x16\n" + "\x12ROUND_STATE_FAILED\x10\r\x12\x18\n" + - "\x14ROUND_STATE_RECOVERY\x10\x0e2\xb4\v\n" + + "\x14ROUND_STATE_RECOVERY\x10\x0e*\xfa\x01\n" + + "\x0fUnrollJobStatus\x12!\n" + + "\x1dUNROLL_JOB_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n" + + "\x19UNROLL_JOB_STATUS_PENDING\x10\x01\x12#\n" + + "\x1fUNROLL_JOB_STATUS_MATERIALIZING\x10\x02\x12!\n" + + "\x1dUNROLL_JOB_STATUS_CSV_PENDING\x10\x03\x12\x1e\n" + + "\x1aUNROLL_JOB_STATUS_SWEEPING\x10\x04\x12\x1f\n" + + "\x1bUNROLL_JOB_STATUS_COMPLETED\x10\x05\x12\x1c\n" + + "\x18UNROLL_JOB_STATUS_FAILED\x10\x062\xcd\f\n" + "\rDaemonService\x12@\n" + "\aGetInfo\x12\x19.daemonrpc.GetInfoRequest\x1a\x1a.daemonrpc.GetInfoResponse\x12@\n" + "\aGenSeed\x12\x19.daemonrpc.GenSeedRequest\x1a\x1a.daemonrpc.GenSeedResponse\x12I\n" + @@ -3249,7 +3565,9 @@ const file_daemon_proto_rawDesc = "" + "ListRounds\x12\x1c.daemonrpc.ListRoundsRequest\x1a\x1d.daemonrpc.ListRoundsResponse\x12N\n" + "\vWatchRounds\x12\x1d.daemonrpc.WatchRoundsRequest\x1a\x1e.daemonrpc.WatchRoundsResponse0\x01\x12L\n" + "\vEstimateFee\x12\x1d.daemonrpc.EstimateFeeRequest\x1a\x1e.daemonrpc.EstimateFeeResponse\x12R\n" + - "\rGetFeeHistory\x12\x1f.daemonrpc.GetFeeHistoryRequest\x1a .daemonrpc.GetFeeHistoryResponseB2Z0github.com/lightninglabs/darepo-client/daemonrpcb\x06proto3" + "\rGetFeeHistory\x12\x1f.daemonrpc.GetFeeHistoryRequest\x1a .daemonrpc.GetFeeHistoryResponse\x12=\n" + + "\x06Unroll\x12\x18.daemonrpc.UnrollRequest\x1a\x19.daemonrpc.UnrollResponse\x12X\n" + + "\x0fGetUnrollStatus\x12!.daemonrpc.GetUnrollStatusRequest\x1a\".daemonrpc.GetUnrollStatusResponseB2Z0github.com/lightninglabs/darepo-client/daemonrpcb\x06proto3" var ( file_daemon_proto_rawDescOnce sync.Once @@ -3263,111 +3581,121 @@ func file_daemon_proto_rawDescGZIP() []byte { return file_daemon_proto_rawDescData } -var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 43) +var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 47) var file_daemon_proto_goTypes = []any{ (VTXOStatus)(0), // 0: daemonrpc.VTXOStatus (RoundState)(0), // 1: daemonrpc.RoundState - (*GetInfoRequest)(nil), // 2: daemonrpc.GetInfoRequest - (*GetInfoResponse)(nil), // 3: daemonrpc.GetInfoResponse - (*GenSeedRequest)(nil), // 4: daemonrpc.GenSeedRequest - (*GenSeedResponse)(nil), // 5: daemonrpc.GenSeedResponse - (*InitWalletRequest)(nil), // 6: daemonrpc.InitWalletRequest - (*InitWalletResponse)(nil), // 7: daemonrpc.InitWalletResponse - (*UnlockWalletRequest)(nil), // 8: daemonrpc.UnlockWalletRequest - (*UnlockWalletResponse)(nil), // 9: daemonrpc.UnlockWalletResponse - (*GetBalanceRequest)(nil), // 10: daemonrpc.GetBalanceRequest - (*GetBalanceResponse)(nil), // 11: daemonrpc.GetBalanceResponse - (*VTXO)(nil), // 12: daemonrpc.VTXO - (*ListVTXOsRequest)(nil), // 13: daemonrpc.ListVTXOsRequest - (*ListVTXOsResponse)(nil), // 14: daemonrpc.ListVTXOsResponse - (*NewAddressRequest)(nil), // 15: daemonrpc.NewAddressRequest - (*NewAddressResponse)(nil), // 16: daemonrpc.NewAddressResponse - (*NewOORReceiveScriptRequest)(nil), // 17: daemonrpc.NewOORReceiveScriptRequest - (*NewOORReceiveScriptResponse)(nil), // 18: daemonrpc.NewOORReceiveScriptResponse - (*GetIndexedVTXOByPkScriptRequest)(nil), // 19: daemonrpc.GetIndexedVTXOByPkScriptRequest - (*GetIndexedVTXOByPkScriptResponse)(nil), // 20: daemonrpc.GetIndexedVTXOByPkScriptResponse - (*GetIndexedOORSessionByTxidRequest)(nil), // 21: daemonrpc.GetIndexedOORSessionByTxidRequest - (*GetIndexedOORSessionByTxidResponse)(nil), // 22: daemonrpc.GetIndexedOORSessionByTxidResponse - (*Output)(nil), // 23: daemonrpc.Output - (*SendVTXORequest)(nil), // 24: daemonrpc.SendVTXORequest - (*SendVTXOResponse)(nil), // 25: daemonrpc.SendVTXOResponse - (*SendOORRequest)(nil), // 26: daemonrpc.SendOORRequest - (*CustomOORInput)(nil), // 27: daemonrpc.CustomOORInput - (*SendOORResponse)(nil), // 28: daemonrpc.SendOORResponse - (*OutpointSelection)(nil), // 29: daemonrpc.OutpointSelection - (*RefreshVTXOsRequest)(nil), // 30: daemonrpc.RefreshVTXOsRequest - (*RefreshVTXOsResponse)(nil), // 31: daemonrpc.RefreshVTXOsResponse - (*BoardRequest)(nil), // 32: daemonrpc.BoardRequest - (*BoardResponse)(nil), // 33: daemonrpc.BoardResponse - (*RoundVTXOInfo)(nil), // 34: daemonrpc.RoundVTXOInfo - (*RoundInfo)(nil), // 35: daemonrpc.RoundInfo - (*ListRoundsRequest)(nil), // 36: daemonrpc.ListRoundsRequest - (*ListRoundsResponse)(nil), // 37: daemonrpc.ListRoundsResponse - (*WatchRoundsRequest)(nil), // 38: daemonrpc.WatchRoundsRequest - (*WatchRoundsResponse)(nil), // 39: daemonrpc.WatchRoundsResponse - (*EstimateFeeRequest)(nil), // 40: daemonrpc.EstimateFeeRequest - (*EstimateFeeResponse)(nil), // 41: daemonrpc.EstimateFeeResponse - (*GetFeeHistoryRequest)(nil), // 42: daemonrpc.GetFeeHistoryRequest - (*FeeHistoryEntry)(nil), // 43: daemonrpc.FeeHistoryEntry - (*GetFeeHistoryResponse)(nil), // 44: daemonrpc.GetFeeHistoryResponse + (UnrollJobStatus)(0), // 2: daemonrpc.UnrollJobStatus + (*GetInfoRequest)(nil), // 3: daemonrpc.GetInfoRequest + (*GetInfoResponse)(nil), // 4: daemonrpc.GetInfoResponse + (*GenSeedRequest)(nil), // 5: daemonrpc.GenSeedRequest + (*GenSeedResponse)(nil), // 6: daemonrpc.GenSeedResponse + (*InitWalletRequest)(nil), // 7: daemonrpc.InitWalletRequest + (*InitWalletResponse)(nil), // 8: daemonrpc.InitWalletResponse + (*UnlockWalletRequest)(nil), // 9: daemonrpc.UnlockWalletRequest + (*UnlockWalletResponse)(nil), // 10: daemonrpc.UnlockWalletResponse + (*GetBalanceRequest)(nil), // 11: daemonrpc.GetBalanceRequest + (*GetBalanceResponse)(nil), // 12: daemonrpc.GetBalanceResponse + (*VTXO)(nil), // 13: daemonrpc.VTXO + (*ListVTXOsRequest)(nil), // 14: daemonrpc.ListVTXOsRequest + (*ListVTXOsResponse)(nil), // 15: daemonrpc.ListVTXOsResponse + (*NewAddressRequest)(nil), // 16: daemonrpc.NewAddressRequest + (*NewAddressResponse)(nil), // 17: daemonrpc.NewAddressResponse + (*NewOORReceiveScriptRequest)(nil), // 18: daemonrpc.NewOORReceiveScriptRequest + (*NewOORReceiveScriptResponse)(nil), // 19: daemonrpc.NewOORReceiveScriptResponse + (*GetIndexedVTXOByPkScriptRequest)(nil), // 20: daemonrpc.GetIndexedVTXOByPkScriptRequest + (*GetIndexedVTXOByPkScriptResponse)(nil), // 21: daemonrpc.GetIndexedVTXOByPkScriptResponse + (*GetIndexedOORSessionByTxidRequest)(nil), // 22: daemonrpc.GetIndexedOORSessionByTxidRequest + (*GetIndexedOORSessionByTxidResponse)(nil), // 23: daemonrpc.GetIndexedOORSessionByTxidResponse + (*Output)(nil), // 24: daemonrpc.Output + (*SendVTXORequest)(nil), // 25: daemonrpc.SendVTXORequest + (*SendVTXOResponse)(nil), // 26: daemonrpc.SendVTXOResponse + (*SendOORRequest)(nil), // 27: daemonrpc.SendOORRequest + (*CustomOORInput)(nil), // 28: daemonrpc.CustomOORInput + (*SendOORResponse)(nil), // 29: daemonrpc.SendOORResponse + (*OutpointSelection)(nil), // 30: daemonrpc.OutpointSelection + (*RefreshVTXOsRequest)(nil), // 31: daemonrpc.RefreshVTXOsRequest + (*RefreshVTXOsResponse)(nil), // 32: daemonrpc.RefreshVTXOsResponse + (*BoardRequest)(nil), // 33: daemonrpc.BoardRequest + (*BoardResponse)(nil), // 34: daemonrpc.BoardResponse + (*RoundVTXOInfo)(nil), // 35: daemonrpc.RoundVTXOInfo + (*RoundInfo)(nil), // 36: daemonrpc.RoundInfo + (*ListRoundsRequest)(nil), // 37: daemonrpc.ListRoundsRequest + (*ListRoundsResponse)(nil), // 38: daemonrpc.ListRoundsResponse + (*WatchRoundsRequest)(nil), // 39: daemonrpc.WatchRoundsRequest + (*WatchRoundsResponse)(nil), // 40: daemonrpc.WatchRoundsResponse + (*EstimateFeeRequest)(nil), // 41: daemonrpc.EstimateFeeRequest + (*EstimateFeeResponse)(nil), // 42: daemonrpc.EstimateFeeResponse + (*GetFeeHistoryRequest)(nil), // 43: daemonrpc.GetFeeHistoryRequest + (*FeeHistoryEntry)(nil), // 44: daemonrpc.FeeHistoryEntry + (*GetFeeHistoryResponse)(nil), // 45: daemonrpc.GetFeeHistoryResponse + (*UnrollRequest)(nil), // 46: daemonrpc.UnrollRequest + (*UnrollResponse)(nil), // 47: daemonrpc.UnrollResponse + (*GetUnrollStatusRequest)(nil), // 48: daemonrpc.GetUnrollStatusRequest + (*GetUnrollStatusResponse)(nil), // 49: daemonrpc.GetUnrollStatusResponse } 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 + 13, // 2: daemonrpc.ListVTXOsResponse.vtxos:type_name -> daemonrpc.VTXO 0, // 3: daemonrpc.GetIndexedVTXOByPkScriptRequest.status_filter:type_name -> daemonrpc.VTXOStatus - 12, // 4: daemonrpc.GetIndexedVTXOByPkScriptResponse.vtxo:type_name -> daemonrpc.VTXO - 23, // 5: daemonrpc.SendVTXORequest.recipients:type_name -> daemonrpc.Output - 23, // 6: daemonrpc.SendOORRequest.recipient:type_name -> daemonrpc.Output - 27, // 7: daemonrpc.SendOORRequest.custom_inputs:type_name -> daemonrpc.CustomOORInput - 29, // 8: daemonrpc.RefreshVTXOsRequest.outpoints:type_name -> daemonrpc.OutpointSelection + 13, // 4: daemonrpc.GetIndexedVTXOByPkScriptResponse.vtxo:type_name -> daemonrpc.VTXO + 24, // 5: daemonrpc.SendVTXORequest.recipients:type_name -> daemonrpc.Output + 24, // 6: daemonrpc.SendOORRequest.recipient:type_name -> daemonrpc.Output + 28, // 7: daemonrpc.SendOORRequest.custom_inputs:type_name -> daemonrpc.CustomOORInput + 30, // 8: daemonrpc.RefreshVTXOsRequest.outpoints:type_name -> daemonrpc.OutpointSelection 1, // 9: daemonrpc.RoundInfo.state:type_name -> daemonrpc.RoundState - 34, // 10: daemonrpc.RoundInfo.vtxos:type_name -> daemonrpc.RoundVTXOInfo - 35, // 11: daemonrpc.ListRoundsResponse.rounds:type_name -> daemonrpc.RoundInfo - 35, // 12: daemonrpc.WatchRoundsResponse.round:type_name -> daemonrpc.RoundInfo - 43, // 13: daemonrpc.GetFeeHistoryResponse.entries:type_name -> daemonrpc.FeeHistoryEntry - 2, // 14: daemonrpc.DaemonService.GetInfo:input_type -> daemonrpc.GetInfoRequest - 4, // 15: daemonrpc.DaemonService.GenSeed:input_type -> daemonrpc.GenSeedRequest - 6, // 16: daemonrpc.DaemonService.InitWallet:input_type -> daemonrpc.InitWalletRequest - 8, // 17: daemonrpc.DaemonService.UnlockWallet:input_type -> daemonrpc.UnlockWalletRequest - 10, // 18: daemonrpc.DaemonService.GetBalance:input_type -> daemonrpc.GetBalanceRequest - 13, // 19: daemonrpc.DaemonService.ListVTXOs:input_type -> daemonrpc.ListVTXOsRequest - 15, // 20: daemonrpc.DaemonService.NewAddress:input_type -> daemonrpc.NewAddressRequest - 17, // 21: daemonrpc.DaemonService.NewOORReceiveScript:input_type -> daemonrpc.NewOORReceiveScriptRequest - 19, // 22: daemonrpc.DaemonService.GetIndexedVTXOByPkScript:input_type -> daemonrpc.GetIndexedVTXOByPkScriptRequest - 21, // 23: daemonrpc.DaemonService.GetIndexedOORSessionByTxid:input_type -> daemonrpc.GetIndexedOORSessionByTxidRequest - 24, // 24: daemonrpc.DaemonService.SendVTXO:input_type -> daemonrpc.SendVTXORequest - 26, // 25: daemonrpc.DaemonService.SendOOR:input_type -> daemonrpc.SendOORRequest - 30, // 26: daemonrpc.DaemonService.RefreshVTXOs:input_type -> daemonrpc.RefreshVTXOsRequest - 32, // 27: daemonrpc.DaemonService.Board:input_type -> daemonrpc.BoardRequest - 36, // 28: daemonrpc.DaemonService.ListRounds:input_type -> daemonrpc.ListRoundsRequest - 38, // 29: daemonrpc.DaemonService.WatchRounds:input_type -> daemonrpc.WatchRoundsRequest - 40, // 30: daemonrpc.DaemonService.EstimateFee:input_type -> daemonrpc.EstimateFeeRequest - 42, // 31: daemonrpc.DaemonService.GetFeeHistory:input_type -> daemonrpc.GetFeeHistoryRequest - 3, // 32: daemonrpc.DaemonService.GetInfo:output_type -> daemonrpc.GetInfoResponse - 5, // 33: daemonrpc.DaemonService.GenSeed:output_type -> daemonrpc.GenSeedResponse - 7, // 34: daemonrpc.DaemonService.InitWallet:output_type -> daemonrpc.InitWalletResponse - 9, // 35: daemonrpc.DaemonService.UnlockWallet:output_type -> daemonrpc.UnlockWalletResponse - 11, // 36: daemonrpc.DaemonService.GetBalance:output_type -> daemonrpc.GetBalanceResponse - 14, // 37: daemonrpc.DaemonService.ListVTXOs:output_type -> daemonrpc.ListVTXOsResponse - 16, // 38: daemonrpc.DaemonService.NewAddress:output_type -> daemonrpc.NewAddressResponse - 18, // 39: daemonrpc.DaemonService.NewOORReceiveScript:output_type -> daemonrpc.NewOORReceiveScriptResponse - 20, // 40: daemonrpc.DaemonService.GetIndexedVTXOByPkScript:output_type -> daemonrpc.GetIndexedVTXOByPkScriptResponse - 22, // 41: daemonrpc.DaemonService.GetIndexedOORSessionByTxid:output_type -> daemonrpc.GetIndexedOORSessionByTxidResponse - 25, // 42: daemonrpc.DaemonService.SendVTXO:output_type -> daemonrpc.SendVTXOResponse - 28, // 43: daemonrpc.DaemonService.SendOOR:output_type -> daemonrpc.SendOORResponse - 31, // 44: daemonrpc.DaemonService.RefreshVTXOs:output_type -> daemonrpc.RefreshVTXOsResponse - 33, // 45: daemonrpc.DaemonService.Board:output_type -> daemonrpc.BoardResponse - 37, // 46: daemonrpc.DaemonService.ListRounds:output_type -> daemonrpc.ListRoundsResponse - 39, // 47: daemonrpc.DaemonService.WatchRounds:output_type -> daemonrpc.WatchRoundsResponse - 41, // 48: daemonrpc.DaemonService.EstimateFee:output_type -> daemonrpc.EstimateFeeResponse - 44, // 49: daemonrpc.DaemonService.GetFeeHistory:output_type -> daemonrpc.GetFeeHistoryResponse - 32, // [32:50] is the sub-list for method output_type - 14, // [14:32] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 35, // 10: daemonrpc.RoundInfo.vtxos:type_name -> daemonrpc.RoundVTXOInfo + 36, // 11: daemonrpc.ListRoundsResponse.rounds:type_name -> daemonrpc.RoundInfo + 36, // 12: daemonrpc.WatchRoundsResponse.round:type_name -> daemonrpc.RoundInfo + 44, // 13: daemonrpc.GetFeeHistoryResponse.entries:type_name -> daemonrpc.FeeHistoryEntry + 2, // 14: daemonrpc.GetUnrollStatusResponse.status:type_name -> daemonrpc.UnrollJobStatus + 3, // 15: daemonrpc.DaemonService.GetInfo:input_type -> daemonrpc.GetInfoRequest + 5, // 16: daemonrpc.DaemonService.GenSeed:input_type -> daemonrpc.GenSeedRequest + 7, // 17: daemonrpc.DaemonService.InitWallet:input_type -> daemonrpc.InitWalletRequest + 9, // 18: daemonrpc.DaemonService.UnlockWallet:input_type -> daemonrpc.UnlockWalletRequest + 11, // 19: daemonrpc.DaemonService.GetBalance:input_type -> daemonrpc.GetBalanceRequest + 14, // 20: daemonrpc.DaemonService.ListVTXOs:input_type -> daemonrpc.ListVTXOsRequest + 16, // 21: daemonrpc.DaemonService.NewAddress:input_type -> daemonrpc.NewAddressRequest + 18, // 22: daemonrpc.DaemonService.NewOORReceiveScript:input_type -> daemonrpc.NewOORReceiveScriptRequest + 20, // 23: daemonrpc.DaemonService.GetIndexedVTXOByPkScript:input_type -> daemonrpc.GetIndexedVTXOByPkScriptRequest + 22, // 24: daemonrpc.DaemonService.GetIndexedOORSessionByTxid:input_type -> daemonrpc.GetIndexedOORSessionByTxidRequest + 25, // 25: daemonrpc.DaemonService.SendVTXO:input_type -> daemonrpc.SendVTXORequest + 27, // 26: daemonrpc.DaemonService.SendOOR:input_type -> daemonrpc.SendOORRequest + 31, // 27: daemonrpc.DaemonService.RefreshVTXOs:input_type -> daemonrpc.RefreshVTXOsRequest + 33, // 28: daemonrpc.DaemonService.Board:input_type -> daemonrpc.BoardRequest + 37, // 29: daemonrpc.DaemonService.ListRounds:input_type -> daemonrpc.ListRoundsRequest + 39, // 30: daemonrpc.DaemonService.WatchRounds:input_type -> daemonrpc.WatchRoundsRequest + 41, // 31: daemonrpc.DaemonService.EstimateFee:input_type -> daemonrpc.EstimateFeeRequest + 43, // 32: daemonrpc.DaemonService.GetFeeHistory:input_type -> daemonrpc.GetFeeHistoryRequest + 46, // 33: daemonrpc.DaemonService.Unroll:input_type -> daemonrpc.UnrollRequest + 48, // 34: daemonrpc.DaemonService.GetUnrollStatus:input_type -> daemonrpc.GetUnrollStatusRequest + 4, // 35: daemonrpc.DaemonService.GetInfo:output_type -> daemonrpc.GetInfoResponse + 6, // 36: daemonrpc.DaemonService.GenSeed:output_type -> daemonrpc.GenSeedResponse + 8, // 37: daemonrpc.DaemonService.InitWallet:output_type -> daemonrpc.InitWalletResponse + 10, // 38: daemonrpc.DaemonService.UnlockWallet:output_type -> daemonrpc.UnlockWalletResponse + 12, // 39: daemonrpc.DaemonService.GetBalance:output_type -> daemonrpc.GetBalanceResponse + 15, // 40: daemonrpc.DaemonService.ListVTXOs:output_type -> daemonrpc.ListVTXOsResponse + 17, // 41: daemonrpc.DaemonService.NewAddress:output_type -> daemonrpc.NewAddressResponse + 19, // 42: daemonrpc.DaemonService.NewOORReceiveScript:output_type -> daemonrpc.NewOORReceiveScriptResponse + 21, // 43: daemonrpc.DaemonService.GetIndexedVTXOByPkScript:output_type -> daemonrpc.GetIndexedVTXOByPkScriptResponse + 23, // 44: daemonrpc.DaemonService.GetIndexedOORSessionByTxid:output_type -> daemonrpc.GetIndexedOORSessionByTxidResponse + 26, // 45: daemonrpc.DaemonService.SendVTXO:output_type -> daemonrpc.SendVTXOResponse + 29, // 46: daemonrpc.DaemonService.SendOOR:output_type -> daemonrpc.SendOORResponse + 32, // 47: daemonrpc.DaemonService.RefreshVTXOs:output_type -> daemonrpc.RefreshVTXOsResponse + 34, // 48: daemonrpc.DaemonService.Board:output_type -> daemonrpc.BoardResponse + 38, // 49: daemonrpc.DaemonService.ListRounds:output_type -> daemonrpc.ListRoundsResponse + 40, // 50: daemonrpc.DaemonService.WatchRounds:output_type -> daemonrpc.WatchRoundsResponse + 42, // 51: daemonrpc.DaemonService.EstimateFee:output_type -> daemonrpc.EstimateFeeResponse + 45, // 52: daemonrpc.DaemonService.GetFeeHistory:output_type -> daemonrpc.GetFeeHistoryResponse + 47, // 53: daemonrpc.DaemonService.Unroll:output_type -> daemonrpc.UnrollResponse + 49, // 54: daemonrpc.DaemonService.GetUnrollStatus:output_type -> daemonrpc.GetUnrollStatusResponse + 35, // [35:55] is the sub-list for method output_type + 15, // [15:35] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_daemon_proto_init() } @@ -3389,8 +3717,8 @@ func file_daemon_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), - NumEnums: 2, - NumMessages: 43, + NumEnums: 3, + NumMessages: 47, NumExtensions: 0, NumServices: 1, }, diff --git a/daemonrpc/daemon.proto b/daemonrpc/daemon.proto index 46d9793b0..2464f9fc0 100644 --- a/daemonrpc/daemon.proto +++ b/daemonrpc/daemon.proto @@ -93,6 +93,17 @@ service DaemonService { // GetFeeHistory returns paginated fee ledger entries from the // client's local accounting database. rpc GetFeeHistory (GetFeeHistoryRequest) returns (GetFeeHistoryResponse); + + // Unroll triggers a unilateral exit for the specified VTXO outpoint. + // The daemon will assemble the recovery proof, spawn a durable unroll + // job, and drive the on-chain recovery process to completion. + rpc Unroll (UnrollRequest) returns (UnrollResponse); + + // GetUnrollStatus returns the current status of an unroll job for the + // specified VTXO outpoint, including recovery chain progress and + // sweep state. + rpc GetUnrollStatus (GetUnrollStatusRequest) + returns (GetUnrollStatusResponse); } message GetInfoRequest { @@ -768,3 +779,68 @@ message GetFeeHistoryResponse { // paid by this client. int64 total_fees_paid_sat = 2; } + +message UnrollRequest { + // outpoint is the VTXO outpoint to unilaterally exit, formatted as + // "txid:index". + string outpoint = 1; +} + +message UnrollResponse { + // created indicates whether a new unroll job was spawned. False if + // an existing job already covers this target. + bool created = 1; + + // actor_id is the identifier of the durable unroll job actor. + string actor_id = 2; +} + +message GetUnrollStatusRequest { + // outpoint is the VTXO outpoint to query, formatted as "txid:index". + string outpoint = 1; +} + +// UnrollJobStatus represents the high-level phase of an unroll job. +enum UnrollJobStatus { + UNROLL_JOB_STATUS_UNSPECIFIED = 0; + + // UNROLL_JOB_STATUS_PENDING indicates the job has been created but + // has not yet started materializing recovery transactions. + UNROLL_JOB_STATUS_PENDING = 1; + + // UNROLL_JOB_STATUS_MATERIALIZING indicates recovery transactions + // are being broadcast and confirmed on-chain. + UNROLL_JOB_STATUS_MATERIALIZING = 2; + + // UNROLL_JOB_STATUS_CSV_PENDING indicates all recovery transactions + // are confirmed and the job is waiting for the CSV delay to expire. + UNROLL_JOB_STATUS_CSV_PENDING = 3; + + // UNROLL_JOB_STATUS_SWEEPING indicates the CSV delay has expired + // and the sweep transaction is being broadcast/confirmed. + UNROLL_JOB_STATUS_SWEEPING = 4; + + // UNROLL_JOB_STATUS_COMPLETED is terminal: the sweep confirmed and + // the funds are in the on-chain wallet. + UNROLL_JOB_STATUS_COMPLETED = 5; + + // UNROLL_JOB_STATUS_FAILED is terminal: the unroll encountered an + // unrecoverable error. + UNROLL_JOB_STATUS_FAILED = 6; +} + +message GetUnrollStatusResponse { + // found is true if an unroll job exists for the requested outpoint. + bool found = 1; + + // status is the current high-level phase of the unroll job. + UnrollJobStatus status = 2; + + // sweep_txid is the txid of the sweep transaction, set once the + // sweep has been broadcast. + string sweep_txid = 3; + + // last_error contains the failure reason if the job is in FAILED + // status. + string last_error = 4; +} diff --git a/daemonrpc/daemon_grpc.pb.go b/daemonrpc/daemon_grpc.pb.go index 2ae377b07..6e5fcb034 100644 --- a/daemonrpc/daemon_grpc.pb.go +++ b/daemonrpc/daemon_grpc.pb.go @@ -37,6 +37,8 @@ const ( DaemonService_WatchRounds_FullMethodName = "/daemonrpc.DaemonService/WatchRounds" DaemonService_EstimateFee_FullMethodName = "/daemonrpc.DaemonService/EstimateFee" DaemonService_GetFeeHistory_FullMethodName = "/daemonrpc.DaemonService/GetFeeHistory" + DaemonService_Unroll_FullMethodName = "/daemonrpc.DaemonService/Unroll" + DaemonService_GetUnrollStatus_FullMethodName = "/daemonrpc.DaemonService/GetUnrollStatus" ) // DaemonServiceClient is the client API for DaemonService service. @@ -112,6 +114,14 @@ type DaemonServiceClient interface { // GetFeeHistory returns paginated fee ledger entries from the // client's local accounting database. GetFeeHistory(ctx context.Context, in *GetFeeHistoryRequest, opts ...grpc.CallOption) (*GetFeeHistoryResponse, error) + // Unroll triggers a unilateral exit for the specified VTXO outpoint. + // The daemon will assemble the recovery proof, spawn a durable unroll + // job, and drive the on-chain recovery process to completion. + Unroll(ctx context.Context, in *UnrollRequest, opts ...grpc.CallOption) (*UnrollResponse, error) + // GetUnrollStatus returns the current status of an unroll job for the + // specified VTXO outpoint, including recovery chain progress and + // sweep state. + GetUnrollStatus(ctx context.Context, in *GetUnrollStatusRequest, opts ...grpc.CallOption) (*GetUnrollStatusResponse, error) } type daemonServiceClient struct { @@ -311,6 +321,26 @@ func (c *daemonServiceClient) GetFeeHistory(ctx context.Context, in *GetFeeHisto return out, nil } +func (c *daemonServiceClient) Unroll(ctx context.Context, in *UnrollRequest, opts ...grpc.CallOption) (*UnrollResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UnrollResponse) + err := c.cc.Invoke(ctx, DaemonService_Unroll_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) GetUnrollStatus(ctx context.Context, in *GetUnrollStatusRequest, opts ...grpc.CallOption) (*GetUnrollStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetUnrollStatusResponse) + err := c.cc.Invoke(ctx, DaemonService_GetUnrollStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // DaemonServiceServer is the server API for DaemonService service. // All implementations must embed UnimplementedDaemonServiceServer // for forward compatibility. @@ -384,6 +414,14 @@ type DaemonServiceServer interface { // GetFeeHistory returns paginated fee ledger entries from the // client's local accounting database. GetFeeHistory(context.Context, *GetFeeHistoryRequest) (*GetFeeHistoryResponse, error) + // Unroll triggers a unilateral exit for the specified VTXO outpoint. + // The daemon will assemble the recovery proof, spawn a durable unroll + // job, and drive the on-chain recovery process to completion. + Unroll(context.Context, *UnrollRequest) (*UnrollResponse, error) + // GetUnrollStatus returns the current status of an unroll job for the + // specified VTXO outpoint, including recovery chain progress and + // sweep state. + GetUnrollStatus(context.Context, *GetUnrollStatusRequest) (*GetUnrollStatusResponse, error) mustEmbedUnimplementedDaemonServiceServer() } @@ -448,6 +486,12 @@ func (UnimplementedDaemonServiceServer) EstimateFee(context.Context, *EstimateFe func (UnimplementedDaemonServiceServer) GetFeeHistory(context.Context, *GetFeeHistoryRequest) (*GetFeeHistoryResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetFeeHistory not implemented") } +func (UnimplementedDaemonServiceServer) Unroll(context.Context, *UnrollRequest) (*UnrollResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Unroll not implemented") +} +func (UnimplementedDaemonServiceServer) GetUnrollStatus(context.Context, *GetUnrollStatusRequest) (*GetUnrollStatusResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetUnrollStatus not implemented") +} func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {} func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {} @@ -786,6 +830,42 @@ func _DaemonService_GetFeeHistory_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _DaemonService_Unroll_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UnrollRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).Unroll(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_Unroll_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).Unroll(ctx, req.(*UnrollRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_GetUnrollStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetUnrollStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).GetUnrollStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_GetUnrollStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).GetUnrollStatus(ctx, req.(*GetUnrollStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + // DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -861,6 +941,14 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetFeeHistory", Handler: _DaemonService_GetFeeHistory_Handler, }, + { + MethodName: "Unroll", + Handler: _DaemonService_Unroll_Handler, + }, + { + MethodName: "GetUnrollStatus", + Handler: _DaemonService_GetUnrollStatus_Handler, + }, }, Streams: []grpc.StreamDesc{ { diff --git a/daemonrpc/daemon_mailboxrpc.pb.go b/daemonrpc/daemon_mailboxrpc.pb.go index c0af2aea8..5f7a50733 100644 --- a/daemonrpc/daemon_mailboxrpc.pb.go +++ b/daemonrpc/daemon_mailboxrpc.pb.go @@ -60,6 +60,10 @@ type DaemonServiceMailboxServer interface { EstimateFee(ctx context.Context, req *EstimateFeeRequest) (*EstimateFeeResponse, error) // GetFeeHistory handles GetFeeHistory. GetFeeHistory(ctx context.Context, req *GetFeeHistoryRequest) (*GetFeeHistoryResponse, error) + // Unroll handles Unroll. + Unroll(ctx context.Context, req *UnrollRequest) (*UnrollResponse, error) + // GetUnrollStatus handles GetUnrollStatus. + GetUnrollStatus(ctx context.Context, req *GetUnrollStatusRequest) (*GetUnrollStatusResponse, error) } // RegisterDaemonServiceMailboxServer registers handlers for DaemonService. @@ -244,6 +248,26 @@ func RegisterDaemonServiceMailboxServer(r rpc.Router, impl DaemonServiceMailboxS return impl.GetFeeHistory(ctx, req) }) + r.Handle("daemonrpc.DaemonService", "Unroll", func() proto.Message { + return &UnrollRequest{} + }, func(ctx context.Context, msg proto.Message) (proto.Message, error) { + req, ok := msg.(*UnrollRequest) + if !ok { + return nil, fmt.Errorf("unexpected request type: %T", msg) + } + + return impl.Unroll(ctx, req) + }) + r.Handle("daemonrpc.DaemonService", "GetUnrollStatus", func() proto.Message { + return &GetUnrollStatusRequest{} + }, func(ctx context.Context, msg proto.Message) (proto.Message, error) { + req, ok := msg.(*GetUnrollStatusRequest) + if !ok { + return nil, fmt.Errorf("unexpected request type: %T", msg) + } + + return impl.GetUnrollStatus(ctx, req) + }) } // GetInfo calls the GetInfo RPC. @@ -659,3 +683,49 @@ func (c *DaemonServiceMailboxClient) GetFeeHistory(ctx context.Context, req *Get return resp, nil } + +// Unroll calls the Unroll RPC. +func (c *DaemonServiceMailboxClient) Unroll(ctx context.Context, req *UnrollRequest, opts ...rpc.RPCOptions) (*UnrollResponse, error) { + var opt rpc.RPCOptions + if len(opts) > 0 { + opt = opts[0] + } + + result, err := c.C.SendRPC(ctx, rpc.ServiceMethod{ + Service: "daemonrpc.DaemonService", + Method: "Unroll", + }, req, opt) + if err != nil { + return nil, err + } + + resp := new(UnrollResponse) + if err := c.C.AwaitRPC(ctx, result.CorrelationID, resp); err != nil { + return nil, err + } + + return resp, nil +} + +// GetUnrollStatus calls the GetUnrollStatus RPC. +func (c *DaemonServiceMailboxClient) GetUnrollStatus(ctx context.Context, req *GetUnrollStatusRequest, opts ...rpc.RPCOptions) (*GetUnrollStatusResponse, error) { + var opt rpc.RPCOptions + if len(opts) > 0 { + opt = opts[0] + } + + result, err := c.C.SendRPC(ctx, rpc.ServiceMethod{ + Service: "daemonrpc.DaemonService", + Method: "GetUnrollStatus", + }, req, opt) + if err != nil { + return nil, err + } + + resp := new(GetUnrollStatusResponse) + if err := c.C.AwaitRPC(ctx, result.CorrelationID, resp); err != nil { + return nil, err + } + + return resp, nil +} From 236cea8fc7b7432f78b5a1ddd2e5ffa86b7c2476 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 2 Apr 2026 11:51:18 +0200 Subject: [PATCH 3/6] darepod: surface onchain wallet balance in GetBalance RPC Add onchain_wallet_confirmed_sat field to GetBalanceResponse and populate it from the backing wallet (LND, lwwallet, or btcwallet) so clients can see confirmed on-chain funds including sweep proceeds. --- daemonrpc/daemon.pb.go | 20 +++++-- daemonrpc/daemon.proto | 5 ++ darepod/rpc_server.go | 93 ++++++++++++++++++++++++++++++++ darepod/rpc_server_test.go | 105 +++++++++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 4 deletions(-) diff --git a/daemonrpc/daemon.pb.go b/daemonrpc/daemon.pb.go index ee4a5f901..59a51aa2d 100644 --- a/daemonrpc/daemon.pb.go +++ b/daemonrpc/daemon.pb.go @@ -819,8 +819,12 @@ type GetBalanceResponse struct { // total_confirmed_sat is the sum of all confirmed balances // (boarding_confirmed_sat + vtxo_balance_sat). TotalConfirmedSat int64 `protobuf:"varint,4,opt,name=total_confirmed_sat,json=totalConfirmedSat,proto3" json:"total_confirmed_sat,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // onchain_wallet_confirmed_sat is the total confirmed on-chain + // balance of the backing wallet (all confirmed UTXOs, including + // sweep proceeds from unilateral exits). + OnchainWalletConfirmedSat int64 `protobuf:"varint,5,opt,name=onchain_wallet_confirmed_sat,json=onchainWalletConfirmedSat,proto3" json:"onchain_wallet_confirmed_sat,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetBalanceResponse) Reset() { @@ -881,6 +885,13 @@ func (x *GetBalanceResponse) GetTotalConfirmedSat() int64 { return 0 } +func (x *GetBalanceResponse) GetOnchainWalletConfirmedSat() int64 { + if x != nil { + return x.OnchainWalletConfirmedSat + } + return 0 +} + type VTXO struct { state protoimpl.MessageState `protogen:"open.v1"` // outpoint is the VTXO's outpoint in "txid:index" format. @@ -3340,12 +3351,13 @@ const file_daemon_proto_rawDesc = "" + "\x0fwallet_password\x18\x01 \x01(\fR\x0ewalletPassword\"?\n" + "\x14UnlockWalletResponse\x12'\n" + "\x0fidentity_pubkey\x18\x01 \x01(\tR\x0eidentityPubkey\"\x13\n" + - "\x11GetBalanceRequest\"\xde\x01\n" + + "\x11GetBalanceRequest\"\x9f\x02\n" + "\x12GetBalanceResponse\x124\n" + "\x16boarding_confirmed_sat\x18\x01 \x01(\x03R\x14boardingConfirmedSat\x128\n" + "\x18boarding_unconfirmed_sat\x18\x02 \x01(\x03R\x16boardingUnconfirmedSat\x12(\n" + "\x10vtxo_balance_sat\x18\x03 \x01(\x03R\x0evtxoBalanceSat\x12.\n" + - "\x13total_confirmed_sat\x18\x04 \x01(\x03R\x11totalConfirmedSat\"\xc6\x03\n" + + "\x13total_confirmed_sat\x18\x04 \x01(\x03R\x11totalConfirmedSat\x12?\n" + + "\x1conchain_wallet_confirmed_sat\x18\x05 \x01(\x03R\x19onchainWalletConfirmedSat\"\xc6\x03\n" + "\x04VTXO\x12\x1a\n" + "\boutpoint\x18\x01 \x01(\tR\boutpoint\x12\x1d\n" + "\n" + diff --git a/daemonrpc/daemon.proto b/daemonrpc/daemon.proto index 2464f9fc0..9db0517b8 100644 --- a/daemonrpc/daemon.proto +++ b/daemonrpc/daemon.proto @@ -217,6 +217,11 @@ message GetBalanceResponse { // total_confirmed_sat is the sum of all confirmed balances // (boarding_confirmed_sat + vtxo_balance_sat). int64 total_confirmed_sat = 4; + + // onchain_wallet_confirmed_sat is the total confirmed on-chain + // balance of the backing wallet (all confirmed UTXOs, including + // sweep proceeds from unilateral exits). + int64 onchain_wallet_confirmed_sat = 5; } // VTXOStatus represents the lifecycle state of a virtual transaction diff --git a/darepod/rpc_server.go b/darepod/rpc_server.go index ac684eb0d..85c43b7ce 100644 --- a/darepod/rpc_server.go +++ b/darepod/rpc_server.go @@ -261,12 +261,105 @@ func (r *RPCServer) GetBalance(ctx context.Context, } } + // Fetch the confirmed balance of the backing on-chain wallet so + // callers can observe sweep proceeds from unilateral exits. + fetchers := r.walletBalanceFetchers() + resp.OnchainWalletConfirmedSat = int64(sumOnchainWalletConfirmed( + ctx, fetchers, func(err error) { + r.server.log.WarnS(ctx, + "Unable to fetch onchain wallet balance", err) + }, + )) + resp.TotalConfirmedSat = resp.BoardingConfirmedSat + resp.VtxoBalanceSat return resp, nil } +// onchainWalletConfirmedFetcher returns the confirmed balance of one +// on-chain wallet backend. Returning an error lets the caller log the +// failure while continuing on to any sibling backends. +type onchainWalletConfirmedFetcher func( + ctx context.Context) (btcutil.Amount, error) + +// walletBalanceFetchers returns one fetcher per active on-chain +// wallet backend. Backends are mutually exclusive in production, but +// returning a slice keeps the summation logic independent of how many +// backends are wired up. +func (r *RPCServer) walletBalanceFetchers() []onchainWalletConfirmedFetcher { + var fetchers []onchainWalletConfirmedFetcher + + r.server.lnd.WhenSome(func(lndSvc *lndclient.GrpcLndServices) { + fetchers = append(fetchers, func( + ctx context.Context) (btcutil.Amount, error) { + + wb, err := lndSvc.Client.WalletBalance(ctx) + if err != nil { + return 0, fmt.Errorf("lnd wallet "+ + "balance: %w", err) + } + + return wb.Confirmed, nil + }) + }) + + r.server.lwWallet.WhenSome(func(w *lwwallet.Wallet) { + fetchers = append(fetchers, func( + ctx context.Context) (btcutil.Amount, error) { + + confirmed, _, err := w.Balance(ctx) + if err != nil { + return 0, fmt.Errorf("lightweight "+ + "wallet balance: %w", err) + } + + return confirmed, nil + }) + }) + + r.server.btcwWallet.WhenSome(func(w *btcwbackend.Wallet) { + fetchers = append(fetchers, func( + ctx context.Context) (btcutil.Amount, error) { + + confirmed, _, err := w.Balance(ctx) + if err != nil { + return 0, fmt.Errorf("btcwallet "+ + "balance: %w", err) + } + + return confirmed, nil + }) + }) + + return fetchers +} + +// sumOnchainWalletConfirmed invokes each fetcher in order and returns +// the accumulated confirmed balance. A per-fetcher error is reported +// via onErr and treated as a zero contribution so that one failing +// backend does not mask the balance of another. +func sumOnchainWalletConfirmed(ctx context.Context, + fetchers []onchainWalletConfirmedFetcher, + onErr func(err error)) btcutil.Amount { + + var total btcutil.Amount + for _, fetch := range fetchers { + confirmed, err := fetch(ctx) + if err != nil { + if onErr != nil { + onErr(err) + } + + continue + } + + total += confirmed + } + + return total +} + // ListVTXOs returns the set of VTXOs known to the wallet, optionally // filtered by status and minimum amount. func (r *RPCServer) ListVTXOs(ctx context.Context, diff --git a/darepod/rpc_server_test.go b/darepod/rpc_server_test.go index c2a424bd8..2449b5b7c 100644 --- a/darepod/rpc_server_test.go +++ b/darepod/rpc_server_test.go @@ -2,6 +2,7 @@ package darepod import ( "context" + "errors" "testing" "github.com/btcsuite/btcd/btcec/v2" @@ -383,3 +384,107 @@ func TestDeriveIdentityPubkeyPreWalletInit(t *testing.T) { }) } } + +// TestSumOnchainWalletConfirmed locks in the invariant that the on-chain +// wallet balance accumulates across every registered backend fetcher and +// that a failing fetcher does not erase the contribution of its +// siblings. A regression to a simple `=` assignment would overwrite the +// running total and trip this test. +func TestSumOnchainWalletConfirmed(t *testing.T) { + t.Parallel() + + makeFetcher := func(amount btcutil.Amount, + err error) onchainWalletConfirmedFetcher { + + return func(context.Context) (btcutil.Amount, error) { + return amount, err + } + } + + tests := []struct { + name string + fetchers []onchainWalletConfirmedFetcher + want btcutil.Amount + wantErrs int + }{ + { + name: "no fetchers returns zero", + fetchers: nil, + want: 0, + }, + { + name: "single backend returns its balance", + fetchers: []onchainWalletConfirmedFetcher{ + makeFetcher(100_000, nil), + }, + want: 100_000, + }, + { + name: "multiple backends accumulate", + fetchers: []onchainWalletConfirmedFetcher{ + makeFetcher(100_000, nil), + makeFetcher(250_000, nil), + makeFetcher(42, nil), + }, + want: 350_042, + }, + { + name: "failing backend does not mask siblings", + fetchers: []onchainWalletConfirmedFetcher{ + makeFetcher(100_000, nil), + makeFetcher(0, errors.New("boom")), + makeFetcher(50_000, nil), + }, + want: 150_000, + wantErrs: 1, + }, + { + name: "all-failing reports zero and logs each error", + fetchers: []onchainWalletConfirmedFetcher{ + makeFetcher(0, errors.New("a")), + makeFetcher(0, errors.New("b")), + }, + want: 0, + wantErrs: 2, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var gotErrs []error + total := sumOnchainWalletConfirmed( + context.Background(), tc.fetchers, + func(err error) { + gotErrs = append(gotErrs, err) + }, + ) + + require.Equal(t, tc.want, total) + require.Len(t, gotErrs, tc.wantErrs) + }) + } +} + +// TestSumOnchainWalletConfirmedNilErrCallback verifies that a nil +// onErr callback is tolerated so callers who do not care about +// per-fetcher failures do not have to supply a noop logger. +func TestSumOnchainWalletConfirmedNilErrCallback(t *testing.T) { + t.Parallel() + + fetchers := []onchainWalletConfirmedFetcher{ + func(context.Context) (btcutil.Amount, error) { + return 0, errors.New("should not panic") + }, + func(context.Context) (btcutil.Amount, error) { + return 77, nil + }, + } + + total := sumOnchainWalletConfirmed( + context.Background(), fetchers, nil, + ) + require.Equal(t, btcutil.Amount(77), total) +} From 02dd267794e71e6a0a45344493711cec87efc904 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 2 Apr 2026 07:34:31 +0200 Subject: [PATCH 4/6] db: add unilateral exit job store Add unilateral_exit_jobs migration, sqlc queries, and persistence store for manager-facing unroll job control-plane rows. Provides UpsertJob, GetJob, ListNonTerminalJobs, and MarkJobTerminal. --- db/migrations.go | 2 +- .../000008_unilateral_exit_store.down.sql | 2 + .../000008_unilateral_exit_store.up.sql | 49 +++ db/sqlc/models.go | 12 + db/sqlc/querier.go | 7 + db/sqlc/queries/unilateral_exit.sql | 41 +++ db/sqlc/schemas/generated_schema.sql | 47 +++ db/sqlc/unilateral_exit.sql.go | 156 +++++++++ db/store.go | 18 ++ db/unilateral_exit_store.go | 305 ++++++++++++++++++ db/unilateral_exit_store_test.go | 134 ++++++++ 11 files changed, 772 insertions(+), 1 deletion(-) create mode 100644 db/sqlc/migrations/000008_unilateral_exit_store.down.sql create mode 100644 db/sqlc/migrations/000008_unilateral_exit_store.up.sql create mode 100644 db/sqlc/queries/unilateral_exit.sql create mode 100644 db/sqlc/unilateral_exit.sql.go create mode 100644 db/unilateral_exit_store.go create mode 100644 db/unilateral_exit_store_test.go diff --git a/db/migrations.go b/db/migrations.go index e28c2e98b..a8b3fa9ea 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -10,7 +10,7 @@ const ( // daemon. // // NOTE: This MUST be updated when a new migration is added. - LatestMigrationVersion uint = 7 + LatestMigrationVersion uint = 8 ) // MigrationTarget is a functional option that can be passed to applyMigrations diff --git a/db/sqlc/migrations/000008_unilateral_exit_store.down.sql b/db/sqlc/migrations/000008_unilateral_exit_store.down.sql new file mode 100644 index 000000000..3492175b2 --- /dev/null +++ b/db/sqlc/migrations/000008_unilateral_exit_store.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_unilateral_exit_jobs_status_updated; +DROP TABLE IF EXISTS unilateral_exit_jobs; diff --git a/db/sqlc/migrations/000008_unilateral_exit_store.up.sql b/db/sqlc/migrations/000008_unilateral_exit_store.up.sql new file mode 100644 index 000000000..51c594875 --- /dev/null +++ b/db/sqlc/migrations/000008_unilateral_exit_store.up.sql @@ -0,0 +1,49 @@ +-- unilateral_exit_jobs stores manager-facing control-plane state for one +-- unroll job per target outpoint. +CREATE TABLE IF NOT EXISTS unilateral_exit_jobs ( + -- target_outpoint_hash identifies the target transaction. + target_outpoint_hash BLOB NOT NULL, + + -- target_outpoint_index identifies the target output index. + target_outpoint_index INTEGER NOT NULL CHECK ( + target_outpoint_index >= 0 + ), + + -- actor_id is the durable actor mailbox id for this target job. + actor_id TEXT NOT NULL, + + -- status is the control-plane job status: + -- 0 = pending + -- 1 = materializing + -- 2 = csv_pending + -- 3 = sweeping (sweep broadcast, awaiting confirmation) + -- 4 = completed + -- 5 = failed + -- 6 = sweep_broadcasting (sweep built, not yet submitted) + status INTEGER NOT NULL, + + -- trigger identifies what started the job: + -- 0 = manual + -- 1 = critical_expiry + -- 2 = restart + -- 3 = fraud_spend + trigger INTEGER NOT NULL, + + -- last_error stores the latest terminal or diagnostic error string. + last_error TEXT, + + -- sweep_txid is the 32-byte txid of the final sweep transaction. + -- NULL until the sweep is broadcast. + sweep_txid BLOB, + + -- created_at is the unix timestamp when the row was first written. + created_at BIGINT NOT NULL, + + -- updated_at is the unix timestamp of the latest row update. + updated_at BIGINT NOT NULL, + + PRIMARY KEY (target_outpoint_hash, target_outpoint_index) +); + +CREATE INDEX IF NOT EXISTS idx_unilateral_exit_jobs_status_updated + ON unilateral_exit_jobs(status, updated_at DESC); diff --git a/db/sqlc/models.go b/db/sqlc/models.go index 3ae625821..d668e304a 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -191,6 +191,18 @@ type RoundVtxoRequest struct { SigningPubkey []byte } +type UnilateralExitJob struct { + TargetOutpointHash []byte + TargetOutpointIndex int32 + ActorID string + Status int32 + Trigger int32 + LastError sql.NullString + SweepTxid []byte + CreatedAt int64 + UpdatedAt int64 +} + type UtxoClassification struct { Classification string } diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 8b51a895a..21df974a7 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -44,6 +44,7 @@ type Querier interface { // Returns cumulative Ark protocol fees paid to the operator (fees_paid // account only). Does not include L1 chain/miner fees (onchain_fees). GetTotalOperatorFeesPaid(ctx context.Context) (int64, error) + GetUnilateralExitJob(ctx context.Context, arg GetUnilateralExitJobParams) (UnilateralExitJob, error) GetVTXO(ctx context.Context, arg GetVTXOParams) (Vtxo, error) // GetVTXOForfeitTx retrieves the persisted forfeit transaction for a VTXO. // Used during recovery to restore the ForfeitingState with its tx. @@ -115,6 +116,9 @@ type Querier interface { // Also filter on spent = FALSE to handle VTXOs marked spent via the earlier // flag before the status field was introduced. ListLiveVTXOs(ctx context.Context) ([]Vtxo, error) + // Status 4 = Completed, 5 = Failed (anchored to Go iota in + // db/unilateral_exit_store.go UnilateralExitJobStatus). + ListNonTerminalUnilateralExitJobs(ctx context.Context) ([]UnilateralExitJob, error) ListOORPackageCheckpoints(ctx context.Context, sessionID []byte) ([]OorPackageCheckpoint, error) ListOORPackages(ctx context.Context) ([]OorPackage, error) ListOORPackagesByDirection(ctx context.Context, direction int32) ([]OorPackage, error) @@ -136,6 +140,7 @@ type Querier interface { ListWalletUTXOLog(ctx context.Context, arg ListWalletUTXOLogParams) ([]WalletUtxoLog, error) ListWalletUTXOLogByBlock(ctx context.Context, blockHeight int32) ([]WalletUtxoLog, error) ListWalletUTXOLogByClassification(ctx context.Context, arg ListWalletUTXOLogByClassificationParams) ([]WalletUtxoLog, error) + MarkUnilateralExitJobTerminal(ctx context.Context, arg MarkUnilateralExitJobTerminalParams) error // MarkVTXOForfeited marks a VTXO as forfeited and records the forfeit // transaction ID and replacement VTXO outpoint. Called when the new round's // commitment transaction confirms. @@ -160,6 +165,8 @@ type Querier interface { UpsertOORRecipientCursor(ctx context.Context, arg UpsertOORRecipientCursorParams) error UpsertOORVTXOBinding(ctx context.Context, arg UpsertOORVTXOBindingParams) (int64, error) UpsertOwnedReceiveScript(ctx context.Context, arg UpsertOwnedReceiveScriptParams) error + // Unilateral-exit job control-plane queries. + UpsertUnilateralExitJob(ctx context.Context, arg UpsertUnilateralExitJobParams) error } var _ Querier = (*Queries)(nil) diff --git a/db/sqlc/queries/unilateral_exit.sql b/db/sqlc/queries/unilateral_exit.sql new file mode 100644 index 000000000..0bafb4b95 --- /dev/null +++ b/db/sqlc/queries/unilateral_exit.sql @@ -0,0 +1,41 @@ +-- Unilateral-exit job control-plane queries. + +-- name: UpsertUnilateralExitJob :exec +INSERT INTO unilateral_exit_jobs ( + target_outpoint_hash, target_outpoint_index, actor_id, status, trigger, + last_error, sweep_txid, created_at, updated_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9 +) +ON CONFLICT (target_outpoint_hash, target_outpoint_index) DO UPDATE SET + actor_id = EXCLUDED.actor_id, + status = EXCLUDED.status, + trigger = EXCLUDED.trigger, + last_error = EXCLUDED.last_error, + sweep_txid = EXCLUDED.sweep_txid, + updated_at = EXCLUDED.updated_at +; + +-- name: GetUnilateralExitJob :one +SELECT * FROM unilateral_exit_jobs +WHERE target_outpoint_hash = $1 + AND target_outpoint_index = $2 +; + +-- name: ListNonTerminalUnilateralExitJobs :many +-- Status 4 = Completed, 5 = Failed (anchored to Go iota in +-- db/unilateral_exit_store.go UnilateralExitJobStatus). +SELECT * FROM unilateral_exit_jobs +WHERE status NOT IN (4, 5) +ORDER BY created_at ASC +; + +-- name: MarkUnilateralExitJobTerminal :exec +UPDATE unilateral_exit_jobs +SET status = $3, + last_error = $4, + updated_at = $5, + sweep_txid = $6 +WHERE target_outpoint_hash = $1 + AND target_outpoint_index = $2 +; diff --git a/db/sqlc/schemas/generated_schema.sql b/db/sqlc/schemas/generated_schema.sql index f6a599e06..8ed596cd9 100644 --- a/db/sqlc/schemas/generated_schema.sql +++ b/db/sqlc/schemas/generated_schema.sql @@ -284,6 +284,9 @@ CREATE INDEX idx_rounds_creation_time CREATE INDEX idx_rounds_status ON rounds(status); +CREATE INDEX idx_unilateral_exit_jobs_status_updated + ON unilateral_exit_jobs(status, updated_at DESC); + CREATE INDEX idx_utxo_log_block ON wallet_utxo_log(block_height); @@ -794,6 +797,50 @@ CREATE TABLE rounds ( FOREIGN KEY (status) REFERENCES round_statuses(status_name) ); +CREATE TABLE unilateral_exit_jobs ( + -- target_outpoint_hash identifies the target transaction. + target_outpoint_hash BLOB NOT NULL, + + -- target_outpoint_index identifies the target output index. + target_outpoint_index INTEGER NOT NULL CHECK ( + target_outpoint_index >= 0 + ), + + -- actor_id is the durable actor mailbox id for this target job. + actor_id TEXT NOT NULL, + + -- status is the control-plane job status: + -- 0 = pending + -- 1 = materializing + -- 2 = csv_pending + -- 3 = sweeping + -- 4 = completed + -- 5 = failed + status INTEGER NOT NULL, + + -- trigger identifies what started the job: + -- 0 = manual + -- 1 = critical_expiry + -- 2 = restart + -- 3 = fraud_spend + trigger INTEGER NOT NULL, + + -- last_error stores the latest terminal or diagnostic error string. + last_error TEXT, + + -- sweep_txid is the 32-byte txid of the final sweep transaction. + -- NULL until the sweep is broadcast. + sweep_txid BLOB, + + -- created_at is the unix timestamp when the row was first written. + created_at BIGINT NOT NULL, + + -- updated_at is the unix timestamp of the latest row update. + updated_at BIGINT NOT NULL, + + PRIMARY KEY (target_outpoint_hash, target_outpoint_index) +); + CREATE TABLE utxo_classifications ( classification TEXT PRIMARY KEY ); diff --git a/db/sqlc/unilateral_exit.sql.go b/db/sqlc/unilateral_exit.sql.go new file mode 100644 index 000000000..3a987c423 --- /dev/null +++ b/db/sqlc/unilateral_exit.sql.go @@ -0,0 +1,156 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: unilateral_exit.sql + +package sqlc + +import ( + "context" + "database/sql" +) + +const GetUnilateralExitJob = `-- name: GetUnilateralExitJob :one +SELECT target_outpoint_hash, target_outpoint_index, actor_id, status, trigger, last_error, sweep_txid, created_at, updated_at FROM unilateral_exit_jobs +WHERE target_outpoint_hash = $1 + AND target_outpoint_index = $2 +` + +type GetUnilateralExitJobParams struct { + TargetOutpointHash []byte + TargetOutpointIndex int32 +} + +func (q *Queries) GetUnilateralExitJob(ctx context.Context, arg GetUnilateralExitJobParams) (UnilateralExitJob, error) { + row := q.db.QueryRowContext(ctx, GetUnilateralExitJob, arg.TargetOutpointHash, arg.TargetOutpointIndex) + var i UnilateralExitJob + err := row.Scan( + &i.TargetOutpointHash, + &i.TargetOutpointIndex, + &i.ActorID, + &i.Status, + &i.Trigger, + &i.LastError, + &i.SweepTxid, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const ListNonTerminalUnilateralExitJobs = `-- name: ListNonTerminalUnilateralExitJobs :many +SELECT target_outpoint_hash, target_outpoint_index, actor_id, status, trigger, last_error, sweep_txid, created_at, updated_at FROM unilateral_exit_jobs +WHERE status NOT IN (4, 5) +ORDER BY created_at ASC +` + +// Status 4 = Completed, 5 = Failed (anchored to Go iota in +// db/unilateral_exit_store.go UnilateralExitJobStatus). +func (q *Queries) ListNonTerminalUnilateralExitJobs(ctx context.Context) ([]UnilateralExitJob, error) { + rows, err := q.db.QueryContext(ctx, ListNonTerminalUnilateralExitJobs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UnilateralExitJob + for rows.Next() { + var i UnilateralExitJob + if err := rows.Scan( + &i.TargetOutpointHash, + &i.TargetOutpointIndex, + &i.ActorID, + &i.Status, + &i.Trigger, + &i.LastError, + &i.SweepTxid, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const MarkUnilateralExitJobTerminal = `-- name: MarkUnilateralExitJobTerminal :exec +UPDATE unilateral_exit_jobs +SET status = $3, + last_error = $4, + updated_at = $5, + sweep_txid = $6 +WHERE target_outpoint_hash = $1 + AND target_outpoint_index = $2 +` + +type MarkUnilateralExitJobTerminalParams struct { + TargetOutpointHash []byte + TargetOutpointIndex int32 + Status int32 + LastError sql.NullString + UpdatedAt int64 + SweepTxid []byte +} + +func (q *Queries) MarkUnilateralExitJobTerminal(ctx context.Context, arg MarkUnilateralExitJobTerminalParams) error { + _, err := q.db.ExecContext(ctx, MarkUnilateralExitJobTerminal, + arg.TargetOutpointHash, + arg.TargetOutpointIndex, + arg.Status, + arg.LastError, + arg.UpdatedAt, + arg.SweepTxid, + ) + return err +} + +const UpsertUnilateralExitJob = `-- name: UpsertUnilateralExitJob :exec + +INSERT INTO unilateral_exit_jobs ( + target_outpoint_hash, target_outpoint_index, actor_id, status, trigger, + last_error, sweep_txid, created_at, updated_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9 +) +ON CONFLICT (target_outpoint_hash, target_outpoint_index) DO UPDATE SET + actor_id = EXCLUDED.actor_id, + status = EXCLUDED.status, + trigger = EXCLUDED.trigger, + last_error = EXCLUDED.last_error, + sweep_txid = EXCLUDED.sweep_txid, + updated_at = EXCLUDED.updated_at +` + +type UpsertUnilateralExitJobParams struct { + TargetOutpointHash []byte + TargetOutpointIndex int32 + ActorID string + Status int32 + Trigger int32 + LastError sql.NullString + SweepTxid []byte + CreatedAt int64 + UpdatedAt int64 +} + +// Unilateral-exit job control-plane queries. +func (q *Queries) UpsertUnilateralExitJob(ctx context.Context, arg UpsertUnilateralExitJobParams) error { + _, err := q.db.ExecContext(ctx, UpsertUnilateralExitJob, + arg.TargetOutpointHash, + arg.TargetOutpointIndex, + arg.ActorID, + arg.Status, + arg.Trigger, + arg.LastError, + arg.SweepTxid, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} diff --git a/db/store.go b/db/store.go index faf5a8366..981af379c 100644 --- a/db/store.go +++ b/db/store.go @@ -293,3 +293,21 @@ func (s *Store) NewOORArtifactStore( return NewOORArtifactPersistenceStore(artifactDB, clk) } + +// NewUnilateralExitStore builds the unilateral-exit persistence store with +// transactional query execution. +func (s *Store) NewUnilateralExitStore( + clk clock.Clock) *UnilateralExitPersistenceStore { + + baseDB := s.BaseDB() + + exitDB := NewTransactionExecutor( + baseDB, + func(tx *sql.Tx) UnilateralExitStore { + return s.queries.WithTx(tx) + }, + s.log, + ) + + return NewUnilateralExitPersistenceStore(exitDB, clk) +} diff --git a/db/unilateral_exit_store.go b/db/unilateral_exit_store.go new file mode 100644 index 000000000..6ad8d123b --- /dev/null +++ b/db/unilateral_exit_store.go @@ -0,0 +1,305 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/db/sqlc" + "github.com/lightningnetwork/lnd/clock" +) + +var ( + // ErrUnilateralExitJobNotFound indicates the job row does not exist. + ErrUnilateralExitJobNotFound = errors.New( + "unilateral exit job not found", + ) +) + +// UnilateralExitJobStatus is the manager-facing status of one target job. +type UnilateralExitJobStatus int32 + +const ( + // UnilateralExitJobStatusPending means the job row exists + // but work has not started materially. + UnilateralExitJobStatusPending UnilateralExitJobStatus = iota + + // UnilateralExitJobStatusMaterializing means proof nodes + // are still being materialized. + UnilateralExitJobStatusMaterializing + + // UnilateralExitJobStatusCSVPending means the target is + // confirmed and the job is waiting for CSV maturity. + UnilateralExitJobStatusCSVPending + + // UnilateralExitJobStatusSweeping means the final sweep has been + // broadcast and the job is awaiting its confirmation. + UnilateralExitJobStatusSweeping + + // UnilateralExitJobStatusCompleted means the job completed + // successfully. + UnilateralExitJobStatusCompleted + + // UnilateralExitJobStatusFailed means the job failed terminally. + UnilateralExitJobStatusFailed + + // UnilateralExitJobStatusSweepBroadcasting means the final sweep + // has been built and persisted but has not yet been submitted for + // broadcast. Appended after the original enum so existing rows at + // status=3 continue to decode as "sweep broadcast, awaiting conf". + UnilateralExitJobStatusSweepBroadcasting +) + +// IsTerminal reports whether the control-plane job status is terminal. +func (s UnilateralExitJobStatus) IsTerminal() bool { + return s == UnilateralExitJobStatusCompleted || + s == UnilateralExitJobStatusFailed +} + +// UnilateralExitJobTrigger records what started an exit job. +type UnilateralExitJobTrigger int32 + +const ( + // UnilateralExitJobTriggerManual is an operator-triggered start. + UnilateralExitJobTriggerManual UnilateralExitJobTrigger = iota + + // UnilateralExitJobTriggerCriticalExpiry is a VTXO expiry handoff. + UnilateralExitJobTriggerCriticalExpiry + + // UnilateralExitJobTriggerRestart marks a restored in-flight job. + UnilateralExitJobTriggerRestart + + // UnilateralExitJobTriggerFraudSpend is reserved for active-job spend + // escalation. + UnilateralExitJobTriggerFraudSpend +) + +// UnilateralExitJobRecord is one manager-faced job control-plane row. +type UnilateralExitJobRecord struct { + TargetOutpoint wire.OutPoint + ActorID string + Status UnilateralExitJobStatus + Trigger UnilateralExitJobTrigger + LastError string + SweepTxid []byte + CreatedAt time.Time + UpdatedAt time.Time +} + +// UnilateralExitStore groups SQL methods needed by the unilateral-exit +// job store. Proof persistence has been removed — proofs are derived on +// demand from the VTXO descriptor and OOR artifact data. +type UnilateralExitStore interface { + UpsertUnilateralExitJob(ctx context.Context, + arg sqlc.UpsertUnilateralExitJobParams) error + + GetUnilateralExitJob(ctx context.Context, + arg sqlc.GetUnilateralExitJobParams) ( + sqlc.UnilateralExitJob, error, + ) + + ListNonTerminalUnilateralExitJobs(ctx context.Context) ( + []sqlc.UnilateralExitJob, error, + ) + + MarkUnilateralExitJobTerminal(ctx context.Context, + arg sqlc.MarkUnilateralExitJobTerminalParams) error +} + +// BatchedUnilateralExitStore combines the query surface with transactions. +type BatchedUnilateralExitStore interface { + UnilateralExitStore + BatchedTx[UnilateralExitStore] +} + +// UnilateralExitPersistenceStore persists immutable proofs and manager-facing +// job rows for the unilateral-exit subsystem. +type UnilateralExitPersistenceStore struct { + db BatchedUnilateralExitStore + clock clock.Clock +} + +// NewUnilateralExitPersistenceStore creates a unilateral-exit store. +func NewUnilateralExitPersistenceStore( + db BatchedUnilateralExitStore, clk clock.Clock, +) *UnilateralExitPersistenceStore { + + return &UnilateralExitPersistenceStore{ + db: db, + clock: clk, + } +} + +// UpsertJob persists or updates one manager-facing job record. +// NOTE: The proof-related methods (UpsertProof, GetProof, MarkProofFailed) +// have been removed. Proofs are now derived on demand from the authoritative +// VTXO descriptor and OOR artifact data via the ProofAssembler. +func (s *UnilateralExitPersistenceStore) UpsertJob(ctx context.Context, + job UnilateralExitJobRecord) error { + + nowUnix := s.clock.Now().Unix() + createdAt := job.CreatedAt.Unix() + if job.CreatedAt.IsZero() { + createdAt = nowUnix + } + + target := job.TargetOutpoint + + writeFn := func(q UnilateralExitStore) error { + return q.UpsertUnilateralExitJob( + ctx, + sqlc.UpsertUnilateralExitJobParams{ + TargetOutpointHash: target.Hash[:], + TargetOutpointIndex: int32(target.Index), + ActorID: job.ActorID, + Status: int32(job.Status), + Trigger: int32(job.Trigger), + LastError: sql.NullString{ + String: job.LastError, + Valid: job.LastError != "", + }, + SweepTxid: job.SweepTxid, + CreatedAt: createdAt, + UpdatedAt: nowUnix, + }, + ) + } + + return s.db.ExecTx(ctx, WriteTxOption(), writeFn) +} + +// GetJob loads one manager-facing job control-plane row. +func (s *UnilateralExitPersistenceStore) GetJob(ctx context.Context, + target wire.OutPoint) (*UnilateralExitJobRecord, error) { + + var job *UnilateralExitJobRecord + + readFn := func(q UnilateralExitStore) error { + row, err := q.GetUnilateralExitJob(ctx, + sqlc.GetUnilateralExitJobParams{ + TargetOutpointHash: target.Hash[:], + TargetOutpointIndex: int32(target.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrUnilateralExitJobNotFound + } + + return err + } + + record, err := jobRecordFromRow(row) + if err != nil { + return err + } + + job = &record + + return nil + } + + err := s.db.ExecTx(ctx, ReadTxOption(), readFn) + if err != nil { + return nil, err + } + + return job, nil +} + +// ListNonTerminalJobs loads all non-terminal manager-facing job rows. +func (s *UnilateralExitPersistenceStore) ListNonTerminalJobs( + ctx context.Context) ([]UnilateralExitJobRecord, error) { + + result := make([]UnilateralExitJobRecord, 0) + + readFn := func(q UnilateralExitStore) error { + rows, err := q.ListNonTerminalUnilateralExitJobs(ctx) + if err != nil { + return err + } + + result = make([]UnilateralExitJobRecord, 0, len(rows)) + for i := range rows { + record, convErr := jobRecordFromRow(rows[i]) + if convErr != nil { + return convErr + } + + result = append(result, record) + } + + return nil + } + + err := s.db.ExecTx(ctx, ReadTxOption(), readFn) + if err != nil { + return nil, err + } + + return result, nil +} + +// MarkJobTerminal updates one job row to a terminal status. +func (s *UnilateralExitPersistenceStore) MarkJobTerminal(ctx context.Context, + target wire.OutPoint, status UnilateralExitJobStatus, + reason string, sweepTxid []byte) error { + + if !status.IsTerminal() { + return fmt.Errorf("status %d is not terminal", status) + } + + writeFn := func(q UnilateralExitStore) error { + return q.MarkUnilateralExitJobTerminal( + ctx, + sqlc.MarkUnilateralExitJobTerminalParams{ + TargetOutpointHash: target.Hash[:], + TargetOutpointIndex: int32(target.Index), + Status: int32(status), + LastError: sql.NullString{ + String: reason, + Valid: reason != "", + }, + UpdatedAt: s.clock.Now().Unix(), + SweepTxid: sweepTxid, + }, + ) + } + + return s.db.ExecTx(ctx, WriteTxOption(), writeFn) +} + +func jobRecordFromRow(row sqlc.UnilateralExitJob) ( + UnilateralExitJobRecord, error) { + + if len(row.TargetOutpointHash) != 32 { + return UnilateralExitJobRecord{}, fmt.Errorf("unexpected "+ + "target outpoint hash length %d", + len(row.TargetOutpointHash)) + } + + var hash [32]byte + copy(hash[:], row.TargetOutpointHash) + + record := UnilateralExitJobRecord{ + TargetOutpoint: wire.OutPoint{ + Hash: hash, + Index: uint32(row.TargetOutpointIndex), + }, + ActorID: row.ActorID, + Status: UnilateralExitJobStatus(row.Status), + Trigger: UnilateralExitJobTrigger(row.Trigger), + SweepTxid: row.SweepTxid, + CreatedAt: time.Unix(row.CreatedAt, 0), + UpdatedAt: time.Unix(row.UpdatedAt, 0), + } + + if row.LastError.Valid { + record.LastError = row.LastError.String + } + + return record, nil +} diff --git a/db/unilateral_exit_store_test.go b/db/unilateral_exit_store_test.go new file mode 100644 index 000000000..32dfea26e --- /dev/null +++ b/db/unilateral_exit_store_test.go @@ -0,0 +1,134 @@ +package db + +import ( + "bytes" + "database/sql" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightningnetwork/lnd/clock" + "github.com/stretchr/testify/require" +) + +// newUnilateralExitStoreForTest creates a unilateral-exit store backed by a +// fresh test database. +func newUnilateralExitStoreForTest( + t *testing.T) *UnilateralExitPersistenceStore { + + t.Helper() + + db := NewTestDB(t) + + exitDB := NewTransactionExecutor( + db.BaseDB, + func(tx *sql.Tx) UnilateralExitStore { + return db.WithTx(tx) + }, + btclog.Disabled, + ) + + return NewUnilateralExitPersistenceStore( + exitDB, clock.NewDefaultClock(), + ) +} + +// TestUnilateralExitStoreListNonTerminalJobs verifies that restore queries only +// return non-terminal manager-facing job rows. +func TestUnilateralExitStoreListNonTerminalJobs(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newUnilateralExitStoreForTest(t) + + pendingTarget := wire.OutPoint{ + Hash: chainhash.Hash{0x11, 0x01}, + Index: 1, + } + completedTarget := wire.OutPoint{ + Hash: chainhash.Hash{0x22, 0x02}, + Index: 2, + } + failedTarget := wire.OutPoint{ + Hash: chainhash.Hash{0x33, 0x03}, + Index: 3, + } + + err := store.UpsertJob(ctx, UnilateralExitJobRecord{ + TargetOutpoint: pendingTarget, + ActorID: "job-pending", + Status: UnilateralExitJobStatusMaterializing, + Trigger: UnilateralExitJobTriggerManual, + CreatedAt: time.Unix(10, 0), + }) + require.NoError(t, err) + + err = store.UpsertJob(ctx, UnilateralExitJobRecord{ + TargetOutpoint: completedTarget, + ActorID: "job-completed", + Status: UnilateralExitJobStatusCompleted, + Trigger: UnilateralExitJobTriggerRestart, + CreatedAt: time.Unix(20, 0), + }) + require.NoError(t, err) + + err = store.UpsertJob(ctx, UnilateralExitJobRecord{ + TargetOutpoint: failedTarget, + ActorID: "job-failed", + Status: UnilateralExitJobStatusFailed, + Trigger: UnilateralExitJobTriggerCriticalExpiry, + LastError: "boom", + CreatedAt: time.Unix(30, 0), + }) + require.NoError(t, err) + + jobs, err := store.ListNonTerminalJobs(ctx) + require.NoError(t, err) + require.Len(t, jobs, 1) + require.Equal(t, pendingTarget, jobs[0].TargetOutpoint) + require.Equal(t, "job-pending", jobs[0].ActorID) + require.Equal(t, UnilateralExitJobStatusMaterializing, + jobs[0].Status) +} + +// TestUnilateralExitStoreUpsertPersistsSweepTxid verifies that terminal +// control-plane updates preserve the sweep txid on conflict updates. +func TestUnilateralExitStoreUpsertPersistsSweepTxid(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newUnilateralExitStoreForTest(t) + target := wire.OutPoint{ + Hash: chainhash.Hash{0x44, 0x04}, + Index: 4, + } + sweepTxid := bytes.Repeat([]byte{0xAB}, chainhash.HashSize) + + err := store.UpsertJob(ctx, UnilateralExitJobRecord{ + TargetOutpoint: target, + ActorID: "job-active", + Status: UnilateralExitJobStatusSweeping, + Trigger: UnilateralExitJobTriggerManual, + CreatedAt: time.Unix(40, 0), + }) + require.NoError(t, err) + + err = store.UpsertJob(ctx, UnilateralExitJobRecord{ + TargetOutpoint: target, + ActorID: "job-completed", + Status: UnilateralExitJobStatusCompleted, + Trigger: UnilateralExitJobTriggerManual, + SweepTxid: sweepTxid, + CreatedAt: time.Unix(40, 0), + }) + require.NoError(t, err) + + job, err := store.GetJob(ctx, target) + require.NoError(t, err) + require.NotNil(t, job) + require.Equal(t, UnilateralExitJobStatusCompleted, job.Status) + require.Equal(t, "job-completed", job.ActorID) + require.Equal(t, sweepTxid, job.SweepTxid) +} From 1c64e29e2800f22ab2bec9e24199912a8d676273 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 8 Apr 2026 16:58:58 +0545 Subject: [PATCH 5/6] unroll: add durable per-target unroll actor and registry Per-target durable actor managing the full unilateral-exit lifecycle: proof assembly, transaction materialization via txconfirm, CSV maturity wait, sweep construction and confirmation. Features: - UnrollRegistryActor: thin registry, dedup by outpoint, boot restore - VTXOUnrollActor: durable FSM per target, delegates to txconfirm - Sweep retry (up to 3 attempts before terminal failure) - Spend watch on target outpoint for early external-spend detection - Proof assembly from VTXO descriptors and OOR artifacts - Checkpoint persistence after every FSM transition Key types: UnrollRegistryActor, VTXOUnrollActor, LocalProofAssembler. --- unroll/README.md | 285 ++++++ unroll/actor.go | 1220 +++++++++++++++++++++++ unroll/actor_test.go | 1721 +++++++++++++++++++++++++++++++++ unroll/db_store.go | 244 +++++ unroll/db_store_test.go | 111 +++ unroll/descriptor_resolver.go | 259 +++++ unroll/doc.go | 110 +++ unroll/fsm_logic.go | 393 ++++++++ unroll/fsm_types.go | 434 +++++++++ unroll/interfaces.go | 41 + unroll/lineage_material.go | 88 ++ unroll/messages.go | 619 ++++++++++++ unroll/messages_test.go | 129 +++ unroll/proof_assembler.go | 464 +++++++++ unroll/registry.go | 889 +++++++++++++++++ unroll/registry_messages.go | 155 +++ unroll/registry_test.go | 892 +++++++++++++++++ unroll/session.go | 66 ++ unroll/snapshot.go | 277 ++++++ unroll/snapshot_test.go | 534 ++++++++++ unroll/state_snapshot.go | 199 ++++ unroll/state_snapshot_test.go | 190 ++++ unroll/sweep.go | 234 +++++ 23 files changed, 9554 insertions(+) create mode 100644 unroll/README.md create mode 100644 unroll/actor.go create mode 100644 unroll/actor_test.go create mode 100644 unroll/db_store.go create mode 100644 unroll/db_store_test.go create mode 100644 unroll/descriptor_resolver.go create mode 100644 unroll/doc.go create mode 100644 unroll/fsm_logic.go create mode 100644 unroll/fsm_types.go create mode 100644 unroll/interfaces.go create mode 100644 unroll/lineage_material.go create mode 100644 unroll/messages.go create mode 100644 unroll/messages_test.go create mode 100644 unroll/proof_assembler.go create mode 100644 unroll/registry.go create mode 100644 unroll/registry_messages.go create mode 100644 unroll/registry_test.go create mode 100644 unroll/session.go create mode 100644 unroll/snapshot.go create mode 100644 unroll/snapshot_test.go create mode 100644 unroll/state_snapshot.go create mode 100644 unroll/state_snapshot_test.go create mode 100644 unroll/sweep.go diff --git a/unroll/README.md b/unroll/README.md new file mode 100644 index 000000000..665c109e2 --- /dev/null +++ b/unroll/README.md @@ -0,0 +1,285 @@ +# unroll + +Durable, per-target unilateral-exit subsystem. One actor per VTXO owns the +full exit lifecycle: assemble the recovery proof, broadcast and confirm +every ancestor transaction, wait out the CSV timelock, build and +broadcast the final timeout-path sweep, and watch for confirmation. A +thin registry actor coordinates the set of per-target actors, handles +admission/dedup, and persists a coarse control-plane record per target so +the daemon can pick up in-flight jobs after a restart. + +## Why unrolling is hard + +A VTXO on the ark side does not live on chain by itself — it sits inside a +tree of transactions rooted at a round commitment, potentially with +out-of-round (OOR) hops stacked on top. To bring funds back to the local +wallet without the operator's help, the client has to: + +1. Assemble the full transaction graph that leads from a root commitment + down to the target VTXO output. +2. Broadcast each ancestor in dependency order, waiting for each to + confirm before its children become spendable. +3. Once the target confirms, wait out its relative-timelock (CSV). +4. Build a timeout-path spend, sign it with the client key, broadcast, + and wait for confirmation. + +Any step can fail — mempool rejection, reorg, daemon restart, operator +racing with a cooperative spend, fee-rate spikes, etc. The unroll +subsystem is engineered so every piece of in-flight work is durable, all +retries are idempotent, and no on-chain action double-fires on restart. + +## Component layout + +The package factors the problem into four pieces that communicate through +narrow interfaces: + +| Component | File(s) | Responsibility | +| --- | --- | --- | +| `UnrollRegistryActor` | `registry.go` | Spawn / dedup / terminal bookkeeping; one instance per daemon. | +| `VTXOUnrollActor` | `actor.go` | Durable per-target actor; owns the FSM session, proof, cached sweep tx. | +| FSM (pure) | `fsm_types.go`, `fsm_logic.go`, `session.go` | Side-effect-free state machine that emits outbox events. | +| Support | `proof_assembler.go`, `sweep.go`, `snapshot.go`, `db_store.go`, `messages.go` | Proof assembly, sweep building, checkpoint codec, DB adapter, durable mailbox codec. | + +External dependencies: + +- [`unrollplan`](../unrollplan) — the pure planner that, given a proof graph + and current state, decides what to broadcast next. +- [`txconfirm`](../txconfirm) — the shared actor that handles broadcast, + CPFP, and confirmation notifications. Its txid-keyed dedup is what makes + unroll retries safe. +- [`chainsource`](../chainsource) — block-epoch, spend-watch, and + fee-estimate subscriptions. +- [`lib/recovery`](../lib/recovery) — the immutable proof graph type. +- [`db`](../db) — control-plane row persistence + (`unilateral_exit_jobs` table). + +## High-level flow + +```mermaid +flowchart LR + Caller["Chain resolver / operator"] -->|EnsureUnroll| Registry + Registry -->|spawn + StartUnrollRequest| Child[VTXOUnrollActor] + Registry -->|UpsertRecord sync| Store[(Registry store)] + Child -->|EnsureConfirmedReq per node / sweep| TxConfirm[txconfirm] + Child -->|SubscribeBlocks / RegisterSpend| Chain[chainsource] + TxConfirm -->|TxConfirmed / TxFailed| Child + Chain -->|BlockEpoch / SpendEvent| Child + Child -->|UnrollTerminatedMsg| Registry + Registry -->|async UpsertRecord / MarkTerminal| Store + Wallet[SweepWallet] <-->|NewWalletPkScript / SignTaprootSpend| Child +``` + +## Per-target state machine + +Each `VTXOUnrollActor` drives one protofsm session through the phases +below. Transitions are computed by `deriveStateTransition` after each +applied event; the pure [`unrollplan.Planner`](../unrollplan) decides +which branch fires. + +```mermaid +stateDiagram-v2 + [*] --> Idle + + Idle --> AwaitingMaterialization: StartEvent / ResumeEvent
ready proof nodes exist + Idle --> AwaitingCSV: target already confirmed
CSV not yet ready + Idle --> AwaitingSweepBroadcast: CSV already matured + Idle --> AwaitingSweepConfirmation: sweep already broadcast + + AwaitingMaterialization --> AwaitingMaterialization: TxConfirmed
(more ancestors left) + AwaitingMaterialization --> AwaitingCSV: target confirmed
CSV not yet ready + AwaitingMaterialization --> AwaitingSweepBroadcast: CSV matured inline + + AwaitingCSV --> AwaitingCSV: HeightUpdated
(CSV not yet ready) + AwaitingCSV --> AwaitingSweepBroadcast: CSV matures + + AwaitingSweepBroadcast --> AwaitingSweepConfirmation: SweepBroadcasted + AwaitingSweepBroadcast --> AwaitingSweepBroadcast: SweepBuildFailed
(retry budget remaining) + AwaitingSweepBroadcast --> Failed: SweepBuildFailed
(budget exhausted) + + AwaitingSweepConfirmation --> Completed: TxConfirmed on sweep + AwaitingSweepConfirmation --> AwaitingSweepBroadcast: TxFailed on sweep
(retry budget remaining) + AwaitingSweepConfirmation --> Failed: TxFailed on sweep
(budget exhausted) + + AwaitingMaterialization --> Failed: TxFailed on proof node + AwaitingMaterialization --> Failed: SpendObserved
external spender + AwaitingCSV --> Failed: SpendObserved
external spender + AwaitingSweepBroadcast --> Failed: SpendObserved
external spender + AwaitingSweepConfirmation --> Failed: SpendObserved
external spender + + Completed --> [*] + Failed --> [*] +``` + +Notes: + +- `Idle → *` represents the initial `StartEvent` / `ResumeEvent` running + the planner once against the restored state; the actual landing state + depends on how much progress was already made before admission. +- Only `Completed` and `Failed` are terminal; the registry is notified + at most once per actor lifetime via `UnrollTerminatedMsg`. +- `SpendObserved` failures are only fired when the spender is neither a + known proof-graph node nor our own sweep; otherwise the event is + absorbed and just bumps the height. + +## Durability invariants + +Two ordering rules are load-bearing. + +### 1. Persist before broadcast + +`startSweep` writes the sweep tx to the checkpoint BEFORE asking txconfirm +to broadcast it. On any retry (same actor lifetime or post-restart) the +same sweep tx is restored, so: + +- txconfirm's txid-keyed dedup absorbs the re-submit. +- We never burn a new BIP32 wallet address on a retry, which would + otherwise race the original sweep on chain. +- A crash between build and broadcast cannot cause a third sweep to + emerge from the ashes. + +```mermaid +sequenceDiagram + participant FSM + participant Behavior as VTXOUnrollActor + participant Store as delivery store + participant TxConfirm as txconfirm + FSM->>Behavior: RequestSweepBuild (outbox) + Behavior->>Behavior: buildSweepTx
(sign + cache) + Behavior->>Store: persistCheckpoint
(sweepTx bytes) + Store-->>Behavior: ok + Behavior->>TxConfirm: EnsureConfirmedReq(sweepTx) + TxConfirm-->>Behavior: EnsureConfirmedResp + Behavior->>FSM: SweepBroadcastedEvent +``` + +### 2. Fail-closed admission + +`UnrollRegistryActor.handleEnsure` calls `Store.UpsertRecord` synchronously +before returning `Created=true`. A crash in the "child spawned but not +persisted" window would otherwise orphan the job: `RestoreNonTerminal` +only walks the durable store. + +```mermaid +sequenceDiagram + participant Caller + participant Registry + participant Store as registry store + participant Child as VTXOUnrollActor + Caller->>Registry: EnsureUnrollRequest + Registry->>Registry: dedup (active, pending, store) + Registry->>Registry: queryBestHeight + Registry->>Child: spawn + StartUnrollRequest + Child-->>Registry: Ack + Registry->>Child: GetStateRequest + Child-->>Registry: GetStateResp + Registry->>Store: UpsertRecord (Phase=Pending/Materializing) + alt write succeeds + Store-->>Registry: ok + Registry-->>Caller: EnsureUnrollResp{Created: true} + else write fails + Store-->>Registry: err + Registry->>Child: Stop + Registry-->>Caller: err (fail-closed) + end +``` + +## Restart flow + +On daemon boot, the registry calls `RestoreNonTerminal`, which re-spawns a +`VTXOUnrollActor` for every non-terminal row in the store and sends each +one `ResumeUnrollRequest`. The actor: + +1. Loads its checkpoint (proof, planner state, sweep tx, last height). +2. Reconstructs the protofsm session in the same state it crashed in. +3. Emits `ReissueInFlightTransactions` for every in-flight proof node and, + if a sweep was already broadcast, `ReissueSweepConfirmation`. +4. The behavior's `routeOutbox` walks those events, re-submitting each + transaction to txconfirm. Dedup turns these into cheap re-subscribes + rather than new broadcasts. + +```mermaid +flowchart LR + Boot[daemon start] --> Rest[Registry.RestoreNonTerminal] + Rest --> List["Store.ListNonTerminalRecords()"] + List --> Spawn[per-target spawn + ResumeUnrollRequest] + Spawn --> Load[Load checkpoint
from delivery store] + Load --> Reissue[Emit Reissue* outbox events] + Reissue --> TxConfirm[Re-submit in-flight txs
to txconfirm] + TxConfirm --> Dedup[txconfirm dedup
= idempotent resub] +``` + +## Registry persistence model + +The registry keeps three in-memory tables plus the durable store: + +| Table | Purpose | +| --- | --- | +| `active` | Live children. Authoritative source of current FSM phase via Ask. | +| `pending` | Latest snapshot whose store write has not yet flushed. | +| `persisting` | Record currently being written (exactly one per outpoint). | +| Store | Control-plane row per target; source of truth across restarts. | + +Updates for non-terminal state changes stay on the async writer path so +the registry goroutine is never held up by a slow store: + +```mermaid +sequenceDiagram + participant Trigger as handleTerminated + participant Registry + participant Writer as async goroutine + participant Store + Trigger->>Registry: requestPersist (Tell) + Registry->>Registry: handlePersistActiveRecord
(snapshot pending) + Registry->>Writer: persistRecordAsync + Writer->>Store: UpsertRecord + alt success + Writer->>Registry: persistRecordResultMsg (no err) + Registry->>Registry: clear pending if matched + else failure + Writer->>Registry: persistRecordResultMsg (err) + Registry->>Registry: schedule backoff retry + end +``` + +## External spend handling + +Every actor registers a spend watch on its target outpoint through +`chainsource.RegisterSpendRequest`. Spend events are classified before +they are allowed to fail the job: + +- Spender is a known proof-graph node → expected materialization + traffic; swallow and update height. +- Spender is our own sweep → same, we are seeing our own success. +- Anything else → the target was spent externally (cooperative + operator path, double-spend, reorg replay). Drive `FailEvent` with a + reason identifying the spender. + +This keeps the actor from terminating on benign events while still +catching real fraud and reorg scenarios promptly. + +## Testing + +- `messages_test.go` — TLV round-trip tests for every durable mailbox + message. +- `db_store_test.go` — Phase ↔ DB status and Trigger ↔ DB trigger + round-trip tests (prevents silent enum downgrades). +- `registry_test.go` — dedup, fail-closed admission, terminal retry, + blocking-store semantics, status fallback to in-memory pending. +- `actor_test.go` — full per-target lifecycle: boot, materialize, + CSV wait, sweep build and broadcast, confirmation, spend-watch + classification, restart resume. + +## See also + +- [`CLAUDE.md`](CLAUDE.md) — stable per-package summary with + invariants. +- [`../docs/durable_actor_architecture.md`](../docs/durable_actor_architecture.md) + — CDC pattern and durable mailbox lifecycle. +- [`../docs/durable_actor_quickstart.md`](../docs/durable_actor_quickstart.md) + — `TLVMessage`, `ActorBehavior`, migration checklist. +- [`../unrollplan/CLAUDE.md`](../unrollplan/CLAUDE.md) — pure planner + semantics. +- [`../txconfirm/CLAUDE.md`](../txconfirm/CLAUDE.md) — broadcast + CPFP + + confirmation actor. +- [`../lib/recovery/CLAUDE.md`](../lib/recovery/CLAUDE.md) — immutable + proof graph. diff --git a/unroll/actor.go b/unroll/actor.go new file mode 100644 index 000000000..31efde52e --- /dev/null +++ b/unroll/actor.go @@ -0,0 +1,1220 @@ +package unroll + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/baselib/protofsm" + "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/darepo-client/lib/recovery" + "github.com/lightninglabs/darepo-client/txconfirm" + "github.com/lightninglabs/darepo-client/unrollplan" + "github.com/lightninglabs/darepo-client/vtxo" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// Config configures one durable per-target VTXO unroll actor. +type Config struct { + // TargetOutpoint is the VTXO being unrolled. + TargetOutpoint wire.OutPoint + + // ActorID is the durable actor mailbox ID. When empty it + // falls back to a deterministic ID derived from the target. + ActorID string + + // DeliveryStore provides durable mailbox and checkpoint persistence. + DeliveryStore actor.DeliveryStore + + // ProofAssembler resolves the immutable local proof for the target. + ProofAssembler ProofAssembler + + // VTXOStore loads the descriptor used for final sweep signing. + VTXOStore vtxo.VTXOStore + + // TxConfirmRef is the shared tx-confirmation actor. + TxConfirmRef actor.ActorRef[txconfirm.Msg, txconfirm.Resp] + + // ChainSource provides fee estimation for sweep construction. + ChainSource actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ] + + // Wallet provides sweep destination derivation and + // timeout-path signing. + Wallet SweepWallet + + // Log is an optional logger. + Log fn.Option[btclog.Logger] + + // MaxSweepFeeRateSatPerVByte clamps pathological fee estimates. + MaxSweepFeeRateSatPerVByte int64 + + // RegistryRef receives terminal notifications from this actor when set. + RegistryRef actor.TellOnlyRef[RegistryMsg] +} + +// VTXOUnrollActor wraps one durable per-target unroll actor. +type VTXOUnrollActor struct { + ref actor.ActorRef[Msg, Resp] + durable *actor.DurableActor[Msg, Resp] + stop func() +} + +// Ref returns the public actor reference. +func (a *VTXOUnrollActor) Ref() actor.ActorRef[Msg, Resp] { + return a.ref +} + +// Stop stops the underlying durable actor. +func (a *VTXOUnrollActor) Stop() { + if a == nil { + return + } + + if a.stop != nil { + a.stop() + return + } + + if a.durable != nil { + a.durable.Stop() + } +} + +// NewVTXOUnrollActor creates and starts one durable VTXO unroll actor. +func NewVTXOUnrollActor(cfg Config) (*VTXOUnrollActor, error) { + if cfg.ActorID == "" { + cfg.ActorID = actorIDForTarget(cfg.TargetOutpoint) + } + + behavior := &behavior{ + cfg: cfg, + log: cfg.Log.UnwrapOr(btclog.Disabled), + } + if err := behavior.restoreCheckpoint(context.Background()); err != nil { + return nil, err + } + + durableCfg := actor.DefaultDurableActorConfig[Msg, Resp]( + cfg.ActorID, behavior, cfg.DeliveryStore, newCodec(), + ) + durableCfg.Log = cfg.Log + + durable := actor.NewDurableActor(durableCfg) + behavior.selfRef = durable.TellRef() + durable.Start() + + return &VTXOUnrollActor{ + ref: durable.Ref(), + durable: durable, + stop: durable.Stop, + }, nil +} + +// behavior is the durable actor behavior for one target outpoint. +type behavior struct { + cfg Config + log btclog.Logger + selfRef actor.TellOnlyRef[Msg] + + proof *recovery.Proof + planner *unrollplan.Planner + desc *vtxo.Descriptor + session *Session + pending *actorCheckpoint + + sweepTx *wire.MsgTx + blockSubActive bool + spendWatchActive bool + terminalNotified bool +} + +// Receive processes one durable actor message. It is the single entry +// point for every input that drives the unroll FSM: admission, restart, +// chain events, txconfirm notifications, external spends, and status +// probes. The job of Receive is purely translation — it maps the durable +// message surface onto the internal FSM [Event] surface and delegates to +// handleEvent, which runs the apply-persist-route-notify pipeline. +// +// Inputs fall into four groups: +// +// - Admission (StartUnrollRequest, ResumeUnrollRequest): open a new +// session or continue a restored one. Trigger is propagated so the +// control-plane knows whether we started manually, near expiry, or +// on restart. +// +// - Chain observations (HeightObservedMsg, TxConfirmedMsg, TxFailedMsg, +// SpendObservedMsg): progress the planner. TxFailedMsg reasons are +// annotated with proof-vs-sweep context here so FSM terminal reasons +// read usefully later. SpendObservedMsg has its own handler because +// it needs to classify the spender before deciding whether to fail. +// +// - Status probes (GetStateRequest): read-only, bypass the FSM apply +// path entirely, just snapshot the current state. +// +// Unknown messages are rejected with a typed error rather than silently +// dropped so codec/dispatch mismatches are loud. +func (b *behavior) Receive(ctx context.Context, msg Msg) fn.Result[Resp] { + switch m := msg.(type) { + case *StartUnrollRequest: + return b.handleEvent(ctx, &StartEvent{ + Height: m.Height, + Trigger: m.Trigger, + }) + + case *ResumeUnrollRequest: + return b.handleEvent(ctx, &ResumeEvent{ + Height: m.Height, + }) + + case *HeightObservedMsg: + return b.handleEvent(ctx, &HeightUpdatedEvent{ + Height: m.Height, + }) + + case *TxConfirmedMsg: + return b.handleEvent(ctx, &TxConfirmedEvent{ + Txid: m.Txid, + Height: m.Height, + }) + + case *TxFailedMsg: + return b.handleEvent(ctx, &TxFailedEvent{ + Txid: m.Txid, + Reason: b.failureReasonForTx(m.Txid, m.Reason), + }) + + case *SpendObservedMsg: + return b.handleSpendObserved(ctx, m) + + case *GetStateRequest: + return fn.Ok[Resp](b.stateResponse()) + + default: + return fn.Err[Resp](fmt.Errorf( + "unknown unroll message: %T", msg, + )) + } +} + +// OnStop stops any loaded protofsm session. +func (b *behavior) OnStop(context.Context) error { + ctx := context.Background() + + b.unsubscribeBlocks(ctx) + b.unregisterSpendWatch(ctx) + + if b.session != nil && b.session.FSM != nil { + b.session.FSM.Stop() + } + + return nil +} + +// handleEvent is the admission wrapper around driveEvent that every +// FSM-driving branch of Receive funnels through. It exists so the lazy +// load of proof / descriptor / planner / session / subscriptions happens +// exactly once per actor lifetime regardless of which event kind arrives +// first, and so the caller sees a uniform AckResp on success. +func (b *behavior) handleEvent(ctx context.Context, + event Event) fn.Result[Resp] { + + if err := b.ensureLoaded(ctx); err != nil { + return fn.Err[Resp](err) + } + + if err := b.driveEvent(ctx, event); err != nil { + return fn.Err[Resp](err) + } + + return fn.Ok[Resp](&AckResp{}) +} + +// driveEvent runs the core four-step pipeline that every FSM transition +// must go through: +// +// 1. Apply: hand the event to the protofsm, which computes the next +// state and any actor-boundary OutboxEvents. The FSM itself does no +// IO; this call returns synchronously with the new state persisted +// in the FSM session. +// +// 2. Persist: write the resulting checkpoint to the delivery store +// BEFORE doing any IO on the outbox. If the process crashes between +// the FSM transition and the outbox routing, restart restores the +// exact same state that was in memory and re-emits the outbox via +// the reissue path, so no work is lost and no work is duplicated +// beyond what txconfirm's txid-keyed dedup already collapses. +// +// 3. Route: interpret each OutboxEvent as a real IO effect — submit +// ready proof nodes to txconfirm, re-arm in-flight subscriptions, +// build and broadcast the sweep, or reattach a sweep-confirmation +// watcher. Handler-level errors (and any events driven inside +// routeOutbox, e.g. TxFailedEvent from an immediate rejection) can +// recursively re-enter driveEvent. +// +// 4. Notify: if the transition reached Completed or Failed, tell the +// registry exactly once so it can move the child out of the active +// map and mark the DB row terminal. Subsequent transitions (e.g. a +// late TxConfirmed after we already failed) are suppressed by +// terminalNotified. +// +// Persist-before-route is the invariant that lets the actor lose its +// process mid-operation without corrupting on-chain state — every side +// effect is driven by a checkpoint that is already on disk. +func (b *behavior) driveEvent(ctx context.Context, event Event) error { + if b.session == nil || b.session.FSM == nil { + return fmt.Errorf("session not initialized") + } + + outbox, err := b.session.FSM.AskEvent(ctx, event).Await(ctx).Unpack() + if err != nil { + return err + } + + if err := b.persistCheckpoint(ctx); err != nil { + return err + } + + if err := b.routeOutbox(ctx, outbox); err != nil { + return err + } + + b.notifyRegistryIfTerminal(ctx) + + return nil +} + +// startSweep constructs the final timeout-path sweep, persists it, and +// hands it to txconfirm for broadcast-and-wait-for-confirmation. +// +// Ordering here is load-bearing. The guiding rule is: never cross the +// actor-boundary with a fresh sweep that the checkpoint has not yet +// seen. Three consequences of breaking that rule make the order +// non-obvious: +// +// 1. The sweep destination comes from a new BIP32 wallet address. A +// second, freshly-derived sweep after a retry burns a new address +// and races the first on chain. If both land we leak data about the +// wallet's key derivation. +// +// 2. txconfirm dedups by txid. If the re-submitted sweep has a +// different txid (even one output byte differs: new pkScript, new +// fee) the dedup misses and we double-broadcast. +// +// 3. A crash between "sign new sweep" and "broadcast new sweep" that +// happens AFTER the on-chain broadcast of an earlier attempt means +// restart has no trail of the broadcast sweep at all — it would +// build a third sweep. +// +// The fix is to reuse b.sweepTx (possibly restored from the checkpoint +// via restoreCheckpoint) when it is already set, and to persist the +// checkpoint BEFORE asking txconfirm to broadcast. On restart the same +// transaction materializes from the checkpoint, txconfirm sees the same +// txid it has been tracking, and the Ask resolves as a benign no-op. +// +// If buildSweepTx itself fails (fee estimation, signing, malformed +// descriptor), we drive a SweepBuildFailedEvent through the FSM so the +// retry budget is accounted for and we reach terminal Failed after +// maxSweepAttempts. +func (b *behavior) startSweep(ctx context.Context) error { + // Reuse the sweep tx restored from the checkpoint (or built on a + // prior attempt inside this actor lifetime) so we converge on a + // single sweep txid / wallet pkScript across retries. + if b.sweepTx == nil { + sweepTx, err := buildSweepTx( + ctx, b.cfg.Wallet, b.cfg.ChainSource, b.proof, b.desc, + b.cfg.MaxSweepFeeRateSatPerVByte, + ) + if err != nil { + return b.driveEvent(ctx, &SweepBuildFailedEvent{ + Reason: err.Error(), + }) + } + + b.sweepTx = sweepTx + } + + // Persist the built sweep before asking txconfirm to broadcast, so + // on any retry the same sweepTx is restored and re-submitted under + // txconfirm's dedup rather than a freshly-derived sweep racing it. + if err := b.persistCheckpoint(ctx); err != nil { + return err + } + + sweepPkScript, err := safeTxOutPkScript(b.sweepTx, 0) + if err != nil { + return fmt.Errorf("sweep tx malformed: %w", err) + } + + sweepLabel := "unroll-sweep-" + b.cfg.TargetOutpoint.String() + + _, err = b.cfg.TxConfirmRef.Ask(ctx, &txconfirm.EnsureConfirmedReq{ + Tx: b.sweepTx, + ConfirmationPkScript: sweepPkScript, + Label: sweepLabel, + Subscriber: b.notificationRef(), + }).Await(ctx).Unpack() + if err != nil { + return err + } + + sweepTxid := b.sweepTx.TxHash() + + return b.driveEvent(ctx, &SweepBroadcastedEvent{Txid: sweepTxid}) +} + +// safeTxOutPkScript returns a defensive copy of tx.TxOut[index].PkScript. +// It reports an error instead of panicking when tx is nil, has no outputs, +// or index is out of range, so malformed proof artifacts surface as a +// retryable error rather than a goroutine panic that terminates the +// actor. +func safeTxOutPkScript(tx *wire.MsgTx, index uint32) ([]byte, error) { + if tx == nil { + return nil, fmt.Errorf("tx is nil") + } + + if index >= uint32(len(tx.TxOut)) { + return nil, fmt.Errorf("output index %d out of range "+ + "(tx has %d outputs)", index, len(tx.TxOut)) + } + + return append([]byte(nil), tx.TxOut[index].PkScript...), nil +} + +// ensureNodeConfirmed hands one ready proof-graph node to txconfirm and +// threads any immediate rejection back through the FSM. +// +// txconfirm is idempotent on txid, so calling this for a node that is +// already broadcast or confirmed is a cheap no-op. That is why both the +// "first time ready" path and the "reissue after restart" path can funnel +// through here without coordination: the shared actor absorbs the +// duplicate. +// +// One subtlety: EnsureConfirmedReq can return an EnsureConfirmedResp with +// State=TxStateFailed synchronously (for example, mempool rejected the +// tx). We translate that into a TxFailedEvent driven right back into the +// FSM so the usual terminal path runs, rather than propagating the error +// up and leaving the actor waiting on a subscription that will never +// fire. +func (b *behavior) ensureNodeConfirmed(ctx context.Context, + txid chainhash.Hash, node *recovery.Node) error { + + if node == nil { + return fmt.Errorf("proof node %s missing", txid) + } + + pkScript, err := safeTxOutPkScript(node.Tx, 0) + if err != nil { + return fmt.Errorf("proof node %s: %w", txid, err) + } + + resp, err := b.cfg.TxConfirmRef.Ask(ctx, &txconfirm.EnsureConfirmedReq{ + Tx: node.Tx, + ConfirmationPkScript: pkScript, + Label: "unroll-node-" + txid.String(), + Subscriber: b.notificationRef(), + }).Await(ctx).Unpack() + if err != nil { + return err + } + + ensureResp, ok := resp.(*txconfirm.EnsureConfirmedResp) + if !ok { + return fmt.Errorf("unexpected txconfirm response %T", resp) + } + + if ensureResp.State == txconfirm.TxStateFailed { + return b.driveEvent(ctx, &TxFailedEvent{ + Txid: txid, + Reason: b.failureReasonForTx( + txid, "txconfirm returned failed state", + ), + }) + } + + return nil +} + +// stateResponse builds the current state response for callers and tests. +func (b *behavior) stateResponse() *GetStateResp { + state, err := b.currentState() + if err != nil { + return &GetStateResp{ + Phase: PhaseFailed, + FailReason: err.Error(), + } + } + + job := stateJob(state) + sweepTxid := effectiveSweepTxid(job.PlannerState, b.sweepTx) + resp := &GetStateResp{ + Started: !isIdleState(state), + Trigger: stateTrigger(state), + Height: stateHeight(state), + Phase: phaseFromState(state), + PlannerState: copyPlannerState(job.PlannerState), + FailReason: job.FailReason, + } + + if sweepTxid != nil { + txid := *sweepTxid + resp.SweepTxid = &txid + } + + return resp +} + +// ensureLoaded lazily constructs every piece of actor-lifetime state the +// FSM needs to make progress: the immutable recovery proof, the VTXO +// descriptor, the pure planner, the protofsm session, the block epoch +// subscription, and the target-outpoint spend watch. +// +// The load is lazy (not done in NewVTXOUnrollActor) for two reasons: +// +// - On restore, the checkpoint has already been pulled from the +// delivery store but the chain subscription and FSM session should +// only spin up once the first real event arrives. Booting the actor +// must not fail if chainsource is momentarily unresponsive. +// +// - Subsequent Receive invocations reuse the cached fields so every +// step (proof assembly, planner construction, subscription) happens +// exactly once per actor lifetime. +// +// Order matters: the planner validates against the proof, the FSM +// session validates against the planner, and the spend watch needs the +// proof + descriptor to derive the target pkScript. The final +// PlannerState.Validate call catches checkpoint/proof drift (e.g. an +// InFlightTxids entry that no longer resolves in the proof) loudly +// instead of letting the FSM silently desync. +func (b *behavior) ensureLoaded(ctx context.Context) error { + if b.proof == nil { + proof, err := b.cfg.ProofAssembler.EnsureProof( + ctx, b.cfg.TargetOutpoint, + ) + if err != nil { + return err + } + + b.proof = proof + } + + if b.desc == nil { + desc, err := b.cfg.VTXOStore.GetVTXO(ctx, b.cfg.TargetOutpoint) + if err != nil { + return err + } + + b.desc = desc + } + + if b.planner == nil { + planner, err := unrollplan.NewPlanner(b.proof) + if err != nil { + return err + } + + b.planner = planner + } + + if b.session == nil { + initialState := State(&Idle{}) + if b.pending != nil && b.pending.Started { + initialState = stateFromCheckpoint(b.pending) + } + + session, err := NewSession( + ctx, b.proof, b.planner, initialState, b.log, + ) + if err != nil { + return err + } + + b.session = session + } + + if err := b.ensureBlockSubscription(ctx); err != nil { + return err + } + + if err := b.ensureSpendWatch(ctx); err != nil { + return err + } + + state, err := b.currentState() + if err != nil { + return err + } + + return stateJob(state).PlannerState.Validate(b.proof) +} + +// notificationRef builds the subscriber ref the actor hands to txconfirm. +// +// txconfirm delivers notifications in its own type space +// ([txconfirm.Notification]) but our durable mailbox only accepts [Msg] +// variants so the delivery store can codec them. This helper threads a +// [chainsource.MapNotification]-style adapter: every txconfirm +// notification is synchronously re-wrapped into the matching mailbox +// message (TxConfirmedMsg / TxFailedMsg) and forwarded to our self-ref +// for durable enqueue. An unknown notification type is mapped to a +// generic TxFailedMsg so the actor still terminates loudly instead of +// silently dropping the callback. +func (b *behavior) notificationRef() actor.TellOnlyRef[txconfirm.Notification] { + return txconfirm.MapNotification( + b.selfRef, + func(msg txconfirm.Notification) Msg { + switch m := msg.(type) { + case *txconfirm.TxConfirmed: + return &TxConfirmedMsg{ + Txid: m.Txid, + Height: m.BlockHeight, + NumConfs: m.NumConfs, + } + + case *txconfirm.TxFailed: + return &TxFailedMsg{ + Txid: m.Txid, + Reason: m.Reason, + } + + default: + return &TxFailedMsg{ + Reason: fmt.Sprintf( + "unknown txconfirm "+ + "notification %T", + msg, + ), + } + } + }, + ) +} + +// restoreCheckpoint restores durable state from the delivery store. +func (b *behavior) restoreCheckpoint(ctx context.Context) error { + if b.cfg.DeliveryStore == nil { + return fmt.Errorf("delivery store must be provided") + } + + checkpoint, err := b.cfg.DeliveryStore.LoadCheckpoint( + ctx, b.cfg.ActorID, + ) + if err != nil { + return err + } + + if checkpoint == nil { + return nil + } + + decoded, err := decodeCheckpoint(checkpoint.StateData) + if err != nil { + return err + } + + if decoded.Version != checkpointVersion { + return fmt.Errorf( + "unknown checkpoint version %d", + decoded.Version, + ) + } + + b.pending = decoded + b.sweepTx = copyTx(decoded.SweepTx) + + return nil +} + +// ensureBlockSubscription starts the actor's shared block epoch subscription on +// first use so CSV waits advance in the live daemon. +func (b *behavior) ensureBlockSubscription(ctx context.Context) error { + if b.blockSubActive { + return nil + } + + notifyRef := chainsource.MapBlockEpoch( + b.selfRef, + func(epoch chainsource.BlockEpoch) Msg { + return &HeightObservedMsg{ + Height: epoch.Height, + } + }, + ) + + _, err := b.cfg.ChainSource.Ask( + ctx, &chainsource.SubscribeBlocksRequest{ + CallerID: b.blockCallerID(), + NotifyActor: fn.Some(notifyRef), + }, + ).Await(ctx).Unpack() + if err != nil { + return fmt.Errorf("subscribe blocks: %w", err) + } + + b.blockSubActive = true + + return nil +} + +// unsubscribeBlocks cancels the actor's block subscription when it stops. +func (b *behavior) unsubscribeBlocks(ctx context.Context) { + if !b.blockSubActive { + return + } + + err := b.cfg.ChainSource.Tell( + ctx, &chainsource.UnsubscribeBlocksRequest{ + CallerID: b.blockCallerID(), + }, + ) + if err == nil { + b.blockSubActive = false + } +} + +// blockCallerID returns the stable chain subscription ID for this actor. +func (b *behavior) blockCallerID() string { + return fmt.Sprintf("unroll.%s", b.cfg.TargetOutpoint.String()) +} + +// ensureSpendWatch registers a one-shot spend watch on the target outpoint so +// the actor detects external spends early. +func (b *behavior) ensureSpendWatch(ctx context.Context) error { + if b.spendWatchActive { + return nil + } + + if b.proof == nil || b.desc == nil { + return nil + } + + targetOutpoint := b.proof.TargetOutpoint() + targetNode, ok := b.proof.Node(targetOutpoint.Hash) + if !ok { + return fmt.Errorf("target tx %s not in proof", + targetOutpoint.Hash) + } + + pkScript, err := safeTxOutPkScript(targetNode.Tx, targetOutpoint.Index) + if err != nil { + return fmt.Errorf("target tx %s: %w", targetOutpoint.Hash, err) + } + + notifyRef := chainsource.MapSpendEvent( + b.selfRef, + func(event chainsource.SpendEvent) Msg { + return &SpendObservedMsg{ + SpendingTxid: event.SpendingTxid, + SpendingHeight: event.SpendingHeight, + } + }, + ) + + _, err = b.cfg.ChainSource.Ask( + ctx, &chainsource.RegisterSpendRequest{ + CallerID: b.spendCallerID(), + Outpoint: &targetOutpoint, + PkScript: pkScript, + HeightHint: uint32(b.desc.CreatedHeight), + NotifyActor: fn.Some(notifyRef), + }, + ).Await(ctx).Unpack() + if err != nil { + return fmt.Errorf("register spend watch: %w", err) + } + + b.spendWatchActive = true + + return nil +} + +// unregisterSpendWatch cancels the actor's target spend watch on stop. +func (b *behavior) unregisterSpendWatch(ctx context.Context) { + if !b.spendWatchActive { + return + } + + targetOutpoint := b.cfg.TargetOutpoint + err := b.cfg.ChainSource.Tell( + ctx, &chainsource.UnregisterSpendRequest{ + CallerID: b.spendCallerID(), + Outpoint: &targetOutpoint, + }, + ) + if err == nil { + b.spendWatchActive = false + } +} + +// spendCallerID returns the stable spend-watch registration ID. +func (b *behavior) spendCallerID() string { + return fmt.Sprintf("unroll-spend.%s", + b.cfg.TargetOutpoint.String()) +} + +// handleSpendObserved processes a chainsource spend notification on the +// target outpoint. The spend watch is a safety net — it fires whenever +// ANY transaction spends the target, and we have to classify what we are +// looking at before we decide the unroll job is dead. +// +// Three cases: +// +// 1. The spender is a known node in our recovery proof. That means an +// ancestor (the target itself, or a transitive parent further up the +// tree) just confirmed; this is normal materialization traffic +// already being tracked by txconfirm. Swallow the event, but use its +// block height to advance the planner's view of the chain. +// +// 2. The spender is our own final sweep (matched by the sweep txid +// recorded in planner state). Again benign — our sweep confirming is +// the goal — so just propagate the height. +// +// 3. Anything else: the target was spent by someone else. This can +// happen if the operator cooperatively claims it, if a fraud party +// beats us to a signed spend, or if a reorg replays a different +// history. In all cases the unroll job cannot finish; drive FailEvent +// with a reason that identifies the spending txid and height so +// operators can triage the cause off-line. +func (b *behavior) handleSpendObserved(ctx context.Context, + msg *SpendObservedMsg) fn.Result[Resp] { + + if err := b.ensureLoaded(ctx); err != nil { + return fn.Err[Resp](err) + } + + // Case 1: the spender is a proof-graph node. That means an + // ancestor of our target just confirmed on chain — totally + // expected. chainsource will also be sending us the corresponding + // TxConfirmedMsg via the txconfirm subscription; here we only + // use the event to bump the best-height watermark so planner + // snapshots are consistent. + if b.proof != nil { + if _, ok := b.proof.Node(msg.SpendingTxid); ok { + return b.handleEvent(ctx, &HeightUpdatedEvent{ + Height: msg.SpendingHeight, + }) + } + } + + // Case 2: the spender is our own sweep. Same benign outcome — we + // are watching our own success from a different vantage point. + // Compare against the sweep txid recorded in planner state + // (SweepBroadcastedEvent populates that) rather than b.sweepTx, + // so we catch the late-arriving spend notification even if the + // behavior has already cleared its in-memory cache. + state, err := b.currentState() + if err == nil { + job := stateJob(state) + if job.PlannerState.Sweep.Txid.IsSome() && + job.PlannerState.Sweep.Txid.UnsafeFromSome() == + msg.SpendingTxid { + + return b.handleEvent(ctx, &HeightUpdatedEvent{ + Height: msg.SpendingHeight, + }) + } + } + + // Case 3: neither of the above. Someone else spent the target. + // This happens if the operator cooperatively claimed the VTXO, + // if a reorg replaced history, or in fraud scenarios. There is + // no way for this unroll to proceed, so terminate with a + // reason string that identifies the spender for operator + // triage. + reason := fmt.Sprintf( + "target %s spent externally by tx %s at height %d", + b.cfg.TargetOutpoint, msg.SpendingTxid, + msg.SpendingHeight, + ) + + return b.handleEvent(ctx, &FailEvent{Reason: reason}) +} + +// persistCheckpoint writes the current durable actor checkpoint. +func (b *behavior) persistCheckpoint(ctx context.Context) error { + state, err := b.currentState() + if err != nil { + return err + } + + checkpoint := checkpointFromState(state, b.sweepTx) + raw, err := encodeCheckpoint(checkpoint) + if err != nil { + return err + } + + err = b.cfg.DeliveryStore.SaveCheckpoint(ctx, actor.CheckpointParams{ + ActorID: b.cfg.ActorID, + StateType: checkpointStateType, + StateData: raw, + Version: checkpointVersion, + }) + if err != nil { + return err + } + + b.pending = checkpoint + + return nil +} + +// routeOutbox interprets each [OutboxEvent] emitted by the FSM as a real +// actor-boundary IO effect. The FSM itself is a pure state function — it +// never talks to txconfirm, never persists anything, never broadcasts — +// so this router is where "what to do next" turns into "do it." +// +// Outbox event semantics: +// +// - EnsureReadyTransactions: newly-unblocked proof nodes. Look each up +// in the immutable proof graph, then submit to txconfirm. A missing +// node is a bug in the proof-vs-checkpoint alignment (not a runtime +// condition) so it surfaces as a hard error instead of being +// silently skipped. +// +// - ReissueInFlightTransactions: restart path. The FSM restored +// InFlightTxids from the checkpoint and needs each one re-submitted +// to txconfirm so the shared actor re-attaches its subscription. +// Same node-missing rule applies — a silent skip would leave the FSM +// permanently waiting on a confirmation that was never re-armed. +// +// - RequestSweepBuild: the planner says the target has matured and the +// final sweep can be constructed. Delegates to startSweep for the +// persist-then-broadcast dance. +// +// - ReissueSweepConfirmation: restart path for a sweep that was +// already broadcast before the crash. The checkpoint carried the +// sweep tx, so we re-submit it to txconfirm (idempotent via dedup) +// to re-attach the confirmation subscription. A nil sweepTx here +// means the checkpoint is corrupt and we fail loudly rather than +// silently losing the job. +func (b *behavior) routeOutbox(ctx context.Context, + outbox []OutboxEvent) error { + + for i := range outbox { + switch evt := outbox[i].(type) { + case *EnsureReadyTransactions: + // Newly-unblocked proof ancestors that the planner + // has determined are ready to broadcast. Look each + // up in the immutable proof graph so we can submit + // the actual wire.MsgTx to txconfirm. + for _, txid := range evt.Txids { + node, ok := b.proof.Node(txid) + if !ok { + // This is a bug, not a runtime + // condition: the FSM told us a txid + // is ready, but our proof graph no + // longer carries it. Silent skip + // would strand the FSM waiting on a + // subscription that never gets armed. + return fmt.Errorf("proof node "+ + "%s missing", txid) + } + + err := b.ensureNodeConfirmed(ctx, txid, node) + if err != nil { + return err + } + } + + case *ReissueInFlightTransactions: + for _, txid := range evt.Txids { + node, ok := b.proof.Node(txid) + if !ok { + // A missing node on reissue means + // the checkpoint referenced a + // transaction our current proof no + // longer knows about; silently + // skipping would leave the FSM + // waiting on a txconfirm + // subscription that was never + // re-registered. + return fmt.Errorf("proof node %s "+ + "missing on reissue", txid) + } + + pkScript, err := safeTxOutPkScript(node.Tx, 0) + if err != nil { + return fmt.Errorf("proof node %s: %w", + txid, err) + } + + _, err = b.cfg.TxConfirmRef.Ask( + ctx, &txconfirm.EnsureConfirmedReq{ + Tx: node.Tx, + ConfirmationPkScript: pkScript, + Label: "unroll-node-" + + txid.String(), + Subscriber: b.notificationRef(), + }, + ).Await(ctx).Unpack() + if err != nil { + return err + } + } + + case *RequestSweepBuild: + if err := b.startSweep(ctx); err != nil { + return err + } + + case *ReissueSweepConfirmation: + if b.sweepTx == nil { + // The FSM asked us to re-arm the sweep + // confirmation watcher, so the checkpoint + // must have carried a sweep transaction; + // a nil sweepTx here signals corrupted + // state rather than a recoverable race. + return fmt.Errorf("sweep tx missing on " + + "reissue") + } + + sweepPkScript, err := safeTxOutPkScript(b.sweepTx, 0) + if err != nil { + return fmt.Errorf("sweep tx malformed: %w", err) + } + + _, err = b.cfg.TxConfirmRef.Ask( + ctx, &txconfirm.EnsureConfirmedReq{ + Tx: b.sweepTx, + ConfirmationPkScript: sweepPkScript, + Label: "unroll-sweep-" + + b.cfg.TargetOutpoint.String(), + Subscriber: b.notificationRef(), + }, + ).Await(ctx).Unpack() + if err != nil { + return err + } + } + } + + return nil +} + +// currentState returns the current concrete protofsm state. +func (b *behavior) currentState() (State, error) { + if b.session != nil && b.session.FSM != nil { + rawState, err := b.session.FSM.CurrentState() + if err != nil { + if errors.Is(err, protofsm.ErrStateMachineShutdown) && + b.pending != nil { + + return stateFromCheckpoint(b.pending), nil + } + + return nil, err + } + + state, ok := rawState.(State) + if !ok { + return nil, fmt.Errorf( + "unexpected unroll state %T", rawState, + ) + } + + return state, nil + } + + if b.pending != nil && b.pending.Started { + return stateFromCheckpoint(b.pending), nil + } + + return &Idle{}, nil +} + +// failureReasonForTx turns a raw txconfirm failure reason into a string +// that identifies whether the failed transaction was a proof-graph node +// or our sweep. +// +// When a failure crosses the boundary into the FSM it becomes the +// terminal FailReason on the control-plane record, and operators reading +// that reason need to know which transaction the mempool or node +// rejected. Rather than exposing planner internals at every call site, +// this helper checks the recorded sweep txid (in either the pending +// checkpoint or the current FSM state) and annotates accordingly. +func (b *behavior) failureReasonForTx(txid chainhash.Hash, + reason string) string { + + if b.pending != nil && b.pending.State.Sweep.Txid.IsSome() && + b.pending.State.Sweep.Txid.UnsafeFromSome() == txid { + + return fmt.Sprintf("sweep tx %s failed: %s", txid, reason) + } + + state, err := b.currentState() + if err == nil { + job := stateJob(state) + if job.PlannerState.Sweep.Txid.IsSome() && + job.PlannerState.Sweep.Txid.UnsafeFromSome() == txid { + + return fmt.Sprintf( + "sweep tx %s failed: %s", + txid, reason, + ) + } + } + + return fmt.Sprintf( + "proof tx %s failed: %s", txid, reason, + ) +} + +// notifyRegistryIfTerminal forwards one UnrollTerminatedMsg to the +// registry when the FSM reaches Completed or Failed, at most once per +// actor lifetime. +// +// The registry uses this to move the outpoint out of its active map and +// mark the durable store terminal. If the FSM receives additional events +// after reaching terminal (e.g. a late TxConfirmed for a proof node that +// materialized before we failed for other reasons) terminalNotified +// keeps us from spamming the registry with repeats. +// +// Failure to Tell is warned but not fatal — the registry will rediscover +// the terminal phase the next time it queries child state, and it holds +// its own persistence retry loop for the control-plane record. +func (b *behavior) notifyRegistryIfTerminal(ctx context.Context) { + if b.cfg.RegistryRef == nil || b.terminalNotified { + return + } + + state, err := b.currentState() + if err != nil { + b.log.WarnS(ctx, "Failed to inspect unroll terminal state", err) + return + } + + phase := phaseFromState(state) + if phase != PhaseCompleted && phase != PhaseFailed { + return + } + + job := stateJob(state) + msg := &UnrollTerminatedMsg{ + Outpoint: b.cfg.TargetOutpoint, + ActorID: b.cfg.ActorID, + Phase: phase, + FailReason: job.FailReason, + } + + if sweepTxid := effectiveSweepTxid( + job.PlannerState, b.sweepTx, + ); sweepTxid != nil { + msg.SweepTxid = sweepTxid + } + + if err := b.cfg.RegistryRef.Tell(ctx, msg); err != nil { + b.log.WarnS(ctx, "Failed to notify unroll registry", err) + return + } + + b.terminalNotified = true +} + +// actorIDForTarget derives a deterministic actor ID for one target outpoint. +func actorIDForTarget(target wire.OutPoint) string { + return "unroll-" + target.String() +} + +// ActorIDForTarget derives the durable actor ID for one target outpoint. +func ActorIDForTarget(target wire.OutPoint) string { + return actorIDForTarget(target) +} + +// copyPlannerState deep-copies one planner state for durable use. Option +// fields and SweepState are value types, so a struct assignment already +// produces an independent copy; only the slices need explicit copies. +func copyPlannerState(state unrollplan.State) unrollplan.State { + copyState := unrollplan.State{ + ConfirmedTxids: append( + []chainhash.Hash(nil), state.ConfirmedTxids..., + ), + InFlightTxids: append( + []chainhash.Hash(nil), state.InFlightTxids..., + ), + TargetConfirmHeight: state.TargetConfirmHeight, + Sweep: state.Sweep, + } + + sortHashes(copyState.ConfirmedTxids) + sortHashes(copyState.InFlightTxids) + + return copyState +} + +// copyTx deep-copies one transaction when present. +func copyTx(tx *wire.MsgTx) *wire.MsgTx { + if tx == nil { + return nil + } + + return tx.Copy() +} + +// removeHash removes one hash when present. +func removeHash(hashes []chainhash.Hash, + hash chainhash.Hash) []chainhash.Hash { + + result := make([]chainhash.Hash, 0, len(hashes)) + for _, current := range hashes { + if current == hash { + continue + } + + result = append(result, current) + } + + return result +} + +// containsHash reports whether one hash is present in the slice. +func containsHash(hashes []chainhash.Hash, hash chainhash.Hash) bool { + for _, current := range hashes { + if current == hash { + return true + } + } + + return false +} + +// sortHashes sorts hashes deterministically by string form. +func sortHashes(hashes []chainhash.Hash) { + sort.Slice(hashes, func(i, j int) bool { + return hashes[i].String() < hashes[j].String() + }) +} + +// copyHash returns a heap-independent pointer copy of one hash. +func copyHash(hash *chainhash.Hash) *chainhash.Hash { + if hash == nil { + return nil + } + + hashCopy := *hash + + return &hashCopy +} + +// appendUniqueSorted appends missing hashes and returns deterministic order. +func appendUniqueSorted(hashes []chainhash.Hash, + newHashes ...chainhash.Hash) []chainhash.Hash { + + result := append([]chainhash.Hash(nil), hashes...) + for _, hash := range newHashes { + if containsHash(result, hash) { + continue + } + + result = append(result, hash) + } + + sortHashes(result) + + return result +} diff --git a/unroll/actor_test.go b/unroll/actor_test.go new file mode 100644 index 000000000..25753aaa7 --- /dev/null +++ b/unroll/actor_test.go @@ -0,0 +1,1721 @@ +package unroll + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/darepo-client/lib/arkscript" + "github.com/lightninglabs/darepo-client/lib/recovery" + "github.com/lightninglabs/darepo-client/txconfirm" + "github.com/lightninglabs/darepo-client/unrollplan" + "github.com/lightninglabs/darepo-client/vtxo" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +const testTimeout = time.Second + +// mockProofAssembler is a programmable proof assembler test double. +type mockProofAssembler struct { + proof *recovery.Proof + err error +} + +// EnsureProof returns the configured result. +func (m *mockProofAssembler) EnsureProof(_ context.Context, + _ wire.OutPoint) (*recovery.Proof, error) { + + return m.proof, m.err +} + +// mockVTXOStore is a minimal descriptor store test double. +type mockVTXOStore struct { + desc *vtxo.Descriptor + err error +} + +// SaveVTXO is unused in these tests. +func (m *mockVTXOStore) SaveVTXO(context.Context, *vtxo.Descriptor) error { + return nil +} + +// GetVTXO returns the configured descriptor. +func (m *mockVTXOStore) GetVTXO(context.Context, + wire.OutPoint) (*vtxo.Descriptor, error) { + + return m.desc, m.err +} + +// ListLiveVTXOs is unused in these tests. +func (m *mockVTXOStore) ListLiveVTXOs(context.Context) ([]*vtxo.Descriptor, + error) { + + return nil, nil +} + +// ListVTXOsByStatus is unused in these tests. +func (m *mockVTXOStore) ListVTXOsByStatus(context.Context, + vtxo.VTXOStatus) ([]*vtxo.Descriptor, error) { + + return nil, nil +} + +// UpdateVTXOStatus is unused in these tests. +func (m *mockVTXOStore) UpdateVTXOStatus(context.Context, + wire.OutPoint, vtxo.VTXOStatus) error { + + return nil +} + +// MarkForfeiting is unused in these tests. +func (m *mockVTXOStore) MarkForfeiting(context.Context, wire.OutPoint, + string, *wire.MsgTx) error { + + return nil +} + +// GetForfeitTx is unused in these tests. +func (m *mockVTXOStore) GetForfeitTx(context.Context, + wire.OutPoint) (*wire.MsgTx, error) { + + return nil, nil +} + +// MarkForfeited is unused in these tests. +func (m *mockVTXOStore) MarkForfeited(context.Context, wire.OutPoint, + chainhash.Hash) error { + + return nil +} + +// DeleteVTXO is unused in these tests. +func (m *mockVTXOStore) DeleteVTXO(context.Context, wire.OutPoint) error { + return nil +} + +// fakeTxConfirmRef is a programmable txconfirm actor test double. +type fakeTxConfirmRef struct { + mu sync.Mutex + + requests []*txconfirm.EnsureConfirmedReq + responseStates map[chainhash.Hash]txconfirm.TxState + confirmHeights map[chainhash.Hash]int32 + failureReasons map[chainhash.Hash]string +} + +// ID returns the fake actor ID. +func (f *fakeTxConfirmRef) ID() string { + return "fake-txconfirm" +} + +// Tell is unused for these tests. +func (f *fakeTxConfirmRef) Tell(context.Context, txconfirm.Msg) error { + return nil +} + +// Ask records the request and returns an awaiting-confirmation response. +func (f *fakeTxConfirmRef) Ask(_ context.Context, + msg txconfirm.Msg) actor.Future[txconfirm.Resp] { + + promise := actor.NewPromise[txconfirm.Resp]() + + req, ok := msg.(*txconfirm.EnsureConfirmedReq) + if !ok { + promise.Complete(fn.Err[txconfirm.Resp]( + fmt.Errorf("unexpected txconfirm msg %T", msg), + )) + + return promise.Future() + } + + f.mu.Lock() + f.requests = append(f.requests, req) + state := f.responseStates[req.Tx.TxHash()] + height := f.confirmHeights[req.Tx.TxHash()] + f.mu.Unlock() + + if state == 0 { + state = txconfirm.TxStateAwaitingConfirmation + } + + if state == txconfirm.TxStateConfirmed { + if height == 0 { + height = 1 + } + + err := req.Subscriber.Tell( + context.Background(), + &txconfirm.TxConfirmed{ + Txid: req.Tx.TxHash(), + BlockHeight: height, + NumConfs: 1, + }, + ) + if err != nil { + promise.Complete(fn.Err[txconfirm.Resp](err)) + return promise.Future() + } + } + + promise.Complete(fn.Ok[txconfirm.Resp](&txconfirm.EnsureConfirmedResp{ + Txid: req.Tx.TxHash(), + State: state, + Created: true, + })) + + return promise.Future() +} + +// lastRequest returns the latest txconfirm request. +func (f *fakeTxConfirmRef) lastRequest( + t *testing.T) *txconfirm.EnsureConfirmedReq { + + t.Helper() + + f.mu.Lock() + defer f.mu.Unlock() + + require.NotEmpty(t, f.requests) + + return f.requests[len(f.requests)-1] +} + +// requestCount returns the number of recorded ensure requests. +func (f *fakeTxConfirmRef) requestCount() int { + f.mu.Lock() + defer f.mu.Unlock() + + return len(f.requests) +} + +// requestCountForTxid returns how many ensure requests were made for one txid. +func (f *fakeTxConfirmRef) requestCountForTxid(txid chainhash.Hash) int { + f.mu.Lock() + defer f.mu.Unlock() + + count := 0 + for _, req := range f.requests { + if req.Tx.TxHash() == txid { + count++ + } + } + + return count +} + +// requestedTxids returns the txids in request order. +func (f *fakeTxConfirmRef) requestedTxids() []chainhash.Hash { + f.mu.Lock() + defer f.mu.Unlock() + + txids := make([]chainhash.Hash, 0, len(f.requests)) + for _, req := range f.requests { + txids = append(txids, req.Tx.TxHash()) + } + + return txids +} + +// setImmediateConfirmed configures one txid to confirm as soon as it is +// ensured. +func (f *fakeTxConfirmRef) setImmediateConfirmed(txid chainhash.Hash, + height int32) { + + f.mu.Lock() + defer f.mu.Unlock() + + if f.responseStates == nil { + f.responseStates = make(map[chainhash.Hash]txconfirm.TxState) + } + if f.confirmHeights == nil { + f.confirmHeights = make(map[chainhash.Hash]int32) + } + + f.responseStates[txid] = txconfirm.TxStateConfirmed + f.confirmHeights[txid] = height +} + +// setImmediateFailed configures one txid to fail as soon as it is ensured. +func (f *fakeTxConfirmRef) setImmediateFailed(txid chainhash.Hash, + reason string) { + + f.mu.Lock() + defer f.mu.Unlock() + + if f.responseStates == nil { + f.responseStates = make(map[chainhash.Hash]txconfirm.TxState) + } + if f.failureReasons == nil { + f.failureReasons = make(map[chainhash.Hash]string) + } + + f.responseStates[txid] = txconfirm.TxStateFailed + f.failureReasons[txid] = reason +} + +// emitConfirmed delivers a txconfirm success notification to the subscriber. +func (f *fakeTxConfirmRef) emitConfirmed(t *testing.T, index int, + txid chainhash.Hash, height int32) { + + t.Helper() + + f.mu.Lock() + require.Less(t, index, len(f.requests)) + subscriber := f.requests[index].Subscriber + targetConfs := f.requests[index].TargetConfs + f.mu.Unlock() + + if targetConfs == 0 { + targetConfs = 1 + } + + err := subscriber.Tell(t.Context(), &txconfirm.TxConfirmed{ + Txid: txid, + BlockHeight: height, + NumConfs: targetConfs, + }) + require.NoError(t, err) +} + +// emitFailed delivers a txconfirm failure notification to the subscriber. +func (f *fakeTxConfirmRef) emitFailed(t *testing.T, index int, + txid chainhash.Hash, reason string) { + + t.Helper() + + f.mu.Lock() + require.Less(t, index, len(f.requests)) + subscriber := f.requests[index].Subscriber + f.mu.Unlock() + + err := subscriber.Tell(t.Context(), &txconfirm.TxFailed{ + Txid: txid, + Reason: reason, + }) + require.NoError(t, err) +} + +// fakeChainSourceRef is a minimal chainsource actor ref for sweep fee +// estimation tests. +type fakeChainSourceRef struct { + mu sync.Mutex + bestHeight int32 + feeRate int64 + feeErr error + blockRef actor.TellOnlyRef[chainsource.BlockEpoch] + spendRef actor.TellOnlyRef[chainsource.SpendEvent] +} + +// ID returns the fake actor ID. +func (f *fakeChainSourceRef) ID() string { + return "fake-chain" +} + +// Tell is unused by the unroll actor tests. +func (f *fakeChainSourceRef) Tell(_ context.Context, + msg chainsource.ChainSourceMsg) error { + + switch msg.(type) { + case *chainsource.UnsubscribeBlocksRequest: + return nil + case *chainsource.UnregisterSpendRequest: + return nil + } + + return nil +} + +// Ask returns fixed fee-estimate responses. +func (f *fakeChainSourceRef) Ask(_ context.Context, + msg chainsource.ChainSourceMsg, +) actor.Future[chainsource.ChainSourceResp] { + + promise := actor.NewPromise[chainsource.ChainSourceResp]() + switch msg := msg.(type) { + case *chainsource.BestHeightRequest: + height := f.bestHeight + if height == 0 { + height = 100 + } + + promise.Complete(fn.Ok[chainsource.ChainSourceResp]( + &chainsource.BestHeightResponse{Height: height}, + )) + + case *chainsource.FeeEstimateRequest: + if f.feeErr != nil { + promise.Complete(fn.Err[chainsource.ChainSourceResp]( + f.feeErr, + )) + + return promise.Future() + } + + feeRate := f.feeRate + if feeRate == 0 { + feeRate = 5 + } + + promise.Complete(fn.Ok[chainsource.ChainSourceResp]( + &chainsource.FeeEstimateResponse{ + SatPerVByte: btcutil.Amount(feeRate), + }, + )) + + case *chainsource.SubscribeBlocksRequest: + f.mu.Lock() + f.blockRef = msg.NotifyActor.UnwrapOr(nil) + f.mu.Unlock() + promise.Complete(fn.Ok[chainsource.ChainSourceResp]( + &chainsource.SubscribeBlocksResponse{}, + )) + + case *chainsource.RegisterSpendRequest: + f.mu.Lock() + f.spendRef = msg.NotifyActor.UnwrapOr(nil) + f.mu.Unlock() + promise.Complete(fn.Ok[chainsource.ChainSourceResp]( + &chainsource.RegisterSpendResponse{}, + )) + + default: + promise.Complete(fn.Err[chainsource.ChainSourceResp]( + fmt.Errorf("unexpected chainsource msg %T", msg), + )) + } + + return promise.Future() +} + +// emitSpend delivers one spend event for the target outpoint to the subscribed +// actor. +func (f *fakeChainSourceRef) emitSpend(t *testing.T, + spendingTxid chainhash.Hash, height int32) { + + t.Helper() + + f.mu.Lock() + ref := f.spendRef + f.mu.Unlock() + + require.NotNil(t, ref) + require.NoError(t, ref.Tell( + t.Context(), + chainsource.SpendEvent{ + SpendingTxid: spendingTxid, + SpendingHeight: height, + }, + )) +} + +// fakeSweepWallet is a minimal signer plus wallet-destination test double. +type fakeSweepWallet struct{} + +// NewWalletPkScript returns a deterministic destination script. +func (w *fakeSweepWallet) NewWalletPkScript(context.Context) ([]byte, error) { + return []byte{txscript.OP_TRUE}, nil +} + +// SignOutputRaw returns a dummy schnorr signature. +func (w *fakeSweepWallet) SignOutputRaw(*wire.MsgTx, + *input.SignDescriptor) (input.Signature, error) { + + return testSignature{}, nil +} + +// ComputeInputScript is unused by the timeout-path helper. +func (w *fakeSweepWallet) ComputeInputScript(*wire.MsgTx, + *input.SignDescriptor) (*input.Script, error) { + + return nil, fmt.Errorf("unused") +} + +// MuSig2CreateSession is unused in these tests. +func (w *fakeSweepWallet) MuSig2CreateSession(input.MuSig2Version, + keychain.KeyLocator, []*btcec.PublicKey, *input.MuSig2Tweaks, + [][musig2.PubNonceSize]byte, *musig2.Nonces) (*input.MuSig2SessionInfo, + error) { + + return nil, fmt.Errorf("unused") +} + +// MuSig2RegisterNonces is unused in these tests. +func (w *fakeSweepWallet) MuSig2RegisterNonces(input.MuSig2SessionID, + [][musig2.PubNonceSize]byte) (bool, error) { + + return false, fmt.Errorf("unused") +} + +// MuSig2RegisterCombinedNonce is unused in these tests. +func (w *fakeSweepWallet) MuSig2RegisterCombinedNonce( + input.MuSig2SessionID, [musig2.PubNonceSize]byte, +) error { + + return fmt.Errorf("unused") +} + +// MuSig2GetCombinedNonce is unused in these tests. +func (w *fakeSweepWallet) MuSig2GetCombinedNonce( + input.MuSig2SessionID, +) ([musig2.PubNonceSize]byte, error) { + + return [musig2.PubNonceSize]byte{}, fmt.Errorf("unused") +} + +// MuSig2Sign is unused in these tests. +func (w *fakeSweepWallet) MuSig2Sign(input.MuSig2SessionID, + [sha256.Size]byte, bool) (*musig2.PartialSignature, error) { + + return nil, fmt.Errorf("unused") +} + +// MuSig2CombineSig is unused in these tests. +func (w *fakeSweepWallet) MuSig2CombineSig(input.MuSig2SessionID, + []*musig2.PartialSignature) (*schnorr.Signature, bool, error) { + + return nil, false, fmt.Errorf("unused") +} + +// MuSig2Cleanup is unused in these tests. +func (w *fakeSweepWallet) MuSig2Cleanup(input.MuSig2SessionID) error { + return nil +} + +// testSignature is a fixed-size dummy signature implementing input.Signature. +type testSignature struct{} + +// Serialize returns a fixed-size signature blob. +func (s testSignature) Serialize() []byte { + return bytes.Repeat([]byte{1}, 64) +} + +// Verify always succeeds in tests. +func (s testSignature) Verify([]byte, *btcec.PublicKey) bool { + return true +} + +// memCheckpointStore is a minimal in-memory checkpoint store for durable actor +// tests. +type memCheckpointStore struct { + mu sync.Mutex + checkpoints map[string]*actor.Checkpoint +} + +// newMemCheckpointStore creates a new in-memory checkpoint store. +func newMemCheckpointStore() *memCheckpointStore { + return &memCheckpointStore{ + checkpoints: make(map[string]*actor.Checkpoint), + } +} + +// SaveCheckpoint stores one checkpoint in memory. +func (s *memCheckpointStore) SaveCheckpoint(_ context.Context, + params actor.CheckpointParams) error { + + s.mu.Lock() + defer s.mu.Unlock() + + s.checkpoints[params.ActorID] = &actor.Checkpoint{ + ActorID: params.ActorID, + StateType: params.StateType, + StateData: append([]byte(nil), params.StateData...), + Version: params.Version, + } + + return nil +} + +// LoadCheckpoint returns one checkpoint when present. +func (s *memCheckpointStore) LoadCheckpoint(_ context.Context, + actorID string) (*actor.Checkpoint, error) { + + s.mu.Lock() + defer s.mu.Unlock() + + checkpoint, ok := s.checkpoints[actorID] + if !ok { + return nil, nil + } + + copyCheckpoint := *checkpoint + copyCheckpoint.StateData = append([]byte(nil), checkpoint.StateData...) + + return ©Checkpoint, nil +} + +// DeleteCheckpoint deletes one stored checkpoint. +func (s *memCheckpointStore) DeleteCheckpoint(_ context.Context, + actorID string) error { + + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.checkpoints, actorID) + + return nil +} + +// EnqueueMessage is unused in these tests. +func (s *memCheckpointStore) EnqueueMessage(context.Context, + actor.EnqueueParams) error { + + return nil +} + +// LeaseNextMessage is unused in these tests. +func (s *memCheckpointStore) LeaseNextMessage(context.Context, string, string, + time.Duration) (*actor.LeasedMessage, error) { + + return nil, nil +} + +// AckMessage is unused in these tests. +func (s *memCheckpointStore) AckMessage(context.Context, string, + string) (int64, error) { + + return 1, nil +} + +// NackMessage is unused in these tests. +func (s *memCheckpointStore) NackMessage(context.Context, string, + string, time.Duration) (int64, error) { + + return 1, nil +} + +// ExtendLease is unused in these tests. +func (s *memCheckpointStore) ExtendLease(context.Context, string, + string, time.Duration) (int64, error) { + + return 1, nil +} + +// MoveToDeadLetter is unused in these tests. +func (s *memCheckpointStore) MoveToDeadLetter(context.Context, string, + string) error { + + return nil +} + +// DeleteMessage is unused in these tests. +func (s *memCheckpointStore) DeleteMessage(context.Context, string) error { + return nil +} + +// SaveAskResult is unused in these tests. +func (s *memCheckpointStore) SaveAskResult(context.Context, + actor.AskResultParams) error { + + return nil +} + +// GetAskResult is unused in these tests. +func (s *memCheckpointStore) GetAskResult(context.Context, + string) (*actor.AskResult, error) { + + return nil, nil +} + +// DeleteAskResult is unused in these tests. +func (s *memCheckpointStore) DeleteAskResult(context.Context, + string) error { + + return nil +} + +// EnqueueOutbox is unused in these tests. +func (s *memCheckpointStore) EnqueueOutbox(context.Context, + actor.OutboxParams) error { + + return nil +} + +// ClaimOutboxBatch is unused in these tests. +func (s *memCheckpointStore) ClaimOutboxBatch(context.Context, + actor.OutboxClaimParams) ([]actor.OutboxMessage, error) { + + return nil, nil +} + +// CompleteOutbox is unused in these tests. +func (s *memCheckpointStore) CompleteOutbox(context.Context, string, + string) error { + + return nil +} + +// FailOutbox is unused in these tests. +func (s *memCheckpointStore) FailOutbox(context.Context, string, + string) error { + + return nil +} + +// IsProcessed is unused in these tests. +func (s *memCheckpointStore) IsProcessed(context.Context, + string) (bool, error) { + + return false, nil +} + +// MarkProcessed is unused in these tests. +func (s *memCheckpointStore) MarkProcessed(context.Context, string, + string, time.Duration) error { + + return nil +} + +// GetDeadLetter is unused in these tests. +func (s *memCheckpointStore) GetDeadLetter(context.Context, + string) (*actor.DeadLetter, error) { + + return nil, nil +} + +// ListDeadLetters is unused in these tests. +func (s *memCheckpointStore) ListDeadLetters(context.Context, string, + int) ([]actor.DeadLetter, error) { + + return nil, nil +} + +// DeleteDeadLetter is unused in these tests. +func (s *memCheckpointStore) DeleteDeadLetter(context.Context, string) error { + return nil +} + +// ExpireLeases is unused in these tests. +func (s *memCheckpointStore) ExpireLeases(context.Context) error { + return nil +} + +// CleanupExpired is unused in these tests. +func (s *memCheckpointStore) CleanupExpired(context.Context) error { + return nil +} + +// newActorHarness creates a new unroll actor behavior behind a regular +// in-memory actor while still persisting checkpoints to the fake store. +func newActorHarness(t *testing.T, proof *recovery.Proof, + desc *vtxo.Descriptor) (*actor.Actor[Msg, Resp], *behavior, + *fakeTxConfirmRef, *memCheckpointStore) { + + t.Helper() + + txconfirmRef := &fakeTxConfirmRef{} + store := newMemCheckpointStore() + cfg := Config{ + TargetOutpoint: proof.TargetOutpoint(), + ActorID: "unroll-test", + DeliveryStore: store, + ProofAssembler: &mockProofAssembler{proof: proof}, + VTXOStore: &mockVTXOStore{desc: desc}, + TxConfirmRef: txconfirmRef, + ChainSource: &fakeChainSourceRef{}, + Wallet: &fakeSweepWallet{}, + Log: fn.Some(btclog.Disabled), + } + behavior := &behavior{ + cfg: cfg, + log: btclog.Disabled, + } + err := behavior.restoreCheckpoint(t.Context()) + require.NoError(t, err) + + actorInstance := actor.NewActor(actor.ActorConfig[Msg, Resp]{ + ID: "unroll-test", + Behavior: behavior, + MailboxSize: 64, + }) + behavior.selfRef = actorInstance.TellRef() + actorInstance.Start() + t.Cleanup(actorInstance.Stop) + + return actorInstance, behavior, txconfirmRef, store +} + +// mustAsk asks the actor and unwraps the response. +func mustAsk(t *testing.T, ref actor.ActorRef[Msg, Resp], + msg Msg) Resp { + + t.Helper() + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + resp, err := ref.Ask(ctx, msg).Await(ctx).Unpack() + require.NoError(t, err) + + return resp +} + +// testDescriptor returns a sweep-capable descriptor. +func testDescriptor(t *testing.T, outpoint wire.OutPoint, + csvDelay uint32) *vtxo.Descriptor { + + t.Helper() + + ownerPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + tapscript, err := arkscript.VTXOTapScript( + ownerPriv.PubKey(), operatorPriv.PubKey(), csvDelay, + ) + require.NoError(t, err) + + outputKey := txscript.ComputeTaprootOutputKey( + tapscript.ControlBlock.InternalKey, tapscript.RootHash, + ) + pkScript, err := txscript.PayToTaprootScript(outputKey) + require.NoError(t, err) + + return &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: 50_000, + PkScript: pkScript, + ClientKey: keychain.KeyDescriptor{ + PubKey: ownerPriv.PubKey(), + }, + OperatorKey: operatorPriv.PubKey(), + TapScript: tapscript, + RelativeExpiry: csvDelay, + } +} + +// buildLinearProof creates a simple root->target proof. +func buildLinearProof(t *testing.T) *recovery.Proof { + t.Helper() + + rootTx := wire.NewMsgTx(2) + rootTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + }) + rootTx.AddTxOut(&wire.TxOut{ + Value: 70_000, + PkScript: []byte{txscript.OP_TRUE}, + }) + + targetTx := wire.NewMsgTx(2) + targetTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: rootTx.TxHash(), + Index: 0, + }, + }) + targetTx.AddTxOut(&wire.TxOut{ + Value: 50_000, + PkScript: []byte{txscript.OP_TRUE}, + }) + + proof, err := recovery.NewProof( + wire.OutPoint{Hash: targetTx.TxHash(), Index: 0}, + 2, + &recovery.Node{Kind: recovery.NodeKindTree, Tx: rootTx}, + &recovery.Node{Kind: recovery.NodeKindTree, Tx: targetTx}, + ) + require.NoError(t, err) + + return proof +} + +// buildMergeProof creates a two-root proof whose target depends on both +// ancestors. +func buildMergeProof(t *testing.T) *recovery.Proof { + t.Helper() + + leftRootTx := wire.NewMsgTx(2) + leftRootTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + }) + leftRootTx.AddTxOut(&wire.TxOut{ + Value: 40_000, + PkScript: []byte{txscript.OP_TRUE}, + }) + + rightRootTx := wire.NewMsgTx(2) + rightRootTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 0, + }, + }) + rightRootTx.AddTxOut(&wire.TxOut{ + Value: 45_000, + PkScript: []byte{txscript.OP_TRUE}, + }) + + targetTx := wire.NewMsgTx(2) + targetTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: leftRootTx.TxHash(), + Index: 0, + }, + }) + targetTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: rightRootTx.TxHash(), + Index: 0, + }, + }) + targetTx.AddTxOut(&wire.TxOut{ + Value: 70_000, + PkScript: []byte{txscript.OP_TRUE}, + }) + + proof, err := recovery.NewProof( + wire.OutPoint{Hash: targetTx.TxHash(), Index: 0}, + 2, + &recovery.Node{Kind: recovery.NodeKindTree, Tx: leftRootTx}, + &recovery.Node{Kind: recovery.NodeKindTree, Tx: rightRootTx}, + &recovery.Node{Kind: recovery.NodeKindTree, Tx: targetTx}, + ) + require.NoError(t, err) + + return proof +} + +// mustDecodeCheckpoint loads and decodes one stored actor checkpoint. +func mustDecodeCheckpoint(t *testing.T, store *memCheckpointStore, + actorID string) *actorCheckpoint { + + t.Helper() + + checkpoint, err := store.LoadCheckpoint(t.Context(), actorID) + require.NoError(t, err) + require.NotNil(t, checkpoint) + + decoded, err := decodeCheckpoint(checkpoint.StateData) + require.NoError(t, err) + + return decoded +} + +// TestStartUnrollSubmitsInitialFrontier verifies that actor start resolves the +// proof, plans the frontier, and sends the first ready tx to txconfirm. +func TestStartUnrollSubmitsInitialFrontier(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + unrollActor, _, txconfirmRef, store := newActorHarness(t, proof, desc) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 100, + Trigger: TriggerManual, + }) + + require.Equal(t, 1, txconfirmRef.requestCount()) + require.Equal( + t, proof.RootTxids()[0], + txconfirmRef.lastRequest(t).Tx.TxHash(), + ) + + checkpoint, err := store.LoadCheckpoint(t.Context(), "unroll-test") + require.NoError(t, err) + require.NotNil(t, checkpoint) +} + +// TestConfirmedNodesAdvanceToSweep verifies that node confirmations move the +// actor from proof materialization into final sweep submission. +func TestConfirmedNodesAdvanceToSweep(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + unrollActor, _, txconfirmRef, _ := newActorHarness(t, proof, desc) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 100, + Trigger: TriggerManual, + }) + require.Equal(t, 1, txconfirmRef.requestCount()) + + txconfirmRef.emitConfirmed(t, 0, proof.RootTxids()[0], 101) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCount() >= 2 + }, testTimeout, 10*time.Millisecond) + require.Equal(t, proof.TargetOutpoint().Hash, + txconfirmRef.lastRequest(t).Tx.TxHash()) + + txconfirmRef.emitConfirmed(t, 1, proof.TargetOutpoint().Hash, 102) + + mustAsk(t, unrollActor.Ref(), &HeightObservedMsg{Height: 103}) + require.Equal(t, 2, txconfirmRef.requestCount()) + + mustAsk(t, unrollActor.Ref(), &HeightObservedMsg{Height: 104}) + require.Eventually(t, func() bool { + return txconfirmRef.requestCount() >= 3 + }, testTimeout, 10*time.Millisecond) + + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + require.Equal(t, PhaseSweepConfirmation, stateResp.Phase) + require.NotNil(t, stateResp.SweepTxid) +} + +// TestResumeReissuesInflightWork verifies that resume reattaches the actor to +// in-flight proof txs without importing the old unroller subsystem. +func TestResumeReissuesInflightWork(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + _, _, txconfirmRef, store := newActorHarness(t, proof, desc) + + raw, err := encodeCheckpoint(&actorCheckpoint{ + Version: checkpointVersion, + Height: 110, + Started: true, + Trigger: TriggerRestart, + State: unrollplan.State{ + InFlightTxids: []chainhash.Hash{proof.RootTxids()[0]}, + }, + }) + require.NoError(t, err) + + err = store.SaveCheckpoint(t.Context(), actor.CheckpointParams{ + ActorID: "resume-test", + StateType: checkpointStateType, + StateData: raw, + Version: checkpointVersion, + }) + require.NoError(t, err) + + cfg := Config{ + TargetOutpoint: proof.TargetOutpoint(), + ActorID: "resume-test", + DeliveryStore: store, + ProofAssembler: &mockProofAssembler{proof: proof}, + VTXOStore: &mockVTXOStore{desc: desc}, + TxConfirmRef: txconfirmRef, + ChainSource: &fakeChainSourceRef{}, + Wallet: &fakeSweepWallet{}, + Log: fn.Some(btclog.Disabled), + } + resumeBehavior := &behavior{ + cfg: cfg, + log: btclog.Disabled, + } + err = resumeBehavior.restoreCheckpoint(t.Context()) + require.NoError(t, err) + resumedActor := actor.NewActor(actor.ActorConfig[Msg, Resp]{ + ID: "resume-test", + Behavior: resumeBehavior, + MailboxSize: 64, + }) + resumeBehavior.selfRef = resumedActor.TellRef() + resumedActor.Start() + t.Cleanup(resumedActor.Stop) + + mustAsk(t, resumedActor.Ref(), &ResumeUnrollRequest{Height: 111}) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCount() >= 1 + }, testTimeout, 10*time.Millisecond) + require.Equal( + t, proof.RootTxids()[0], + txconfirmRef.lastRequest(t).Tx.TxHash(), + ) +} + +// TestStartUnrollMultiParentSubmitsAllRoots verifies that the initial planner +// frontier contains every independent root transaction. +func TestStartUnrollMultiParentSubmitsAllRoots(t *testing.T) { + proof := buildMergeProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + unrollActor, _, txconfirmRef, store := newActorHarness(t, proof, desc) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 200, + Trigger: TriggerManual, + }) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCount() == 2 + }, testTimeout, 10*time.Millisecond) + + require.ElementsMatch( + t, proof.RootTxids(), + txconfirmRef.requestedTxids(), + ) + + checkpoint := mustDecodeCheckpoint(t, store, "unroll-test") + require.ElementsMatch( + t, proof.RootTxids(), checkpoint.State.InFlightTxids, + ) +} + +// TestMultiParentChildBlockedUntilAllParentsConfirm verifies that a merge node +// is not submitted until every required parent is confirmed. +func TestMultiParentChildBlockedUntilAllParentsConfirm(t *testing.T) { + proof := buildMergeProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + unrollActor, _, txconfirmRef, _ := newActorHarness(t, proof, desc) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 200, + Trigger: TriggerManual, + }) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCount() == 2 + }, testTimeout, 10*time.Millisecond) + + rootTxids := proof.RootTxids() + txconfirmRef.emitConfirmed(t, 0, rootTxids[0], 201) + + time.Sleep(25 * time.Millisecond) + require.Equal(t, 2, txconfirmRef.requestCount()) + require.Equal(t, 0, + txconfirmRef.requestCountForTxid(proof.TargetOutpoint().Hash)) + + txconfirmRef.emitConfirmed(t, 1, rootTxids[1], 202) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCountForTxid( + proof.TargetOutpoint().Hash, + ) == 1 + }, testTimeout, 10*time.Millisecond) +} + +// TestStartUnrollAlreadyConfirmedRootAdvances verifies that an already +// confirmed ancestor reported by txconfirm does not stall unroll progress. +func TestStartUnrollAlreadyConfirmedRootAdvances(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + txid := proof.RootTxids()[0] + unrollActor, _, txconfirmRef, _ := newActorHarness(t, proof, desc) + txconfirmRef.setImmediateConfirmed(txid, 101) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 100, + Trigger: TriggerManual, + }) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCountForTxid( + proof.TargetOutpoint().Hash, + ) == 1 + }, testTimeout, 10*time.Millisecond) +} + +// TestProofTxFailureTransitionsToFailed verifies that proof-transaction +// failure terminates the actor and persists the failure state. +func TestProofTxFailureTransitionsToFailed(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + rootTxid := proof.RootTxids()[0] + unrollActor, _, txconfirmRef, store := newActorHarness(t, proof, desc) + txconfirmRef.setImmediateFailed(rootTxid, "rejected") + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 100, + Trigger: TriggerManual, + }) + + require.Eventually(t, func() bool { + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + + return stateResp.Phase == PhaseFailed + }, testTimeout, 10*time.Millisecond) + + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + require.Contains(t, stateResp.FailReason, "txconfirm returned failed") + + checkpoint := mustDecodeCheckpoint(t, store, "unroll-test") + require.Equal(t, "proof tx "+rootTxid.String()+ + " failed: txconfirm returned failed state", checkpoint.Fail) +} + +// TestResumeReissuesSweepConfirmation verifies that resume reattaches +// txconfirm to an already-built sweep transaction. +func TestResumeReissuesSweepConfirmation(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + sweepTx, err := buildSweepTx( + t.Context(), &fakeSweepWallet{}, &fakeChainSourceRef{}, + proof, desc, 0, + ) + require.NoError(t, err) + + txconfirmRef := &fakeTxConfirmRef{} + store := newMemCheckpointStore() + sweepTxid := sweepTx.TxHash() + + raw, err := encodeCheckpoint(&actorCheckpoint{ + Version: checkpointVersion, + Height: 110, + Started: true, + Trigger: TriggerRestart, + State: unrollplan.State{ + ConfirmedTxids: []chainhash.Hash{ + proof.RootTxids()[0], + proof.TargetOutpoint().Hash, + }, + TargetConfirmHeight: fn.Some[int32](108), + Sweep: unrollplan.SweepState{ + Status: unrollplan.SweepStatusBroadcasted, + Txid: fn.Some(sweepTxid), + }, + }, + SweepTx: sweepTx, + }) + require.NoError(t, err) + + err = store.SaveCheckpoint(t.Context(), actor.CheckpointParams{ + ActorID: "resume-sweep-test", + StateType: checkpointStateType, + StateData: raw, + Version: checkpointVersion, + }) + require.NoError(t, err) + + cfg := Config{ + TargetOutpoint: proof.TargetOutpoint(), + ActorID: "resume-sweep-test", + DeliveryStore: store, + ProofAssembler: &mockProofAssembler{proof: proof}, + VTXOStore: &mockVTXOStore{desc: desc}, + TxConfirmRef: txconfirmRef, + ChainSource: &fakeChainSourceRef{}, + Wallet: &fakeSweepWallet{}, + Log: fn.Some(btclog.Disabled), + } + resumeBehavior := &behavior{ + cfg: cfg, + log: btclog.Disabled, + } + err = resumeBehavior.restoreCheckpoint(t.Context()) + require.NoError(t, err) + + resumedActor := actor.NewActor(actor.ActorConfig[Msg, Resp]{ + ID: "resume-sweep-test", + Behavior: resumeBehavior, + MailboxSize: 64, + }) + resumeBehavior.selfRef = resumedActor.TellRef() + resumedActor.Start() + t.Cleanup(resumedActor.Stop) + + mustAsk(t, resumedActor.Ref(), &ResumeUnrollRequest{Height: 111}) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCountForTxid(sweepTxid) == 1 + }, testTimeout, 10*time.Millisecond) +} + +// TestBuildSweepTx verifies the copied sweep-construction helper works without +// importing the legacy unroller package. +func TestBuildSweepTx(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + + sweepTx, err := buildSweepTx( + t.Context(), &fakeSweepWallet{}, &fakeChainSourceRef{}, + proof, desc, 0, + ) + require.NoError(t, err) + require.Len(t, sweepTx.TxIn, 1) + require.Len(t, sweepTx.TxOut, 1) + require.NotEmpty(t, sweepTx.TxIn[0].Witness) +} + +// TestBuildSweepTxFallsBackWithoutFeeEstimate verifies the sweep builder uses +// the regtest fallback fee when the backend has no estimate available yet. +func TestBuildSweepTxFallsBackWithoutFeeEstimate(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + + sweepTx, err := buildSweepTx( + t.Context(), &fakeSweepWallet{}, &fakeChainSourceRef{ + feeErr: fmt.Errorf("no fee estimates available"), + }, proof, desc, 0, + ) + require.NoError(t, err) + + targetOutput, err := proof.TargetOutput() + require.NoError(t, err) + + expectedFee := defaultSweepFallbackFeeRateSatPerVByte * + estimatedSweepVBytes + require.Equal(t, targetOutput.Value-expectedFee, sweepTx.TxOut[0].Value) +} + +// TestSweepConfirmationCompletesActor verifies that confirming the final sweep +// transitions the actor into terminal completion and persists that state. +func TestSweepConfirmationCompletesActor(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + unrollActor, _, txconfirmRef, store := newActorHarness(t, proof, desc) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 100, + Trigger: TriggerManual, + }) + + txconfirmRef.emitConfirmed(t, 0, proof.RootTxids()[0], 101) + require.Eventually(t, func() bool { + return txconfirmRef.requestCountForTxid( + proof.TargetOutpoint().Hash, + ) == 1 + }, testTimeout, 10*time.Millisecond) + + txconfirmRef.emitConfirmed(t, 1, proof.TargetOutpoint().Hash, 102) + mustAsk(t, unrollActor.Ref(), &HeightObservedMsg{Height: 104}) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCount() == 3 + }, testTimeout, 10*time.Millisecond) + + sweepTxid := txconfirmRef.lastRequest(t).Tx.TxHash() + txconfirmRef.emitConfirmed(t, 2, sweepTxid, 105) + + require.Eventually(t, func() bool { + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + + return stateResp.Phase == PhaseCompleted + }, testTimeout, 10*time.Millisecond) + + checkpoint := mustDecodeCheckpoint(t, store, "unroll-test") + require.Equal(t, unrollplan.SweepStatusConfirmed, + checkpoint.State.Sweep.Status) + require.True(t, checkpoint.State.Sweep.ConfirmHeight.IsSome()) +} + +// TestGetStateAfterFSMShutdownKeepsCompletedCheckpoint verifies that callers +// still observe the last checkpointed completed state after the protofsm has +// been stopped during actor teardown. +func TestGetStateAfterFSMShutdownKeepsCompletedCheckpoint(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + unrollActor, beh, txconfirmRef, _ := newActorHarness(t, proof, desc) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 100, + Trigger: TriggerManual, + }) + + txconfirmRef.emitConfirmed(t, 0, proof.RootTxids()[0], 101) + require.Eventually(t, func() bool { + return txconfirmRef.requestCountForTxid( + proof.TargetOutpoint().Hash, + ) == 1 + }, testTimeout, 10*time.Millisecond) + + txconfirmRef.emitConfirmed(t, 1, proof.TargetOutpoint().Hash, 102) + mustAsk(t, unrollActor.Ref(), &HeightObservedMsg{Height: 104}) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCount() == 3 + }, testTimeout, 10*time.Millisecond) + + sweepTxid := txconfirmRef.lastRequest(t).Tx.TxHash() + txconfirmRef.emitConfirmed(t, 2, sweepTxid, 105) + + require.Eventually(t, func() bool { + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + + return stateResp.Phase == PhaseCompleted + }, testTimeout, 10*time.Millisecond) + + require.NotNil(t, beh.session) + require.NotNil(t, beh.session.FSM) + + beh.session.FSM.Stop() + + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + require.Equal(t, PhaseCompleted, stateResp.Phase) + require.Empty(t, stateResp.FailReason) + require.Equal(t, sweepTxid, *stateResp.SweepTxid) +} + +// TestGetStateUsesStoredSweepTxid verifies that GetState keeps exposing the +// sweep txid when the checkpointed planner state is terminal but missing the +// txid field. +func TestGetStateUsesStoredSweepTxid(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + unrollActor, beh, _, _ := newActorHarness(t, proof, desc) + + sweepTx := wire.NewMsgTx(2) + beh.pending = &actorCheckpoint{ + Version: checkpointVersion, + Height: 106, + Started: true, + Trigger: TriggerManual, + State: unrollplan.State{ + ConfirmedTxids: []chainhash.Hash{ + proof.TargetOutpoint().Hash, + }, + TargetConfirmHeight: fn.Some[int32](103), + Sweep: unrollplan.SweepState{ + Status: unrollplan.SweepStatusConfirmed, + ConfirmHeight: fn.Some[int32](106), + }, + }, + } + beh.sweepTx = sweepTx + + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + require.Equal(t, PhaseCompleted, stateResp.Phase) + require.NotNil(t, stateResp.SweepTxid) + require.Equal(t, sweepTx.TxHash(), *stateResp.SweepTxid) +} + +// TestResumeMultiParentPartialConfirmation verifies that resume only reissues +// the remaining root transaction and advances once that root confirms. +func TestResumeMultiParentPartialConfirmation(t *testing.T) { + proof := buildMergeProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + rootTxids := proof.RootTxids() + store := newMemCheckpointStore() + txconfirmRef := &fakeTxConfirmRef{} + + raw, err := encodeCheckpoint(&actorCheckpoint{ + Version: checkpointVersion, + Height: 210, + Started: true, + Trigger: TriggerRestart, + State: unrollplan.State{ + ConfirmedTxids: []chainhash.Hash{rootTxids[0]}, + InFlightTxids: []chainhash.Hash{rootTxids[1]}, + }, + }) + require.NoError(t, err) + + err = store.SaveCheckpoint(t.Context(), actor.CheckpointParams{ + ActorID: "resume-partial-merge", + StateType: checkpointStateType, + StateData: raw, + Version: checkpointVersion, + }) + require.NoError(t, err) + + cfg := Config{ + TargetOutpoint: proof.TargetOutpoint(), + ActorID: "resume-partial-merge", + DeliveryStore: store, + ProofAssembler: &mockProofAssembler{proof: proof}, + VTXOStore: &mockVTXOStore{desc: desc}, + TxConfirmRef: txconfirmRef, + ChainSource: &fakeChainSourceRef{}, + Wallet: &fakeSweepWallet{}, + Log: fn.Some(btclog.Disabled), + } + resumeBehavior := &behavior{ + cfg: cfg, + log: btclog.Disabled, + } + err = resumeBehavior.restoreCheckpoint(t.Context()) + require.NoError(t, err) + + resumedActor := actor.NewActor(actor.ActorConfig[Msg, Resp]{ + ID: "resume-partial-merge", + Behavior: resumeBehavior, + MailboxSize: 64, + }) + resumeBehavior.selfRef = resumedActor.TellRef() + resumedActor.Start() + t.Cleanup(resumedActor.Stop) + + mustAsk(t, resumedActor.Ref(), &ResumeUnrollRequest{Height: 211}) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCountForTxid(rootTxids[1]) == 1 + }, testTimeout, 10*time.Millisecond) + require.Equal(t, 0, + txconfirmRef.requestCountForTxid(proof.TargetOutpoint().Hash)) + + txconfirmRef.emitConfirmed(t, 0, rootTxids[1], 212) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCountForTxid( + proof.TargetOutpoint().Hash, + ) == 1 + }, testTimeout, 10*time.Millisecond) +} + +// TestResumeCSVWaitDoesNotSweepUntilMature verifies that resuming in the CSV +// waiting phase does not build the sweep until a mature height is observed. +func TestResumeCSVWaitDoesNotSweepUntilMature(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + store := newMemCheckpointStore() + txconfirmRef := &fakeTxConfirmRef{} + + raw, err := encodeCheckpoint(&actorCheckpoint{ + Version: checkpointVersion, + Height: 103, + Started: true, + Trigger: TriggerRestart, + State: unrollplan.State{ + ConfirmedTxids: []chainhash.Hash{ + proof.RootTxids()[0], + proof.TargetOutpoint().Hash, + }, + TargetConfirmHeight: fn.Some[int32](102), + }, + }) + require.NoError(t, err) + + err = store.SaveCheckpoint(t.Context(), actor.CheckpointParams{ + ActorID: "resume-csv-test", + StateType: checkpointStateType, + StateData: raw, + Version: checkpointVersion, + }) + require.NoError(t, err) + + cfg := Config{ + TargetOutpoint: proof.TargetOutpoint(), + ActorID: "resume-csv-test", + DeliveryStore: store, + ProofAssembler: &mockProofAssembler{proof: proof}, + VTXOStore: &mockVTXOStore{desc: desc}, + TxConfirmRef: txconfirmRef, + ChainSource: &fakeChainSourceRef{}, + Wallet: &fakeSweepWallet{}, + Log: fn.Some(btclog.Disabled), + } + resumeBehavior := &behavior{ + cfg: cfg, + log: btclog.Disabled, + } + err = resumeBehavior.restoreCheckpoint(t.Context()) + require.NoError(t, err) + + resumedActor := actor.NewActor(actor.ActorConfig[Msg, Resp]{ + ID: "resume-csv-test", + Behavior: resumeBehavior, + MailboxSize: 64, + }) + resumeBehavior.selfRef = resumedActor.TellRef() + resumedActor.Start() + t.Cleanup(resumedActor.Stop) + + mustAsk(t, resumedActor.Ref(), &ResumeUnrollRequest{Height: 103}) + + stateResp, ok := mustAsk( + t, resumedActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + require.Equal(t, PhaseCSVPending, stateResp.Phase) + require.Equal(t, 0, txconfirmRef.requestCount()) + + mustAsk(t, resumedActor.Ref(), &HeightObservedMsg{Height: 104}) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCount() == 1 + }, testTimeout, 10*time.Millisecond) + + stateResp, ok = mustAsk( + t, resumedActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + require.Equal(t, PhaseSweepConfirmation, stateResp.Phase) +} + +// TestSweepFailureRetriesThenFails verifies that sweep txconfirm failures +// are retried up to maxSweepAttempts before the actor terminates. +func TestSweepFailureRetriesThenFails(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + unrollActor, _, txconfirmRef, store := newActorHarness(t, proof, desc) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 100, + Trigger: TriggerManual, + }) + + txconfirmRef.emitConfirmed(t, 0, proof.RootTxids()[0], 101) + require.Eventually(t, func() bool { + return txconfirmRef.requestCountForTxid( + proof.TargetOutpoint().Hash, + ) == 1 + }, testTimeout, 10*time.Millisecond) + + txconfirmRef.emitConfirmed(t, 1, proof.TargetOutpoint().Hash, 102) + mustAsk(t, unrollActor.Ref(), &HeightObservedMsg{Height: 104}) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCount() == 3 + }, testTimeout, 10*time.Millisecond) + + // First sweep failure: should retry (attempt 1 of 3). + sweep1Txid := txconfirmRef.lastRequest(t).Tx.TxHash() + txconfirmRef.emitFailed(t, 2, sweep1Txid, "sweep rejected") + + // The retry should submit a new sweep to txconfirm. + require.Eventually(t, func() bool { + return txconfirmRef.requestCount() == 4 + }, testTimeout, 10*time.Millisecond) + + // Actor must still be running, not failed. + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + require.NotEqual(t, PhaseFailed, stateResp.Phase) + + // Second sweep failure: should retry (attempt 2 of 3). + sweep2Txid := txconfirmRef.lastRequest(t).Tx.TxHash() + txconfirmRef.emitFailed(t, 3, sweep2Txid, "sweep rejected again") + + require.Eventually(t, func() bool { + return txconfirmRef.requestCount() == 5 + }, testTimeout, 10*time.Millisecond) + + stateResp, ok = mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + require.NotEqual(t, PhaseFailed, stateResp.Phase) + + // Third sweep failure: should transition to terminal failure. + sweep3Txid := txconfirmRef.lastRequest(t).Tx.TxHash() + txconfirmRef.emitFailed(t, 4, sweep3Txid, "sweep rejected final") + + require.Eventually(t, func() bool { + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + + return stateResp.Phase == PhaseFailed + }, testTimeout, 10*time.Millisecond) + + stateResp, ok = mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + require.Contains(t, stateResp.FailReason, "sweep tx") + + checkpoint := mustDecodeCheckpoint(t, store, "unroll-test") + require.Contains(t, checkpoint.Fail, "sweep tx") + require.Equal(t, maxSweepAttempts, checkpoint.SweepAttempts) +} + +// TestExternalSpendTerminatesActor verifies that an external spend of the +// target VTXO (not our proof nodes or sweep) terminates the actor. +func TestExternalSpendTerminatesActor(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + unrollActor, beh, _, _ := newActorHarness(t, proof, desc) + + chainSource, ok := beh.cfg.ChainSource.(*fakeChainSourceRef) + require.True(t, ok) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 100, + Trigger: TriggerManual, + }) + + // Ensure spend watch is registered. + require.Eventually(t, func() bool { + chainSource.mu.Lock() + defer chainSource.mu.Unlock() + + return chainSource.spendRef != nil + }, testTimeout, 10*time.Millisecond) + + // Simulate an external party spending the target VTXO. + externalTxid := chainhash.Hash{0xee} + chainSource.emitSpend(t, externalTxid, 101) + + require.Eventually(t, func() bool { + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + + return stateResp.Phase == PhaseFailed + }, testTimeout, 10*time.Millisecond) + + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + require.Contains(t, stateResp.FailReason, "spent externally") +} + +// TestStartUnrollIsIdempotent verifies that reissuing start does not duplicate +// already-in-flight proof requests. +func TestStartUnrollIsIdempotent(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + unrollActor, _, txconfirmRef, _ := newActorHarness(t, proof, desc) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 100, + Trigger: TriggerManual, + }) + require.Equal(t, 1, txconfirmRef.requestCount()) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 101, + Trigger: TriggerManual, + }) + + time.Sleep(25 * time.Millisecond) + require.Equal( + t, 1, + txconfirmRef.requestCountForTxid( + proof.RootTxids()[0], + ), + ) +} + +var _ input.Signature = testSignature{} +var _ SweepWallet = (*fakeSweepWallet)(nil) +var _ vtxo.VTXOStore = (*mockVTXOStore)(nil) +var _ actor.DeliveryStore = (*memCheckpointStore)(nil) +var _ actor.ActorRef[txconfirm.Msg, txconfirm.Resp] = (*fakeTxConfirmRef)(nil) +var _ actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, +] = (*fakeChainSourceRef)(nil) +var _ *waddrmgr.Tapscript +var _ = btcutil.Amount(0) +var _ = psbt.Packet{} diff --git a/unroll/db_store.go b/unroll/db_store.go new file mode 100644 index 000000000..a6afc8247 --- /dev/null +++ b/unroll/db_store.go @@ -0,0 +1,244 @@ +package unroll + +import ( + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/db" +) + +// db_store.go adapts the db package's UnilateralExitJob table into the +// [RegistryStore] interface the in-memory registry consumes. +// +// Two enum translations happen here and are covered by round-trip tests: +// +// - Phase ↔ DB status. The two sweep-related phases +// (PhaseSweepBroadcast, PhaseSweepConfirmation) deliberately map to +// two distinct DB statuses (SweepBroadcasting, Sweeping) so the +// operator-visible lifecycle does not collapse "sweep built but +// not yet submitted" into "sweep broadcast awaiting confirmation". +// The Go enum appends SweepBroadcasting at the end of iota so +// existing rows written against the older numeric layout keep +// decoding to the same Phase they were originally written as. +// +// - Trigger ↔ DB trigger. TriggerFraudSpend round-trips through +// its own DB constant; earlier revisions silently downgraded it to +// TriggerManual, losing the "target was externally spent" signal +// from the control plane. +// +// Round-trip tests in db_store_test.go pin these mappings. + +// DBRegistryStore adapts the legacy unilateral-exit job store into the new +// unroll registry control-plane store. +type DBRegistryStore struct { + // UEStore is the underlying unilateral-exit persistence store. + UEStore *db.UnilateralExitPersistenceStore +} + +// UpsertRecord stores one registry record in the unilateral-exit job table. +func (s *DBRegistryStore) UpsertRecord(ctx context.Context, + record RegistryRecord) error { + + if s == nil || s.UEStore == nil { + return fmt.Errorf("unilateral-exit store must be provided") + } + + return s.UEStore.UpsertJob(ctx, db.UnilateralExitJobRecord{ + TargetOutpoint: record.TargetOutpoint, + ActorID: record.ActorID, + Status: statusForPhase(record.Phase), + Trigger: triggerToDB(record.Trigger), + LastError: record.FailReason, + SweepTxid: sweepTxidBytes(record.SweepTxid), + }) +} + +// GetRecord returns one registry record when present. +func (s *DBRegistryStore) GetRecord(ctx context.Context, + target wire.OutPoint) (*RegistryRecord, error) { + + if s == nil || s.UEStore == nil { + return nil, fmt.Errorf("unilateral-exit store must be provided") + } + + job, err := s.UEStore.GetJob(ctx, target) + if errors.Is(err, db.ErrUnilateralExitJobNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + + record := recordFromDB(*job) + + return &record, nil +} + +// ListNonTerminalRecords returns all non-terminal registry records. +func (s *DBRegistryStore) ListNonTerminalRecords( + ctx context.Context) ([]RegistryRecord, error) { + + if s == nil || s.UEStore == nil { + return nil, fmt.Errorf("unilateral-exit store must be provided") + } + + jobs, err := s.UEStore.ListNonTerminalJobs(ctx) + if err != nil { + return nil, err + } + + records := make([]RegistryRecord, 0, len(jobs)) + for i := range jobs { + records = append(records, recordFromDB(jobs[i])) + } + + return records, nil +} + +// MarkTerminal marks one target terminal in the unilateral-exit job table. +func (s *DBRegistryStore) MarkTerminal(ctx context.Context, + target wire.OutPoint, phase Phase, failReason string, + sweepTxid *chainhash.Hash) error { + + if s == nil || s.UEStore == nil { + return fmt.Errorf("unilateral-exit store must be provided") + } + + status := statusForPhase(phase) + if !status.IsTerminal() { + return fmt.Errorf("phase %s is not terminal", phase) + } + + return s.UEStore.MarkJobTerminal( + ctx, target, status, failReason, sweepTxidBytes(sweepTxid), + ) +} + +// recordFromDB converts one unilateral-exit job row into a registry record. +func recordFromDB(job db.UnilateralExitJobRecord) RegistryRecord { + return RegistryRecord{ + TargetOutpoint: job.TargetOutpoint, + ActorID: job.ActorID, + Trigger: triggerFromDB(job.Trigger), + Phase: phaseFromDB(job.Status), + FailReason: job.LastError, + SweepTxid: sweepTxidFromBytes(job.SweepTxid), + } +} + +// statusForPhase maps a registry phase into the legacy job status enum. +// PhaseSweepBroadcast and PhaseSweepConfirmation use distinct DB statuses +// so operators can distinguish "sweep built, not yet submitted" from +// "sweep broadcast, awaiting confirmation" on restart — collapsing the +// two would silently erase half the sweep lifecycle. +func statusForPhase(phase Phase) db.UnilateralExitJobStatus { + switch phase { + case PhasePending: + return db.UnilateralExitJobStatusPending + + case PhaseCSVPending: + return db.UnilateralExitJobStatusCSVPending + + case PhaseSweepBroadcast: + return db.UnilateralExitJobStatusSweepBroadcasting + + case PhaseSweepConfirmation: + return db.UnilateralExitJobStatusSweeping + + case PhaseCompleted: + return db.UnilateralExitJobStatusCompleted + + case PhaseFailed: + return db.UnilateralExitJobStatusFailed + + default: + return db.UnilateralExitJobStatusMaterializing + } +} + +// phaseFromDB maps a unilateral-exit job status into the new registry phase. +func phaseFromDB(status db.UnilateralExitJobStatus) Phase { + switch status { + case db.UnilateralExitJobStatusPending: + return PhasePending + + case db.UnilateralExitJobStatusCSVPending: + return PhaseCSVPending + + case db.UnilateralExitJobStatusSweepBroadcasting: + return PhaseSweepBroadcast + + case db.UnilateralExitJobStatusSweeping: + return PhaseSweepConfirmation + + case db.UnilateralExitJobStatusCompleted: + return PhaseCompleted + + case db.UnilateralExitJobStatusFailed: + return PhaseFailed + + default: + return PhaseMaterializing + } +} + +// triggerToDB maps a new unroll trigger into the legacy db enum. +func triggerToDB(trigger StartTrigger) db.UnilateralExitJobTrigger { + switch trigger { + case TriggerCriticalExpiry: + return db.UnilateralExitJobTriggerCriticalExpiry + + case TriggerRestart: + return db.UnilateralExitJobTriggerRestart + + case TriggerFraudSpend: + return db.UnilateralExitJobTriggerFraudSpend + + default: + return db.UnilateralExitJobTriggerManual + } +} + +// triggerFromDB maps a legacy db trigger into the new unroll trigger. +// FraudSpend rows previously round-tripped as TriggerManual, which hid +// the external-spend escalation class from the control plane entirely; +// round-trip it through a dedicated constant now that one exists. +func triggerFromDB(trigger db.UnilateralExitJobTrigger) StartTrigger { + switch trigger { + case db.UnilateralExitJobTriggerCriticalExpiry: + return TriggerCriticalExpiry + + case db.UnilateralExitJobTriggerRestart: + return TriggerRestart + + case db.UnilateralExitJobTriggerFraudSpend: + return TriggerFraudSpend + + default: + return TriggerManual + } +} + +// sweepTxidBytes converts an optional txid into the stored byte format. +func sweepTxidBytes(txid *chainhash.Hash) []byte { + if txid == nil { + return nil + } + + return append([]byte(nil), txid[:]...) +} + +// sweepTxidFromBytes converts stored bytes into an optional txid. +func sweepTxidFromBytes(raw []byte) *chainhash.Hash { + if len(raw) != chainhash.HashSize { + return nil + } + + var hash chainhash.Hash + copy(hash[:], raw) + + return &hash +} diff --git a/unroll/db_store_test.go b/unroll/db_store_test.go new file mode 100644 index 000000000..4aba4b684 --- /dev/null +++ b/unroll/db_store_test.go @@ -0,0 +1,111 @@ +package unroll + +import ( + "testing" + + "github.com/lightninglabs/darepo-client/db" + "github.com/stretchr/testify/require" +) + +// TestPhaseDBRoundTrip pins the Phase↔UnilateralExitJobStatus mapping so +// schema drift or table-entry reshuffles fail loudly rather than silently +// collapsing distinct phases onto a single DB status (which previously +// erased the "sweep built but not yet broadcast" vs "sweep broadcast +// awaiting confirmation" distinction). +func TestPhaseDBRoundTrip(t *testing.T) { + t.Parallel() + + cases := []struct { + phase Phase + want db.UnilateralExitJobStatus + }{ + {PhasePending, db.UnilateralExitJobStatusPending}, + {PhaseMaterializing, db.UnilateralExitJobStatusMaterializing}, + {PhaseCSVPending, db.UnilateralExitJobStatusCSVPending}, + { + PhaseSweepBroadcast, + db.UnilateralExitJobStatusSweepBroadcasting, + }, + { + PhaseSweepConfirmation, + db.UnilateralExitJobStatusSweeping, + }, + {PhaseCompleted, db.UnilateralExitJobStatusCompleted}, + {PhaseFailed, db.UnilateralExitJobStatusFailed}, + } + + for _, tc := range cases { + tc := tc + t.Run(string(tc.phase), func(t *testing.T) { + t.Parallel() + + gotStatus := statusForPhase(tc.phase) + require.Equal(t, tc.want, gotStatus, + "statusForPhase(%q)", tc.phase) + + gotPhase := phaseFromDB(gotStatus) + require.Equal(t, tc.phase, gotPhase, + "round-trip phaseFromDB(statusForPhase(%q))", + tc.phase) + }) + } +} + +// TestTriggerDBRoundTrip pins the StartTrigger↔UnilateralExitJobTrigger +// mapping so FraudSpend rows round-trip through a dedicated constant +// rather than silently decoding as TriggerManual. +func TestTriggerDBRoundTrip(t *testing.T) { + t.Parallel() + + cases := []struct { + trigger StartTrigger + want db.UnilateralExitJobTrigger + }{ + {TriggerManual, db.UnilateralExitJobTriggerManual}, + { + TriggerCriticalExpiry, + db.UnilateralExitJobTriggerCriticalExpiry, + }, + {TriggerRestart, db.UnilateralExitJobTriggerRestart}, + { + TriggerFraudSpend, + db.UnilateralExitJobTriggerFraudSpend, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(triggerName(tc.trigger), func(t *testing.T) { + t.Parallel() + + gotDB := triggerToDB(tc.trigger) + require.Equal(t, tc.want, gotDB, + "triggerToDB(%v)", tc.trigger) + + gotTrigger := triggerFromDB(gotDB) + require.Equal(t, tc.trigger, gotTrigger, + "round-trip triggerFromDB(triggerToDB(%v))", + tc.trigger) + }) + } +} + +// triggerName returns a stable subtest name for a StartTrigger. +func triggerName(t StartTrigger) string { + switch t { + case TriggerManual: + return "manual" + + case TriggerCriticalExpiry: + return "critical_expiry" + + case TriggerRestart: + return "restart" + + case TriggerFraudSpend: + return "fraud_spend" + + default: + return "unknown" + } +} diff --git a/unroll/descriptor_resolver.go b/unroll/descriptor_resolver.go new file mode 100644 index 000000000..c06b53e3d --- /dev/null +++ b/unroll/descriptor_resolver.go @@ -0,0 +1,259 @@ +package unroll + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/recovery" + "github.com/lightninglabs/darepo-client/vtxo" +) + +// DescriptorLineageResolver assembles the raw transaction graph a VTXO +// needs to surface on chain, strictly from locally persisted state (the +// VTXO store and the OOR artifact store). +// +// "Lineage" here has two parts: +// +// 1. The round-birth ancestry: the tree of transactions the operator +// signed at round creation, ending in the leaf that carries our +// VTXO. This lives in the descriptor's TreePath. +// +// 2. Any OOR chain stacked on top: for VTXOs the client received via +// out-of-round transfers, there is a sequence of checkpoint and +// ark transactions that hop the funds from one VTXO to the next. +// These live in the OOR artifact store. +// +// The resolver normalizes both sources into a single LineageMaterial +// bundle that [BuildProofFromMaterial] can stitch into a proof graph. +type DescriptorLineageResolver struct { + // VTXOStore provides VTXO descriptor lookups. + VTXOStore vtxo.VTXOStore + + // ArtifactStore resolves OOR unroll packages for chained VTXOs. + ArtifactStore packageResolver +} + +// ResolveLineage produces the lineage material bundle for one target. +// +// The algorithm has three stages: +// +// 1. Load-and-validate the VTXO descriptor. validateProofDescriptor +// encodes the "hard local start contract" — the descriptor must be +// non-terminal and have every field we need (TreePath, round id, +// commitment txid, created height, batch expiry). If any of these +// are missing we fail fast because no amount of retrying will +// produce data the local store never had. +// +// 2. Seed the lineage bundle with the CSV delay and the descriptor's +// round-birth tree path. Every VTXO has at least this ancestry. +// +// 3. If ChainDepth > 0 the VTXO was received via one or more OOR +// hops, so we delegate to resolveOORArtifacts to walk the artifact +// store. ChainDepth == 0 means the VTXO came straight from a +// round, no artifacts needed. +// +// The returned LineageMaterial is caller-owned but the transactions +// inside are not deep-copied; BuildProofFromMaterial treats them as +// read-only. +func (r *DescriptorLineageResolver) ResolveLineage(ctx context.Context, + target wire.OutPoint) (*LineageMaterial, error) { + + if r.VTXOStore == nil { + return nil, fmt.Errorf("vtxo store must be provided") + } + + // Stage 1: load the descriptor and validate that it has everything + // we need for proof assembly. ErrUnrollTargetNotFound maps a bare + // "no such VTXO" from the store into a typed sentinel so callers + // can distinguish "we do not know about this VTXO" from actual + // storage errors. + desc, err := r.VTXOStore.GetVTXO(ctx, target) + if err != nil { + return nil, fmt.Errorf("%w: %w", + ErrUnrollTargetNotFound, err) + } + + if err := validateProofDescriptor(desc); err != nil { + return nil, err + } + + // Stage 2: seed with the round-birth tree and the descriptor's + // CSV delay. CSVDelay is the per-VTXO relative expiry the planner + // later uses to decide when the timeout path is ready. + mat := &LineageMaterial{ + TargetOutpoint: target, + CSVDelay: desc.RelativeExpiry, + } + + if desc.TreePath != nil { + mat.TreePaths = append(mat.TreePaths, desc.TreePath) + } + + // Stage 3: if the VTXO has any OOR hops, walk the artifact store + // to collect the checkpoint + ark transactions that stitch the + // chain together. ChainDepth is the authoritative count of hops, + // bumped each time the VTXO was received OOR. + if desc.ChainDepth > 0 { + if r.ArtifactStore == nil { + return nil, fmt.Errorf("%w: missing local OOR "+ + "artifact resolver", + ErrUnrollProofUnavailable) + } + + extraNodes, err := r.resolveOORArtifacts(ctx, target, mat) + if err != nil { + return nil, err + } + + mat.ExtraNodes = extraNodes + } + + return mat, nil +} + +// resolveOORArtifacts walks the OOR artifact store for one target and +// normalizes the stored packages into recovery.Node entries. +// +// An OOR package is the bundle of transactions that hopped a VTXO from +// one party to another outside of a round. For recovery purposes we +// care about two sub-transactions in each package: +// +// - The final checkpoint PSBT(s): one or more bridging transactions +// that chain from the previous VTXO's outpoint into the next. +// - The ark PSBT: the transaction that produces the current VTXO +// output. +// +// The algorithm: +// +// 1. Ask the artifact store to resolve every package whose output +// eventually leads to this target. The store returns packages plus +// a list of "unresolved checkpoint inputs" — parents that the +// store could not locate. +// +// 2. An unresolved input is only fatal if it is not also in the +// round-birth tree. The tree path supplies the ultimate roots of +// the lineage, so a checkpoint whose parent is a tree node is +// resolvable even though the artifact store does not carry it. +// This cross-check prevents false "incomplete lineage" failures +// when the first OOR hop consumes a tree leaf directly. +// +// 3. For each resolved package, extract the underlying wire.MsgTx +// from every checkpoint PSBT and from the ark PSBT, de-duplicate +// by txid, and tag each with its recovery Kind +// (NodeKindCheckpoint vs NodeKindArk) so the proof graph knows +// which role each transaction plays. +// +// Returning fewer nodes than expected is handled upstream by +// validateInputCompleteness — that pass walks the target's inputs and +// fails loudly if any parent is still missing after this function +// returns. +func (r *DescriptorLineageResolver) resolveOORArtifacts( + ctx context.Context, target wire.OutPoint, + mat *LineageMaterial) ([]*recovery.Node, error) { + + resolved, err := r.ArtifactStore.ResolveUnrollPackages(ctx, target) + if err != nil { + return nil, fmt.Errorf("%w: resolve unroll packages: %w", + ErrUnrollProofUnavailable, err) + } + + // Build an index of every txid already present in the round-birth + // tree. An OOR package whose earliest parent is a tree node is + // fully resolvable — the tree path supplies the parent — so we + // can strike those parents off the artifact store's + // "unresolved" list. + treeTxids := make(map[chainhash.Hash]struct{}) + for _, tp := range mat.TreePaths { + if tp == nil || tp.Root == nil { + continue + } + + for treeNode := range tp.Root.NodesIter() { + tx, err := proofTxFromTreeNode(treeNode) + if err != nil { + // Skip degenerate tree nodes rather than fail + // the whole lineage; BuildProofFromMaterial + // will reject any downstream conflict anyway. + continue + } + + treeTxids[tx.TxHash()] = struct{}{} + } + } + + // Any checkpoint input the artifact store marked unresolved but + // that we find in the tree path is fine. Everything else is a + // genuine gap and means we cannot assemble a full proof from + // local state. + var trulyUnresolved []wire.OutPoint + for _, op := range resolved.UnresolvedCheckpointInputs { + if _, ok := treeTxids[op.Hash]; !ok { + trulyUnresolved = append(trulyUnresolved, op) + } + } + + if len(trulyUnresolved) > 0 { + return nil, fmt.Errorf("%w: unresolved checkpoint "+ + "inputs for %v: %v", + ErrUnrollProofUnavailable, target, trulyUnresolved) + } + + // Stitch every package into extraNodes. `seen` collapses + // duplicates across packages so a shared transaction appears once + // in the proof (e.g. a checkpoint that bridges two OOR hops). + var extraNodes []*recovery.Node + seen := make(map[chainhash.Hash]struct{}) + + for i := range resolved.Packages { + pkg := resolved.Packages[i] + if pkg == nil { + return nil, fmt.Errorf("%w: package %d missing", + ErrUnrollProofInvalid, i) + } + + // A single OOR package can carry multiple checkpoint PSBTs + // (e.g. one per input branch when the package spends from a + // multi-input source). + for j := range pkg.FinalCheckpointPSBTs { + tx, err := extractFinalizedTx( + pkg.FinalCheckpointPSBTs[j], + ) + if err != nil { + return nil, err + } + + txid := tx.TxHash() + if _, ok := seen[txid]; ok { + continue + } + + seen[txid] = struct{}{} + extraNodes = append(extraNodes, &recovery.Node{ + Kind: recovery.NodeKindCheckpoint, + Tx: tx, + }) + } + + // Every package also carries exactly one ark-tx that + // produces the VTXO output at the end of that hop. + tx, err := extractFinalizedTx(pkg.ArkPSBT) + if err != nil { + return nil, err + } + + txid := tx.TxHash() + if _, ok := seen[txid]; ok { + continue + } + + seen[txid] = struct{}{} + extraNodes = append(extraNodes, &recovery.Node{ + Kind: recovery.NodeKindArk, + Tx: tx, + }) + } + + return extraNodes, nil +} diff --git a/unroll/doc.go b/unroll/doc.go new file mode 100644 index 000000000..a261f51a6 --- /dev/null +++ b/unroll/doc.go @@ -0,0 +1,110 @@ +// Package unroll drives the unilateral-exit lifecycle for one VTXO: it +// broadcasts and confirms every ancestor transaction required to put the +// target output on chain, waits out its CSV timeout, then builds and +// broadcasts the final timeout-path sweep that hands the funds back to the +// local wallet. +// +// # Architecture +// +// Unilateral exit is not a single transaction, it is a graph of +// transactions — a VTXO may sit several "hops" deep inside a tree created +// by a round plus optional out-of-round (OOR) chains stacked on top. To +// reclaim funds locally the client has to: +// +// 1. Gather every ancestor transaction needed to materialize the target +// output on chain (the "recovery proof"). +// 2. Broadcast each ancestor that is ready, in dependency order, +// waiting for each to confirm before its children become ready. +// 3. Once the target itself confirms, wait for its relative timelock +// (CSV) to mature. +// 4. Build, sign (with the client's timeout-path witness), and broadcast +// a sweep that spends the target to a wallet-owned output. +// 5. Wait for the sweep to confirm. +// +// Any step can fail — a mempool rejection, a reorg, a restart, an operator +// racing us with a cooperative forfeit, etc. The package is built so that +// every piece of ongoing work survives a crash, and so that retries never +// double-spend, burn addresses, or silently lose a job. +// +// # Component Split +// +// The package separates four concerns: +// +// - [unrollplan.Planner] (external): a pure function that, given a proof +// graph plus current durable state (confirmed txids, in-flight txids, +// target confirm height, sweep status), decides what to do next: +// which transactions are ready to broadcast, which are still blocked +// by their parents, whether the CSV has matured, whether the sweep +// should be built. The planner has no IO — it only computes. +// +// - [VTXOUnrollActor]: one durable actor per target outpoint. It owns +// the FSM session, the recovery proof, the planner, the cached sweep +// transaction, and the checkpoint. All IO — [txconfirm] Asks, chain +// subscriptions, persistence, registry notifications — runs here. +// +// - [UnrollRegistryActor]: a thin coordinator on top of the set of +// per-target actors. It owns spawn, dedup, terminal bookkeeping, and +// writes a coarse control-plane record per target to the store so the +// daemon can restore in-flight jobs after restart. +// +// - Support code: [LocalProofAssembler] + [DescriptorLineageResolver] +// walk the local VTXO + OOR artifact state into an immutable +// recovery.Proof; [buildSweepTx] builds and signs the final sweep; +// [snapshot.go] encodes/decodes the per-actor TLV checkpoint. +// +// The FSM itself (see [fsm_types.go], [fsm_logic.go]) models lifecycle +// phases only: Idle → AwaitingMaterialization → AwaitingCSV → +// AwaitingSweepBroadcast → AwaitingSweepConfirmation → Completed (or +// Failed from any non-terminal state). Every transition that needs IO +// emits one or more [OutboxEvent]s; the behavior's routeOutbox then +// translates those into real txconfirm calls. This keeps the FSM testable +// without mocks and keeps IO concerns out of state math. +// +// # Durability +// +// Two rules are load-bearing: +// +// 1. "Persist before broadcast." startSweep calls persistCheckpoint +// before it asks txconfirm to broadcast the sweep. On any retry (same +// actor lifetime or after restart) the same sweepTx is restored +// instead of re-derived, so txconfirm's txid-keyed dedup turns the +// retry into a benign no-op. Without this, a crash between signing +// and broadcasting would leave the BIP32 key burned on the first try +// and send a fresh sweep with a different pkScript on the second, +// racing the original on chain. +// +// 2. "Fail-closed admission." UnrollRegistryActor.handleEnsure calls +// Store.UpsertRecord synchronously before it returns Created=true. A +// crash in that window would otherwise orphan the spawned child: +// RestoreNonTerminal reads only the durable store, so an unpersisted +// child would be invisible on restart. +// +// # Reorg and External Spend Handling +// +// The actor registers a spend watch on the target outpoint via +// chainsource. If something other than our sweep or a known proof node +// spends the target — an operator cooperative path, a double-spend, a +// reorg-replaced parent — the FSM is driven to Failed with a reason +// string identifying the external spender. Spends by our own sweep or by +// known proof nodes are benign and just advance height. +// +// # Restart Flow +// +// On daemon start, the registry calls RestoreNonTerminal, which lists +// every non-terminal record, spawns a VTXOUnrollActor per target, and +// sends ResumeUnrollRequest. The actor loads its checkpoint (proof graph, +// planner state, sweep tx, last height), reconstructs the FSM, and emits +// ReissueInFlightTransactions / ReissueSweepConfirmation outbox events so +// the behavior re-subscribes txconfirm for every tx that was in flight. +// txconfirm's dedup makes every re-submit idempotent, so no duplicate +// broadcasts escape the client. +// +// # Documentation +// +// Per-package docs: +// - [../unroll/CLAUDE.md] — stable summary of types, relationships, +// invariants. +// - [../docs/durable_actor_architecture.md] — CDC pattern, durable +// mailbox lifecycle. +// - [../docs/durable_actor_quickstart.md] — TLVMessage, ActorBehavior. +package unroll diff --git a/unroll/fsm_logic.go b/unroll/fsm_logic.go new file mode 100644 index 000000000..7aeb45018 --- /dev/null +++ b/unroll/fsm_logic.go @@ -0,0 +1,393 @@ +package unroll + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/darepo-client/unrollplan" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// processEventWithJob is the single-source-of-truth update function for +// every non-Idle state. Each concrete FSM state (AwaitingMaterialization, +// AwaitingCSV, AwaitingSweepBroadcast, AwaitingSweepConfirmation) +// delegates its ProcessEvent implementation here instead of duplicating +// event handling per state. +// +// The function runs in two phases: +// +// 1. Mutate a deep copy of the inbound JobState based on the event +// kind. This is where "what just happened" is recorded: a height +// moved, a tx confirmed, a tx failed, the sweep broadcast, etc. The +// apply* helpers isolate the per-event arithmetic. +// +// 2. Hand the updated JobState to deriveStateTransition, which asks +// the pure [unrollplan.Planner] what to do next from this state and +// picks the matching FSM state + outbox. +// +// Splitting event application from transition derivation is what lets +// ResumeEvent re-run the planner without inventing any new mutation: it +// just bumps height and flips the reissue flag so deriveStateTransition +// emits Reissue* outbox events. +func processEventWithJob(ctx context.Context, job *JobState, + event Event, env *Environment) (*StateTransition, error) { + + if job == nil { + return nil, fmt.Errorf("job state must be provided") + } + + nextJob := job.Copy() + reissue := false + + switch e := event.(type) { + case *ResumeEvent: + if e.Height > nextJob.Height { + nextJob.Height = e.Height + } + reissue = true + + case *HeightUpdatedEvent: + if e.Height > nextJob.Height { + nextJob.Height = e.Height + } + + case *TxConfirmedEvent: + applyConfirmedEvent(nextJob, e, env) + + case *TxFailedEvent: + applyFailedEvent(nextJob, e) + + case *SweepBroadcastedEvent: + nextJob.PlannerState.Sweep.Status = + unrollplan.SweepStatusBroadcasted + nextJob.PlannerState.Sweep.Txid = fn.Some(e.Txid) + + case *SweepBuildFailedEvent: + applySweepBuildFailed(nextJob, e.Reason) + + case *FailEvent: + nextJob.FailReason = e.Reason + + case *StartEvent: + if e.Height > nextJob.Height { + nextJob.Height = e.Height + } + nextJob.Trigger = e.Trigger + + default: + return nil, fmt.Errorf("unexpected event %T", event) + } + + return deriveStateTransition(ctx, nextJob, env, reissue) +} + +// deriveStateTransition is the core "what FSM state should we be in +// now?" function. It is a pure reduction: given the updated JobState, +// the immutable Environment (proof graph + planner), and a reissue +// flag, it returns the next concrete State plus any OutboxEvents the +// actor boundary needs to execute. +// +// The decision order reflects lifecycle precedence: +// +// 1. Planner-state invariants (ConfirmedTxids and InFlightTxids +// consistent with the proof graph) are validated up front so the +// FSM fails loudly on desync rather than making progress on a +// corrupted state. +// +// 2. FailReason != "" short-circuits to Failed. This catches both +// proof-tx terminal failures (set in applyFailedEvent) and +// explicit FailEvents (e.g. from external spend detection). +// +// 3. On a reissue (ResumeEvent path) we emit ReissueInFlightTransactions +// for every currently in-flight node and ReissueSweepConfirmation +// if the sweep was already broadcast. This re-arms every txconfirm +// subscription that the checkpoint knows about. +// +// 4. The planner decides phase: +// - Done (every input confirmed, sweep confirmed if needed) → +// Completed. +// - Sweep already broadcasted → AwaitingSweepConfirmation. +// - NeedSweep → AwaitingSweepBroadcast + RequestSweepBuild outbox. +// - CSV not ready yet → AwaitingCSV. +// - Otherwise → AwaitingMaterialization with EnsureReadyTransactions +// for any newly-unblocked ready frontier. +// +// Notice: no IO, no time, no randomness. This function can be exercised +// deterministically in unit tests, which is why the FSM intentionally +// lives separate from the behavior. +func deriveStateTransition(_ context.Context, job *JobState, + env *Environment, reissue bool) (*StateTransition, error) { + + if job == nil { + return nil, fmt.Errorf("job state must be provided") + } + + if env == nil || env.Proof == nil || env.Planner == nil { + return nil, fmt.Errorf( + "unroll environment must be fully populated", + ) + } + + // Guard against checkpoint/proof drift: every txid recorded as + // confirmed or in-flight must resolve against the current proof + // graph. A mismatch means the checkpoint references a transaction + // our resolver no longer knows about — fail loudly now instead of + // driving the FSM into an impossible state. + if err := job.PlannerState.Validate(env.Proof); err != nil { + return nil, err + } + + // Terminal short-circuit. applyFailedEvent populates FailReason + // for proof-tx terminal failures, and handleSpendObserved emits + // explicit FailEvents for external-spend detection; both land + // here before any planner work. + if job.FailReason != "" { + return &StateTransition{ + NextState: &Failed{Job: job.Copy()}, + }, nil + } + + // Consult the pure planner. Plan() is stateless; it reads + // PlannerState + the proof graph and returns a snapshot with Done + // / NeedSweep / CSV / Ready fields. All phase decisions below + // come from that snapshot. + snapshot, err := env.Planner.Plan(job.Height, &job.PlannerState) + if err != nil { + return nil, err + } + + // On a restart-triggered evaluation we re-emit the reissue + // outbox events for every subscription the checkpoint knows + // about. These are additive to whatever the phase decision + // below needs — txconfirm dedup absorbs the duplicates on + // on-chain submission. + var outbox []OutboxEvent + if reissue { + if len(job.PlannerState.InFlightTxids) > 0 { + outbox = append(outbox, &ReissueInFlightTransactions{ + Txids: append([]chainhash.Hash(nil), + job.PlannerState.InFlightTxids...), + }) + } + + sweepBroadcasted := job.PlannerState.Sweep.Status == + unrollplan.SweepStatusBroadcasted + if sweepBroadcasted { + outbox = append(outbox, &ReissueSweepConfirmation{}) + } + } + + // Phase decision cascade. Order is deliberate: Done before any + // sweep branch, sweep-broadcasted before sweep-build (so a + // restart with a broadcast sweep does not re-enter build), build + // before CSV (since NeedSweep implies CSV already matured), and + // materialization as the default. + switch { + case snapshot.Done: + // Every required tx (proof nodes + sweep) has confirmed. + return transitionWithOutbox( + &Completed{Job: job.Copy()}, outbox, + ), nil + + case job.PlannerState.Sweep.Status == unrollplan.SweepStatusBroadcasted: + // Sweep already out on the wire, waiting for confirm. + return transitionWithOutbox( + &AwaitingSweepConfirmation{Job: job.Copy()}, outbox, + ), nil + + case snapshot.NeedSweep: + // Target confirmed + CSV matured = it is time to build + // and broadcast the sweep. The RequestSweepBuild outbox + // event is what triggers startSweep in the actor behavior. + outbox = append(outbox, &RequestSweepBuild{}) + return transitionWithOutbox( + &AwaitingSweepBroadcast{Job: job.Copy()}, outbox, + ), nil + + case snapshot.CSV.IsSome() && !snapshot.CSV.UnsafeFromSome().Ready: + // Target confirmed but the CSV delay has not matured; + // nothing to do until HeightUpdatedEvent carries the + // chain forward. + return transitionWithOutbox( + &AwaitingCSV{Job: job.Copy()}, outbox, + ), nil + + default: + // There is still proof ancestry to confirm. Hand the + // ready frontier to the actor so it can submit each one + // to txconfirm; record them as in-flight so subsequent + // runs do not try to resubmit (idempotent at the + // txconfirm layer either way, but a clean planner state + // is a nicer invariant to hold). + readyTxids := readyTxids(snapshot.Ready) + if len(readyTxids) > 0 { + job.PlannerState.InFlightTxids = appendUniqueSorted( + job.PlannerState.InFlightTxids, readyTxids..., + ) + outbox = append(outbox, &EnsureReadyTransactions{ + Txids: readyTxids, + }) + } + + return transitionWithOutbox( + &AwaitingMaterialization{Job: job.Copy()}, outbox, + ), nil + } +} + +// transitionWithOutbox wraps the next state and optional outbox into one state +// transition result. +func transitionWithOutbox(nextState State, + outbox []OutboxEvent) *StateTransition { + + transition := &StateTransition{ + NextState: nextState, + } + + if len(outbox) == 0 { + return transition + } + + transition.NewEvents = fn.Some(EmittedEvent{ + Outbox: outbox, + }) + + return transition +} + +// applyConfirmedEvent records a single txconfirm success against the +// durable job state. It dispatches on whether the confirmed txid is: +// +// - The final sweep (by txid match against PlannerState.Sweep.Txid): +// flip Sweep.Status to Confirmed and remember its block height. +// This is what graduates AwaitingSweepConfirmation → Completed. +// +// - A proof-graph node: move the txid from InFlightTxids to +// ConfirmedTxids. If this happens to be the target transaction +// itself we also record TargetConfirmHeight — the planner needs +// that to compute when the CSV delay has matured. +// +// Height is always advanced (max of current and event height) so late +// confirmations for earlier blocks do not roll the clock back. +func applyConfirmedEvent(job *JobState, event *TxConfirmedEvent, + env *Environment) { + + if job == nil || event == nil { + return + } + + if job.PlannerState.Sweep.Txid.IsSome() && + job.PlannerState.Sweep.Txid.UnsafeFromSome() == event.Txid { + + job.PlannerState.Sweep.Status = unrollplan.SweepStatusConfirmed + job.PlannerState.Sweep.ConfirmHeight = fn.Some(event.Height) + if event.Height > job.Height { + job.Height = event.Height + } + + return + } + + job.PlannerState.ConfirmedTxids = appendUniqueSorted( + job.PlannerState.ConfirmedTxids, event.Txid, + ) + job.PlannerState.InFlightTxids = removeHash( + job.PlannerState.InFlightTxids, event.Txid, + ) + + if env != nil && env.Proof != nil && + event.Txid == env.Proof.TargetOutpoint().Hash && + job.PlannerState.TargetConfirmHeight.IsNone() { + + job.PlannerState.TargetConfirmHeight = fn.Some(event.Height) + } + + if event.Height > job.Height { + job.Height = event.Height + } +} + +// applyFailedEvent records a single txconfirm failure. Proof-node +// failures and sweep failures have different semantics: +// +// - A proof-graph transaction failing is terminal. There is no way +// for the client to rebuild or replace an operator-signed proof +// node, so the only option is to surface the reason and stop. +// +// - A sweep failing is often recoverable (fee too low, mempool +// contention, fee-spike eviction). applySweepBuildFailed bumps a +// retry counter and, if the budget is not exhausted, clears the +// planner's sweep state so deriveStateTransition will emit a fresh +// RequestSweepBuild. The actor's cached sweepTx also gets cleared +// on the next attempt because the FSM state carries +// SweepStatusPending again. +func applyFailedEvent(job *JobState, event *TxFailedEvent) { + if job == nil || event == nil { + return + } + + // Detect sweep-tx failure by matching against the recorded sweep txid. + if job.PlannerState.Sweep.Txid.IsSome() && + job.PlannerState.Sweep.Txid.UnsafeFromSome() == event.Txid { + + applySweepBuildFailed(job, event.Reason) + + return + } + + // Proof-tx failure is always terminal. + job.PlannerState.InFlightTxids = removeHash( + job.PlannerState.InFlightTxids, event.Txid, + ) + + job.FailReason = event.Reason +} + +// applySweepBuildFailed records a sweep build or broadcast failure and +// decides whether to terminate or retry. +// +// Budget: maxSweepAttempts tries. Past that, we stop hammering the +// mempool and transition to Failed with the most recent reason. +// +// Within the budget, we reset sweep-specific planner fields (Status and +// Txid) so the planner returns NeedSweep=true on the next evaluation +// and deriveStateTransition emits a fresh RequestSweepBuild. Note that +// the actor behavior's cached b.sweepTx is NOT cleared here — startSweep +// reuses it on retry to keep txconfirm's txid-keyed dedup working and +// to avoid burning a new BIP32 wallet address per attempt. If the +// failure cause is persistent (fee-rate too low, double-spend of the +// input) the retry will simply rediscover the same rejection, and we +// rely on maxSweepAttempts to bound the loop. +func applySweepBuildFailed(job *JobState, reason string) { + job.SweepAttempts++ + + if job.SweepAttempts >= maxSweepAttempts { + job.FailReason = reason + + return + } + + // Reset sweep to pending so the planner sees NeedSweep again. + job.PlannerState.Sweep.Status = unrollplan.SweepStatusPending + job.PlannerState.Sweep.Txid = fn.None[chainhash.Hash]() +} + +// readyTxids projects one planner ready-frontier into a deterministic +// txid list. +// +// The planner returns frontier entries in whatever order its internal +// graph walk produced, which can differ across invocations even when +// the logical set is the same. Sorting here gives us stable +// checkpoint bytes (good for diffing), stable log lines, and stable +// txconfirm Ask ordering. +func readyTxids(frontier []unrollplan.TxFrontier) []chainhash.Hash { + txids := make([]chainhash.Hash, 0, len(frontier)) + for i := range frontier { + txids = append(txids, frontier[i].Txid) + } + + sortHashes(txids) + + return txids +} diff --git a/unroll/fsm_types.go b/unroll/fsm_types.go new file mode 100644 index 000000000..5d95dbd3d --- /dev/null +++ b/unroll/fsm_types.go @@ -0,0 +1,434 @@ +package unroll + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/protofsm" + "github.com/lightninglabs/darepo-client/lib/recovery" + "github.com/lightninglabs/darepo-client/unrollplan" +) + +// StateMachine is the protofsm instance for one VTXO unroll session. +type StateMachine = protofsm.StateMachine[Event, OutboxEvent, *Environment] + +// StateTransition is the unroll-specific protofsm transition type. +type StateTransition = protofsm.StateTransition[ + Event, OutboxEvent, *Environment, +] + +// EmittedEvent is the unroll-specific protofsm emitted-event type. +type EmittedEvent = protofsm.EmittedEvent[Event, OutboxEvent] + +// Environment carries immutable context for one unroll FSM instance. +type Environment struct { + // Proof is the immutable local recovery proof for the target. + Proof *recovery.Proof + + // Planner evaluates ready, blocked, CSV, and sweep progress. + Planner *unrollplan.Planner +} + +// contextErrorReporter reports protofsm execution errors through the actor +// logger. +// +//nolint:containedctx +type contextErrorReporter struct { + ctx context.Context + logger btclog.Logger + prefix string +} + +// newContextErrorReporter creates an FSM error reporter for one actor session. +func newContextErrorReporter(ctx context.Context, logger btclog.Logger, + prefix string) *contextErrorReporter { + + return &contextErrorReporter{ + ctx: ctx, + logger: logger, + prefix: prefix, + } +} + +// ReportError logs one FSM execution error. +func (r *contextErrorReporter) ReportError(err error) { + r.logger.WithPrefix(r.prefix).ErrorS(r.ctx, "FSM error", err) +} + +// maxSweepAttempts is the maximum number of sweep build or broadcast failures +// tolerated before the actor transitions to terminal failure. +const maxSweepAttempts = 3 + +// JobState is the durable state owned by the unroll FSM. +type JobState struct { + // Height is the current best height known to the actor. + Height int32 + + // Trigger identifies why the actor was started. + Trigger StartTrigger + + // PlannerState is the durable caller-owned planning progress. + PlannerState unrollplan.State + + // FailReason records a terminal failure reason, if any. + FailReason string + + // SweepAttempts counts sweep build or broadcast failures so the actor + // can retry up to maxSweepAttempts before giving up. + SweepAttempts int +} + +// Copy returns a deep copy of the job state. +func (j *JobState) Copy() *JobState { + if j == nil { + return nil + } + + copyState := &JobState{ + Height: j.Height, + Trigger: j.Trigger, + PlannerState: copyPlannerState(j.PlannerState), + FailReason: j.FailReason, + SweepAttempts: j.SweepAttempts, + } + + return copyState +} + +// Event is the sealed input event surface accepted by the unroll FSM. +type Event interface { + eventSealed() +} + +// StartEvent starts a new VTXO unroll session. +type StartEvent struct { + // Height is the current best height at start time. + Height int32 + + // Trigger identifies why the actor was started. + Trigger StartTrigger +} + +// eventSealed marks StartEvent as an FSM event. +func (e *StartEvent) eventSealed() {} + +// ResumeEvent resumes a previously checkpointed VTXO unroll session. +type ResumeEvent struct { + // Height is the current best height at resume time. + Height int32 +} + +// eventSealed marks ResumeEvent as an FSM event. +func (e *ResumeEvent) eventSealed() {} + +// HeightUpdatedEvent records a newly observed best height. +type HeightUpdatedEvent struct { + // Height is the latest observed best height. + Height int32 +} + +// eventSealed marks HeightUpdatedEvent as an FSM event. +func (e *HeightUpdatedEvent) eventSealed() {} + +// TxConfirmedEvent records confirmation of one proof or sweep transaction. +type TxConfirmedEvent struct { + // Txid is the confirmed transaction hash. + Txid chainhash.Hash + + // Height is the block height where the transaction confirmed. + Height int32 +} + +// eventSealed marks TxConfirmedEvent as an FSM event. +func (e *TxConfirmedEvent) eventSealed() {} + +// TxFailedEvent records terminal failure of one proof or sweep transaction. +type TxFailedEvent struct { + // Txid identifies the failed transaction when known. + Txid chainhash.Hash + + // Reason is the stable human-readable failure reason. + Reason string +} + +// eventSealed marks TxFailedEvent as an FSM event. +func (e *TxFailedEvent) eventSealed() {} + +// SweepBroadcastedEvent records that the actor built the final sweep and +// submitted it to txconfirm. +type SweepBroadcastedEvent struct { + // Txid is the final sweep transaction hash. + Txid chainhash.Hash +} + +// eventSealed marks SweepBroadcastedEvent as an FSM event. +func (e *SweepBroadcastedEvent) eventSealed() {} + +// FailEvent records a generic terminal failure. +type FailEvent struct { + // Reason is the stable human-readable failure reason. + Reason string +} + +// eventSealed marks FailEvent as an FSM event. +func (e *FailEvent) eventSealed() {} + +// SweepBuildFailedEvent records a sweep construction failure. The actor retries +// up to maxSweepAttempts before giving up. +type SweepBuildFailedEvent struct { + // Reason is the stable human-readable failure reason. + Reason string +} + +// eventSealed marks SweepBuildFailedEvent as an FSM event. +func (e *SweepBuildFailedEvent) eventSealed() {} + +// OutboxEvent is the sealed outbox side-effect surface emitted by the FSM. +type OutboxEvent interface { + outboxEventSealed() +} + +// EnsureReadyTransactions asks the actor boundary to submit newly-ready proof +// txids to txconfirm. +type EnsureReadyTransactions struct { + // Txids are the newly-ready proof txids to submit. + Txids []chainhash.Hash +} + +// outboxEventSealed marks EnsureReadyTransactions as an outbox event. +func (o *EnsureReadyTransactions) outboxEventSealed() {} + +// ReissueInFlightTransactions asks the actor boundary to reattach to already +// in-flight proof txids after a restart. +type ReissueInFlightTransactions struct { + // Txids are the in-flight proof txids to reissue to txconfirm. + Txids []chainhash.Hash +} + +// outboxEventSealed marks ReissueInFlightTransactions as an outbox event. +func (o *ReissueInFlightTransactions) outboxEventSealed() {} + +// RequestSweepBuild asks the actor boundary to build and submit the final +// timeout sweep. +type RequestSweepBuild struct{} + +// outboxEventSealed marks RequestSweepBuild as an outbox event. +func (o *RequestSweepBuild) outboxEventSealed() {} + +// ReissueSweepConfirmation asks the actor boundary to reattach txconfirm to an +// already-broadcast sweep after a restart. +type ReissueSweepConfirmation struct{} + +// outboxEventSealed marks ReissueSweepConfirmation as an outbox event. +func (o *ReissueSweepConfirmation) outboxEventSealed() {} + +// State is the sealed interface implemented by all unroll FSM states. +type State interface { + protofsm.State[Event, OutboxEvent, *Environment] + stateSealed() +} + +// Idle is the initial FSM state before the actor has started work. +type Idle struct{} + +// String returns a human-readable state label. +func (s *Idle) String() string { + return "Idle" +} + +// IsTerminal returns false because Idle is not terminal. +func (s *Idle) IsTerminal() bool { + return false +} + +// stateSealed marks Idle as implementing State. +func (s *Idle) stateSealed() {} + +// ProcessEvent handles FSM events while idle. +func (s *Idle) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*StateTransition, error) { + + switch e := event.(type) { + case *StartEvent: + job := &JobState{ + Height: e.Height, + Trigger: e.Trigger, + } + + return deriveStateTransition(ctx, job, env, false) + + case *ResumeEvent: + job := &JobState{ + Height: e.Height, + Trigger: TriggerRestart, + FailReason: "", + } + + return deriveStateTransition(ctx, job, env, true) + + default: + return nil, fmt.Errorf("unexpected event %T in %s", event, s) + } +} + +// AwaitingMaterialization indicates proof transactions are still being +// broadcast or confirmed. +type AwaitingMaterialization struct { + // Job is the durable FSM state. + Job *JobState +} + +// String returns a human-readable state label. +func (s *AwaitingMaterialization) String() string { + return "AwaitingMaterialization" +} + +// IsTerminal returns false because this state is not terminal. +func (s *AwaitingMaterialization) IsTerminal() bool { + return false +} + +// stateSealed marks AwaitingMaterialization as implementing State. +func (s *AwaitingMaterialization) stateSealed() {} + +// ProcessEvent handles FSM events while proof materialization is ongoing. +func (s *AwaitingMaterialization) ProcessEvent(ctx context.Context, + event Event, env *Environment) (*StateTransition, error) { + + return processEventWithJob(ctx, s.Job, event, env) +} + +// AwaitingCSV indicates the target confirmed but its CSV delay has not matured +// yet. +type AwaitingCSV struct { + // Job is the durable FSM state. + Job *JobState +} + +// String returns a human-readable state label. +func (s *AwaitingCSV) String() string { + return "AwaitingCSV" +} + +// IsTerminal returns false because this state is not terminal. +func (s *AwaitingCSV) IsTerminal() bool { + return false +} + +// stateSealed marks AwaitingCSV as implementing State. +func (s *AwaitingCSV) stateSealed() {} + +// ProcessEvent handles FSM events while waiting for CSV. +func (s *AwaitingCSV) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*StateTransition, error) { + + return processEventWithJob(ctx, s.Job, event, env) +} + +// AwaitingSweepBroadcast indicates the sweep is ready and the actor boundary +// needs to build and submit it. +type AwaitingSweepBroadcast struct { + // Job is the durable FSM state. + Job *JobState +} + +// String returns a human-readable state label. +func (s *AwaitingSweepBroadcast) String() string { + return "AwaitingSweepBroadcast" +} + +// IsTerminal returns false because this state is not terminal. +func (s *AwaitingSweepBroadcast) IsTerminal() bool { + return false +} + +// stateSealed marks AwaitingSweepBroadcast as implementing State. +func (s *AwaitingSweepBroadcast) stateSealed() {} + +// ProcessEvent handles FSM events while the sweep is waiting to be built. +func (s *AwaitingSweepBroadcast) ProcessEvent(ctx context.Context, + event Event, env *Environment) (*StateTransition, error) { + + return processEventWithJob(ctx, s.Job, event, env) +} + +// AwaitingSweepConfirmation indicates the sweep has been submitted to +// txconfirm and is waiting for confirmation. +type AwaitingSweepConfirmation struct { + // Job is the durable FSM state. + Job *JobState +} + +// String returns a human-readable state label. +func (s *AwaitingSweepConfirmation) String() string { + return "AwaitingSweepConfirmation" +} + +// IsTerminal returns false because this state is not terminal. +func (s *AwaitingSweepConfirmation) IsTerminal() bool { + return false +} + +// stateSealed marks AwaitingSweepConfirmation as implementing State. +func (s *AwaitingSweepConfirmation) stateSealed() {} + +// ProcessEvent handles FSM events while waiting for sweep confirmation. +func (s *AwaitingSweepConfirmation) ProcessEvent(ctx context.Context, + event Event, env *Environment) (*StateTransition, error) { + + return processEventWithJob(ctx, s.Job, event, env) +} + +// Completed indicates the final sweep has confirmed. +type Completed struct { + // Job is the durable FSM state. + Job *JobState +} + +// String returns a human-readable state label. +func (s *Completed) String() string { + return "Completed" +} + +// IsTerminal returns true because this state is terminal. +func (s *Completed) IsTerminal() bool { + return true +} + +// stateSealed marks Completed as implementing State. +func (s *Completed) stateSealed() {} + +// ProcessEvent rejects further events in the terminal completed state. +func (s *Completed) ProcessEvent(context.Context, Event, + *Environment) (*StateTransition, error) { + + return nil, fmt.Errorf("completed state is terminal") +} + +// Failed indicates the actor reached terminal failure. +type Failed struct { + // Job is the durable FSM state. + Job *JobState +} + +// String returns a human-readable state label. +func (s *Failed) String() string { + return "Failed" +} + +// IsTerminal returns true because this state is terminal. +func (s *Failed) IsTerminal() bool { + return true +} + +// stateSealed marks Failed as implementing State. +func (s *Failed) stateSealed() {} + +// ProcessEvent rejects further events in the terminal failed state. +func (s *Failed) ProcessEvent(context.Context, Event, + *Environment) (*StateTransition, error) { + + return nil, fmt.Errorf("failed state is terminal") +} diff --git a/unroll/interfaces.go b/unroll/interfaces.go new file mode 100644 index 000000000..6a03ba431 --- /dev/null +++ b/unroll/interfaces.go @@ -0,0 +1,41 @@ +package unroll + +import ( + "context" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/darepo-client/lib/recovery" + "github.com/lightninglabs/darepo-client/txconfirm" + "github.com/lightninglabs/darepo-client/vtxo" + "github.com/lightningnetwork/lnd/input" +) + +// ProofAssembler resolves the immutable local recovery proof for one target +// outpoint. +type ProofAssembler interface { + // EnsureProof builds or retrieves the recovery proof for the target. + EnsureProof(ctx context.Context, target wire.OutPoint) ( + *recovery.Proof, error, + ) +} + +// SweepWallet provides the wallet operations needed to build and sign the +// final timeout sweep. +type SweepWallet interface { + input.Signer + + // NewWalletPkScript returns a fresh wallet-managed destination script + // for the sweep output. + NewWalletPkScript(ctx context.Context) ([]byte, error) +} + +// ChainSource is the subset of the chainsource actor API used by the unroll +// actor. +type ChainSource = chainsource.ChainSourceMsg + +// TxConfirmRef is the shared tx-confirmation actor used by unroll jobs. +type TxConfirmRef = txconfirm.Msg + +// VTXOStore is the descriptor store the actor uses to load its target input. +type VTXOStore = vtxo.VTXOStore diff --git a/unroll/lineage_material.go b/unroll/lineage_material.go new file mode 100644 index 000000000..5cbd85505 --- /dev/null +++ b/unroll/lineage_material.go @@ -0,0 +1,88 @@ +package unroll + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/recovery" + "github.com/lightninglabs/darepo-client/lib/tree" +) + +// LineageMaterial is the normalized internal representation of all local +// lineage fragments that contribute to one unroll target. +type LineageMaterial struct { + // TargetOutpoint identifies the VTXO being unrolled. + TargetOutpoint wire.OutPoint + + // CSVDelay is the relative CSV lock on the target's timeout spend path. + CSVDelay uint32 + + // TreePaths holds zero or more rooted tree fragments that contribute + // ancestry to the target. + TreePaths []*tree.Tree + + // ExtraNodes holds zero or more finalized non-tree transactions + // that bridge or extend lineage beyond the tree fragments. + ExtraNodes []*recovery.Node +} + +// Validate checks that the material is internally consistent enough for proof +// assembly. +func (m *LineageMaterial) Validate() error { + if m == nil { + return fmt.Errorf("%w: lineage material is nil", + ErrUnrollProofUnavailable) + } + + if m.TargetOutpoint == (wire.OutPoint{}) { + return fmt.Errorf("%w: lineage material missing target", + ErrUnrollProofUnavailable) + } + + if len(m.TreePaths) == 0 && len(m.ExtraNodes) == 0 { + return fmt.Errorf("%w: lineage material has no tree "+ + "paths and no extra nodes", + ErrUnrollProofUnavailable) + } + + for i, tp := range m.TreePaths { + if tp == nil || tp.Root == nil { + return fmt.Errorf("%w: tree path %d missing root", + ErrUnrollProofUnavailable, i) + } + } + + seen := make(map[chainhash.Hash]struct{}) + for i, node := range m.ExtraNodes { + if node == nil { + return fmt.Errorf("%w: extra node %d is nil", + ErrUnrollProofInvalid, i) + } + + txid, err := node.TXID() + if err != nil { + return fmt.Errorf("%w: extra node %d: %w", + ErrUnrollProofInvalid, i, err) + } + + if _, dup := seen[txid]; dup { + return fmt.Errorf("%w: duplicate extra node %s", + ErrUnrollProofInvalid, txid) + } + + seen[txid] = struct{}{} + } + + return nil +} + +// LineageResolver gathers normalized local lineage material for one unroll +// target. +type LineageResolver interface { + // ResolveLineage returns the normalized lineage material required to + // assemble a recovery proof for the given target. + ResolveLineage(ctx context.Context, + target wire.OutPoint) (*LineageMaterial, error) +} diff --git a/unroll/messages.go b/unroll/messages.go new file mode 100644 index 000000000..977f2fefa --- /dev/null +++ b/unroll/messages.go @@ -0,0 +1,619 @@ +package unroll + +import ( + "fmt" + "io" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/unrollplan" + "github.com/lightningnetwork/lnd/tlv" +) + +// messages.go defines the durable mailbox surface for the per-target +// unroll actor. Every [Msg] here implements [actor.TLVMessage] with a +// hand-written Encode/Decode pair so that the delivery-store codec can +// persist and later restore the exact message bytes, with no JSON +// round-tripping. +// +// Two TLV layers are in play: +// +// 1. Outer identifiers (0x7900..) tell the durable-mailbox codec +// which message variant is being carried. These are globally unique +// across the actor's mailbox. +// +// 2. Inner record types (1, 3, 5, ...) namespace the payload fields +// of each message. Because the outer codec has already resolved the +// variant, inner types can restart at 1 per message, and odd +// numbering leaves slots for additive even-typed extensions +// (lightning-style schema evolution). +// +// Round-trip tests in messages_test.go pin the byte layout of every +// message so a change in encoding semantics cannot slip past review. + +// Outer TLV identifiers carried by the durable mailbox codec. +const ( + startUnrollRequestTLVType tlv.Type = 0x7900 + resumeUnrollRequestTLVType tlv.Type = 0x7901 + heightObservedMsgTLVType tlv.Type = 0x7902 + txConfirmedMsgTLVType tlv.Type = 0x7903 + txFailedMsgTLVType tlv.Type = 0x7904 + getStateRequestTLVType tlv.Type = 0x7905 + spendObservedMsgTLVType tlv.Type = 0x7906 +) + +// Inner payload TLV record types. Each message has its own namespace — the +// outer mailbox codec already identifies the message, so inner types can +// start at 1. Odd values leave room for additive even-typed extensions. +const ( + startUnrollHeightRecType tlv.Type = 1 + startUnrollTriggerRecType tlv.Type = 3 + + resumeUnrollHeightRecType tlv.Type = 1 + + heightObservedHeightRecType tlv.Type = 1 + + txConfirmedTxidRecType tlv.Type = 1 + txConfirmedHeightRecType tlv.Type = 3 + txConfirmedNumConfsRecType tlv.Type = 5 + + txFailedTxidRecType tlv.Type = 1 + txFailedReasonRecType tlv.Type = 3 + + spendObservedTxidRecType tlv.Type = 1 + spendObservedHeightRecType tlv.Type = 3 +) + +// StartTrigger identifies what caused the unroll actor to start. +type StartTrigger int32 + +const ( + // TriggerManual indicates an operator-triggered start. + TriggerManual StartTrigger = iota + + // TriggerCriticalExpiry indicates a VTXO critical-expiry handoff. + TriggerCriticalExpiry + + // TriggerRestart indicates a restored in-flight job. + TriggerRestart + + // TriggerFraudSpend indicates the job was started because the + // target outpoint was seen spent externally and the actor needs to + // escalate to fraud-handling. The DB side reserves status=3 on the + // trigger column for this case; earlier revisions silently + // downgraded FraudSpend rows to TriggerManual on restore. + TriggerFraudSpend +) + +// Phase identifies the coarse durable phase of the new unroll actor. +type Phase string + +const ( + // PhasePending indicates the actor exists but has not started work. + PhasePending Phase = "pending" + + // PhaseMaterializing indicates proof transactions are still being + // materialized or confirmed. + PhaseMaterializing Phase = "materializing" + + // PhaseCSVPending indicates the target confirmed and the actor + // is waiting + // for CSV maturity. + PhaseCSVPending Phase = "csv_pending" + + // PhaseSweepBroadcast indicates the sweep is ready and is being + // submitted + // to txconfirm. + PhaseSweepBroadcast Phase = "sweep_broadcast" + + // PhaseSweepConfirmation indicates the sweep has been broadcast and is + // awaiting confirmation. + PhaseSweepConfirmation Phase = "sweep_confirmation" + + // PhaseCompleted indicates the sweep confirmed successfully. + PhaseCompleted Phase = "completed" + + // PhaseFailed indicates the actor reached terminal failure. + PhaseFailed Phase = "failed" +) + +// Msg is the durable mailbox surface accepted by the VTXO unroll actor. +type Msg interface { + actor.TLVMessage + unrollMsgSealed() +} + +// Resp is the response surface returned by the VTXO unroll actor. +type Resp interface { + actor.Message + unrollRespSealed() +} + +// StartUnrollRequest starts the actor at the given best height. +type StartUnrollRequest struct { + actor.BaseMessage + + // Height is the current best height. + Height int32 + + // Trigger identifies why the unroll started. + Trigger StartTrigger +} + +// MessageType returns the stable message type identifier. +func (m *StartUnrollRequest) MessageType() string { + return "StartUnrollRequest" +} + +// TLVType returns the durable mailbox type ID. +func (m *StartUnrollRequest) TLVType() tlv.Type { + return startUnrollRequestTLVType +} + +// Encode serializes the message as a TLV stream. +func (m *StartUnrollRequest) Encode(w io.Writer) error { + height := uint32(m.Height) + trigger := uint32(m.Trigger) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(startUnrollHeightRecType, &height), + tlv.MakePrimitiveRecord(startUnrollTriggerRecType, &trigger), + ) + if err != nil { + return fmt.Errorf("create stream: %w", err) + } + + return stream.Encode(w) +} + +// Decode deserializes the message from a TLV stream. +func (m *StartUnrollRequest) Decode(r io.Reader) error { + var height, trigger uint32 + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(startUnrollHeightRecType, &height), + tlv.MakePrimitiveRecord(startUnrollTriggerRecType, &trigger), + ) + if err != nil { + return fmt.Errorf("create stream: %w", err) + } + + if err := stream.Decode(r); err != nil { + return fmt.Errorf("decode: %w", err) + } + + m.Height = int32(height) + m.Trigger = StartTrigger(int32(trigger)) + + return nil +} + +// unrollMsgSealed seals StartUnrollRequest into the message surface. +func (m *StartUnrollRequest) unrollMsgSealed() {} + +// ResumeUnrollRequest resumes the actor from a durable checkpoint. +type ResumeUnrollRequest struct { + actor.BaseMessage + + // Height is the current best height at resume time. + Height int32 +} + +// MessageType returns the stable message type identifier. +func (m *ResumeUnrollRequest) MessageType() string { + return "ResumeUnrollRequest" +} + +// TLVType returns the durable mailbox type ID. +func (m *ResumeUnrollRequest) TLVType() tlv.Type { + return resumeUnrollRequestTLVType +} + +// Encode serializes the message as a TLV stream. +func (m *ResumeUnrollRequest) Encode(w io.Writer) error { + height := uint32(m.Height) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(resumeUnrollHeightRecType, &height), + ) + if err != nil { + return fmt.Errorf("create stream: %w", err) + } + + return stream.Encode(w) +} + +// Decode deserializes the message from a TLV stream. +func (m *ResumeUnrollRequest) Decode(r io.Reader) error { + var height uint32 + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(resumeUnrollHeightRecType, &height), + ) + if err != nil { + return fmt.Errorf("create stream: %w", err) + } + + if err := stream.Decode(r); err != nil { + return fmt.Errorf("decode: %w", err) + } + + m.Height = int32(height) + + return nil +} + +// unrollMsgSealed seals ResumeUnrollRequest into the message surface. +func (m *ResumeUnrollRequest) unrollMsgSealed() {} + +// HeightObservedMsg reports a new best height to the actor. +type HeightObservedMsg struct { + actor.BaseMessage + + // Height is the latest observed best height. + Height int32 +} + +// MessageType returns the stable message type identifier. +func (m *HeightObservedMsg) MessageType() string { + return "HeightObservedMsg" +} + +// TLVType returns the durable mailbox type ID. +func (m *HeightObservedMsg) TLVType() tlv.Type { + return heightObservedMsgTLVType +} + +// Encode serializes the message as a TLV stream. +func (m *HeightObservedMsg) Encode(w io.Writer) error { + height := uint32(m.Height) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(heightObservedHeightRecType, &height), + ) + if err != nil { + return fmt.Errorf("create stream: %w", err) + } + + return stream.Encode(w) +} + +// Decode deserializes the message from a TLV stream. +func (m *HeightObservedMsg) Decode(r io.Reader) error { + var height uint32 + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(heightObservedHeightRecType, &height), + ) + if err != nil { + return fmt.Errorf("create stream: %w", err) + } + + if err := stream.Decode(r); err != nil { + return fmt.Errorf("decode: %w", err) + } + + m.Height = int32(height) + + return nil +} + +// unrollMsgSealed seals HeightObservedMsg into the message surface. +func (m *HeightObservedMsg) unrollMsgSealed() {} + +// TxConfirmedMsg reports that txconfirm observed one transaction confirmed. +type TxConfirmedMsg struct { + actor.BaseMessage + + // Txid is the confirmed transaction hash. + Txid chainhash.Hash + + // Height is the block height where the transaction confirmed. + Height int32 + + // NumConfs is the observed confirmation count. + NumConfs uint32 +} + +// MessageType returns the stable message type identifier. +func (m *TxConfirmedMsg) MessageType() string { + return "TxConfirmedMsg" +} + +// TLVType returns the durable mailbox type ID. +func (m *TxConfirmedMsg) TLVType() tlv.Type { + return txConfirmedMsgTLVType +} + +// Encode serializes the message as a TLV stream. +func (m *TxConfirmedMsg) Encode(w io.Writer) error { + txid := [32]byte(m.Txid) + height := uint32(m.Height) + numConfs := m.NumConfs + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(txConfirmedTxidRecType, &txid), + tlv.MakePrimitiveRecord(txConfirmedHeightRecType, &height), + tlv.MakePrimitiveRecord( + txConfirmedNumConfsRecType, &numConfs, + ), + ) + if err != nil { + return fmt.Errorf("create stream: %w", err) + } + + return stream.Encode(w) +} + +// Decode deserializes the message from a TLV stream. +func (m *TxConfirmedMsg) Decode(r io.Reader) error { + var ( + txid [32]byte + height uint32 + numConfs uint32 + ) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(txConfirmedTxidRecType, &txid), + tlv.MakePrimitiveRecord(txConfirmedHeightRecType, &height), + tlv.MakePrimitiveRecord( + txConfirmedNumConfsRecType, &numConfs, + ), + ) + if err != nil { + return fmt.Errorf("create stream: %w", err) + } + + if err := stream.Decode(r); err != nil { + return fmt.Errorf("decode: %w", err) + } + + m.Txid = chainhash.Hash(txid) + m.Height = int32(height) + m.NumConfs = numConfs + + return nil +} + +// unrollMsgSealed seals TxConfirmedMsg into the message surface. +func (m *TxConfirmedMsg) unrollMsgSealed() {} + +// TxFailedMsg reports a terminal txconfirm failure for one transaction. +type TxFailedMsg struct { + actor.BaseMessage + + // Txid identifies the failed transaction. + Txid chainhash.Hash + + // Reason is the stable human-readable failure reason. + Reason string +} + +// MessageType returns the stable message type identifier. +func (m *TxFailedMsg) MessageType() string { + return "TxFailedMsg" +} + +// TLVType returns the durable mailbox type ID. +func (m *TxFailedMsg) TLVType() tlv.Type { + return txFailedMsgTLVType +} + +// Encode serializes the message as a TLV stream. +func (m *TxFailedMsg) Encode(w io.Writer) error { + txid := [32]byte(m.Txid) + reason := []byte(m.Reason) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(txFailedTxidRecType, &txid), + tlv.MakePrimitiveRecord(txFailedReasonRecType, &reason), + ) + if err != nil { + return fmt.Errorf("create stream: %w", err) + } + + return stream.Encode(w) +} + +// Decode deserializes the message from a TLV stream. +func (m *TxFailedMsg) Decode(r io.Reader) error { + var ( + txid [32]byte + reason []byte + ) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(txFailedTxidRecType, &txid), + tlv.MakePrimitiveRecord(txFailedReasonRecType, &reason), + ) + if err != nil { + return fmt.Errorf("create stream: %w", err) + } + + if err := stream.Decode(r); err != nil { + return fmt.Errorf("decode: %w", err) + } + + m.Txid = chainhash.Hash(txid) + m.Reason = string(reason) + + return nil +} + +// unrollMsgSealed seals TxFailedMsg into the message surface. +func (m *TxFailedMsg) unrollMsgSealed() {} + +// SpendObservedMsg reports that the target outpoint was spent on-chain. +type SpendObservedMsg struct { + actor.BaseMessage + + // SpendingTxid is the transaction that spent the target. + SpendingTxid chainhash.Hash + + // SpendingHeight is the block height of the spending transaction. + SpendingHeight int32 +} + +// MessageType returns the stable message type identifier. +func (m *SpendObservedMsg) MessageType() string { + return "SpendObservedMsg" +} + +// TLVType returns the durable mailbox type ID. +func (m *SpendObservedMsg) TLVType() tlv.Type { + return spendObservedMsgTLVType +} + +// Encode serializes the message as a TLV stream. +func (m *SpendObservedMsg) Encode(w io.Writer) error { + txid := [32]byte(m.SpendingTxid) + height := uint32(m.SpendingHeight) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(spendObservedTxidRecType, &txid), + tlv.MakePrimitiveRecord(spendObservedHeightRecType, &height), + ) + if err != nil { + return fmt.Errorf("create stream: %w", err) + } + + return stream.Encode(w) +} + +// Decode deserializes the message from a TLV stream. +func (m *SpendObservedMsg) Decode(r io.Reader) error { + var ( + txid [32]byte + height uint32 + ) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(spendObservedTxidRecType, &txid), + tlv.MakePrimitiveRecord(spendObservedHeightRecType, &height), + ) + if err != nil { + return fmt.Errorf("create stream: %w", err) + } + + if err := stream.Decode(r); err != nil { + return fmt.Errorf("decode: %w", err) + } + + m.SpendingTxid = chainhash.Hash(txid) + m.SpendingHeight = int32(height) + + return nil +} + +// unrollMsgSealed seals SpendObservedMsg into the message surface. +func (m *SpendObservedMsg) unrollMsgSealed() {} + +// GetStateRequest asks the actor for its current in-memory state summary. +type GetStateRequest struct { + actor.BaseMessage +} + +// MessageType returns the stable message type identifier. +func (m *GetStateRequest) MessageType() string { + return "GetStateRequest" +} + +// TLVType returns the durable mailbox type ID. +func (m *GetStateRequest) TLVType() tlv.Type { + return getStateRequestTLVType +} + +// Encode serializes the empty-payload message. An empty TLV stream +// decodes cleanly on the other end, so we don't need to emit any +// records. +func (m *GetStateRequest) Encode(_ io.Writer) error { + return nil +} + +// Decode deserializes the empty-payload message. The codec consumes any +// bytes already framed for the outer type so we can drain and drop. +func (m *GetStateRequest) Decode(r io.Reader) error { + _, err := io.Copy(io.Discard, r) + + return err +} + +// unrollMsgSealed seals GetStateRequest into the message surface. +func (m *GetStateRequest) unrollMsgSealed() {} + +// AckResp is a trivial response used by Tell-first workflows. +type AckResp struct { + actor.BaseMessage +} + +// MessageType returns the stable message type identifier. +func (m *AckResp) MessageType() string { + return "AckResp" +} + +// unrollRespSealed seals AckResp into the response surface. +func (m *AckResp) unrollRespSealed() {} + +// GetStateResp reports the actor's current durable and derived state. +type GetStateResp struct { + actor.BaseMessage + + // Started reports whether the actor has been started. + Started bool + + // Trigger identifies why the actor was started. + Trigger StartTrigger + + // Height is the current best height tracked by the actor. + Height int32 + + // Phase is the coarse phase derived from planner state. + Phase Phase + + // PlannerState is the durable planner-owned progress state. + PlannerState unrollplan.State + + // FailReason records the terminal failure reason, if any. + FailReason string + + // SweepTxid records the sweep txid when the actor has built one. + SweepTxid *chainhash.Hash +} + +// MessageType returns the stable message type identifier. +func (m *GetStateResp) MessageType() string { + return "GetStateResp" +} + +// unrollRespSealed seals GetStateResp into the response surface. +func (m *GetStateResp) unrollRespSealed() {} + +// newCodec creates a message codec with every unroll durable message type +// registered. +func newCodec() *actor.MessageCodec { + codec := actor.NewMessageCodec() + + codec.MustRegister(startUnrollRequestTLVType, + func() actor.TLVMessage { return &StartUnrollRequest{} }, + ) + codec.MustRegister(resumeUnrollRequestTLVType, + func() actor.TLVMessage { return &ResumeUnrollRequest{} }, + ) + codec.MustRegister(heightObservedMsgTLVType, + func() actor.TLVMessage { return &HeightObservedMsg{} }, + ) + codec.MustRegister(txConfirmedMsgTLVType, + func() actor.TLVMessage { return &TxConfirmedMsg{} }, + ) + codec.MustRegister(txFailedMsgTLVType, + func() actor.TLVMessage { return &TxFailedMsg{} }, + ) + codec.MustRegister(getStateRequestTLVType, + func() actor.TLVMessage { return &GetStateRequest{} }, + ) + codec.MustRegister(spendObservedMsgTLVType, + func() actor.TLVMessage { return &SpendObservedMsg{} }, + ) + + return codec +} diff --git a/unroll/messages_test.go b/unroll/messages_test.go new file mode 100644 index 000000000..b4975fff4 --- /dev/null +++ b/unroll/messages_test.go @@ -0,0 +1,129 @@ +package unroll + +import ( + "bytes" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/stretchr/testify/require" +) + +// TestDurableMessageTLVRoundTrip pins the TLV encoding of every unroll +// durable mailbox message so a field order or record-type shuffle breaks +// loudly rather than silently corrupting persisted inbox rows. Every +// message here implements actor.TLVMessage, so the encode path is the +// same one the durable mailbox codec drives on disk. +func TestDurableMessageTLVRoundTrip(t *testing.T) { + t.Parallel() + + var txid chainhash.Hash + copy(txid[:], bytes.Repeat([]byte{0xab}, chainhash.HashSize)) + + t.Run("StartUnrollRequest", func(t *testing.T) { + t.Parallel() + + orig := &StartUnrollRequest{ + Height: 12345, + Trigger: TriggerCriticalExpiry, + } + + var buf bytes.Buffer + require.NoError(t, orig.Encode(&buf)) + + got := &StartUnrollRequest{} + require.NoError(t, got.Decode(bytes.NewReader(buf.Bytes()))) + require.Equal(t, orig.Height, got.Height) + require.Equal(t, orig.Trigger, got.Trigger) + }) + + t.Run("ResumeUnrollRequest", func(t *testing.T) { + t.Parallel() + + orig := &ResumeUnrollRequest{Height: 98765} + + var buf bytes.Buffer + require.NoError(t, orig.Encode(&buf)) + + got := &ResumeUnrollRequest{} + require.NoError(t, got.Decode(bytes.NewReader(buf.Bytes()))) + require.Equal(t, orig.Height, got.Height) + }) + + t.Run("HeightObservedMsg", func(t *testing.T) { + t.Parallel() + + orig := &HeightObservedMsg{Height: 500} + + var buf bytes.Buffer + require.NoError(t, orig.Encode(&buf)) + + got := &HeightObservedMsg{} + require.NoError(t, got.Decode(bytes.NewReader(buf.Bytes()))) + require.Equal(t, orig.Height, got.Height) + }) + + t.Run("TxConfirmedMsg", func(t *testing.T) { + t.Parallel() + + orig := &TxConfirmedMsg{ + Txid: txid, + Height: 42, + NumConfs: 6, + } + + var buf bytes.Buffer + require.NoError(t, orig.Encode(&buf)) + + got := &TxConfirmedMsg{} + require.NoError(t, got.Decode(bytes.NewReader(buf.Bytes()))) + require.Equal(t, orig.Txid, got.Txid) + require.Equal(t, orig.Height, got.Height) + require.Equal(t, orig.NumConfs, got.NumConfs) + }) + + t.Run("TxFailedMsg", func(t *testing.T) { + t.Parallel() + + orig := &TxFailedMsg{ + Txid: txid, + Reason: "rejected by mempool", + } + + var buf bytes.Buffer + require.NoError(t, orig.Encode(&buf)) + + got := &TxFailedMsg{} + require.NoError(t, got.Decode(bytes.NewReader(buf.Bytes()))) + require.Equal(t, orig.Txid, got.Txid) + require.Equal(t, orig.Reason, got.Reason) + }) + + t.Run("SpendObservedMsg", func(t *testing.T) { + t.Parallel() + + orig := &SpendObservedMsg{ + SpendingTxid: txid, + SpendingHeight: 777, + } + + var buf bytes.Buffer + require.NoError(t, orig.Encode(&buf)) + + got := &SpendObservedMsg{} + require.NoError(t, got.Decode(bytes.NewReader(buf.Bytes()))) + require.Equal(t, orig.SpendingTxid, got.SpendingTxid) + require.Equal(t, orig.SpendingHeight, got.SpendingHeight) + }) + + t.Run("GetStateRequest", func(t *testing.T) { + t.Parallel() + + orig := &GetStateRequest{} + + var buf bytes.Buffer + require.NoError(t, orig.Encode(&buf)) + + got := &GetStateRequest{} + require.NoError(t, got.Decode(bytes.NewReader(buf.Bytes()))) + }) +} diff --git a/unroll/proof_assembler.go b/unroll/proof_assembler.go new file mode 100644 index 000000000..74ae7451f --- /dev/null +++ b/unroll/proof_assembler.go @@ -0,0 +1,464 @@ +package unroll + +import ( + "bytes" + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/lib/recovery" + "github.com/lightninglabs/darepo-client/lib/tree" + "github.com/lightninglabs/darepo-client/lib/tx/psbtutil" + "github.com/lightninglabs/darepo-client/vtxo" +) + +var ( + // ErrUnrollTargetNotFound indicates the requested local target does not + // exist or cannot be used for unilateral exit. + ErrUnrollTargetNotFound = errors.New("unroll target not found") + + // ErrUnrollProofUnavailable indicates local data was insufficient + // to build a unilateral-exit proof. + ErrUnrollProofUnavailable = errors.New("unroll proof unavailable") + + // ErrUnrollProofInvalid indicates a locally assembled or decoded + // proof was invalid. + ErrUnrollProofInvalid = errors.New("unroll proof invalid") +) + +type packageResolver interface { + ResolveUnrollPackages(ctx context.Context, + outpoint wire.OutPoint) (*db.OORUnrollPackages, error) +} + +// LocalProofAssembler builds unilateral-exit proofs from strictly local state. +type LocalProofAssembler struct { + // Resolver gathers normalized lineage material for a target. When nil, + // EnsureProof falls back to DescriptorLineageResolver using + // VTXOStore and + // ArtifactStore. + Resolver LineageResolver + + // VTXOStore provides VTXO descriptor lookups for proof assembly. + VTXOStore vtxo.VTXOStore + + // ArtifactStore resolves OOR unroll packages for chained VTXOs. + ArtifactStore packageResolver +} + +// EnsureProof builds an immutable [recovery.Proof] for the target +// entirely from local authoritative state (the VTXO descriptor store +// and the OOR artifact store). +// +// The strict-locality contract is deliberate: if the operator is +// cooperating we would never be unrolling in the first place, so the +// unroll flow cannot depend on any operator RPC. Everything it needs +// must have been persisted when the VTXO was received (round commit, +// tree path) or when it was chained through OOR (checkpoint artifacts). +// +// The assembler itself is stateless; heavy lifting happens inside the +// configured [LineageResolver] (typically [DescriptorLineageResolver]) +// which walks the VTXO store plus any OOR chain back to its round roots +// and normalizes the resulting transactions into a [LineageMaterial] +// bundle. [BuildProofFromMaterial] then stitches that bundle into a +// proof graph. +func (a *LocalProofAssembler) EnsureProof(ctx context.Context, + target wire.OutPoint) (*recovery.Proof, error) { + + if a == nil { + return nil, fmt.Errorf("proof assembler must be provided") + } + + resolver := a.resolver() + mat, err := resolver.ResolveLineage(ctx, target) + if err != nil { + return nil, err + } + + return BuildProofFromMaterial(mat) +} + +// resolver returns the configured LineageResolver or creates a fallback +// DescriptorLineageResolver from the legacy fields. +func (a *LocalProofAssembler) resolver() LineageResolver { + if a.Resolver != nil { + return a.Resolver + } + + return &DescriptorLineageResolver{ + VTXOStore: a.VTXOStore, + ArtifactStore: a.ArtifactStore, + } +} + +// BuildProofFromMaterial stitches a [LineageMaterial] bundle into an +// immutable [recovery.Proof]. +// +// The build is three passes: +// +// 1. Walk every TreePath's node graph in pre-order, turning each +// tree.Node into a recovery.Node of kind NodeKindTree. Duplicate +// txids across tree paths are tolerated (the bundle may contain +// overlapping ancestry) but conflicting duplicates — same txid, +// different raw bytes — are rejected so we cannot ship a proof +// that is internally ambiguous about which transaction is signed. +// +// 2. Append any ExtraNodes (e.g. OOR hop transactions pulled from +// the artifact store), subject to the same conflict check. +// +// 3. Call recovery.NewProof, which builds the actual DAG and checks +// the target's outpoint is reachable. Then validateInputCompleteness +// walks the target transaction's inputs to make sure every parent +// either resides in the proof graph or is a known external input +// (batch outpoint) — this catches the lineage-resolver returning +// a proof that skipped some branch. +// +// The returned proof is immutable. No caller in the unroll flow ever +// mutates the returned graph; the planner and FSM use it as read-only +// reference data throughout the actor's lifetime. +func BuildProofFromMaterial(mat *LineageMaterial) (*recovery.Proof, + error) { + + if err := mat.Validate(); err != nil { + return nil, err + } + + nodes := make([]*recovery.Node, 0) + seen := make(map[chainhash.Hash]*recovery.Node) + + for i, tp := range mat.TreePaths { + if err := addTreePathNodes(&nodes, seen, tp); err != nil { + return nil, fmt.Errorf("tree path %d: %w", i, err) + } + } + + for _, extra := range mat.ExtraNodes { + if err := addProofNode(&nodes, seen, extra); err != nil { + return nil, err + } + } + + proof, err := recovery.NewProof( + mat.TargetOutpoint, mat.CSVDelay, nodes..., + ) + if err != nil { + return nil, fmt.Errorf("%w: %w", + ErrUnrollProofInvalid, err) + } + + if err := validateInputCompleteness(proof, mat); err != nil { + return nil, err + } + + return proof, nil +} + +// validateInputCompleteness checks that every input of the target +// transaction has a known parent. A valid parent is either: +// +// - A node inside the proof graph (standard ancestor), or +// - A known external batch outpoint (the root of a tree path — the +// parent of a tree is a round commitment, which is broadcast by +// the operator and does not live in the client's proof set). +// +// If an input spends from anything else, the proof is incomplete — the +// lineage resolver missed a branch. We fail loudly rather than ship a +// proof that would make the FSM sit in AwaitingMaterialization forever +// waiting on a transaction it cannot produce. +func validateInputCompleteness(proof *recovery.Proof, + mat *LineageMaterial) error { + + targetNode, err := proof.TargetNode() + if err != nil { + return fmt.Errorf("%w: %w", ErrUnrollProofInvalid, err) + } + + isRoot := false + for _, rootTxid := range proof.RootTxids() { + if rootTxid == proof.TargetOutpoint().Hash { + isRoot = true + break + } + } + + if isRoot { + return nil + } + + knownExternal := make(map[chainhash.Hash]struct{}) + for _, tp := range mat.TreePaths { + if tp == nil { + continue + } + + knownExternal[tp.BatchOutpoint.Hash] = struct{}{} + } + + for _, txIn := range targetNode.Tx.TxIn { + parentHash := txIn.PreviousOutPoint.Hash + if _, inProof := proof.Node(parentHash); inProof { + continue + } + + if _, ext := knownExternal[parentHash]; ext { + continue + } + + return fmt.Errorf( + "%w: target %s has input spending from %s "+ + "which is neither in the proof nor a "+ + "known external input (incomplete "+ + "lineage branch)", + ErrUnrollProofUnavailable, + proof.TargetOutpoint().Hash, parentHash, + ) + } + + return nil +} + +// validateProofDescriptor enforces the hard local start contract for one +// unilateral-exit target descriptor. +func validateProofDescriptor(desc *vtxo.Descriptor) error { + switch { + case desc == nil: + return fmt.Errorf("%w: descriptor missing", + ErrUnrollTargetNotFound) + + case desc.TreePath == nil: + return fmt.Errorf("%w: descriptor missing tree path", + ErrUnrollProofUnavailable) + + case desc.CommitmentTxID == (chainhash.Hash{}): + return fmt.Errorf("%w: descriptor missing commitment txid", + ErrUnrollProofUnavailable) + + case desc.RoundID == "": + return fmt.Errorf("%w: descriptor missing round id", + ErrUnrollProofUnavailable) + + case desc.CreatedHeight == 0: + return fmt.Errorf("%w: descriptor missing created height", + ErrUnrollProofUnavailable) + + case desc.BatchExpiry == 0: + return fmt.Errorf("%w: descriptor missing batch expiry", + ErrUnrollProofUnavailable) + + case desc.ChainDepth < 0: + return fmt.Errorf("%w: invalid chain depth %d", + ErrUnrollProofInvalid, desc.ChainDepth) + + case desc.Status == vtxo.VTXOStatusSpent || + desc.Status == vtxo.VTXOStatusForfeited || + desc.Status == vtxo.VTXOStatusFailed: + + return fmt.Errorf("%w: target %v is terminal (%s)", + ErrUnrollTargetNotFound, desc.Outpoint, desc.Status) + } + + return nil +} + +// addTreePathNodes appends the round-birth ancestry from one descriptor tree +// path into the in-progress proof node set. +func addTreePathNodes(nodes *[]*recovery.Node, + seen map[chainhash.Hash]*recovery.Node, treePath *tree.Tree) error { + + if treePath == nil || treePath.Root == nil { + return fmt.Errorf("%w: tree path missing root", + ErrUnrollProofUnavailable) + } + + for treeNode := range treePath.Root.NodesIter() { + tx, err := proofTxFromTreeNode(treeNode) + if err != nil { + return err + } + + node := &recovery.Node{ + Kind: recovery.NodeKindTree, + Tx: tx, + } + + if err := addProofNode(nodes, seen, node); err != nil { + return err + } + } + + return nil +} + +// addProofNode appends one proof node while rejecting conflicting duplicate +// txids. +func addProofNode(nodes *[]*recovery.Node, + seen map[chainhash.Hash]*recovery.Node, node *recovery.Node) error { + + txid, err := node.TXID() + if err != nil { + return fmt.Errorf("%w: %w", ErrUnrollProofInvalid, err) + } + + existing, ok := seen[txid] + if ok { + equal, err := sameNode(existing, node) + if err != nil { + return err + } + + if !equal { + return fmt.Errorf("%w: conflicting proof node %s", + ErrUnrollProofInvalid, txid) + } + + return nil + } + + seen[txid] = node + *nodes = append(*nodes, node) + + return nil +} + +// sameNode reports whether two proof nodes represent the same transaction and +// role. +func sameNode(a, b *recovery.Node) (bool, error) { + switch { + case a == nil || b == nil: + return false, fmt.Errorf("%w: proof node cannot be nil", + ErrUnrollProofInvalid) + + case a.Kind != b.Kind: + return false, nil + } + + var aBuf bytes.Buffer + if err := a.Tx.Serialize(&aBuf); err != nil { + return false, err + } + + var bBuf bytes.Buffer + if err := b.Tx.Serialize(&bBuf); err != nil { + return false, err + } + + return bytes.Equal(aBuf.Bytes(), bBuf.Bytes()), nil +} + +// extractFinalizedTx prefers the fully finalized transaction from a persisted +// PSBT, but falls back to the unsigned transaction for synthetic test packets. +func extractFinalizedTx(pkt *psbt.Packet) (*wire.MsgTx, error) { + if pkt == nil { + return nil, fmt.Errorf("%w: psbt must be provided", + ErrUnrollProofInvalid) + } + + raw, err := psbtutil.Serialize(pkt) + if err != nil { + return nil, fmt.Errorf("%w: serialize psbt: %w", + ErrUnrollProofInvalid, err) + } + + cloned, err := psbtutil.Parse(raw) + if err != nil { + return nil, fmt.Errorf("%w: parse psbt: %w", + ErrUnrollProofInvalid, err) + } + + tx, extractErr := psbt.Extract(cloned) + if extractErr == nil { + return tx, nil + } + + err = psbt.MaybeFinalizeAll(cloned) + if err == nil { + tx, extractErr = psbt.Extract(cloned) + if extractErr == nil { + return tx, nil + } + } + + for i := range cloned.Inputs { + if len(cloned.Inputs[i].FinalScriptWitness) > 0 { + continue + } + + if err := psbt.Finalize(cloned, i); err == nil { + continue + } + + err := finalizeTaprootScriptSpend( + &cloned.Inputs[i], + ) + if err != nil { + return nil, fmt.Errorf("%w: finalize "+ + "taproot script spend input %d: %v", + ErrUnrollProofInvalid, i, err) + } + } + + tx, extractErr = psbt.Extract(cloned) + if extractErr == nil { + return tx, nil + } + + return nil, fmt.Errorf("%w: psbt not fully finalized "+ + "(last extract error: %v)", ErrUnrollProofInvalid, + extractErr) +} + +// finalizeTaprootScriptSpend constructs FinalScriptWitness from PSBT taproot +// script-spend signature fields. +func finalizeTaprootScriptSpend(in *psbt.PInput) error { + if len(in.TaprootScriptSpendSig) == 0 { + return fmt.Errorf("no taproot script spend signatures") + } + + if len(in.TaprootLeafScript) == 0 { + return fmt.Errorf("no taproot leaf scripts") + } + + leaf := in.TaprootLeafScript[0] + var witnessItems [][]byte + for _, sig := range in.TaprootScriptSpendSig { + witnessItems = append(witnessItems, sig.Signature) + } + + witnessItems = append(witnessItems, leaf.Script) + witnessItems = append(witnessItems, leaf.ControlBlock) + + witness := wire.TxWitness(witnessItems) + var buf bytes.Buffer + if err := psbt.WriteTxWitness(&buf, witness); err != nil { + return fmt.Errorf("encode witness: %w", err) + } + in.FinalScriptWitness = buf.Bytes() + + return nil +} + +// proofTxFromTreeNode prefers the signed tree transaction when available, but +// falls back to the unsigned form for synthetic test trees. +func proofTxFromTreeNode(node *tree.Node) (*wire.MsgTx, error) { + if node == nil { + return nil, fmt.Errorf("%w: tree node missing", + ErrUnrollProofInvalid) + } + + tx, err := node.ToSignedTx() + if err == nil { + return tx, nil + } + + tx, err = node.ToTx() + if err != nil { + return nil, fmt.Errorf("%w: tree node tx: %w", + ErrUnrollProofInvalid, err) + } + + return tx, nil +} diff --git a/unroll/registry.go b/unroll/registry.go new file mode 100644 index 000000000..62c6661ed --- /dev/null +++ b/unroll/registry.go @@ -0,0 +1,889 @@ +package unroll + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/darepo-client/txconfirm" + "github.com/lightninglabs/darepo-client/vtxo" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +const ( + // initialPersistRetryDelay is the first delay used when retrying + // control-plane persistence for a live unroll child. + initialPersistRetryDelay = 250 * time.Millisecond + + // maxPersistRetryDelay caps the exponential backoff for control-plane + // persistence retries. + maxPersistRetryDelay = 5 * time.Second +) + +// RegistryRecord stores the coarse control-plane view of one unroll target. +type RegistryRecord struct { + // TargetOutpoint identifies the target VTXO. + TargetOutpoint wire.OutPoint + + // ActorID identifies the durable per-target actor. + ActorID string + + // Trigger records why the target was started. + Trigger StartTrigger + + // Phase is the last known coarse lifecycle phase. + Phase Phase + + // FailReason stores the terminal failure when present. + FailReason string + + // SweepTxid stores the terminal sweep txid when known. + SweepTxid *chainhash.Hash +} + +// IsTerminal reports whether the record reached a terminal phase. +func (r RegistryRecord) IsTerminal() bool { + return r.Phase == PhaseCompleted || r.Phase == PhaseFailed +} + +// RegistryStore is the control-plane persistence surface used by the unroll +// registry. +type RegistryStore interface { + // UpsertRecord stores the latest control-plane view for one target. + UpsertRecord(ctx context.Context, record RegistryRecord) error + + // GetRecord returns one target record when present. + GetRecord(ctx context.Context, target wire.OutPoint) ( + *RegistryRecord, error, + ) + + // ListNonTerminalRecords returns all targets that still need restore. + ListNonTerminalRecords(ctx context.Context) ([]RegistryRecord, error) + + // MarkTerminal persists one terminal target state. + MarkTerminal(ctx context.Context, target wire.OutPoint, phase Phase, + failReason string, sweepTxid *chainhash.Hash) error +} + +// RegistryConfig configures the thin unroll registry actor. +type RegistryConfig struct { + // Store persists coarse registry records for restore. + Store RegistryStore + + // DeliveryStore provides durable mailbox and checkpoint persistence for + // child actors. + DeliveryStore actor.DeliveryStore + + // ProofAssembler resolves immutable proofs for child actors. + ProofAssembler ProofAssembler + + // VTXOStore loads target descriptors for child actors. + VTXOStore vtxo.VTXOStore + + // TxConfirmRef is the shared tx-confirmation actor. + TxConfirmRef actor.ActorRef[txconfirm.Msg, txconfirm.Resp] + + // ChainSource provides best-height and fee-estimate queries. + ChainSource actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ] + + // Wallet provides sweep destination derivation and signing. + Wallet SweepWallet + + // Log is an optional logger. + Log fn.Option[btclog.Logger] + + // MaxSweepFeeRateSatPerVByte clamps pathological fee estimates. + MaxSweepFeeRateSatPerVByte int64 +} + +// UnrollRegistryActor wraps the thin unroll registry actor. +type UnrollRegistryActor struct { + ref actor.ActorRef[RegistryMsg, RegistryResp] + registry *actor.Actor[RegistryMsg, RegistryResp] + behavior *registryBehavior +} + +// Ref returns the public registry actor reference. +func (a *UnrollRegistryActor) Ref() actor.ActorRef[RegistryMsg, RegistryResp] { + return a.ref +} + +// RestoreNonTerminal resumes all non-terminal records from the control store. +func (a *UnrollRegistryActor) RestoreNonTerminal(ctx context.Context) error { + if a == nil || a.behavior == nil { + return fmt.Errorf("registry actor not initialized") + } + + return a.behavior.restoreNonTerminal(ctx) +} + +// Stop stops the underlying registry actor. +func (a *UnrollRegistryActor) Stop() { + if a == nil || a.registry == nil { + return + } + + a.registry.Stop() +} + +// NewUnrollRegistryActor creates and starts the thin unroll registry actor. +func NewUnrollRegistryActor(cfg RegistryConfig) *UnrollRegistryActor { + behavior := ®istryBehavior{ + cfg: cfg, + log: cfg.Log.UnwrapOr(btclog.Disabled), + active: make(map[wire.OutPoint]*VTXOUnrollActor), + pending: make(map[wire.OutPoint]RegistryRecord), + persisting: make(map[wire.OutPoint]RegistryRecord), + } + + registry := actor.NewActor(actor.ActorConfig[RegistryMsg, RegistryResp]{ + ID: "unroll-registry", + Behavior: behavior, + MailboxSize: 64, + }) + behavior.selfRef = registry.TellRef() + registry.Start() + + return &UnrollRegistryActor{ + ref: registry.Ref(), + registry: registry, + behavior: behavior, + } +} + +// registryBehavior is the thin control-plane actor around per-target unroll +// actors. +type registryBehavior struct { + cfg RegistryConfig + log btclog.Logger + selfRef actor.TellOnlyRef[RegistryMsg] + + active map[wire.OutPoint]*VTXOUnrollActor + pending map[wire.OutPoint]RegistryRecord + persisting map[wire.OutPoint]RegistryRecord + + spawnFunc func(context.Context, wire.OutPoint) (*VTXOUnrollActor, error) +} + +// persistActiveRecordMsg asks the registry to retry persisting one active +// child's control-plane view. +type persistActiveRecordMsg struct { + actor.BaseMessage + + // Outpoint identifies the active child to persist. + Outpoint wire.OutPoint + + // Attempt is the zero-based retry attempt number. + Attempt int +} + +// MessageType returns the stable message type identifier. +func (m *persistActiveRecordMsg) MessageType() string { + return "persistActiveRecordMsg" +} + +// registryMsgSealed seals persistActiveRecordMsg into the registry surface. +func (m *persistActiveRecordMsg) registryMsgSealed() {} + +// persistRecordResultMsg reports the outcome of one asynchronous control-plane +// persistence attempt. +type persistRecordResultMsg struct { + actor.BaseMessage + + // Outpoint identifies the target whose record was persisted. + Outpoint wire.OutPoint + + // Attempt is the zero-based retry attempt number. + Attempt int + + // Record is the exact snapshot that was written. + Record RegistryRecord + + // Err is populated when the write failed. + Err string +} + +// MessageType returns the stable message type identifier. +func (m *persistRecordResultMsg) MessageType() string { + return "persistRecordResultMsg" +} + +// registryMsgSealed seals persistRecordResultMsg into the registry surface. +func (m *persistRecordResultMsg) registryMsgSealed() {} + +// Receive processes one registry message. +func (r *registryBehavior) Receive(ctx context.Context, + msg RegistryMsg) fn.Result[RegistryResp] { + + switch req := msg.(type) { + case *EnsureUnrollRequest: + return r.handleEnsure(ctx, req) + + case *GetStatusRequest: + return r.handleGetStatus(ctx, req) + + case *UnrollTerminatedMsg: + return r.handleTerminated(ctx, req) + + case *persistActiveRecordMsg: + return r.handlePersistActiveRecord(ctx, req) + + case *persistRecordResultMsg: + return r.handlePersistRecordResult(ctx, req) + + default: + return fn.Err[RegistryResp]( + fmt.Errorf("unknown registry message: %T", msg), + ) + } +} + +// OnStop stops all active child actors when the registry shuts down. +func (r *registryBehavior) OnStop(context.Context) error { + for _, child := range r.active { + child.Stop() + } + + return nil +} + +// handleEnsure is the admission gate for new unroll jobs. It runs a +// four-stage check to decide whether the caller is re-asking for an +// already-tracked target or requesting a brand-new unroll, spawns and +// starts the child when needed, and makes the control-plane record +// durable before returning success. +// +// Deduplication trail, in order of cost: +// +// 1. r.active (in-memory): a live child is running right now. Return +// its ActorID with Created=false. No store hit. +// +// 2. r.pending (in-memory): a child has terminated but its latest +// snapshot has not yet flushed to the durable store. Returning the +// pending ActorID here avoids clobbering a terminal sweep txid or +// failure reason with a fresh restart record. +// +// 3. Store.GetRecord: the record exists on disk but the child was +// never restored (e.g. the registry was just started and the caller +// asked before RestoreNonTerminal completed). Same dedup semantics. +// +// Only if all three miss do we spawn a fresh child, fetch best height, +// send StartUnrollRequest, and read back the resulting state. +// +// The final store write is synchronous on purpose: returning Created=true +// is a promise that RestoreNonTerminal will see this target on the next +// boot. Async-only persist would open a crash window where the child +// exists in memory but not on disk, and the caller would never know the +// job had been silently dropped. If the sync write fails, we roll back +// the child and surface the error. +func (r *registryBehavior) handleEnsure(ctx context.Context, + req *EnsureUnrollRequest) fn.Result[RegistryResp] { + + if child, ok := r.active[req.Outpoint]; ok { + return fn.Ok[RegistryResp](&EnsureUnrollResp{ + ActorID: child.Ref().ID(), + Created: false, + }) + } + + // Terminal records leave the active map before their persistence + // write completes, so fall back to the in-memory pending cache and + // the durable store. Re-spawning a fresh actor on top of an existing + // record would clobber the recorded sweep txid or fail reason. + if record, ok := r.pending[req.Outpoint]; ok { + return fn.Ok[RegistryResp](&EnsureUnrollResp{ + ActorID: record.ActorID, + Created: false, + }) + } + + existing, err := r.cfg.Store.GetRecord(ctx, req.Outpoint) + if err != nil { + return fn.Err[RegistryResp](fmt.Errorf( + "lookup existing record: %w", err, + )) + } + if existing != nil { + return fn.Ok[RegistryResp](&EnsureUnrollResp{ + ActorID: existing.ActorID, + Created: false, + }) + } + + height, err := r.queryBestHeight(ctx) + if err != nil { + return fn.Err[RegistryResp](fmt.Errorf("best height: %w", err)) + } + + child, err := r.spawn(ctx, req.Outpoint) + if err != nil { + return fn.Err[RegistryResp](fmt.Errorf("spawn child: %w", err)) + } + + _, err = child.Ref().Ask(ctx, &StartUnrollRequest{ + Height: height, + Trigger: req.Trigger, + }).Await(ctx).Unpack() + if err != nil { + child.Stop() + return fn.Err[RegistryResp](fmt.Errorf("start child: %w", err)) + } + + r.active[req.Outpoint] = child + + state, err := r.childState(ctx, child) + if err != nil { + child.Stop() + delete(r.active, req.Outpoint) + return fn.Err[RegistryResp]( + fmt.Errorf("read child state: %w", err), + ) + } + + record := recordFromChildState( + req.Outpoint, child.Ref().ID(), state, + ) + + // Persist the control-plane record synchronously before returning + // Created=true so a crash between accept and writeback cannot orphan + // the child: RestoreNonTerminal only sees durable records, so an + // unpersisted job would be silently lost on restart. + err = r.cfg.Store.UpsertRecord(ctx, cloneRegistryRecord(record)) + if err != nil { + child.Stop() + delete(r.active, req.Outpoint) + + return fn.Err[RegistryResp]( + fmt.Errorf("persist unroll record: %w", err), + ) + } + + r.pending[req.Outpoint] = cloneRegistryRecord(record) + + return fn.Ok[RegistryResp](&EnsureUnrollResp{ + ActorID: child.Ref().ID(), + Created: true, + }) +} + +// handleGetStatus answers a status probe by walking the same three +// layers that handleEnsure uses for dedup, but in the opposite role — +// here we want the MOST up-to-date view and are willing to fall back +// when the fresher layer is unavailable. +// +// Read order: +// +// 1. r.active + child state: if the child is alive, Ask it directly +// and report Active=true. This is the only source that reflects +// non-terminal phases like PhaseMaterializing / PhaseCSVPending +// accurately — the store is only written on admission and +// termination. +// +// 2. r.pending: child has terminated but async persist has not +// flushed. Report the cached terminal phase. +// +// 3. Store.GetRecord: neither of the above applies — report whatever +// the store says. +// +// Found=false is returned only when all three layers say nothing is +// known about the outpoint, letting callers distinguish "never +// requested" from "requested and in progress/terminal". +func (r *registryBehavior) handleGetStatus(ctx context.Context, + req *GetStatusRequest) fn.Result[RegistryResp] { + + if child, ok := r.active[req.Outpoint]; ok { + state, err := r.childState(ctx, child) + if err == nil { + return fn.Ok[RegistryResp](&GetStatusResp{ + Found: true, + Active: true, + ActorID: child.Ref().ID(), + State: state, + Phase: state.Phase, + Trigger: state.Trigger, + }) + } + + r.log.WarnS(ctx, "Failed to read active unroll state; "+ + "falling back to cached status", err, + slog.String("outpoint", req.Outpoint.String()), + slog.String("actor_id", child.Ref().ID()), + ) + } + + if record, ok := r.pending[req.Outpoint]; ok { + cached := cloneRegistryRecord(record) + return fn.Ok[RegistryResp](&GetStatusResp{ + Found: true, + Active: false, + ActorID: cached.ActorID, + Phase: cached.Phase, + Trigger: cached.Trigger, + FailReason: cached.FailReason, + SweepTxid: copyHash(cached.SweepTxid), + }) + } + + record, err := r.cfg.Store.GetRecord(ctx, req.Outpoint) + if err != nil { + return fn.Err[RegistryResp](fmt.Errorf("get record: %w", err)) + } + + if record == nil { + return fn.Ok[RegistryResp](&GetStatusResp{}) + } + + return fn.Ok[RegistryResp](&GetStatusResp{ + Found: true, + Active: false, + ActorID: record.ActorID, + Phase: record.Phase, + Trigger: record.Trigger, + FailReason: record.FailReason, + SweepTxid: copyHash(record.SweepTxid), + }) +} + +// handleTerminated moves the child out of the active map and schedules +// the terminal snapshot for persistence. +// +// The terminal snapshot is assembled from three sources in increasing +// order of freshness so we always persist the richest view available: +// +// 1. The inbound notification itself (Phase, FailReason, SweepTxid). +// This is the fallback if the child has already been stopped. +// +// 2. The cached r.pending record (if any) — same fields overwritten +// from the notification, but Trigger and ActorID survive so we do +// not drop the known history. +// +// 3. A live childState Ask if the child is still in r.active. This is +// authoritative and supersedes the above. Only if this Ask fails +// (child already torn down, mailbox full, etc.) do we fall back to +// the cached notification data. +// +// After the snapshot is built, the child is stopped, removed from +// active, and the snapshot is enqueued for async persistence via +// requestPersist. Terminal writes intentionally stay on the async retry +// path — unlike admission, a failed terminal write does not orphan the +// job (the in-memory pending record keeps answering GetStatus), so there +// is no reason to block the registry goroutine on a flaky store. +func (r *registryBehavior) handleTerminated(ctx context.Context, + req *UnrollTerminatedMsg) fn.Result[RegistryResp] { + + record := RegistryRecord{ + TargetOutpoint: req.Outpoint, + ActorID: req.ActorID, + Phase: req.Phase, + FailReason: req.FailReason, + SweepTxid: copyHash(req.SweepTxid), + } + + if cached, ok := r.pending[req.Outpoint]; ok { + record = cloneRegistryRecord(cached) + record.Phase = req.Phase + record.FailReason = req.FailReason + record.SweepTxid = copyHash(req.SweepTxid) + if record.ActorID == "" { + record.ActorID = req.ActorID + } + } + + if child, ok := r.active[req.Outpoint]; ok { + state, err := r.childState(ctx, child) + if err == nil { + record = recordFromChildState( + req.Outpoint, child.Ref().ID(), state, + ) + } else { + r.log.WarnS(ctx, "Failed to read terminal unroll state; "+ + "using cached notification data", err, + slog.String("outpoint", req.Outpoint.String()), + slog.String("actor_id", child.Ref().ID()), + ) + } + + child.Stop() + delete(r.active, req.Outpoint) + } + + r.pending[req.Outpoint] = cloneRegistryRecord(record) + r.requestPersist(req.Outpoint, 0) + + return fn.Ok[RegistryResp](&RegistryAckResp{}) +} + +// restoreNonTerminal is the daemon's boot entry point for the unroll +// subsystem. It reads every record from the durable store that is not +// already Completed or Failed, spawns a fresh VTXOUnrollActor per +// target, and sends ResumeUnrollRequest to each. +// +// The per-target behavior then loads its checkpoint (proof, planner +// state, sweep tx, last height), reconstructs the FSM in the same state +// it left off, and re-arms txconfirm subscriptions for every in-flight +// node and for the sweep (see routeOutbox's Reissue* branches). Thanks +// to txconfirm's txid-keyed dedup, none of this produces on-chain +// duplicates — already-broadcast transactions are absorbed and +// already-confirmed ones return immediately with their status. +// +// When restore fails for an individual target (spawn fails, or the +// resume Ask fails), we mark that target terminal with PhaseFailed and +// a descriptive reason rather than leaving the store entry non-terminal +// forever. A fresh Ensure from the chain resolver can then try again +// with a clean slate if the cause is transient. +func (r *registryBehavior) restoreNonTerminal(ctx context.Context) error { + records, err := r.cfg.Store.ListNonTerminalRecords(ctx) + if err != nil { + return fmt.Errorf("list non-terminal records: %w", err) + } + + if len(records) == 0 { + return nil + } + + height, err := r.queryBestHeight(ctx) + if err != nil { + return fmt.Errorf("best height for restore: %w", err) + } + + for i := range records { + record := records[i] + if _, ok := r.active[record.TargetOutpoint]; ok { + continue + } + + child, err := r.spawn(ctx, record.TargetOutpoint) + if err != nil { + _ = r.cfg.Store.MarkTerminal( + ctx, record.TargetOutpoint, PhaseFailed, + "spawn failed on restore: "+err.Error(), nil, + ) + + continue + } + + _, err = child.Ref().Ask(ctx, &ResumeUnrollRequest{ + Height: height, + }).Await(ctx).Unpack() + if err != nil { + child.Stop() + _ = r.cfg.Store.MarkTerminal( + ctx, record.TargetOutpoint, PhaseFailed, + "resume failed on restore: "+err.Error(), nil, + ) + + continue + } + + r.active[record.TargetOutpoint] = child + } + + return nil +} + +// handlePersistActiveRecord is half of the two-message pair that drives +// async writes to the control-plane store (the other half is +// handlePersistRecordResult). +// +// The flow exists so the registry goroutine never blocks on a slow +// store: requestPersist Tells the registry a +// persistActiveRecordMsg, which snapshots the latest r.pending view +// synchronously, hands the write off to a goroutine, and returns +// immediately. The goroutine Tells back a persistRecordResultMsg when +// it's done. +// +// Concurrency is controlled by r.persisting, which tracks the exact +// record currently being written for each outpoint. If a new snapshot +// arrives while a write is already in flight, we drop this message: the +// completion handler will see that r.pending has diverged from +// r.persisting and automatically re-enqueue a fresh attempt. This keeps +// the write path strictly serial per outpoint (never overlapping writes +// for the same target) without requiring a lock on the store. +func (r *registryBehavior) handlePersistActiveRecord(ctx context.Context, + req *persistActiveRecordMsg) fn.Result[RegistryResp] { + + record, ok, err := r.recordForPersistence(ctx, req.Outpoint) + if err != nil { + r.log.WarnS(ctx, "Failed to snapshot unroll record for "+ + "persistence", err, + slog.String("outpoint", req.Outpoint.String()), + slog.Int("attempt", req.Attempt+1), + ) + r.schedulePersistRetry(req.Outpoint, req.Attempt+1) + + return fn.Ok[RegistryResp](&RegistryAckResp{}) + } + + if !ok { + delete(r.persisting, req.Outpoint) + return fn.Ok[RegistryResp](&RegistryAckResp{}) + } + + // If a persist is already in flight for this outpoint, drop this + // snapshot; handlePersistRecordResult will re-enqueue from r.pending + // on completion if the record has diverged in the meantime. + if _, ok := r.persisting[req.Outpoint]; ok { + return fn.Ok[RegistryResp](&RegistryAckResp{}) + } + + r.persisting[req.Outpoint] = cloneRegistryRecord(record) + r.persistRecordAsync(req.Outpoint, req.Attempt, record) + + return fn.Ok[RegistryResp](&RegistryAckResp{}) +} + +// handlePersistRecordResult reconciles an async store write with the +// current r.pending view. Four cases play out: +// +// - Write succeeded AND r.pending matches what we wrote: the in-memory +// cache is now redundant with the store, so drop it. Subsequent +// GetStatus calls read from the store or from an active child. +// +// - Write succeeded but r.pending has diverged (a new update came in +// while the write was in flight): re-enqueue an immediate retry so +// the newer view catches up. +// +// - Write failed AND r.pending matches what we tried: schedule a +// backoff retry (see persistRetryDelay) rather than hot-looping. +// +// - Write failed but r.pending has already diverged: the newer view +// is about to try anyway, so just re-enqueue immediately with +// attempt=0 to reset the backoff. +func (r *registryBehavior) handlePersistRecordResult(ctx context.Context, + req *persistRecordResultMsg) fn.Result[RegistryResp] { + + delete(r.persisting, req.Outpoint) + + if req.Err == "" { + record, ok := r.pending[req.Outpoint] + if ok && sameRegistryRecord(record, req.Record) { + delete(r.pending, req.Outpoint) + } else if ok { + r.requestPersist(req.Outpoint, 0) + } + + return fn.Ok[RegistryResp](&RegistryAckResp{}) + } + + err := fmt.Errorf("%s", req.Err) + r.log.WarnS(ctx, "Failed to persist unroll record", err, + slog.String("outpoint", req.Outpoint.String()), + slog.Int("attempt", req.Attempt+1), + slog.String("phase", string(req.Record.Phase)), + ) + + record, ok := r.pending[req.Outpoint] + if ok && sameRegistryRecord(record, req.Record) { + r.schedulePersistRetry(req.Outpoint, req.Attempt+1) + } else if ok { + r.requestPersist(req.Outpoint, 0) + } + + return fn.Ok[RegistryResp](&RegistryAckResp{}) +} + +// spawn creates one per-target unroll actor. +func (r *registryBehavior) spawn(ctx context.Context, + target wire.OutPoint) (*VTXOUnrollActor, error) { + + if r.spawnFunc != nil { + return r.spawnFunc(ctx, target) + } + + return NewVTXOUnrollActor(Config{ + TargetOutpoint: target, + DeliveryStore: r.cfg.DeliveryStore, + ProofAssembler: r.cfg.ProofAssembler, + VTXOStore: r.cfg.VTXOStore, + TxConfirmRef: r.cfg.TxConfirmRef, + ChainSource: r.cfg.ChainSource, + Wallet: r.cfg.Wallet, + Log: r.cfg.Log, + MaxSweepFeeRateSatPerVByte: r.cfg.MaxSweepFeeRateSatPerVByte, + RegistryRef: r.selfRef, + }) +} + +// queryBestHeight queries the current best height from chainsource. +func (r *registryBehavior) queryBestHeight(ctx context.Context) (int32, error) { + resp, err := r.cfg.ChainSource.Ask( + ctx, &chainsource.BestHeightRequest{}, + ).Await(ctx).Unpack() + if err != nil { + return 0, err + } + + bestHeight, ok := resp.(*chainsource.BestHeightResponse) + if !ok { + return 0, fmt.Errorf("unexpected best height response %T", resp) + } + + return bestHeight.Height, nil +} + +// childState reads the detailed state from one active child actor. +func (r *registryBehavior) childState(ctx context.Context, + child *VTXOUnrollActor) (*GetStateResp, error) { + + resp, err := child.Ref().Ask( + ctx, &GetStateRequest{}, + ).Await(ctx).Unpack() + if err != nil { + return nil, err + } + + state, ok := resp.(*GetStateResp) + if !ok { + return nil, fmt.Errorf( + "unexpected child state response %T", resp, + ) + } + + return state, nil +} + +// recordForPersistence snapshots the latest control-plane view for one target. +func (r *registryBehavior) recordForPersistence(ctx context.Context, + target wire.OutPoint) (RegistryRecord, bool, error) { + + if record, ok := r.pending[target]; ok { + return cloneRegistryRecord(record), true, nil + } + + child, ok := r.active[target] + if !ok { + return RegistryRecord{}, false, nil + } + + state, err := r.childState(ctx, child) + if err != nil { + return RegistryRecord{}, false, fmt.Errorf( + "read child state: %w", err, + ) + } + + record := recordFromChildState(target, child.Ref().ID(), state) + + return record, true, nil +} + +// persistRecordAsync writes one record on a background goroutine and reports +// the result back to the registry actor. +func (r *registryBehavior) persistRecordAsync(target wire.OutPoint, + attempt int, record RegistryRecord) { + + go func() { + cloned := cloneRegistryRecord(record) + err := r.cfg.Store.UpsertRecord( + context.Background(), cloned, + ) + + result := &persistRecordResultMsg{ + Outpoint: target, + Attempt: attempt, + Record: cloned, + } + if err != nil { + result.Err = err.Error() + } + + _ = r.selfRef.Tell(context.Background(), result) + }() +} + +// requestPersist enqueues one immediate persistence attempt for the target. +func (r *registryBehavior) requestPersist(target wire.OutPoint, + attempt int) { + + _ = r.selfRef.Tell(context.Background(), &persistActiveRecordMsg{ + Outpoint: target, + Attempt: attempt, + }) +} + +// schedulePersistRetry enqueues one delayed retry for record persistence. +func (r *registryBehavior) schedulePersistRetry(target wire.OutPoint, + attempt int) { + + delay := persistRetryDelay(attempt) + + time.AfterFunc(delay, func() { + msg := &persistActiveRecordMsg{ + Outpoint: target, + Attempt: attempt, + } + _ = r.selfRef.Tell(context.Background(), msg) + }) +} + +// persistRetryDelay computes exponential backoff for persistence +// retries, clamped at maxPersistRetryDelay. The first retry waits +// initialPersistRetryDelay (250ms) and each subsequent attempt doubles +// up to the cap (5s). This is deliberately conservative: terminal +// records are idempotent and not time-sensitive, so we favor low store +// pressure over fast convergence. +func persistRetryDelay(attempt int) time.Duration { + delay := initialPersistRetryDelay + for i := 0; i < attempt; i++ { + if delay >= maxPersistRetryDelay/2 { + return maxPersistRetryDelay + } + + delay *= 2 + } + + return delay +} + +// recordFromChildState converts one live child snapshot into a registry +// record. +func recordFromChildState(target wire.OutPoint, actorID string, + state *GetStateResp) RegistryRecord { + + return RegistryRecord{ + TargetOutpoint: target, + ActorID: actorID, + Trigger: state.Trigger, + Phase: state.Phase, + FailReason: state.FailReason, + SweepTxid: copyHash(state.SweepTxid), + } +} + +// cloneRegistryRecord deep-copies one registry record. +func cloneRegistryRecord(record RegistryRecord) RegistryRecord { + record.SweepTxid = copyHash(record.SweepTxid) + return record +} + +// sameRegistryRecord reports whether two registry records carry the same +// control-plane snapshot. +func sameRegistryRecord(a, b RegistryRecord) bool { + if a.TargetOutpoint != b.TargetOutpoint || + a.ActorID != b.ActorID || + a.Trigger != b.Trigger || + a.Phase != b.Phase || + a.FailReason != b.FailReason { + + return false + } + + switch { + case a.SweepTxid == nil && b.SweepTxid == nil: + return true + + case a.SweepTxid == nil || b.SweepTxid == nil: + return false + + default: + return *a.SweepTxid == *b.SweepTxid + } +} diff --git a/unroll/registry_messages.go b/unroll/registry_messages.go new file mode 100644 index 000000000..8b69d96e8 --- /dev/null +++ b/unroll/registry_messages.go @@ -0,0 +1,155 @@ +package unroll + +import ( + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/baselib/actor" +) + +// RegistryMsg is the sealed message surface accepted by the unroll registry. +type RegistryMsg interface { + actor.Message + registryMsgSealed() +} + +// RegistryResp is the sealed response surface returned by the unroll +// registry. +type RegistryResp interface { + actor.Message + registryRespSealed() +} + +// EnsureUnrollRequest asks the registry to ensure one target has a running +// unroll actor. +type EnsureUnrollRequest struct { + actor.BaseMessage + + // Outpoint identifies the target VTXO to unroll. + Outpoint wire.OutPoint + + // Trigger identifies why the unroll was requested. + Trigger StartTrigger +} + +// MessageType returns the stable message type identifier. +func (m *EnsureUnrollRequest) MessageType() string { + return "EnsureUnrollRequest" +} + +// registryMsgSealed seals EnsureUnrollRequest into the registry surface. +func (m *EnsureUnrollRequest) registryMsgSealed() {} + +// EnsureUnrollResp acknowledges an EnsureUnrollRequest. +type EnsureUnrollResp struct { + actor.BaseMessage + + // ActorID is the spawned or existing per-target actor ID. + ActorID string + + // Created reports whether this request created a new running actor. + Created bool +} + +// MessageType returns the stable message type identifier. +func (m *EnsureUnrollResp) MessageType() string { + return "EnsureUnrollResp" +} + +// registryRespSealed seals EnsureUnrollResp into the registry surface. +func (m *EnsureUnrollResp) registryRespSealed() {} + +// GetStatusRequest asks the registry for one target's current status. +type GetStatusRequest struct { + actor.BaseMessage + + // Outpoint identifies the target VTXO. + Outpoint wire.OutPoint +} + +// MessageType returns the stable message type identifier. +func (m *GetStatusRequest) MessageType() string { + return "GetStatusRequest" +} + +// registryMsgSealed seals GetStatusRequest into the registry surface. +func (m *GetStatusRequest) registryMsgSealed() {} + +// GetStatusResp reports one target's current status. +type GetStatusResp struct { + actor.BaseMessage + + // Found reports whether the target exists in the registry view. + Found bool + + // Active reports whether the target currently has a running actor. + Active bool + + // ActorID is the durable actor ID when known. + ActorID string + + // State is the detailed child state when an active actor was queried. + State *GetStateResp + + // Phase is the last known coarse phase when no active child was + // queried. + Phase Phase + + // Trigger is the original start trigger when known. + Trigger StartTrigger + + // FailReason is the last known terminal failure when present. + FailReason string + + // SweepTxid is the last known sweep txid when present. + SweepTxid *chainhash.Hash +} + +// MessageType returns the stable message type identifier. +func (m *GetStatusResp) MessageType() string { + return "GetStatusResp" +} + +// registryRespSealed seals GetStatusResp into the registry surface. +func (m *GetStatusResp) registryRespSealed() {} + +// UnrollTerminatedMsg notifies the registry that one child actor reached a +// terminal state. +type UnrollTerminatedMsg struct { + actor.BaseMessage + + // Outpoint identifies the target VTXO. + Outpoint wire.OutPoint + + // ActorID identifies the child actor instance. + ActorID string + + // Phase is the terminal phase reached by the actor. + Phase Phase + + // FailReason is populated for terminal failures. + FailReason string + + // SweepTxid is populated when the actor built a sweep transaction. + SweepTxid *chainhash.Hash +} + +// MessageType returns the stable message type identifier. +func (m *UnrollTerminatedMsg) MessageType() string { + return "UnrollTerminatedMsg" +} + +// registryMsgSealed seals UnrollTerminatedMsg into the registry surface. +func (m *UnrollTerminatedMsg) registryMsgSealed() {} + +// RegistryAckResp is a generic acknowledgement response. +type RegistryAckResp struct { + actor.BaseMessage +} + +// MessageType returns the stable message type identifier. +func (m *RegistryAckResp) MessageType() string { + return "RegistryAckResp" +} + +// registryRespSealed seals RegistryAckResp into the registry surface. +func (m *RegistryAckResp) registryRespSealed() {} diff --git a/unroll/registry_test.go b/unroll/registry_test.go new file mode 100644 index 000000000..7056ea5d8 --- /dev/null +++ b/unroll/registry_test.go @@ -0,0 +1,892 @@ +package unroll + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/darepo-client/lib/recovery" + "github.com/lightninglabs/darepo-client/unrollplan" + "github.com/lightninglabs/darepo-client/vtxo" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// memRegistryStore is a minimal in-memory registry control-plane store. +type memRegistryStore struct { + mu sync.Mutex + records map[wire.OutPoint]RegistryRecord +} + +// newMemRegistryStore creates a new in-memory registry store. +func newMemRegistryStore() *memRegistryStore { + return &memRegistryStore{ + records: make(map[wire.OutPoint]RegistryRecord), + } +} + +// UpsertRecord stores one registry record. +func (s *memRegistryStore) UpsertRecord(_ context.Context, + record RegistryRecord) error { + + s.mu.Lock() + defer s.mu.Unlock() + + s.records[record.TargetOutpoint] = cloneRegistryRecord(record) + + return nil +} + +// GetRecord returns one registry record when present. +func (s *memRegistryStore) GetRecord(_ context.Context, + target wire.OutPoint) (*RegistryRecord, error) { + + s.mu.Lock() + defer s.mu.Unlock() + + record, ok := s.records[target] + if !ok { + return nil, nil + } + + cloned := cloneRegistryRecord(record) + + return &cloned, nil +} + +// ListNonTerminalRecords returns all non-terminal records. +func (s *memRegistryStore) ListNonTerminalRecords( + _ context.Context) ([]RegistryRecord, error) { + + s.mu.Lock() + defer s.mu.Unlock() + + result := make([]RegistryRecord, 0, len(s.records)) + for _, record := range s.records { + if record.IsTerminal() { + continue + } + + result = append(result, cloneRegistryRecord(record)) + } + + return result, nil +} + +// MarkTerminal records one terminal phase. +func (s *memRegistryStore) MarkTerminal(_ context.Context, + target wire.OutPoint, phase Phase, failReason string, + sweepTxid *chainhash.Hash) error { + + s.mu.Lock() + defer s.mu.Unlock() + + record := s.records[target] + record.TargetOutpoint = target + record.Phase = phase + record.FailReason = failReason + record.SweepTxid = copyHash(sweepTxid) + s.records[target] = record + + return nil +} + +// flakyRegistryStore fails a configured number of initial upserts, then +// behaves like the in-memory store. +type flakyRegistryStore struct { + *memRegistryStore + + mu sync.Mutex + upsertErrors int +} + +// newFlakyRegistryStore creates a store that fails the first n upserts. +func newFlakyRegistryStore(upsertErrors int) *flakyRegistryStore { + return &flakyRegistryStore{ + memRegistryStore: newMemRegistryStore(), + upsertErrors: upsertErrors, + } +} + +// UpsertRecord stores one registry record unless this store is still +// configured to fail. +func (s *flakyRegistryStore) UpsertRecord(ctx context.Context, + record RegistryRecord) error { + + s.mu.Lock() + if s.upsertErrors > 0 { + s.upsertErrors-- + s.mu.Unlock() + + return errors.New("injected upsert failure") + } + s.mu.Unlock() + + return s.memRegistryStore.UpsertRecord(ctx, record) +} + +// terminalFlakyRegistryStore fails a configured number of terminal-phase +// upserts, then behaves like the in-memory store. Non-terminal writes +// always succeed so the fail-closed admission path is not interfered +// with; only the terminal retry loop is exercised. +type terminalFlakyRegistryStore struct { + *memRegistryStore + + mu sync.Mutex + upsertErrors int +} + +// newTerminalFlakyRegistryStore creates a store that fails the first n +// terminal-phase upserts. +func newTerminalFlakyRegistryStore( + upsertErrors int) *terminalFlakyRegistryStore { + + return &terminalFlakyRegistryStore{ + memRegistryStore: newMemRegistryStore(), + upsertErrors: upsertErrors, + } +} + +// UpsertRecord stores non-terminal records inline and fails the first N +// terminal upserts so the registry's terminal retry path is exercised. +func (s *terminalFlakyRegistryStore) UpsertRecord(ctx context.Context, + record RegistryRecord) error { + + if !record.IsTerminal() { + return s.memRegistryStore.UpsertRecord(ctx, record) + } + + s.mu.Lock() + if s.upsertErrors > 0 { + s.upsertErrors-- + s.mu.Unlock() + + return errors.New("injected terminal upsert failure") + } + s.mu.Unlock() + + return s.memRegistryStore.UpsertRecord(ctx, record) +} + +// alwaysFailUpsertRegistryStore rejects every terminal-phase upsert while +// keeping the non-terminal initial write and all read paths backed by the +// in-memory store. This matches the fail-closed admission contract that the +// registry now enforces on EnsureUnroll: the initial record must land +// durably for the admission to succeed, but subsequent terminal updates +// flow through the async retry path so tests can exercise the retry loop. +type alwaysFailUpsertRegistryStore struct { + *memRegistryStore +} + +// newAlwaysFailUpsertRegistryStore creates a store that rejects every +// terminal-phase upsert while letting the initial Pending write succeed. +func newAlwaysFailUpsertRegistryStore() *alwaysFailUpsertRegistryStore { + return &alwaysFailUpsertRegistryStore{ + memRegistryStore: newMemRegistryStore(), + } +} + +// UpsertRecord lets non-terminal writes through and fails every terminal +// write so the registry's async retry machinery is exercised without +// preventing the fail-closed admission write from succeeding. +func (s *alwaysFailUpsertRegistryStore) UpsertRecord(ctx context.Context, + record RegistryRecord) error { + + if !record.IsTerminal() { + return s.memRegistryStore.UpsertRecord(ctx, record) + } + + return errors.New("injected upsert failure") +} + +// blockingRegistryStore holds terminal-phase upserts until released so tests +// can verify that registry status remains available while persistence is +// stalled. Non-terminal writes (including the fail-closed admission write +// in EnsureUnroll) pass through immediately so spawning still completes. +type blockingRegistryStore struct { + *memRegistryStore + + started chan struct{} + release chan struct{} + once sync.Once +} + +// newBlockingRegistryStore creates a store with a gate around terminal-phase +// UpsertRecord calls; non-terminal writes proceed inline. +func newBlockingRegistryStore() *blockingRegistryStore { + return &blockingRegistryStore{ + memRegistryStore: newMemRegistryStore(), + started: make(chan struct{}), + release: make(chan struct{}), + } +} + +// UpsertRecord passes non-terminal writes straight through and waits on the +// release gate for terminal writes so tests can observe the registry while +// the terminal persist is stalled. +func (s *blockingRegistryStore) UpsertRecord(ctx context.Context, + record RegistryRecord) error { + + if !record.IsTerminal() { + return s.memRegistryStore.UpsertRecord(ctx, record) + } + + s.once.Do(func() { + close(s.started) + }) + + select { + case <-s.release: + case <-ctx.Done(): + return ctx.Err() + } + + return s.memRegistryStore.UpsertRecord(ctx, record) +} + +// fakeRegistryChainSourceRef is a minimal chainsource actor ref for registry +// tests. +type fakeRegistryChainSourceRef struct { + height int32 +} + +// ID returns the fake actor ID. +func (f *fakeRegistryChainSourceRef) ID() string { + return "fake-registry-chain" +} + +// Tell is unused by registry tests. +func (f *fakeRegistryChainSourceRef) Tell(_ context.Context, + msg chainsource.ChainSourceMsg) error { + + switch msg.(type) { + case *chainsource.UnsubscribeBlocksRequest: + return nil + case *chainsource.UnregisterSpendRequest: + return nil + } + + return nil +} + +// Ask returns fixed best-height and fee-estimate responses. +func (f *fakeRegistryChainSourceRef) Ask(_ context.Context, + msg chainsource.ChainSourceMsg, +) actor.Future[chainsource.ChainSourceResp] { + + promise := actor.NewPromise[chainsource.ChainSourceResp]() + + switch msg.(type) { + case *chainsource.BestHeightRequest: + promise.Complete(fn.Ok[chainsource.ChainSourceResp]( + &chainsource.BestHeightResponse{Height: f.height}, + )) + + case *chainsource.FeeEstimateRequest: + promise.Complete(fn.Ok[chainsource.ChainSourceResp]( + &chainsource.FeeEstimateResponse{SatPerVByte: 5}, + )) + + case *chainsource.SubscribeBlocksRequest: + promise.Complete(fn.Ok[chainsource.ChainSourceResp]( + &chainsource.SubscribeBlocksResponse{}, + )) + + case *chainsource.RegisterSpendRequest: + promise.Complete(fn.Ok[chainsource.ChainSourceResp]( + &chainsource.RegisterSpendResponse{}, + )) + + default: + promise.Complete(fn.Err[chainsource.ChainSourceResp]( + fmt.Errorf("unexpected chainsource msg %T", msg), + )) + } + + return promise.Future() +} + +// newRegistryHarness creates a running registry actor with real child actors. +func newRegistryHarness(t *testing.T, proof *recovery.Proof, + desc *vtxo.Descriptor) (*UnrollRegistryActor, *memRegistryStore, + *memCheckpointStore, *fakeTxConfirmRef) { + + t.Helper() + + store := newMemRegistryStore() + checkpoints := newMemCheckpointStore() + txconfirmRef := &fakeTxConfirmRef{} + + cfg := RegistryConfig{ + Store: store, + DeliveryStore: checkpoints, + ProofAssembler: &mockProofAssembler{proof: proof}, + VTXOStore: &mockVTXOStore{desc: desc}, + TxConfirmRef: txconfirmRef, + ChainSource: &fakeRegistryChainSourceRef{height: 200}, + Wallet: &fakeSweepWallet{}, + } + registry := newRegistryHarnessWithSpawn(t, cfg) + t.Cleanup(registry.Stop) + + return registry, store, checkpoints, txconfirmRef +} + +// newRegistryHarnessWithSpawn creates a registry actor whose child-spawn path +// uses plain in-memory actors so unit tests do not depend on a full durable +// mailbox implementation. +func newRegistryHarnessWithSpawn(t *testing.T, + cfg RegistryConfig) *UnrollRegistryActor { + + t.Helper() + + regBehavior := ®istryBehavior{ + cfg: cfg, + log: btclog.Disabled, + active: make(map[wire.OutPoint]*VTXOUnrollActor), + pending: make(map[wire.OutPoint]RegistryRecord), + persisting: make(map[wire.OutPoint]RegistryRecord), + } + registryActor := actor.NewActor(actor.ActorConfig[ + RegistryMsg, RegistryResp, + ]{ + ID: "unroll-registry-test", + Behavior: regBehavior, + MailboxSize: 64, + }) + regBehavior.selfRef = registryActor.TellRef() + + regBehavior.spawnFunc = func(_ context.Context, + target wire.OutPoint) (*VTXOUnrollActor, error) { + + maxFee := cfg.MaxSweepFeeRateSatPerVByte + childCfg := Config{ + TargetOutpoint: target, + ActorID: actorIDForTarget(target), + DeliveryStore: cfg.DeliveryStore, + ProofAssembler: cfg.ProofAssembler, + VTXOStore: cfg.VTXOStore, + TxConfirmRef: cfg.TxConfirmRef, + ChainSource: cfg.ChainSource, + Wallet: cfg.Wallet, + RegistryRef: registryActor.TellRef(), + MaxSweepFeeRateSatPerVByte: maxFee, + } + childBehavior := &behavior{ + cfg: childCfg, + log: btclog.Disabled, + } + err := childBehavior.restoreCheckpoint(t.Context()) + if err != nil { + return nil, err + } + + childActor := actor.NewActor(actor.ActorConfig[Msg, Resp]{ + ID: actorIDForTarget(target), + Behavior: childBehavior, + MailboxSize: 64, + }) + childBehavior.selfRef = childActor.TellRef() + childActor.Start() + + return &VTXOUnrollActor{ + ref: childActor.Ref(), + stop: childActor.Stop, + }, nil + } + + registryActor.Start() + + return &UnrollRegistryActor{ + ref: registryActor.Ref(), + registry: registryActor, + behavior: regBehavior, + } +} + +// TestRegistryEnsureDedupesSameTarget verifies that the registry creates one +// actor per target and deduplicates repeated starts. +func TestRegistryEnsureDedupesSameTarget(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + registry, store, _, txconfirmRef := newRegistryHarness(t, proof, desc) + + resp, err := registry.Ref().Ask(t.Context(), &EnsureUnrollRequest{ + Outpoint: proof.TargetOutpoint(), + Trigger: TriggerManual, + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + ensureResp, ok := resp.(*EnsureUnrollResp) + require.True(t, ok) + require.True(t, ensureResp.Created) + require.Equal(t, 1, txconfirmRef.requestCount()) + + resp, err = registry.Ref().Ask(t.Context(), &EnsureUnrollRequest{ + Outpoint: proof.TargetOutpoint(), + Trigger: TriggerManual, + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + ensureResp, ok = resp.(*EnsureUnrollResp) + require.True(t, ok) + require.False(t, ensureResp.Created) + require.Equal(t, 1, txconfirmRef.requestCount()) + + record, err := store.GetRecord(t.Context(), proof.TargetOutpoint()) + require.NoError(t, err) + require.NotNil(t, record) + require.Equal(t, PhaseMaterializing, record.Phase) +} + +// TestRegistryTerminalNotificationMarksStore verifies that terminal child +// notifications clear the active map and persist terminal control-plane state. +func TestRegistryTerminalNotificationMarksStore(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + registry, store, _, txconfirmRef := newRegistryHarness(t, proof, desc) + txconfirmRef.setImmediateFailed(proof.RootTxids()[0], "rejected") + + _, err := registry.Ref().Ask(t.Context(), &EnsureUnrollRequest{ + Outpoint: proof.TargetOutpoint(), + Trigger: TriggerManual, + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + require.Eventually(t, func() bool { + resp, err := registry.Ref().Ask( + t.Context(), &GetStatusRequest{ + Outpoint: proof.TargetOutpoint(), + }, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + + status, ok := resp.(*GetStatusResp) + require.True(t, ok) + + return status.Found && !status.Active && + status.Phase == PhaseFailed + }, testTimeout, 10*time.Millisecond) + + require.Eventually(t, func() bool { + record, err := store.GetRecord( + t.Context(), proof.TargetOutpoint(), + ) + require.NoError(t, err) + + return record != nil && + record.Phase == PhaseFailed && + record.FailReason != "" + }, testTimeout, 10*time.Millisecond) + + record, err := store.GetRecord(t.Context(), proof.TargetOutpoint()) + require.NoError(t, err) + require.NotNil(t, record) + require.Contains(t, record.FailReason, "proof tx") + + // A repeat EnsureUnroll for the same outpoint after termination + // must not spawn a fresh actor or clobber the stored failure + // reason; it should return Created=false pointing at the existing + // actor id so the caller can observe the terminal record. + storedActorID := record.ActorID + + resp, err := registry.Ref().Ask(t.Context(), &EnsureUnrollRequest{ + Outpoint: proof.TargetOutpoint(), + Trigger: TriggerManual, + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + ensureResp, ok := resp.(*EnsureUnrollResp) + require.True(t, ok) + require.False(t, ensureResp.Created) + require.Equal(t, storedActorID, ensureResp.ActorID) + + after, err := store.GetRecord(t.Context(), proof.TargetOutpoint()) + require.NoError(t, err) + require.NotNil(t, after) + require.Equal(t, PhaseFailed, after.Phase) + require.Contains(t, after.FailReason, "proof tx") +} + +// TestRegistryRestoreNonTerminal verifies that the registry respawns and +// resumes non-terminal records from the control-plane store. +func TestRegistryRestoreNonTerminal(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + store := newMemRegistryStore() + checkpoints := newMemCheckpointStore() + txconfirmRef := &fakeTxConfirmRef{} + + raw, err := encodeCheckpoint(&actorCheckpoint{ + Version: checkpointVersion, + Height: 150, + Started: true, + Trigger: TriggerRestart, + State: unrollplan.State{ + InFlightTxids: []chainhash.Hash{proof.RootTxids()[0]}, + }, + }) + require.NoError(t, err) + + actorID := actorIDForTarget(proof.TargetOutpoint()) + err = checkpoints.SaveCheckpoint(t.Context(), actor.CheckpointParams{ + ActorID: actorID, + StateType: checkpointStateType, + StateData: raw, + Version: checkpointVersion, + }) + require.NoError(t, err) + + err = store.UpsertRecord(t.Context(), RegistryRecord{ + TargetOutpoint: proof.TargetOutpoint(), + ActorID: actorID, + Trigger: TriggerRestart, + Phase: PhaseMaterializing, + }) + require.NoError(t, err) + + registry := newRegistryHarnessWithSpawn(t, RegistryConfig{ + Store: store, + DeliveryStore: checkpoints, + ProofAssembler: &mockProofAssembler{proof: proof}, + VTXOStore: &mockVTXOStore{desc: desc}, + TxConfirmRef: txconfirmRef, + ChainSource: &fakeRegistryChainSourceRef{height: 201}, + Wallet: &fakeSweepWallet{}, + }) + t.Cleanup(registry.Stop) + + err = registry.RestoreNonTerminal(t.Context()) + require.NoError(t, err) + + require.Eventually(t, func() bool { + txid := proof.RootTxids()[0] + + return txconfirmRef.requestCountForTxid(txid) == 1 + }, testTimeout, 10*time.Millisecond) + + resp, err := registry.Ref().Ask(t.Context(), &GetStatusRequest{ + Outpoint: proof.TargetOutpoint(), + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + status, ok := resp.(*GetStatusResp) + require.True(t, ok) + require.True(t, status.Found) + require.True(t, status.Active) + require.Equal(t, PhaseMaterializing, status.Phase) +} + +// TestRegistryEnsureFailsClosedOnInitialPersistFailure verifies the +// fail-closed admission contract: if the initial control-plane upsert +// fails, EnsureUnroll surfaces that error, no child is left in the +// active map, and the caller can retry once the store is healthy. +// Prior behavior returned Created=true even when the record was not yet +// durable, opening a crash window where a child would be orphaned on +// restart (RestoreNonTerminal only walks the durable store). +func TestRegistryEnsureFailsClosedOnInitialPersistFailure(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + + // Fail the very first UpsertRecord call, then succeed. + store := newFlakyRegistryStore(1) + checkpoints := newMemCheckpointStore() + txconfirmRef := &fakeTxConfirmRef{} + + registry := newRegistryHarnessWithSpawn(t, RegistryConfig{ + Store: store, + DeliveryStore: checkpoints, + ProofAssembler: &mockProofAssembler{proof: proof}, + VTXOStore: &mockVTXOStore{desc: desc}, + TxConfirmRef: txconfirmRef, + ChainSource: &fakeRegistryChainSourceRef{height: 200}, + Wallet: &fakeSweepWallet{}, + }) + t.Cleanup(registry.Stop) + + // The admission write hits the injected failure so EnsureUnroll must + // surface that error rather than return Created=true with an + // unpersisted in-memory child. + _, err := registry.Ref().Ask(t.Context(), &EnsureUnrollRequest{ + Outpoint: proof.TargetOutpoint(), + Trigger: TriggerManual, + }).Await(t.Context()).Unpack() + require.Error(t, err) + require.Contains(t, err.Error(), "persist unroll record") + + // No record should have been persisted and no child should remain + // accessible via status queries. + record, err := store.GetRecord(t.Context(), proof.TargetOutpoint()) + require.NoError(t, err) + require.Nil(t, record) + + statusResp, err := registry.Ref().Ask(t.Context(), &GetStatusRequest{ + Outpoint: proof.TargetOutpoint(), + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + gotStatus, ok := statusResp.(*GetStatusResp) + require.True(t, ok) + require.False(t, gotStatus.Found) + + // A retry against the now-healthy store must succeed and land a + // durable record so RestoreNonTerminal could pick it up on reboot. + resp, err := registry.Ref().Ask(t.Context(), &EnsureUnrollRequest{ + Outpoint: proof.TargetOutpoint(), + Trigger: TriggerManual, + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + ensureResp, ok := resp.(*EnsureUnrollResp) + require.True(t, ok) + require.True(t, ensureResp.Created) + + require.Eventually(t, func() bool { + record, err := store.GetRecord( + t.Context(), proof.TargetOutpoint(), + ) + require.NoError(t, err) + + return record != nil + }, testTimeout, 10*time.Millisecond) +} + +// TestRegistryEnsurePersistsBeforeAck locks in the invariant that the +// control-plane record is durable in the store before EnsureUnroll +// returns Created=true, so a crash immediately after admission does not +// orphan the child on restart (RestoreNonTerminal reads from the durable +// store, not in-memory state). +func TestRegistryEnsurePersistsBeforeAck(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + registry, store, _, _ := newRegistryHarness(t, proof, desc) + + resp, err := registry.Ref().Ask(t.Context(), &EnsureUnrollRequest{ + Outpoint: proof.TargetOutpoint(), + Trigger: TriggerManual, + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + ensureResp, ok := resp.(*EnsureUnrollResp) + require.True(t, ok) + require.True(t, ensureResp.Created) + + // The record must already be durable at this point; no Eventually + // loop: a sync-persist regression would show up as a transient + // absence here. + record, err := store.GetRecord(t.Context(), proof.TargetOutpoint()) + require.NoError(t, err) + require.NotNil(t, record) + require.Equal(t, ensureResp.ActorID, record.ActorID) + require.Equal(t, TriggerManual, record.Trigger) +} + +// TestRegistryTerminalPersistRetriesUntilDurable verifies that a +// terminal record that transiently fails to persist is retried by the +// async writer loop and eventually lands in the control-plane store. +// The fail-closed admission contract handles the initial Pending write +// synchronously, but terminal updates stay on the retry path so a flaky +// store on the terminal write does not lose the failure record. +func TestRegistryTerminalPersistRetriesUntilDurable(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + + // Fail the first TERMINAL upsert, then succeed. The non-terminal + // admission write is never failed so the child boots cleanly and + // the fail-closed admission contract is unaffected. + store := newTerminalFlakyRegistryStore(1) + checkpoints := newMemCheckpointStore() + txconfirmRef := &fakeTxConfirmRef{} + + registry := newRegistryHarnessWithSpawn(t, RegistryConfig{ + Store: store, + DeliveryStore: checkpoints, + ProofAssembler: &mockProofAssembler{proof: proof}, + VTXOStore: &mockVTXOStore{desc: desc}, + TxConfirmRef: txconfirmRef, + ChainSource: &fakeRegistryChainSourceRef{height: 200}, + Wallet: &fakeSweepWallet{}, + }) + t.Cleanup(registry.Stop) + + _, err := registry.Ref().Ask(t.Context(), &EnsureUnrollRequest{ + Outpoint: proof.TargetOutpoint(), + Trigger: TriggerManual, + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + // Drive the child to terminal AFTER admission so the first terminal + // upsert hits the injected failure and the retry path must land + // the record. + txconfirmRef.emitFailed(t, 0, proof.RootTxids()[0], "rejected") + + require.Eventually(t, func() bool { + record, err := store.GetRecord( + t.Context(), proof.TargetOutpoint(), + ) + require.NoError(t, err) + + return record != nil && + record.Phase == PhaseFailed && + record.FailReason != "" + }, testTimeout, 10*time.Millisecond) + + record, err := store.GetRecord(t.Context(), proof.TargetOutpoint()) + require.NoError(t, err) + require.NotNil(t, record) + require.Contains(t, record.FailReason, "rejected") +} + +// TestRegistryStatusFallsBackToPendingTerminalRecord verifies that the +// registry can still answer status queries from memory after a child +// has terminated when every terminal control-plane upsert fails. The +// initial admission write is fail-closed, so the non-terminal phase +// upsert must succeed for admission to complete; only the terminal +// retries stay rejected, exercising the in-memory pending fallback. +func TestRegistryStatusFallsBackToPendingTerminalRecord(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + store := newAlwaysFailUpsertRegistryStore() + checkpoints := newMemCheckpointStore() + txconfirmRef := &fakeTxConfirmRef{} + + registry := newRegistryHarnessWithSpawn(t, RegistryConfig{ + Store: store, + DeliveryStore: checkpoints, + ProofAssembler: &mockProofAssembler{proof: proof}, + VTXOStore: &mockVTXOStore{desc: desc}, + TxConfirmRef: txconfirmRef, + ChainSource: &fakeRegistryChainSourceRef{height: 200}, + Wallet: &fakeSweepWallet{}, + }) + t.Cleanup(registry.Stop) + + _, err := registry.Ref().Ask(t.Context(), &EnsureUnrollRequest{ + Outpoint: proof.TargetOutpoint(), + Trigger: TriggerManual, + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + // Drive the child to terminal AFTER admission so only the terminal + // upsert hits the always-fail injection. + txconfirmRef.emitFailed(t, 0, proof.RootTxids()[0], "rejected") + + require.Eventually(t, func() bool { + resp, err := registry.Ref().Ask( + t.Context(), &GetStatusRequest{ + Outpoint: proof.TargetOutpoint(), + }, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + + status, ok := resp.(*GetStatusResp) + require.True(t, ok) + if !status.Found || status.Active || + status.Phase != PhaseFailed { + + return false + } + + // The durable store reflects the initial admission write + // (non-terminal) because every terminal upsert is rejected + // by the injected store; the registry must still report + // PhaseFailed from the in-memory pending snapshot so callers + // see the real state despite the stalled writeback. + record, err := store.GetRecord( + t.Context(), proof.TargetOutpoint(), + ) + require.NoError(t, err) + + return record != nil && !record.IsTerminal() && + status.ActorID != "" && + status.FailReason != "" + }, testTimeout, 10*time.Millisecond) +} + +// TestRegistryTerminalStatusRemainsQueryableWhilePersistBlocked verifies +// that the registry can still report a fast terminal failure while the +// control store is blocked on persisting the terminal row. The initial +// admission write is fail-closed and non-terminal, so it passes through +// the blocking store inline; only the terminal write is gated. +func TestRegistryTerminalStatusRemainsQueryableWhilePersistBlocked( + t *testing.T) { + + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + store := newBlockingRegistryStore() + checkpoints := newMemCheckpointStore() + txconfirmRef := &fakeTxConfirmRef{} + + registry := newRegistryHarnessWithSpawn(t, RegistryConfig{ + Store: store, + DeliveryStore: checkpoints, + ProofAssembler: &mockProofAssembler{proof: proof}, + VTXOStore: &mockVTXOStore{desc: desc}, + TxConfirmRef: txconfirmRef, + ChainSource: &fakeRegistryChainSourceRef{height: 200}, + Wallet: &fakeSweepWallet{}, + }) + t.Cleanup(registry.Stop) + + resp, err := registry.Ref().Ask(t.Context(), &EnsureUnrollRequest{ + Outpoint: proof.TargetOutpoint(), + Trigger: TriggerManual, + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + ensureResp, ok := resp.(*EnsureUnrollResp) + require.True(t, ok) + require.True(t, ensureResp.Created) + + // Drive the child to terminal AFTER admission so only the terminal + // upsert is gated by the blocking store. + txconfirmRef.emitFailed(t, 0, proof.RootTxids()[0], "rejected") + + select { + case <-store.started: + case <-time.After(testTimeout): + t.Fatal("timed out waiting for blocked registry persist") + } + + require.Eventually(t, func() bool { + resp, err := registry.Ref().Ask( + t.Context(), &GetStatusRequest{ + Outpoint: proof.TargetOutpoint(), + }, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + + status, ok := resp.(*GetStatusResp) + require.True(t, ok) + + return status.Found && status.Phase == PhaseFailed + }, testTimeout, 10*time.Millisecond) + + close(store.release) + + require.Eventually(t, func() bool { + record, err := store.GetRecord( + t.Context(), proof.TargetOutpoint(), + ) + require.NoError(t, err) + + return record != nil && record.Phase == PhaseFailed + }, testTimeout, 10*time.Millisecond) +} + +var _ RegistryStore = (*memRegistryStore)(nil) +var _ RegistryStore = (*flakyRegistryStore)(nil) +var _ RegistryStore = (*terminalFlakyRegistryStore)(nil) +var _ RegistryStore = (*alwaysFailUpsertRegistryStore)(nil) +var _ RegistryStore = (*blockingRegistryStore)(nil) +var _ actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, +] = (*fakeRegistryChainSourceRef)(nil) diff --git a/unroll/session.go b/unroll/session.go new file mode 100644 index 000000000..b58a75bf2 --- /dev/null +++ b/unroll/session.go @@ -0,0 +1,66 @@ +package unroll + +import ( + "context" + "fmt" + + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/protofsm" + "github.com/lightninglabs/darepo-client/lib/recovery" + "github.com/lightninglabs/darepo-client/unrollplan" +) + +// Session groups a running unroll FSM with the immutable proof it executes. +type Session struct { + // Proof is the immutable recovery proof this session executes. + Proof *recovery.Proof + + // Planner is the pure planner bound to the immutable proof. + Planner *unrollplan.Planner + + // FSM is the running protofsm instance. + FSM *StateMachine +} + +// NewSession creates a new unroll FSM session with the provided initial state. +func NewSession(ctx context.Context, proof *recovery.Proof, + planner *unrollplan.Planner, initial State, + logger btclog.Logger) (*Session, error) { + + if proof == nil { + return nil, fmt.Errorf("proof must be provided") + } + + if planner == nil { + return nil, fmt.Errorf("planner must be provided") + } + + if initial == nil { + return nil, fmt.Errorf("initial state must be provided") + } + + if logger == nil { + logger = btclog.Disabled + } + + fsmCfg := protofsm.StateMachineCfg[Event, OutboxEvent, *Environment]{ + Logger: logger.WithPrefix(proof.TargetOutpoint().String()), + ErrorReporter: newContextErrorReporter( + ctx, logger, proof.TargetOutpoint().String(), + ), + InitialState: initial, + Env: &Environment{ + Proof: proof, + Planner: planner, + }, + } + + sm := protofsm.NewStateMachine(fsmCfg) + sm.Start(ctx) + + return &Session{ + Proof: proof, + Planner: planner, + FSM: &sm, + }, nil +} diff --git a/unroll/snapshot.go b/unroll/snapshot.go new file mode 100644 index 000000000..c7611496f --- /dev/null +++ b/unroll/snapshot.go @@ -0,0 +1,277 @@ +package unroll + +import ( + "bytes" + "fmt" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/unrollplan" + "github.com/lightningnetwork/lnd/tlv" +) + +// snapshot.go implements the TLV codec for the per-target unroll actor's +// durable checkpoint. +// +// A checkpoint captures every piece of mutable state the actor needs to +// resume its FSM after a restart: +// +// - Version: gates incompatible schema changes so a newer build +// refuses to decode rows it cannot reason about. +// - Height: the best chain height the actor had observed, so a resume +// starts with non-stale clock data. +// - Started / Trigger: whether the actor has left Idle, and why it +// was started (critical-expiry, manual, restart, fraud-spend). +// - State: the pure [unrollplan.State] payload — confirmed/in-flight +// txids, target confirm height, sweep status and txid. +// - SweepTx: the serialized final sweep. Persisted so restart +// re-submits the exact same bytes to txconfirm (txid-keyed dedup +// then makes the re-submit a benign no-op). Without byte-exact +// restoration we would risk broadcasting a differently-signed +// sweep with a fresh wallet pkScript. +// - Fail: the FailReason if the actor has reached terminal failure. +// - SweepAttempts: number of sweep build/broadcast failures so far, +// compared against maxSweepAttempts when deciding whether to keep +// retrying. +// +// TLV was picked over JSON for three reasons: schema evolution (new +// optional records slot in without breaking old readers), determinism +// (canonical ordering by record type means identical states encode to +// identical bytes — useful for equality checks and diffing), and +// compactness (sweep tx stays in its native wire format). + +const ( + checkpointStateType = "unroll.vtxo" + checkpointVersion = 1 +) + +// Outer record types for the actor checkpoint TLV stream. Odd type values are +// used throughout so that future extensions can slot even types in without +// breaking canonical encoding ordering. +const ( + // checkpointVersionRecordType carries the codec version byte. + checkpointVersionRecordType tlv.Type = 1 + + // checkpointHeightRecordType carries the best height tracked by the + // actor at checkpoint time. + checkpointHeightRecordType tlv.Type = 3 + + // checkpointStartedRecordType carries a 1-byte bool indicating whether + // the actor has started (i.e. left the Idle state). + checkpointStartedRecordType tlv.Type = 5 + + // checkpointTriggerRecordType carries the start trigger enum. + checkpointTriggerRecordType tlv.Type = 7 + + // checkpointStateRecordType carries the nested planner state bytes + // produced by unrollplan.EncodeState. + checkpointStateRecordType tlv.Type = 9 + + // checkpointSweepTxRecordType is optional; present only when a sweep + // transaction has been built. Payload is wire.MsgTx.Serialize bytes. + checkpointSweepTxRecordType tlv.Type = 11 + + // checkpointFailRecordType is optional; present only when the actor + // has recorded a failure reason. + checkpointFailRecordType tlv.Type = 13 + + // checkpointSweepAttemptsRecordType carries the cumulative count of + // sweep-build attempts. + checkpointSweepAttemptsRecordType tlv.Type = 15 +) + +// actorCheckpoint is the durable checkpoint shape for one VTXO unroll actor. +type actorCheckpoint struct { + Version uint8 + Height int32 + Started bool + Trigger StartTrigger + State unrollplan.State + SweepTx *wire.MsgTx + Fail string + SweepAttempts int +} + +// encodeCheckpoint serializes one actor checkpoint into canonical TLV +// bytes. +// +// Canonical here means: records always appear in ascending type order +// (enforced by tlv.Stream) and optional fields are omitted entirely +// when empty. This is what lets us compare "has anything changed since +// last checkpoint?" by byte equality in the registry's pending/persisted +// divergence check — two semantically-identical states encode to +// identical bytes. +// +// The sweep tx is serialized via wire.MsgTx.Serialize so the stored +// bytes are directly re-playable; we never re-derive the tx on restart. +func encodeCheckpoint(value *actorCheckpoint) ([]byte, error) { + if value == nil { + return nil, fmt.Errorf("checkpoint cannot be nil") + } + + version := value.Version + height := uint32(value.Height) + started := uint8(0) + if value.Started { + started = 1 + } + trigger := uint32(value.Trigger) + attempts := uint32(value.SweepAttempts) + + stateBytes, err := unrollplan.EncodeState(&value.State) + if err != nil { + return nil, fmt.Errorf("encode planner state: %w", err) + } + + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + checkpointVersionRecordType, &version, + ), + tlv.MakePrimitiveRecord( + checkpointHeightRecordType, &height, + ), + tlv.MakePrimitiveRecord( + checkpointStartedRecordType, &started, + ), + tlv.MakePrimitiveRecord( + checkpointTriggerRecordType, &trigger, + ), + tlv.MakePrimitiveRecord( + checkpointStateRecordType, &stateBytes, + ), + } + + if value.SweepTx != nil { + var sweepBuf bytes.Buffer + if err := value.SweepTx.Serialize(&sweepBuf); err != nil { + return nil, fmt.Errorf("serialize sweep tx: %w", err) + } + sweepBytes := sweepBuf.Bytes() + records = append(records, tlv.MakePrimitiveRecord( + checkpointSweepTxRecordType, &sweepBytes, + )) + } + + if value.Fail != "" { + failBytes := []byte(value.Fail) + records = append(records, tlv.MakePrimitiveRecord( + checkpointFailRecordType, &failBytes, + )) + } + + records = append(records, tlv.MakePrimitiveRecord( + checkpointSweepAttemptsRecordType, &attempts, + )) + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, fmt.Errorf("create checkpoint stream: %w", err) + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, fmt.Errorf("encode checkpoint: %w", err) + } + + return buf.Bytes(), nil +} + +// decodeCheckpoint parses TLV-encoded checkpoint bytes back into an +// actorCheckpoint. +// +// The decoder distinguishes missing-but-optional records (SweepTx, Fail) +// from missing-but-required records (Version) by consulting the parsed +// types map from tlv.Stream.DecodeWithParsedTypes. A missing Version +// field is a hard error: without it we cannot rule out rows written by +// an older schema that would silently decode into a partially populated +// struct. +// +// Unknown Version values are also rejected. A newer daemon starting +// against a store written by an even newer future daemon refuses to +// guess at forward-compat semantics, rather than quietly operating on +// truncated state. +func decodeCheckpoint(raw []byte) (*actorCheckpoint, error) { + var ( + version uint8 + height uint32 + started uint8 + trigger uint32 + stateBytes []byte + sweepBytes []byte + failBytes []byte + attempts uint32 + ) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord( + checkpointVersionRecordType, &version, + ), + tlv.MakePrimitiveRecord( + checkpointHeightRecordType, &height, + ), + tlv.MakePrimitiveRecord( + checkpointStartedRecordType, &started, + ), + tlv.MakePrimitiveRecord( + checkpointTriggerRecordType, &trigger, + ), + tlv.MakePrimitiveRecord( + checkpointStateRecordType, &stateBytes, + ), + tlv.MakePrimitiveRecord( + checkpointSweepTxRecordType, &sweepBytes, + ), + tlv.MakePrimitiveRecord( + checkpointFailRecordType, &failBytes, + ), + tlv.MakePrimitiveRecord( + checkpointSweepAttemptsRecordType, &attempts, + ), + ) + if err != nil { + return nil, fmt.Errorf("create checkpoint stream: %w", err) + } + + parsed, err := stream.DecodeWithParsedTypes(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("decode checkpoint: %w", err) + } + + if _, ok := parsed[checkpointVersionRecordType]; !ok { + return nil, fmt.Errorf("checkpoint missing version record") + } + if version != checkpointVersion { + return nil, fmt.Errorf("unsupported checkpoint version %d "+ + "(expected %d)", version, checkpointVersion) + } + + state, err := unrollplan.DecodeState(stateBytes) + if err != nil { + return nil, fmt.Errorf("decode planner state: %w", err) + } + + checkpoint := &actorCheckpoint{ + Version: version, + Height: int32(height), + Started: started != 0, + Trigger: StartTrigger(int32(trigger)), + State: *state, + SweepAttempts: int(attempts), + } + + if _, ok := parsed[checkpointSweepTxRecordType]; ok { + tx := wire.NewMsgTx(0) + err := tx.Deserialize(bytes.NewReader(sweepBytes)) + if err != nil { + return nil, fmt.Errorf( + "deserialize sweep tx: %w", err, + ) + } + checkpoint.SweepTx = tx + } + + if _, ok := parsed[checkpointFailRecordType]; ok { + checkpoint.Fail = string(failBytes) + } + + return checkpoint, nil +} diff --git a/unroll/snapshot_test.go b/unroll/snapshot_test.go new file mode 100644 index 000000000..d0cf5d333 --- /dev/null +++ b/unroll/snapshot_test.go @@ -0,0 +1,534 @@ +package unroll + +import ( + "bytes" + "fmt" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/unrollplan" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// TestEncodeCheckpointNilRejected verifies the guard at the top of +// encodeCheckpoint so a misuse surfaces with a clear error rather than a +// segfault. +func TestEncodeCheckpointNilRejected(t *testing.T) { + _, err := encodeCheckpoint(nil) + require.ErrorContains(t, err, "checkpoint cannot be nil") +} + +// TestCheckpointCodecRoundTripHandcrafted exercises the codec across the +// canonical shapes: fresh (idle), started without sweep, started with sweep +// tx, started with failure recorded. +func TestCheckpointCodecRoundTripHandcrafted(t *testing.T) { + targetTxid := hashFromByteCk(0xAA) + sweepTxid := hashFromByteCk(0xBB) + sweepTx := buildDummySweepTx(t, sweepTxid) + + cases := []struct { + name string + checkpoint *actorCheckpoint + }{ + { + name: "fresh_idle", + checkpoint: &actorCheckpoint{ + Version: checkpointVersion, + }, + }, + { + name: "started_materializing", + checkpoint: &actorCheckpoint{ + Version: checkpointVersion, + Height: 150, + Started: true, + Trigger: TriggerManual, + State: unrollplan.State{ + ConfirmedTxids: []chainhash.Hash{ + targetTxid, + }, + TargetConfirmHeight: fn.Some[int32]( + 150, + ), + }, + SweepAttempts: 0, + }, + }, + { + name: "sweep_broadcast", + checkpoint: &actorCheckpoint{ + Version: checkpointVersion, + Height: 200, + Started: true, + Trigger: TriggerRestart, + State: unrollplan.State{ + ConfirmedTxids: []chainhash.Hash{ + targetTxid, + }, + TargetConfirmHeight: fn.Some[int32]( + 180, + ), + Sweep: unrollplan.SweepState{ + Status: unrollplan. + SweepStatusBroadcasted, + Txid: fn.Some(sweepTxid), + }, + }, + SweepTx: sweepTx, + SweepAttempts: 1, + }, + }, + { + name: "failed", + checkpoint: &actorCheckpoint{ + Version: checkpointVersion, + Height: 123, + Started: true, + Trigger: TriggerManual, + State: unrollplan.State{ + ConfirmedTxids: []chainhash.Hash{ + targetTxid, + }, + }, + Fail: "broadcaster rejected tx", + SweepAttempts: 3, + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + raw, err := encodeCheckpoint(tc.checkpoint) + require.NoError(t, err) + + decoded, err := decodeCheckpoint(raw) + require.NoError(t, err) + requireCheckpointEqual(t, tc.checkpoint, decoded) + + // Canonical encoding: the second encode of the decoded + // value must match the first encode byte-for-byte. + raw2, err := encodeCheckpoint(decoded) + require.NoError(t, err) + require.True(t, bytes.Equal(raw, raw2), + "encoding must be canonical") + }) + } +} + +// TestCheckpointCodecVersionMismatch verifies that a checkpoint encoded with +// an unsupported version byte is rejected by the decoder. This is the safety +// net that prevents us from silently loading data written by an older or +// future codec. +func TestCheckpointCodecVersionMismatch(t *testing.T) { + raw, err := encodeCheckpoint(&actorCheckpoint{ + Version: checkpointVersion, + }) + require.NoError(t, err) + + // The version record is the first TLV; its payload byte sits at + // offset 2 (type=1, length=1, value=1). Flip it to an unsupported + // version and confirm the decoder rejects the blob. + require.GreaterOrEqual(t, len(raw), 3) + raw[2] = 99 + + _, err = decodeCheckpoint(raw) + require.ErrorContains(t, err, "unsupported checkpoint version") +} + +// TestCheckpointCodecCorruptDataRejected asserts that malformed input (empty, +// truncated, random garbage) is rejected with an error rather than panicking +// or returning a zero-value checkpoint. +func TestCheckpointCodecCorruptDataRejected(t *testing.T) { + cases := []struct { + name string + raw []byte + }{ + {name: "empty", raw: nil}, + {name: "single_byte", raw: []byte{0x01}}, + { + name: "garbage", + raw: []byte{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + _, err := decodeCheckpoint(tc.raw) + require.Error(t, err) + }) + } +} + +// TestCheckpointCodecRapidRoundTrip is the property-based guarantee that any +// internally-consistent actorCheckpoint survives Encode → Decode → Encode +// with byte-for-byte canonical output. +func TestCheckpointCodecRapidRoundTrip(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + cp := drawCheckpoint(t) + + raw, err := encodeCheckpoint(cp) + if err != nil { + t.Fatalf("encode failed: %v", err) + } + + decoded, err := decodeCheckpoint(raw) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + + if !checkpointsEqual(cp, decoded) { + t.Fatalf("round-trip mismatch:\nwant %#v\ngot %#v", + cp, decoded) + } + + raw2, err := encodeCheckpoint(decoded) + if err != nil { + t.Fatalf("re-encode failed: %v", err) + } + if !bytes.Equal(raw, raw2) { + t.Fatalf("canonical encoding violated") + } + }) +} + +// drawCheckpoint produces a random, internally consistent actorCheckpoint +// with enough variability to exercise every optional field combination. +func drawCheckpoint(t *rapid.T) *actorCheckpoint { + cp := &actorCheckpoint{ + Version: checkpointVersion, + Height: rapid.Int32Range(0, 10_000_000). + Draw(t, "height"), + Started: rapid.Bool().Draw(t, "started"), + Trigger: StartTrigger(rapid.Int32Range( + int32(TriggerManual), int32(TriggerRestart), + ).Draw(t, "trigger")), + SweepAttempts: rapid.IntRange(0, 16). + Draw(t, "sweepAttempts"), + State: drawPlannerState(t), + } + + if rapid.Bool().Draw(t, "hasSweepTx") { + cp.SweepTx = buildRandomTx(t) + } + + if rapid.Bool().Draw(t, "hasFail") { + cp.Fail = rapid.StringN(1, 128, -1).Draw(t, "failReason") + } + + return cp +} + +// drawPlannerState mirrors unrollplan's own rapid generator but operates +// against its exported types so the checkpoint test does not depend on +// private helpers. +func drawPlannerState(t *rapid.T) unrollplan.State { + state := unrollplan.State{} + + numConfirmed := rapid.IntRange(0, 4).Draw(t, "numConfirmed") + confirmed := drawDistinctHashesCk(t, numConfirmed, "confirmed", nil) + state.ConfirmedTxids = confirmed + + used := make(map[chainhash.Hash]struct{}, len(confirmed)) + for _, h := range confirmed { + used[h] = struct{}{} + } + + numInflight := rapid.IntRange(0, 4).Draw(t, "numInflight") + state.InFlightTxids = drawDistinctHashesCk( + t, numInflight, "inflight", used, + ) + + if rapid.Bool().Draw(t, "hasTargetHeight") { + state.TargetConfirmHeight = fn.Some(rapid.Int32Range( + 0, 1_000_000, + ).Draw(t, "targetHeight")) + } + + state.Sweep = drawSweepStateCk(t) + + return state +} + +// drawSweepStateCk produces a SweepState whose optional fields are consistent +// with its Status value so the underlying planner codec accepts it. +func drawSweepStateCk(t *rapid.T) unrollplan.SweepState { + status := unrollplan.SweepStatus(rapid.IntRange( + int(unrollplan.SweepStatusPending), + int(unrollplan.SweepStatusConfirmed), + ).Draw(t, "sweepStatus")) + + sweep := unrollplan.SweepState{Status: status} + + switch status { + case unrollplan.SweepStatusPending: + // Pending sweep: neither optional field is set. + + case unrollplan.SweepStatusBroadcasted: + sweep.Txid = fn.Some(drawHashCk(t, "sweepTxid")) + + case unrollplan.SweepStatusConfirmed: + sweep.Txid = fn.Some(drawHashCk(t, "sweepTxid")) + sweep.ConfirmHeight = fn.Some(rapid.Int32Range( + 0, 1_000_000, + ).Draw(t, "sweepHeight")) + } + + return sweep +} + +// drawDistinctHashesCk draws n distinct random hashes, skipping any that +// collide with the caller-provided used set. The duplicate-input guards in +// unrollplan.EncodeState would otherwise reject the generated state. +func drawDistinctHashesCk(t *rapid.T, n int, label string, + used map[chainhash.Hash]struct{}) []chainhash.Hash { + + if used == nil { + used = make(map[chainhash.Hash]struct{}) + } + + out := make([]chainhash.Hash, 0, n) + attempts := 0 + for len(out) < n && attempts < n*4 { + attempts++ + h := drawHashCk(t, fmt.Sprintf("%s-%d", label, attempts)) + if _, dup := used[h]; dup { + continue + } + used[h] = struct{}{} + out = append(out, h) + } + + return out +} + +// drawHashCk draws one 32-byte hash uniformly. +func drawHashCk(t *rapid.T, label string) chainhash.Hash { + raw := rapid.SliceOfN( + rapid.Byte(), chainhash.HashSize, chainhash.HashSize, + ).Draw(t, label) + + var h chainhash.Hash + copy(h[:], raw) + + return h +} + +// buildRandomTx produces a deterministically-generated wire.MsgTx with random +// inputs and outputs. It must round-trip unchanged through +// wire.MsgTx.Serialize / Deserialize. +func buildRandomTx(t *rapid.T) *wire.MsgTx { + tx := wire.NewMsgTx(2) + + numIn := rapid.IntRange(1, 3).Draw(t, "numIn") + for i := 0; i < numIn; i++ { + hash := drawHashCk(t, fmt.Sprintf("inHash-%d", i)) + idx := rapid.Uint32Range(0, 10). + Draw(t, fmt.Sprintf("inIdx-%d", i)) + + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: hash, + Index: idx, + }, + Sequence: wire.MaxTxInSequenceNum, + }) + } + + numOut := rapid.IntRange(1, 3).Draw(t, "numOut") + for i := 0; i < numOut; i++ { + amount := rapid.Int64Range(1, 1_000_000). + Draw(t, fmt.Sprintf("outAmt-%d", i)) + pkSize := rapid.IntRange(1, 32). + Draw(t, fmt.Sprintf("pkSize-%d", i)) + pkScript := rapid.SliceOfN(rapid.Byte(), pkSize, pkSize). + Draw(t, fmt.Sprintf("pkScript-%d", i)) + + tx.AddTxOut(&wire.TxOut{ + Value: amount, + PkScript: pkScript, + }) + } + + return tx +} + +// buildDummySweepTx produces a small but well-formed sweep transaction +// spending the supplied parent txid. Used by the handcrafted round-trip +// cases so they don't need to allocate a rapid generator. +func buildDummySweepTx(t *testing.T, parent chainhash.Hash) *wire.MsgTx { + t.Helper() + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: parent, Index: 0}, + Sequence: wire.MaxTxInSequenceNum, + }) + tx.AddTxOut(&wire.TxOut{ + Value: 1_000, + PkScript: []byte{0x00, 0x14, 0x01, 0x02, 0x03, 0x04}, + }) + + return tx +} + +// checkpointsEqual deep-compares two actorCheckpoints. Planner-state fields +// use set-equality for the txid slices because the encoder sorts them; a +// bitwise comparison would otherwise fail on equivalent but differently- +// ordered inputs. +func checkpointsEqual(a, b *actorCheckpoint) bool { + if a.Version != b.Version { + return false + } + if a.Height != b.Height { + return false + } + if a.Started != b.Started { + return false + } + if a.Trigger != b.Trigger { + return false + } + if a.SweepAttempts != b.SweepAttempts { + return false + } + if a.Fail != b.Fail { + return false + } + if !plannerStatesEqualCk(a.State, b.State) { + return false + } + + return txsEqualCk(a.SweepTx, b.SweepTx) +} + +// plannerStatesEqualCk compares two unrollplan.State values using +// order-insensitive semantics for the txid slices. +func plannerStatesEqualCk(a, b unrollplan.State) bool { + if !hashSlicesEqualAsSetCk(a.ConfirmedTxids, b.ConfirmedTxids) { + return false + } + if !hashSlicesEqualAsSetCk(a.InFlightTxids, b.InFlightTxids) { + return false + } + if !optsEqualCk(a.TargetConfirmHeight, b.TargetConfirmHeight) { + return false + } + + return sweepStatesEqualCk(a.Sweep, b.Sweep) +} + +// sweepStatesEqualCk compares two SweepState values field by field. +func sweepStatesEqualCk(a, b unrollplan.SweepState) bool { + if a.Status != b.Status { + return false + } + if !optsEqualCk(a.Txid, b.Txid) { + return false + } + + return optsEqualCk(a.ConfirmHeight, b.ConfirmHeight) +} + +// txsEqualCk compares two serialized transactions. A byte-wise comparison +// avoids depending on MsgTx equality semantics (which does not exist as a +// method and whose struct equality is sensitive to uninitialised witness +// slices). +func txsEqualCk(a, b *wire.MsgTx) bool { + switch { + case a == nil && b == nil: + return true + case a == nil || b == nil: + return false + } + + var abuf, bbuf bytes.Buffer + if err := a.Serialize(&abuf); err != nil { + return false + } + if err := b.Serialize(&bbuf); err != nil { + return false + } + + return bytes.Equal(abuf.Bytes(), bbuf.Bytes()) +} + +// optsEqualCk compares two fn.Option values by presence + inner equality. +func optsEqualCk[T comparable](a, b fn.Option[T]) bool { + if a.IsSome() != b.IsSome() { + return false + } + if a.IsNone() { + return true + } + + return a.UnsafeFromSome() == b.UnsafeFromSome() +} + +// hashSlicesEqualAsSetCk compares two hash slices ignoring order. +func hashSlicesEqualAsSetCk(a, b []chainhash.Hash) bool { + if len(a) != len(b) { + return false + } + seen := make(map[chainhash.Hash]int, len(a)) + for _, h := range a { + seen[h]++ + } + for _, h := range b { + seen[h]-- + } + for _, v := range seen { + if v != 0 { + return false + } + } + + return true +} + +// requireCheckpointEqual compares two checkpoints and emits a clear diagnostic +// on mismatch. Used by the handcrafted cases. +func requireCheckpointEqual(t *testing.T, want, got *actorCheckpoint) { + t.Helper() + + require.Equal(t, want.Version, got.Version) + require.Equal(t, want.Height, got.Height) + require.Equal(t, want.Started, got.Started) + require.Equal(t, want.Trigger, got.Trigger) + require.Equal(t, want.Fail, got.Fail) + require.Equal(t, want.SweepAttempts, got.SweepAttempts) + require.ElementsMatch(t, + want.State.ConfirmedTxids, got.State.ConfirmedTxids, + ) + require.ElementsMatch(t, + want.State.InFlightTxids, got.State.InFlightTxids, + ) + require.Equal(t, + want.State.TargetConfirmHeight.IsSome(), + got.State.TargetConfirmHeight.IsSome(), + ) + if want.State.TargetConfirmHeight.IsSome() { + require.Equal(t, + want.State.TargetConfirmHeight.UnsafeFromSome(), + got.State.TargetConfirmHeight.UnsafeFromSome(), + ) + } + require.Equal(t, want.State.Sweep.Status, got.State.Sweep.Status) + require.True(t, txsEqualCk(want.SweepTx, got.SweepTx), + "sweep transactions differ") +} + +// hashFromByteCk builds a chainhash.Hash with the first byte set to b, useful +// for hand-rolled distinct test hashes. +func hashFromByteCk(b byte) chainhash.Hash { + var h chainhash.Hash + h[0] = b + + return h +} diff --git a/unroll/state_snapshot.go b/unroll/state_snapshot.go new file mode 100644 index 000000000..6f6699b83 --- /dev/null +++ b/unroll/state_snapshot.go @@ -0,0 +1,199 @@ +package unroll + +import ( + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/unrollplan" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// checkpointFromState exports the current protofsm state into the durable actor +// checkpoint shape. +func checkpointFromState(state State, + sweepTx *wire.MsgTx) *actorCheckpoint { + + checkpoint := &actorCheckpoint{ + Version: checkpointVersion, + SweepTx: copyTx(sweepTx), + } + + if state == nil || isIdleState(state) { + return checkpoint + } + + job := stateJob(state) + checkpoint.Height = job.Height + checkpoint.Started = true + checkpoint.Trigger = job.Trigger + checkpoint.State = copyPlannerState(job.PlannerState) + if sweepTxid := effectiveSweepTxid( + job.PlannerState, sweepTx, + ); sweepTxid != nil { + checkpoint.State.Sweep.Txid = fn.Some(*sweepTxid) + } + checkpoint.Fail = job.FailReason + checkpoint.SweepAttempts = job.SweepAttempts + + return checkpoint +} + +// effectiveSweepTxid returns the durable sweep txid from planner state when +// present, or derives it from the stored sweep transaction once the sweep has +// advanced beyond pending. +func effectiveSweepTxid(state unrollplan.State, + sweepTx *wire.MsgTx) *chainhash.Hash { + + if state.Sweep.Txid.IsSome() { + hash := state.Sweep.Txid.UnsafeFromSome() + return &hash + } + + if state.Sweep.Status == unrollplan.SweepStatusPending || + sweepTx == nil { + + return nil + } + + txid := sweepTx.TxHash() + + return &txid +} + +// stateFromCheckpoint restores a concrete protofsm state from the durable +// checkpoint shape. +func stateFromCheckpoint(checkpoint *actorCheckpoint) State { + if checkpoint == nil || !checkpoint.Started { + return &Idle{} + } + + job := &JobState{ + Height: checkpoint.Height, + Trigger: checkpoint.Trigger, + PlannerState: copyPlannerState(checkpoint.State), + FailReason: checkpoint.Fail, + SweepAttempts: checkpoint.SweepAttempts, + } + + switch phaseFromPlannerState(job) { + case PhaseCompleted: + return &Completed{Job: job} + + case PhaseFailed: + return &Failed{Job: job} + + case PhaseSweepConfirmation: + return &AwaitingSweepConfirmation{Job: job} + + case PhaseSweepBroadcast: + return &AwaitingSweepBroadcast{Job: job} + + case PhaseCSVPending: + return &AwaitingCSV{Job: job} + + default: + return &AwaitingMaterialization{Job: job} + } +} + +// phaseFromState projects the concrete protofsm state into the public coarse +// phase enum. +func phaseFromState(state State) Phase { + switch state.(type) { + case *Idle: + return PhasePending + + case *AwaitingMaterialization: + return PhaseMaterializing + + case *AwaitingCSV: + return PhaseCSVPending + + case *AwaitingSweepBroadcast: + return PhaseSweepBroadcast + + case *AwaitingSweepConfirmation: + return PhaseSweepConfirmation + + case *Completed: + return PhaseCompleted + + case *Failed: + return PhaseFailed + + default: + return PhaseFailed + } +} + +// phaseFromPlannerState derives a coarse phase from the durable planner state +// when restoring from checkpoint before the planner is bound. +func phaseFromPlannerState(job *JobState) Phase { + if job == nil { + return PhasePending + } + + if job.FailReason != "" { + return PhaseFailed + } + + switch { + case job.PlannerState.Sweep.Status == unrollplan.SweepStatusConfirmed: + return PhaseCompleted + + case job.PlannerState.Sweep.Status == unrollplan.SweepStatusBroadcasted: + return PhaseSweepConfirmation + + case job.PlannerState.TargetConfirmHeight.IsSome(): + return PhaseCSVPending + + default: + return PhaseMaterializing + } +} + +// stateJob extracts the durable job state from a concrete protofsm state. +func stateJob(state State) *JobState { + switch s := state.(type) { + case *Idle: + return &JobState{} + + case *AwaitingMaterialization: + return s.Job.Copy() + + case *AwaitingCSV: + return s.Job.Copy() + + case *AwaitingSweepBroadcast: + return s.Job.Copy() + + case *AwaitingSweepConfirmation: + return s.Job.Copy() + + case *Completed: + return s.Job.Copy() + + case *Failed: + return s.Job.Copy() + + default: + panic(fmt.Sprintf("unexpected state type %T", state)) + } +} + +// stateHeight returns the best height tracked by the current state. +func stateHeight(state State) int32 { + return stateJob(state).Height +} + +// stateTrigger returns the start trigger tracked by the current state. +func stateTrigger(state State) StartTrigger { + return stateJob(state).Trigger +} + +// isIdleState reports whether the current state is idle. +func isIdleState(state State) bool { + _, ok := state.(*Idle) + return ok +} diff --git a/unroll/state_snapshot_test.go b/unroll/state_snapshot_test.go new file mode 100644 index 000000000..dd26903b5 --- /dev/null +++ b/unroll/state_snapshot_test.go @@ -0,0 +1,190 @@ +package unroll + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/unrollplan" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// TestCheckpointRoundTripByPhase verifies that durable checkpoints restore to +// the expected concrete protofsm state shape. +func TestCheckpointRoundTripByPhase(t *testing.T) { + targetTxid := chainhash.Hash{0xAA} + sweepTxid := chainhash.Hash{0xBB} + + testCases := []struct { + name string + state State + typ interface{} + }{ + { + name: "materialization", + state: &AwaitingMaterialization{ + Job: &JobState{ + Height: 100, + Trigger: TriggerManual, + PlannerState: unrollState( + chainhash.Hash{0x01}, + fn.None[int32](), nil, + ), + }, + }, + typ: &AwaitingMaterialization{}, + }, + { + name: "csv_pending", + state: &AwaitingCSV{ + Job: &JobState{ + Height: 104, + Trigger: TriggerRestart, + PlannerState: unrollState( + targetTxid, + fn.Some[int32](103), nil, + ), + }, + }, + typ: &AwaitingCSV{}, + }, + { + name: "sweep_confirmation", + state: &AwaitingSweepConfirmation{ + Job: &JobState{ + Height: 105, + Trigger: TriggerManual, + PlannerState: unrollState( + targetTxid, + fn.Some[int32](103), + &sweepTxid, + ), + }, + }, + typ: &AwaitingSweepConfirmation{}, + }, + { + name: "completed", + state: &Completed{ + Job: &JobState{ + Height: 106, + Trigger: TriggerManual, + PlannerState: completedUnrollState( + targetTxid, + fn.Some[int32](103), + sweepTxid, + ), + }, + }, + typ: &Completed{}, + }, + { + name: "failed", + state: &Failed{ + Job: &JobState{ + Height: 107, + Trigger: TriggerRestart, + PlannerState: unrollState( + targetTxid, + fn.None[int32](), nil, + ), + FailReason: "boom", + }, + }, + typ: &Failed{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + checkpoint := checkpointFromState(tc.state, nil) + restored := stateFromCheckpoint(checkpoint) + + require.IsType(t, tc.typ, restored) + require.Equal( + t, phaseFromState(tc.state), + phaseFromState(restored), + ) + require.Equal( + t, stateHeight(tc.state), + stateHeight(restored), + ) + require.Equal( + t, stateTrigger(tc.state), + stateTrigger(restored), + ) + require.Equal( + t, stateJob(tc.state).FailReason, + stateJob(restored).FailReason, + ) + require.Equal( + t, stateJob(tc.state).PlannerState, + stateJob(restored).PlannerState, + ) + }) + } +} + +// TestCheckpointFromStateUsesStoredSweepTxid verifies that checkpointing keeps +// a terminal sweep txid observable even if the planner state is missing the +// hash but the actor still has the sweep transaction. +func TestCheckpointFromStateUsesStoredSweepTxid(t *testing.T) { + targetTxid := chainhash.Hash{0xAA} + sweepTx := wire.NewMsgTx(2) + sweepTxid := sweepTx.TxHash() + + sweepState := unrollplan.SweepState{ + Status: unrollplan.SweepStatusConfirmed, + ConfirmHeight: fn.Some[int32](110), + } + state := &Completed{ + Job: &JobState{ + Height: 106, + Trigger: TriggerManual, + PlannerState: unrollplan.State{ + ConfirmedTxids: []chainhash.Hash{ + targetTxid, + }, + TargetConfirmHeight: fn.Some[int32](103), + Sweep: sweepState, + }, + }, + } + + checkpoint := checkpointFromState(state, sweepTx) + + require.True(t, checkpoint.State.Sweep.Txid.IsSome()) + require.Equal( + t, sweepTxid, checkpoint.State.Sweep.Txid.UnsafeFromSome(), + ) +} + +// unrollState builds a minimal planner state for checkpoint tests. +func unrollState(targetTxid chainhash.Hash, targetHeight fn.Option[int32], + sweepTxid *chainhash.Hash) unrollplan.State { + + state := unrollplan.State{ + ConfirmedTxids: []chainhash.Hash{targetTxid}, + TargetConfirmHeight: targetHeight, + } + + if sweepTxid != nil { + state.Sweep.Status = unrollplan.SweepStatusBroadcasted + state.Sweep.Txid = fn.Some(*sweepTxid) + } + + return state +} + +// completedUnrollState builds a minimal completed planner state. +func completedUnrollState(targetTxid chainhash.Hash, + targetHeight fn.Option[int32], + sweepTxid chainhash.Hash) unrollplan.State { + + state := unrollState(targetTxid, targetHeight, &sweepTxid) + state.Sweep.Status = unrollplan.SweepStatusConfirmed + state.Sweep.ConfirmHeight = fn.Some[int32](110) + + return state +} diff --git a/unroll/sweep.go b/unroll/sweep.go new file mode 100644 index 000000000..bfe7fb6c9 --- /dev/null +++ b/unroll/sweep.go @@ -0,0 +1,234 @@ +package unroll + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/darepo-client/lib/arkscript" + "github.com/lightninglabs/darepo-client/lib/recovery" + "github.com/lightninglabs/darepo-client/vtxo" +) + +const ( + // estimatedSweepVBytes is a conservative virtual-size estimate for the + // timeout-path sweep spend. + estimatedSweepVBytes = 200 + + // defaultSweepFallbackFeeRateSatPerVByte is used when fee estimation is + // temporarily unavailable on regtest or a cold backend. + defaultSweepFallbackFeeRateSatPerVByte int64 = 2 + + // defaultMaxSweepFeeRateSatPerVByte clamps pathological fee estimates. + defaultMaxSweepFeeRateSatPerVByte int64 = 100 +) + +// estimateSweepFeeRate asks chainsource for a 6-block fee estimate and +// clamps it to a sane range. +// +// Three failure modes are handled: +// +// - Ask error (chainsource unavailable or estimator cold): fall back +// to a small fixed rate so regtest / fresh-sync daemons can still +// produce a plausible sweep. This fallback is clamped so it never +// exceeds maxFeeRate. +// +// - Non-positive estimate: reject with an error. A zero-rate sweep +// would be rejected by every node, so pretending is worse than +// failing fast. +// +// - Estimate above the cap (fee-spike, bad estimator signal): +// clamp to maxFeeRate. The cap exists to protect against a +// pathological backend returning e.g. 10000 sat/vB and burning +// the entire sweep value in miner fees. +func estimateSweepFeeRate(ctx context.Context, + chainSource actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ], maxFeeRate int64) (int64, error) { + + if maxFeeRate <= 0 { + maxFeeRate = defaultMaxSweepFeeRateSatPerVByte + } + + resp, err := chainSource.Ask( + ctx, &chainsource.FeeEstimateRequest{TargetConf: 6}, + ).Await(ctx).Unpack() + if err != nil { + fallbackFeeRate := defaultSweepFallbackFeeRateSatPerVByte + if fallbackFeeRate > maxFeeRate { + fallbackFeeRate = maxFeeRate + } + + return fallbackFeeRate, nil + } + + feeResp, ok := resp.(*chainsource.FeeEstimateResponse) + if !ok { + return 0, fmt.Errorf("unexpected fee response %T", resp) + } + + feeRate := int64(feeResp.SatPerVByte) + if feeRate <= 0 { + return 0, fmt.Errorf("fee rate must be positive") + } + + if feeRate > maxFeeRate { + feeRate = maxFeeRate + } + + return feeRate, nil +} + +// buildSweepTx constructs and signs the final timeout-path sweep. +// +// Structure of the produced transaction: +// +// - Version 2 (required for CSV-relative timelocks). +// - One input spending proof.TargetOutpoint with Sequence set to the +// descriptor's RelativeExpiry. That sequence value is what arms the +// CSV check on chain; spending earlier is consensus-invalid, so the +// actor must wait for the CSV to mature (AwaitingCSV) before +// submitting. +// - One P2TR output paying to a fresh wallet pkScript. Value = +// inputValue - fee. A non-positive sweep value fails construction +// outright since publishing a tx with value <= 0 is nonsensical. +// +// Signing uses the taproot timeout-path leaf (leaf index 1 in the +// standard VTXO tap tree). The leaf script is rebuilt from the +// descriptor's policy keys and CSV delay via arkscript, and the +// resulting witness is stored on TxIn[0]. +// +// This function is deliberately pure: every IO boundary (fee estimate, +// wallet pkScript, wallet signing) is threaded in through parameters so +// buildSweepTx is directly test-friendly with injected fakes. +func buildSweepTx(ctx context.Context, wallet SweepWallet, + chainSource actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ], proof *recovery.Proof, + desc *vtxo.Descriptor, maxFeeRate int64) (*wire.MsgTx, error) { + + if wallet == nil { + return nil, fmt.Errorf("sweep wallet must be provided") + } + + if chainSource == nil { + return nil, fmt.Errorf("chain source must be provided") + } + + if proof == nil { + return nil, fmt.Errorf("proof must be provided") + } + + if desc == nil { + return nil, fmt.Errorf("descriptor must be provided") + } + + // Resolve the target's TxOut (value + pkScript). This is the + // source of truth for fee math and signing — the VTXO descriptor + // stores a redundant PkScript but the proof-derived output is + // authoritative. + targetOutput, err := proof.TargetOutput() + if err != nil { + return nil, err + } + + feeRate, err := estimateSweepFeeRate(ctx, chainSource, maxFeeRate) + if err != nil { + return nil, fmt.Errorf("estimate fee: %w", err) + } + + // Ask the wallet for a fresh P2TR address. Every sweep attempt in + // a single actor lifetime reuses b.sweepTx (and therefore this + // pkScript) so we only burn one BIP32 address per VTXO — caller + // (startSweep) is responsible for that reuse. + sweepPkScript, err := wallet.NewWalletPkScript(ctx) + if err != nil { + return nil, fmt.Errorf("sweep pkscript: %w", err) + } + + if len(sweepPkScript) == 0 { + return nil, fmt.Errorf("wallet returned empty pkscript") + } + + // Version 2 is required for CSV-relative timelocks. Sequence = + // desc.RelativeExpiry is what tells consensus "this input is only + // valid at least RelativeExpiry blocks after the target + // confirmed" — the same CSV the planner is tracking off-chain. + sweepTx := wire.NewMsgTx(2) + sweepTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: proof.TargetOutpoint(), + Sequence: desc.RelativeExpiry, + }) + + // Fee math is deliberately simple: a conservative static vsize + // estimate (taproot key-path + timeout leaf spend fits well under + // the estimatedSweepVBytes budget) times the clamped fee rate. + // The dust check below handles the pathological case of a VTXO + // whose value is below the sweep fee at current rates — in that + // case the unroll cannot produce a viable spend and must fail. + inputValue := btcutil.Amount(targetOutput.Value) + fee := btcutil.Amount(feeRate * estimatedSweepVBytes) + sweepValue := inputValue - fee + if sweepValue <= 0 { + return nil, fmt.Errorf( + "sweep value %d not positive after fee %d", + sweepValue, fee) + } + + sweepTx.AddTxOut(&wire.TxOut{ + Value: int64(sweepValue), + + // Defensive copy: sweepPkScript came from the wallet and + // the returned tx lives on past this call. + PkScript: append([]byte(nil), sweepPkScript...), + }) + + if desc.ClientKey.PubKey == nil { + return nil, fmt.Errorf("descriptor missing ClientKey pubkey") + } + if desc.OperatorKey == nil { + return nil, fmt.Errorf("descriptor missing OperatorKey") + } + + // Derive the timeout-path spend info from the policy keys. The + // legacy leaf index 1 maps to the CSV-gated exit leaf in the + // standard VTXO tap tree. + spendInfo, err := arkscript.NewVTXOSpendInfoFromPolicy( + desc.ClientKey.PubKey, desc.OperatorKey, + desc.RelativeExpiry, 1, + ) + if err != nil { + return nil, fmt.Errorf("timeout spend info: %w", err) + } + + // BuildSignDescriptor wires together everything the taproot + // signer needs: the client key to sign with, the prevout being + // spent (value + pkScript), pre-computed sighashes for the new + // tx, and the input index (0 — we only have one input). + prevFetcher := txscript.NewCannedPrevOutputFetcher( + targetOutput.PkScript, targetOutput.Value, + ) + sigHashes := txscript.NewTxSigHashes(sweepTx, prevFetcher) + signDesc := spendInfo.BuildSignDescriptor( + desc.ClientKey, targetOutput, sigHashes, prevFetcher, 0, + ) + + // Sign and assemble the taproot timeout-path witness. This is + // the only IO path that crosses into the wallet — from here on + // the sweep tx is fully signed and byte-stable, which is what + // lets startSweep persist its bytes before broadcasting. + witness, err := arkscript.VTXOTimeoutSpendWitness( + wallet, signDesc, sweepTx, + ) + if err != nil { + return nil, fmt.Errorf("timeout witness: %w", err) + } + + sweepTx.TxIn[0].Witness = witness + + return sweepTx, nil +} From cb6e1b3d5e214862c2baa8243fe2a35bfc9377ca Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 21 Apr 2026 22:41:14 -0700 Subject: [PATCH 6/6] docs: refresh per-package docs for unroll fixes series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unroll/CLAUDE.md documenting the durable per-target unroll subsystem alongside the registry, FSM events, persist-before-broadcast contract, TLV-encoded durable messages, and safe TxOut indexing. Update vtxo, lib/actormsg, and db docs to reflect the ForceUnrollEvent handling in SpendingState / ForfeitingState, the handleForceUnroll Tell→Ask change and new ForceUnrollResponse.Reason field, and the unilateral_exit_jobs migration landed by this series. --- ARCHITECTURE.md | 1 + db/AGENTS.md | 3 +- db/CLAUDE.md | 3 +- lib/actormsg/AGENTS.md | 1 + lib/actormsg/CLAUDE.md | 1 + unroll/AGENTS.md | 173 +++++++++++++++++++++++++++++++++++++++++ unroll/CLAUDE.md | 173 +++++++++++++++++++++++++++++++++++++++++ vtxo/AGENTS.md | 2 + vtxo/CLAUDE.md | 2 + 9 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 unroll/AGENTS.md create mode 100644 unroll/CLAUDE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0e24b3f07..ea01ae0e6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -37,6 +37,7 @@ package may import from a higher layer. | [`chainbackends`](chainbackends/) | LND-backed `ChainBackend` implementation plus lndclient adapters (`TxBroadcaster`, `PackageSubmitter`) | | [`chain`](chain/) | Bitcoind RPC utilities (package relay, `SubmitPackage`) | | [`txconfirm`](txconfirm/) | Generic "broadcast + CPFP fee-bump + notify on confirm" actor with per-parent fee-input reservations and BIP-125 Rule 3/4 enforcement | +| [`unroll`](unroll/) | Durable per-target unilateral-exit actor + thin registry: owns proof assembly, materialization, CSV maturity, final sweep build, persist-before-broadcast, and control-plane record persistence | | [`lndbackend`](lndbackend/) | `BoardingBackend` implementation via LND's wallet kit | | [`lwwallet`](lwwallet/) | Lightweight in-process wallet (btcwallet + Esplora, no external LND) | | [`btcwbackend`](btcwbackend/) | Neutrino-backed wallet backend (btcwallet + compact block filters) | diff --git a/db/AGENTS.md b/db/AGENTS.md index 96160a794..c70bf46a5 100644 --- a/db/AGENTS.md +++ b/db/AGENTS.md @@ -39,7 +39,8 @@ and client-side fee accounting. Supports SQLite and PostgreSQL backends. - Default retry logic: 10 retries with exponential backoff (40ms initial, capped at 3s). - **Never write raw SQL in Go** — add queries to `db/queries/`, regenerate with `make sqlc`. - Per-subsystem logging: uses instance logger instead of global package logger. -- Latest migration: `000007_utxo_audit_log` adds an append-only UTXO audit log (`wallet_utxo_log`) with FK-constrained enum tables (`utxo_classifications`, `utxo_events`), indexes on block_height, outpoint, and classification, and a `UNIQUE(outpoint_hash, outpoint_index, event)` index that makes inserts idempotent under `RestartMessage` replay. +- Latest migration: `000008_unilateral_exit_store` adds `unilateral_exit_jobs`, one row per target outpoint, holding the manager-facing control-plane view for the per-target unroll actor. `status` is an INTEGER column with documented values 0-6 (the `sweep_broadcasting` and `sweeping` values deliberately distinguish "sweep built, not yet submitted" from "sweep broadcast, awaiting confirmation"). `trigger` is 0-3 (`manual`, `critical_expiry`, `restart`, `fraud_spend`). `UnilateralExitJobStatusSweepBroadcasting` is appended at the end of the Go enum (iota value 6) so existing rows written at status=3 continue to decode as "sweep broadcast, awaiting conf" rather than silently shifting semantics; `UnilateralExitJobTriggerFraudSpend` round-trips through the Go `unroll.TriggerFraudSpend` constant rather than being silently downgraded to `TriggerManual`. +- Prior migration: `000007_utxo_audit_log` adds an append-only UTXO audit log (`wallet_utxo_log`) with FK-constrained enum tables (`utxo_classifications`, `utxo_events`), indexes on block_height, outpoint, and classification, and a `UNIQUE(outpoint_hash, outpoint_index, event)` index that makes inserts idempotent under `RestartMessage` replay. - Migration `000006_fee_accounting` seeds the client chart of accounts — `wallet_balance`, `vtxo_balance` (assets); `fees_paid`, `onchain_fees`, `transfers_out` (expenses); `transfers_in` (revenue); `opening_balance` (equity, the source-of-funds counterparty for wallet UTXO deposits) — and `ledger_entries`. `ledger_entries` carries three optional scope columns — `round_id` (16-byte UUID), `session_id` (32-byte OOR identifier), and `idempotency_key` (outpoint-derived BLOB) — each paired with its own partial unique index (`idx_client_ledger_idempotent_round`, `_session`, `_key`) so every event class gets an at-least-once-idempotent path without colliding with the others. `InsertClientLedgerEntry` uses `ON CONFLICT DO NOTHING` so a redelivered durable-actor message resolves to a silent no-op across all three indexes. The `account_types` enum adds `equity` alongside `asset`, `liability`, `revenue`, `expense`. Ledger event types include `wallet_utxo_created` so the deposit leg written by `handleUTXOCreated` (debit `wallet_balance`, credit `opening_balance`) has a classification distinct from fee / transfer events. ## Deep Docs diff --git a/db/CLAUDE.md b/db/CLAUDE.md index 96160a794..c70bf46a5 100644 --- a/db/CLAUDE.md +++ b/db/CLAUDE.md @@ -39,7 +39,8 @@ and client-side fee accounting. Supports SQLite and PostgreSQL backends. - Default retry logic: 10 retries with exponential backoff (40ms initial, capped at 3s). - **Never write raw SQL in Go** — add queries to `db/queries/`, regenerate with `make sqlc`. - Per-subsystem logging: uses instance logger instead of global package logger. -- Latest migration: `000007_utxo_audit_log` adds an append-only UTXO audit log (`wallet_utxo_log`) with FK-constrained enum tables (`utxo_classifications`, `utxo_events`), indexes on block_height, outpoint, and classification, and a `UNIQUE(outpoint_hash, outpoint_index, event)` index that makes inserts idempotent under `RestartMessage` replay. +- Latest migration: `000008_unilateral_exit_store` adds `unilateral_exit_jobs`, one row per target outpoint, holding the manager-facing control-plane view for the per-target unroll actor. `status` is an INTEGER column with documented values 0-6 (the `sweep_broadcasting` and `sweeping` values deliberately distinguish "sweep built, not yet submitted" from "sweep broadcast, awaiting confirmation"). `trigger` is 0-3 (`manual`, `critical_expiry`, `restart`, `fraud_spend`). `UnilateralExitJobStatusSweepBroadcasting` is appended at the end of the Go enum (iota value 6) so existing rows written at status=3 continue to decode as "sweep broadcast, awaiting conf" rather than silently shifting semantics; `UnilateralExitJobTriggerFraudSpend` round-trips through the Go `unroll.TriggerFraudSpend` constant rather than being silently downgraded to `TriggerManual`. +- Prior migration: `000007_utxo_audit_log` adds an append-only UTXO audit log (`wallet_utxo_log`) with FK-constrained enum tables (`utxo_classifications`, `utxo_events`), indexes on block_height, outpoint, and classification, and a `UNIQUE(outpoint_hash, outpoint_index, event)` index that makes inserts idempotent under `RestartMessage` replay. - Migration `000006_fee_accounting` seeds the client chart of accounts — `wallet_balance`, `vtxo_balance` (assets); `fees_paid`, `onchain_fees`, `transfers_out` (expenses); `transfers_in` (revenue); `opening_balance` (equity, the source-of-funds counterparty for wallet UTXO deposits) — and `ledger_entries`. `ledger_entries` carries three optional scope columns — `round_id` (16-byte UUID), `session_id` (32-byte OOR identifier), and `idempotency_key` (outpoint-derived BLOB) — each paired with its own partial unique index (`idx_client_ledger_idempotent_round`, `_session`, `_key`) so every event class gets an at-least-once-idempotent path without colliding with the others. `InsertClientLedgerEntry` uses `ON CONFLICT DO NOTHING` so a redelivered durable-actor message resolves to a silent no-op across all three indexes. The `account_types` enum adds `equity` alongside `asset`, `liability`, `revenue`, `expense`. Ledger event types include `wallet_utxo_created` so the deposit leg written by `handleUTXOCreated` (debit `wallet_balance`, credit `opening_balance`) has a classification distinct from fee / transfer events. ## Deep Docs diff --git a/lib/actormsg/AGENTS.md b/lib/actormsg/AGENTS.md index bacb56da8..ecc8cb360 100644 --- a/lib/actormsg/AGENTS.md +++ b/lib/actormsg/AGENTS.md @@ -16,6 +16,7 @@ package boundaries. Lives in `lib/` to break import cycles between `vtxo`, - `SelectAndReserveForfeitRequest` / `SelectAndReserveForfeitResponse` — Ask-message to atomically select and reserve VTXOs for cooperative forfeit (directed sends). Combines coin selection and PendingForfeit reservation in one step to close a race window. - `ReserveForfeitRequest` / `ReleaseForfeitRequest` — Forfeit reservation admission messages. - `ReleaseSpendRequest` / `CompleteSpendRequest` — Spend lifecycle completion messages. +- `ForceUnrollRequest` / `ForceUnrollResponse` — Ask-message that routes an operator or chain-resolver unroll trigger through the VTXO manager into the per-VTXO FSM. `ForceUnrollResponse.Accepted` is true when the request caused a state transition; when false, `Reason` distinguishes `"no such vtxo"` from `"already terminal"` so callers don't misread a silent self-loop as success. - `RegisterIntentMsg` — Carries pre-composed cooperative intent package to round actor. - `TriggerBoardMsg` — Carries VTXO amounts for boarding registration to round actor. - `SelectedVTXO` — Describes a VTXO selected for spend (outpoint, amount, pkscript). diff --git a/lib/actormsg/CLAUDE.md b/lib/actormsg/CLAUDE.md index bacb56da8..ecc8cb360 100644 --- a/lib/actormsg/CLAUDE.md +++ b/lib/actormsg/CLAUDE.md @@ -16,6 +16,7 @@ package boundaries. Lives in `lib/` to break import cycles between `vtxo`, - `SelectAndReserveForfeitRequest` / `SelectAndReserveForfeitResponse` — Ask-message to atomically select and reserve VTXOs for cooperative forfeit (directed sends). Combines coin selection and PendingForfeit reservation in one step to close a race window. - `ReserveForfeitRequest` / `ReleaseForfeitRequest` — Forfeit reservation admission messages. - `ReleaseSpendRequest` / `CompleteSpendRequest` — Spend lifecycle completion messages. +- `ForceUnrollRequest` / `ForceUnrollResponse` — Ask-message that routes an operator or chain-resolver unroll trigger through the VTXO manager into the per-VTXO FSM. `ForceUnrollResponse.Accepted` is true when the request caused a state transition; when false, `Reason` distinguishes `"no such vtxo"` from `"already terminal"` so callers don't misread a silent self-loop as success. - `RegisterIntentMsg` — Carries pre-composed cooperative intent package to round actor. - `TriggerBoardMsg` — Carries VTXO amounts for boarding registration to round actor. - `SelectedVTXO` — Describes a VTXO selected for spend (outpoint, amount, pkscript). diff --git a/unroll/AGENTS.md b/unroll/AGENTS.md new file mode 100644 index 000000000..e2ad52ceb --- /dev/null +++ b/unroll/AGENTS.md @@ -0,0 +1,173 @@ +# unroll + +## Purpose + +Durable per-target unilateral-exit subsystem. One `VTXOUnrollActor` per VTXO +outpoint owns the full exit lifecycle (proof assembly → proof-node confirmation +→ CSV maturity → final sweep build → broadcast → confirmation) on top of a +pure `unrollplan.Planner` and the shared `txconfirm` actor. A thin +`UnrollRegistryActor` owns spawn / dedup / terminal bookkeeping and persists a +control-plane record per target to `db` so restart can restore in-flight jobs. + +## Key Types + +### Per-target actor +- `VTXOUnrollActor` — One durable actor per target outpoint. Wraps a + `baselib/actor.DurableActor[Msg, Resp]` and owns the FSM session, proof, + planner, and cached sweep transaction for this one VTXO. +- `Config` — Per-actor wiring: `TargetOutpoint`, `ActorID`, `DeliveryStore`, + `ProofAssembler`, `VTXOStore`, `TxConfirmRef`, `ChainSource`, `Wallet` + (`SweepWallet`), `MaxSweepFeeRateSatPerVByte`, and a `RegistryRef` for + terminal notifications. +- `behavior` — Actor behavior. Holds `b.sweepTx` (restored from checkpoint on + boot) so retries and replays converge on a single sweep txid / pkScript + under `txconfirm`'s txid-keyed dedup. +- `Msg` / `Resp` / `Event` / `OutboxEvent` — Sealed durable-mailbox, + response, FSM event, and FSM outbox surfaces. +- `StartUnrollRequest` / `ResumeUnrollRequest` / `HeightObservedMsg` / + `TxConfirmedMsg` / `TxFailedMsg` / `SpendObservedMsg` / `GetStateRequest` — + Durable mailbox messages. Each ships a per-message TLV codec (no JSON) with + a pinned record-type layout; round-trip tests live in + `messages_test.go`. +- `StartTrigger` — What caused the job to start: `TriggerManual`, + `TriggerCriticalExpiry`, `TriggerRestart`, `TriggerFraudSpend`. +- `Phase` — Coarse derived phase for control-plane visibility: + `PhasePending` / `PhaseMaterializing` / `PhaseCSVPending` / + `PhaseSweepBroadcast` / `PhaseSweepConfirmation` / `PhaseCompleted` / + `PhaseFailed`. +- `JobState` — Durable FSM state (height, trigger, planner state, + `FailReason`, `SweepAttempts`). + +### Registry +- `UnrollRegistryActor` — Thin coordinator over the set of + `VTXOUnrollActor`s. Handles `EnsureUnrollRequest` / `GetStatusRequest` + admission, receives `UnrollTerminatedMsg` from children, persists records, + and `RestoreNonTerminal` on boot. +- `RegistryConfig` — Store, `DeliveryStore`, `ProofAssembler`, `VTXOStore`, + `TxConfirmRef`, `ChainSource`, `Wallet`, `MaxSweepFeeRateSatPerVByte`. +- `RegistryRecord` — Control-plane row: `TargetOutpoint`, `ActorID`, + `Phase`, `Trigger`, `FailReason`, `SweepTxid`. +- `RegistryStore` — Persistence surface: `UpsertRecord`, `GetRecord`, + `ListNonTerminalRecords`, `MarkTerminal`. `DBRegistryStore` in + `db_store.go` is the production implementation; it adapts to the + `db.UnilateralExitStore` enum through `statusForPhase` / `phaseFromDB` + and `triggerToDB` / `triggerFromDB` which are locked in by round-trip + tests in `db_store_test.go`. +- `EnsureUnrollRequest` / `EnsureUnrollResp` — Admission API. Dedup runs + against `r.active`, `r.pending`, and `Store.GetRecord` — a repeat request + after a child terminated returns `Created=false` with the historical + `ActorID` rather than spawning a fresh actor and clobbering the sweep txid + / failure reason. + +### Support +- `LocalProofAssembler` — Assembles a `recovery.Proof` from the VTXO + descriptor and its OOR artifact lineage. Implements `ProofAssembler`. +- `DescriptorLineageResolver` — Walks OOR checkpoint artifacts to produce + the list of lineage transactions that must be confirmed before sweep. +- `SweepWallet` — Wallet interface: `NewWalletPkScript`, + `SignTaprootSpend`. +- `safeTxOutPkScript(tx, index)` — Bounds-checking helper used at every + `tx.TxOut[i].PkScript` site so malformed proof artifacts (operator-sourced + OOR inputs) surface as retryable errors instead of goroutine panics. + +## Relationships + +- **Depends on**: `baselib/actor` (`DurableActor`, `TLVMessage`, codec), + `baselib/protofsm` (FSM engine), `lib/recovery` (immutable proof graph), + `unrollplan` (pure planner + TLV state codec), `txconfirm` (broadcast + + CPFP + confirmation), `chainsource` (best-height + spend watch + fee + estimate), `vtxo` (`Descriptor`, `VTXOStore`), `db` (`UnilateralExitStore`, + `RegistryRecord` DB shape), `lib/arkscript` (timeout-path spend info). +- **Depended on by**: `darepod` (wires the registry into the daemon via the + lazy chain-resolver seam; wiring lives in PR #264). +- **Sends**: + - → `txconfirm` (Ask): `EnsureConfirmedReq` — one per proof node and one + for the final sweep. Dedup by txid makes retried sends idempotent. + - → `chainsource` (Ask): `RegisterSpendRequest` on the target outpoint to + catch external spends, `BestHeightRequest`, `FeeEstimateRequest`. + - → registry (Tell): `UnrollTerminatedMsg` from each child on terminal + transition. + - → `vtxo` (indirect via chain-resolver seam, wired in #264): + control-plane callbacks. +- **Receives**: + - ← API (registry): `EnsureUnrollRequest`, `GetStatusRequest` + (from `darepod` RPC layer via chain resolver). + - ← registry (internal): `persistActiveRecordMsg`, + `persistRecordResultMsg`, `UnrollTerminatedMsg`. + - ← per-target actor (mailbox): `StartUnrollRequest`, `ResumeUnrollRequest`, + `HeightObservedMsg`, `TxConfirmedMsg`, `TxFailedMsg`, `SpendObservedMsg`, + `GetStateRequest`. + - ← `txconfirm` notification subscriber: `TxConfirmed`, `TxFailed` + → mapped to `TxConfirmedMsg` / `TxFailedMsg`. + - ← `chainsource` block epochs: re-wrapped as `HeightObservedMsg`. + - ← `chainsource` spend notifications: re-wrapped as `SpendObservedMsg`. + +## Invariants + +- **Persist-before-broadcast.** `startSweep` calls `persistCheckpoint` + (writing `b.sweepTx` into the TLV checkpoint) BEFORE `txconfirm.Ask`. Any + handler-level retry or crash-restart restores the same sweep tx, and + `txconfirm`'s txid-keyed dedup makes the re-submit a benign no-op instead + of broadcasting a second sweep with a freshly-derived wallet pkScript that + races the first on chain. +- **Sweep tx reuse.** `startSweep` skips `buildSweepTx` when `b.sweepTx` is + already set (either from a prior attempt this actor lifetime or restored + from the checkpoint). This converges every retry on a single sweep + txid / pkScript and avoids burning BIP32 wallet addresses on fee-spike + retries. +- **Reissue must fail hard on missing state.** The `ReissueInFlightTransactions` + and `ReissueSweepConfirmation` outbox branches return an error on a missing + proof node or nil `sweepTx`. A silent `continue` would strand the FSM in + `AwaitingMaterialization` or `AwaitingSweepConfirmation` with no pending + `txconfirm` subscription and no way to advance. +- **Registry deduplication covers the whole trail.** `handleEnsure` checks + `r.active`, `r.pending`, AND `Store.GetRecord` before spawning — a repeat + request for an already-terminal outpoint returns the historical `ActorID` + and does not overwrite the stored sweep txid or failure reason. +- **Fail-closed admission write.** `handleEnsure` calls `Store.UpsertRecord` + synchronously and only returns `Created=true` after the record is durable. + If the initial write fails, the spawned child is stopped, removed from + `r.active`, and the caller sees a wrapped error instead of a silent + orphan. Without this invariant, a crash between admission and the former + async persist would leave the child unknown to `RestoreNonTerminal` on + reboot, silently losing the job. Subsequent updates stay on the async + `requestPersist` path so the registry goroutine is not held hostage by + every state transition. +- **Durable mailbox messages are TLV, not JSON.** Every message in + `messages.go` implements `actor.TLVMessage` with a hand-written + `Encode`/`Decode` pair driven by `tlv.Stream`. Inner record types start at + 1 per message (the outer mailbox codec identifies which message). The + checkpoint codec in `snapshot.go` is also TLV. +- **Checkpoint persists the sweep tx** via + `wire.MsgTx.Serialize` under `checkpointSweepTxRecordType` so restore + produces the exact same `b.sweepTx` that the pre-broadcast commit wrote. +- **Phase ↔ DB status mapping is lossless.** `PhaseSweepBroadcast` maps to + `UnilateralExitJobStatusSweepBroadcasting` (=6) and + `PhaseSweepConfirmation` maps to `UnilateralExitJobStatusSweeping` (=3) — + the two used to collapse onto the same DB value and silently erase the + "sweep built but not yet broadcast" vs "sweep broadcast awaiting conf" + distinction. `TriggerFraudSpend` round-trips through a dedicated + `UnilateralExitJobTriggerFraudSpend` constant instead of silently + downgrading to `TriggerManual`. +- **FSM outbox events are side-effect-only.** `RequestSweepBuild`, + `EnsureReadyTransactions`, `ReissueInFlightTransactions`, and + `ReissueSweepConfirmation` never mutate `JobState`; they are routed by + `behavior.routeOutbox` to `txconfirm.Ask` calls outside the FSM. +- **All TxOut indexing goes through `safeTxOutPkScript`.** Operator-sourced + OOR artifacts flow into proof assembly; a zero-output or short-output + proof node is mapped to a retryable error rather than panicking the actor + goroutine. + +## Deep Docs + +- [docs/durable_actor_quickstart.md](../docs/durable_actor_quickstart.md) — + `TLVMessage`, `ActorBehavior`, migration checklist. +- [docs/durable_actor_architecture.md](../docs/durable_actor_architecture.md) — + CDC pattern and durable mailbox lifecycle. +- [unrollplan/CLAUDE.md](../unrollplan/CLAUDE.md) — Pure planner that this + actor drives. +- [txconfirm/CLAUDE.md](../txconfirm/CLAUDE.md) — Shared broadcast + CPFP + actor. +- [lib/recovery/CLAUDE.md](../lib/recovery/CLAUDE.md) — Immutable proof + graph. +- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map. diff --git a/unroll/CLAUDE.md b/unroll/CLAUDE.md new file mode 100644 index 000000000..e2ad52ceb --- /dev/null +++ b/unroll/CLAUDE.md @@ -0,0 +1,173 @@ +# unroll + +## Purpose + +Durable per-target unilateral-exit subsystem. One `VTXOUnrollActor` per VTXO +outpoint owns the full exit lifecycle (proof assembly → proof-node confirmation +→ CSV maturity → final sweep build → broadcast → confirmation) on top of a +pure `unrollplan.Planner` and the shared `txconfirm` actor. A thin +`UnrollRegistryActor` owns spawn / dedup / terminal bookkeeping and persists a +control-plane record per target to `db` so restart can restore in-flight jobs. + +## Key Types + +### Per-target actor +- `VTXOUnrollActor` — One durable actor per target outpoint. Wraps a + `baselib/actor.DurableActor[Msg, Resp]` and owns the FSM session, proof, + planner, and cached sweep transaction for this one VTXO. +- `Config` — Per-actor wiring: `TargetOutpoint`, `ActorID`, `DeliveryStore`, + `ProofAssembler`, `VTXOStore`, `TxConfirmRef`, `ChainSource`, `Wallet` + (`SweepWallet`), `MaxSweepFeeRateSatPerVByte`, and a `RegistryRef` for + terminal notifications. +- `behavior` — Actor behavior. Holds `b.sweepTx` (restored from checkpoint on + boot) so retries and replays converge on a single sweep txid / pkScript + under `txconfirm`'s txid-keyed dedup. +- `Msg` / `Resp` / `Event` / `OutboxEvent` — Sealed durable-mailbox, + response, FSM event, and FSM outbox surfaces. +- `StartUnrollRequest` / `ResumeUnrollRequest` / `HeightObservedMsg` / + `TxConfirmedMsg` / `TxFailedMsg` / `SpendObservedMsg` / `GetStateRequest` — + Durable mailbox messages. Each ships a per-message TLV codec (no JSON) with + a pinned record-type layout; round-trip tests live in + `messages_test.go`. +- `StartTrigger` — What caused the job to start: `TriggerManual`, + `TriggerCriticalExpiry`, `TriggerRestart`, `TriggerFraudSpend`. +- `Phase` — Coarse derived phase for control-plane visibility: + `PhasePending` / `PhaseMaterializing` / `PhaseCSVPending` / + `PhaseSweepBroadcast` / `PhaseSweepConfirmation` / `PhaseCompleted` / + `PhaseFailed`. +- `JobState` — Durable FSM state (height, trigger, planner state, + `FailReason`, `SweepAttempts`). + +### Registry +- `UnrollRegistryActor` — Thin coordinator over the set of + `VTXOUnrollActor`s. Handles `EnsureUnrollRequest` / `GetStatusRequest` + admission, receives `UnrollTerminatedMsg` from children, persists records, + and `RestoreNonTerminal` on boot. +- `RegistryConfig` — Store, `DeliveryStore`, `ProofAssembler`, `VTXOStore`, + `TxConfirmRef`, `ChainSource`, `Wallet`, `MaxSweepFeeRateSatPerVByte`. +- `RegistryRecord` — Control-plane row: `TargetOutpoint`, `ActorID`, + `Phase`, `Trigger`, `FailReason`, `SweepTxid`. +- `RegistryStore` — Persistence surface: `UpsertRecord`, `GetRecord`, + `ListNonTerminalRecords`, `MarkTerminal`. `DBRegistryStore` in + `db_store.go` is the production implementation; it adapts to the + `db.UnilateralExitStore` enum through `statusForPhase` / `phaseFromDB` + and `triggerToDB` / `triggerFromDB` which are locked in by round-trip + tests in `db_store_test.go`. +- `EnsureUnrollRequest` / `EnsureUnrollResp` — Admission API. Dedup runs + against `r.active`, `r.pending`, and `Store.GetRecord` — a repeat request + after a child terminated returns `Created=false` with the historical + `ActorID` rather than spawning a fresh actor and clobbering the sweep txid + / failure reason. + +### Support +- `LocalProofAssembler` — Assembles a `recovery.Proof` from the VTXO + descriptor and its OOR artifact lineage. Implements `ProofAssembler`. +- `DescriptorLineageResolver` — Walks OOR checkpoint artifacts to produce + the list of lineage transactions that must be confirmed before sweep. +- `SweepWallet` — Wallet interface: `NewWalletPkScript`, + `SignTaprootSpend`. +- `safeTxOutPkScript(tx, index)` — Bounds-checking helper used at every + `tx.TxOut[i].PkScript` site so malformed proof artifacts (operator-sourced + OOR inputs) surface as retryable errors instead of goroutine panics. + +## Relationships + +- **Depends on**: `baselib/actor` (`DurableActor`, `TLVMessage`, codec), + `baselib/protofsm` (FSM engine), `lib/recovery` (immutable proof graph), + `unrollplan` (pure planner + TLV state codec), `txconfirm` (broadcast + + CPFP + confirmation), `chainsource` (best-height + spend watch + fee + estimate), `vtxo` (`Descriptor`, `VTXOStore`), `db` (`UnilateralExitStore`, + `RegistryRecord` DB shape), `lib/arkscript` (timeout-path spend info). +- **Depended on by**: `darepod` (wires the registry into the daemon via the + lazy chain-resolver seam; wiring lives in PR #264). +- **Sends**: + - → `txconfirm` (Ask): `EnsureConfirmedReq` — one per proof node and one + for the final sweep. Dedup by txid makes retried sends idempotent. + - → `chainsource` (Ask): `RegisterSpendRequest` on the target outpoint to + catch external spends, `BestHeightRequest`, `FeeEstimateRequest`. + - → registry (Tell): `UnrollTerminatedMsg` from each child on terminal + transition. + - → `vtxo` (indirect via chain-resolver seam, wired in #264): + control-plane callbacks. +- **Receives**: + - ← API (registry): `EnsureUnrollRequest`, `GetStatusRequest` + (from `darepod` RPC layer via chain resolver). + - ← registry (internal): `persistActiveRecordMsg`, + `persistRecordResultMsg`, `UnrollTerminatedMsg`. + - ← per-target actor (mailbox): `StartUnrollRequest`, `ResumeUnrollRequest`, + `HeightObservedMsg`, `TxConfirmedMsg`, `TxFailedMsg`, `SpendObservedMsg`, + `GetStateRequest`. + - ← `txconfirm` notification subscriber: `TxConfirmed`, `TxFailed` + → mapped to `TxConfirmedMsg` / `TxFailedMsg`. + - ← `chainsource` block epochs: re-wrapped as `HeightObservedMsg`. + - ← `chainsource` spend notifications: re-wrapped as `SpendObservedMsg`. + +## Invariants + +- **Persist-before-broadcast.** `startSweep` calls `persistCheckpoint` + (writing `b.sweepTx` into the TLV checkpoint) BEFORE `txconfirm.Ask`. Any + handler-level retry or crash-restart restores the same sweep tx, and + `txconfirm`'s txid-keyed dedup makes the re-submit a benign no-op instead + of broadcasting a second sweep with a freshly-derived wallet pkScript that + races the first on chain. +- **Sweep tx reuse.** `startSweep` skips `buildSweepTx` when `b.sweepTx` is + already set (either from a prior attempt this actor lifetime or restored + from the checkpoint). This converges every retry on a single sweep + txid / pkScript and avoids burning BIP32 wallet addresses on fee-spike + retries. +- **Reissue must fail hard on missing state.** The `ReissueInFlightTransactions` + and `ReissueSweepConfirmation` outbox branches return an error on a missing + proof node or nil `sweepTx`. A silent `continue` would strand the FSM in + `AwaitingMaterialization` or `AwaitingSweepConfirmation` with no pending + `txconfirm` subscription and no way to advance. +- **Registry deduplication covers the whole trail.** `handleEnsure` checks + `r.active`, `r.pending`, AND `Store.GetRecord` before spawning — a repeat + request for an already-terminal outpoint returns the historical `ActorID` + and does not overwrite the stored sweep txid or failure reason. +- **Fail-closed admission write.** `handleEnsure` calls `Store.UpsertRecord` + synchronously and only returns `Created=true` after the record is durable. + If the initial write fails, the spawned child is stopped, removed from + `r.active`, and the caller sees a wrapped error instead of a silent + orphan. Without this invariant, a crash between admission and the former + async persist would leave the child unknown to `RestoreNonTerminal` on + reboot, silently losing the job. Subsequent updates stay on the async + `requestPersist` path so the registry goroutine is not held hostage by + every state transition. +- **Durable mailbox messages are TLV, not JSON.** Every message in + `messages.go` implements `actor.TLVMessage` with a hand-written + `Encode`/`Decode` pair driven by `tlv.Stream`. Inner record types start at + 1 per message (the outer mailbox codec identifies which message). The + checkpoint codec in `snapshot.go` is also TLV. +- **Checkpoint persists the sweep tx** via + `wire.MsgTx.Serialize` under `checkpointSweepTxRecordType` so restore + produces the exact same `b.sweepTx` that the pre-broadcast commit wrote. +- **Phase ↔ DB status mapping is lossless.** `PhaseSweepBroadcast` maps to + `UnilateralExitJobStatusSweepBroadcasting` (=6) and + `PhaseSweepConfirmation` maps to `UnilateralExitJobStatusSweeping` (=3) — + the two used to collapse onto the same DB value and silently erase the + "sweep built but not yet broadcast" vs "sweep broadcast awaiting conf" + distinction. `TriggerFraudSpend` round-trips through a dedicated + `UnilateralExitJobTriggerFraudSpend` constant instead of silently + downgrading to `TriggerManual`. +- **FSM outbox events are side-effect-only.** `RequestSweepBuild`, + `EnsureReadyTransactions`, `ReissueInFlightTransactions`, and + `ReissueSweepConfirmation` never mutate `JobState`; they are routed by + `behavior.routeOutbox` to `txconfirm.Ask` calls outside the FSM. +- **All TxOut indexing goes through `safeTxOutPkScript`.** Operator-sourced + OOR artifacts flow into proof assembly; a zero-output or short-output + proof node is mapped to a retryable error rather than panicking the actor + goroutine. + +## Deep Docs + +- [docs/durable_actor_quickstart.md](../docs/durable_actor_quickstart.md) — + `TLVMessage`, `ActorBehavior`, migration checklist. +- [docs/durable_actor_architecture.md](../docs/durable_actor_architecture.md) — + CDC pattern and durable mailbox lifecycle. +- [unrollplan/CLAUDE.md](../unrollplan/CLAUDE.md) — Pure planner that this + actor drives. +- [txconfirm/CLAUDE.md](../txconfirm/CLAUDE.md) — Shared broadcast + CPFP + actor. +- [lib/recovery/CLAUDE.md](../lib/recovery/CLAUDE.md) — Immutable proof + graph. +- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map. diff --git a/vtxo/AGENTS.md b/vtxo/AGENTS.md index 676e83b98..58beac2ff 100644 --- a/vtxo/AGENTS.md +++ b/vtxo/AGENTS.md @@ -54,6 +54,8 @@ when the local wallet owns the receive script. - SpendingState is persisted as VTXOStatusSpending and survives restarts. - OOR completion transitions VTXOs to SpentState through the VTXO actor FSM, not by direct store writes. - A VTXO in SpendingState cannot be admitted for cooperative consumption, and vice versa. +- `ForceUnrollEvent` is accepted in `LiveState`, `PendingForfeitState`, `SpendingState`, and `ForfeitingState`: each transitions to `UnilateralExitState` and emits the same `ExpiringNotification` / `VTXOStatusUpdate` / `VTXOTerminatedNotification` outbox shape. Terminal states (`UnilateralExit`, `Spent`, `Forfeited`, `Failed`) self-loop; the manager maps that self-loop back to `ForceUnrollResponse{Accepted: false, Reason: "already terminal"}` so the caller sees a distinct outcome from "no such VTXO". +- `Manager.handleForceUnroll` uses `Ask` (not `Tell`) so FSM errors and self-loop no-ops surface as structured `ForceUnrollResponse{Accepted, Reason}` instead of a uniform `Accepted:true` that masks work that was never scheduled. - Admission types (`SelectAndReserveSpendRequest`, `SelectAndReserveForfeitRequest`, `ReserveForfeitRequest`, etc.) are defined in `lib/actormsg` and re-exported as type aliases to avoid wallet → vtxo → round → wallet import cycles. - `selectAndReserveVTXOs` is a shared helper parameterized by `reserveParams` that serves both the OOR spend and cooperative forfeit coin selection paths, avoiding code duplication. - `IncomingVTXOHandler` only handles `VTXO_EVENT_TYPE_CREATED` events. Other event kinds, missing/short outpoints, empty pkScripts, oversized values (`> int64` or `> MaxSatoshi`), and tapscript derivation failures all return success without persisting — they cannot crash the actor or block the indexer push stream. Real DB lookup/save errors are surfaced. diff --git a/vtxo/CLAUDE.md b/vtxo/CLAUDE.md index 676e83b98..58beac2ff 100644 --- a/vtxo/CLAUDE.md +++ b/vtxo/CLAUDE.md @@ -54,6 +54,8 @@ when the local wallet owns the receive script. - SpendingState is persisted as VTXOStatusSpending and survives restarts. - OOR completion transitions VTXOs to SpentState through the VTXO actor FSM, not by direct store writes. - A VTXO in SpendingState cannot be admitted for cooperative consumption, and vice versa. +- `ForceUnrollEvent` is accepted in `LiveState`, `PendingForfeitState`, `SpendingState`, and `ForfeitingState`: each transitions to `UnilateralExitState` and emits the same `ExpiringNotification` / `VTXOStatusUpdate` / `VTXOTerminatedNotification` outbox shape. Terminal states (`UnilateralExit`, `Spent`, `Forfeited`, `Failed`) self-loop; the manager maps that self-loop back to `ForceUnrollResponse{Accepted: false, Reason: "already terminal"}` so the caller sees a distinct outcome from "no such VTXO". +- `Manager.handleForceUnroll` uses `Ask` (not `Tell`) so FSM errors and self-loop no-ops surface as structured `ForceUnrollResponse{Accepted, Reason}` instead of a uniform `Accepted:true` that masks work that was never scheduled. - Admission types (`SelectAndReserveSpendRequest`, `SelectAndReserveForfeitRequest`, `ReserveForfeitRequest`, etc.) are defined in `lib/actormsg` and re-exported as type aliases to avoid wallet → vtxo → round → wallet import cycles. - `selectAndReserveVTXOs` is a shared helper parameterized by `reserveParams` that serves both the OOR spend and cooperative forfeit coin selection paths, avoiding code duplication. - `IncomingVTXOHandler` only handles `VTXO_EVENT_TYPE_CREATED` events. Other event kinds, missing/short outpoints, empty pkScripts, oversized values (`> int64` or `> MaxSatoshi`), and tapscript derivation failures all return success without persisting — they cannot crash the actor or block the indexer push stream. Real DB lookup/save errors are surfaced.