From a6f68c02d2711b606f53e1c035315daa10fbfe63 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 14:41:08 +0200 Subject: [PATCH 01/10] oor: notify VTXO manager after materialization Teach the OOR actor to forward durably materialized incoming VTXOs into the VTXO manager. This keeps OOR on the existing persist-then-notify architecture while avoiding a second store write. The manager gains a dedicated message for already-persisted descriptors, and focused unit tests cover the new notifier wiring. --- oor/actor.go | 40 +++++++ oor/events.go | 12 +- oor/local_persistence_handler.go | 26 +---- oor/local_persistence_handler_test.go | 157 +------------------------- vtxo/manager.go | 44 ++++++++ vtxo/messages.go | 26 +++++ 6 files changed, 127 insertions(+), 178 deletions(-) diff --git a/oor/actor.go b/oor/actor.go index 60e6d40c6..4717c3fbc 100644 --- a/oor/actor.go +++ b/oor/actor.go @@ -12,6 +12,7 @@ import ( "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/build" "github.com/lightninglabs/darepo-client/serverconn" + "github.com/lightninglabs/darepo-client/vtxo" fn "github.com/lightningnetwork/lnd/fn/v2" ) @@ -64,6 +65,10 @@ type ClientActorCfg struct { // ActorID is the durable mailbox id used for this actor instance. // Re-using the same ActorID across restarts enables checkpoint restore. ActorID string + + // VTXOManager receives notifications after incoming VTXOs are durably + // materialized so it can spawn VTXO actors for monitoring. + VTXOManager actor.TellOnlyRef[vtxo.ManagerMsg] } // OORClientActor wraps the outgoing-transfer client FSM in a durable actor @@ -948,6 +953,12 @@ func (b *oorDurableBehavior) driveOutbox(ctx context.Context, } for _, followUp := range followUps { + // When incoming VTXOs are materialized, forward + // them to the VTXO manager so it can spawn + // monitoring actors. This mirrors the rounds + // actor pattern for VTXOCreatedNotification. + b.notifyMaterializedVTXOs(ctx, followUp) + finalizeState, err := b.captureFinalizeStateForEvent( fsm, followUp, ) @@ -986,6 +997,35 @@ func (b *oorDurableBehavior) driveOutbox(ctx context.Context, return nil } +// notifyMaterializedVTXOs forwards newly materialized incoming VTXOs to the +// VTXO manager when the follow-up event carries descriptors. This mirrors +// the rounds actor pattern where VTXOCreatedNotification is Tell'd to the +// manager from the actor's dispatch loop. +func (b *oorDurableBehavior) notifyMaterializedVTXOs(ctx context.Context, + followUp Event) { + + handled, ok := followUp.(*IncomingHandledEvent) + if !ok || len(handled.MaterializedVTXOs) == 0 { + return + } + + if b.cfg.VTXOManager == nil { + return + } + + notification := &vtxo.VTXOsMaterializedNotification{ + VTXOs: handled.MaterializedVTXOs, + } + + if err := b.cfg.VTXOManager.Tell(ctx, notification); err != nil { + b.logger(ctx).WarnS( + ctx, "Failed to notify VTXO manager of "+ + "materialized incoming VTXOs", err, + slog.Int("num_vtxos", + len(handled.MaterializedVTXOs))) + } +} + // persistCheckpoint snapshots every active session into a single TLV // blob and writes it to the durable delivery store. func (b *oorDurableBehavior) persistCheckpoint(ctx context.Context) error { diff --git a/oor/events.go b/oor/events.go index 53267dd57..787a45d48 100644 --- a/oor/events.go +++ b/oor/events.go @@ -6,6 +6,7 @@ import ( "github.com/btcsuite/btcd/btcutil/psbt" "github.com/lightninglabs/darepo-client/lib/scripts" oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/lightninglabs/darepo-client/vtxo" ) // Event is a sealed interface for all events that can drive the OOR transfer @@ -163,8 +164,15 @@ type IncomingTransferEvent struct { func (e *IncomingTransferEvent) eventSealed() {} // IncomingHandledEvent indicates the application/wallet has processed the -// incoming transfer notification. -type IncomingHandledEvent struct{} +// incoming transfer notification. When VTXOs are materialized, the +// descriptors are attached so the actor can forward them to the VTXO +// manager for actor activation. +type IncomingHandledEvent struct { + // MaterializedVTXOs contains descriptors that were durably + // persisted during materialization. The OOR actor forwards these + // to the VTXO manager so it can spawn monitoring actors. + MaterializedVTXOs []*vtxo.Descriptor +} // eventSealed marks this as implementing the sealed Event interface. func (e *IncomingHandledEvent) eventSealed() {} diff --git a/oor/local_persistence_handler.go b/oor/local_persistence_handler.go index 1621e4f35..f3be4e214 100644 --- a/oor/local_persistence_handler.go +++ b/oor/local_persistence_handler.go @@ -43,12 +43,6 @@ type IncomingMetadataResolver func(ctx context.Context, sessionID SessionID, recipient ArkRecipientOutput, ark *psbt.Packet, finalCheckpoints []*psbt.Packet) (IncomingVTXOMetadata, error) -// IncomingVTXONotifier is called after incoming VTXOs are durably -// materialized, allowing callers to spawn/manage VTXO actors for expiry and -// spend monitoring. -type IncomingVTXONotifier func(ctx context.Context, - vtxos []*vtxo.Descriptor) error - // LocalPersistenceOutboxHandler implements the persistence-related outbox // requests emitted by the OOR FSM. // @@ -85,11 +79,6 @@ type LocalPersistenceOutboxHandler struct { // ResolveIncomingMetadata resolves authoritative lineage/expiry // metadata for each incoming recipient output. ResolveIncomingMetadata IncomingMetadataResolver - - // NotifyIncomingVTXOs is invoked after incoming VTXOs are persisted. - // Production wiring should use this to notify the VTXO manager so newly - // received OOR VTXOs are actively monitored. - NotifyIncomingVTXOs IncomingVTXONotifier } // Handle executes one outbox request and emits follow-up FSM events. @@ -180,12 +169,6 @@ func (h *LocalPersistenceOutboxHandler) handleMaterializeIncoming( ) } - if h.NotifyIncomingVTXOs == nil { - return nil, fmt.Errorf( - "incoming VTXO notifier must be provided", - ) - } - if len(msg.Recipients) == 0 { return nil, fmt.Errorf("incoming recipients must be provided") } @@ -289,12 +272,9 @@ func (h *LocalPersistenceOutboxHandler) handleMaterializeIncoming( slog.Int("owned_recipients", ownedRecipients), slog.Int("materialized_vtxos", len(materializedVTXOs))) - err := h.NotifyIncomingVTXOs(ctx, materializedVTXOs) - if err != nil { - return nil, err - } - - return []Event{&IncomingHandledEvent{}}, nil + return []Event{&IncomingHandledEvent{ + MaterializedVTXOs: materializedVTXOs, + }}, nil } // handleIncomingAck forwards the ack request to the transport boundary (if diff --git a/oor/local_persistence_handler_test.go b/oor/local_persistence_handler_test.go index be23afbd0..d82120913 100644 --- a/oor/local_persistence_handler_test.go +++ b/oor/local_persistence_handler_test.go @@ -176,19 +176,12 @@ func TestLocalPersistenceOutboxHandlerMaterializeIncoming(t *testing.T) { sessionID := SessionID(arkPSBT.UnsignedTx.TxHash()) store := newTestVTXOStore() packageStore := &testPackageStore{} - notifyCalls := 0 handler := &LocalPersistenceOutboxHandler{ Store: store, PackageStore: packageStore, OperatorKey: operatorKey, ExitDelay: 10, - NotifyIncomingVTXOs: func(_ context.Context, - _ []*vtxo.Descriptor) error { - - notifyCalls++ - return nil - }, ResolveIncomingClientKey: func(ctx context.Context, recipient ArkRecipientOutput) ( keychain.KeyDescriptor, error) { @@ -234,6 +227,10 @@ func TestLocalPersistenceOutboxHandlerMaterializeIncoming(t *testing.T) { require.Len(t, events, 1) require.IsType(t, &IncomingHandledEvent{}, events[0]) + handledEvt, ok := events[0].(*IncomingHandledEvent) + require.True(t, ok) + require.Len(t, handledEvt.MaterializedVTXOs, 1) + desc, err := store.GetVTXO(ctx, wire.OutPoint{ Hash: arkPSBT.UnsignedTx.TxHash(), Index: recipients[0].OutputIndex, @@ -252,7 +249,6 @@ func TestLocalPersistenceOutboxHandlerMaterializeIncoming(t *testing.T) { require.IsType(t, &IncomingHandledEvent{}, events[0]) require.Equal(t, 2, packageStore.packageCalls) require.Equal(t, 2, packageStore.bindingCalls) - require.Equal(t, 2, notifyCalls) require.Equal(t, PackageDirectionIncoming, packageStore.lastDirection) require.Equal(t, chainhash.Hash(sessionID), packageStore.lastSessionID) } @@ -284,11 +280,6 @@ func TestLocalPersistenceOutboxHandlerMaterializeIncomingSkipsNotOwned( Store: store, OperatorKey: operatorKey, ExitDelay: 10, - NotifyIncomingVTXOs: func(_ context.Context, - _ []*vtxo.Descriptor) error { - - return nil - }, ResolveIncomingClientKey: func(ctx context.Context, recipient ArkRecipientOutput) ( keychain.KeyDescriptor, error) { @@ -365,11 +356,6 @@ func TestLocalPersistenceOutboxHandlerMaterializeIncomingRequiresOwned( Store: store, OperatorKey: operatorKey, ExitDelay: 10, - NotifyIncomingVTXOs: func(_ context.Context, - _ []*vtxo.Descriptor) error { - - return nil - }, ResolveIncomingClientKey: func(ctx context.Context, recipient ArkRecipientOutput) ( keychain.KeyDescriptor, error) { @@ -408,141 +394,6 @@ func TestLocalPersistenceOutboxHandlerMaterializeIncomingRequiresOwned( require.Empty(t, events) } -// TestLocalPersistenceOutboxHandlerMaterializeIncomingNotifierFailure asserts -// notifier failures abort incoming materialization completion. -func TestLocalPersistenceOutboxHandlerMaterializeIncomingNotifierFailure( - t *testing.T) { - - t.Parallel() - - ctx := t.Context() - - arkPSBT, finalCheckpoints, recipients, parentCommitment, recipientKey, - operatorKey := - buildTestIncomingMaterialization(t) - - sessionID := SessionID(arkPSBT.UnsignedTx.TxHash()) - store := newTestVTXOStore() - - handler := &LocalPersistenceOutboxHandler{ - Store: store, - OperatorKey: operatorKey, - ExitDelay: 10, - NotifyIncomingVTXOs: func(_ context.Context, - _ []*vtxo.Descriptor) error { - - return fmt.Errorf("notify failed") - }, - ResolveIncomingClientKey: func(ctx context.Context, - recipient ArkRecipientOutput) ( - keychain.KeyDescriptor, error) { - - _ = ctx - _ = recipient - - return keychain.KeyDescriptor{ - PubKey: recipientKey.PubKey(), - }, nil - }, - ResolveIncomingMetadata: func(ctx context.Context, - sessionID SessionID, recipient ArkRecipientOutput, - ark *psbt.Packet, - finalCheckpoints []*psbt.Packet) ( - IncomingVTXOMetadata, error) { - - _ = ctx - _ = sessionID - _ = recipient - _ = ark - _ = finalCheckpoints - - return IncomingVTXOMetadata{ - RoundID: "round-incoming", - CommitmentTxID: parentCommitment, - BatchExpiry: 1000, - TreeDepth: 1, - CreatedHeight: 700, - }, nil - }, - } - - req := &MaterializeIncomingVTXOsRequest{ - SessionID: sessionID, - ArkPSBT: arkPSBT, - FinalCheckpointPSBTs: finalCheckpoints, - Recipients: recipients, - } - events, err := handler.Handle(ctx, sessionID, req) - require.Error(t, err) - require.ErrorContains(t, err, "notify failed") - require.Empty(t, events) -} - -// TestLocalPersistenceOutboxHandlerMaterializeIncomingRequiresNotifier asserts -// notifier wiring is mandatory for incoming materialization. -func TestLocalPersistenceOutboxHandlerMaterializeIncomingRequiresNotifier( - t *testing.T) { - - t.Parallel() - - ctx := t.Context() - - arkPSBT, finalCheckpoints, recipients, parentCommitment, recipientKey, - operatorKey := - buildTestIncomingMaterialization(t) - - sessionID := SessionID(arkPSBT.UnsignedTx.TxHash()) - store := newTestVTXOStore() - - handler := &LocalPersistenceOutboxHandler{ - Store: store, - OperatorKey: operatorKey, - ExitDelay: 10, - ResolveIncomingClientKey: func(ctx context.Context, - recipient ArkRecipientOutput) ( - keychain.KeyDescriptor, error) { - - _ = ctx - _ = recipient - - return keychain.KeyDescriptor{ - PubKey: recipientKey.PubKey(), - }, nil - }, - ResolveIncomingMetadata: func(ctx context.Context, - sessionID SessionID, recipient ArkRecipientOutput, - ark *psbt.Packet, - finalCheckpoints []*psbt.Packet) ( - IncomingVTXOMetadata, error) { - - _ = ctx - _ = sessionID - _ = recipient - _ = ark - _ = finalCheckpoints - - return IncomingVTXOMetadata{ - RoundID: "round-incoming", - CommitmentTxID: parentCommitment, - BatchExpiry: 1000, - TreeDepth: 1, - CreatedHeight: 700, - }, nil - }, - } - - req := &MaterializeIncomingVTXOsRequest{ - SessionID: sessionID, - ArkPSBT: arkPSBT, - FinalCheckpointPSBTs: finalCheckpoints, - Recipients: recipients, - } - events, err := handler.Handle(ctx, sessionID, req) - require.Error(t, err) - require.ErrorContains(t, err, "incoming VTXO notifier") - require.Empty(t, events) -} - // TestLocalPersistenceOutboxHandlerIncomingAck asserts incoming ack requests // emit IncomingAckSentEvent. func TestLocalPersistenceOutboxHandlerIncomingAck(t *testing.T) { diff --git a/vtxo/manager.go b/vtxo/manager.go index b49656cc5..66d4d3b39 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -133,6 +133,9 @@ func (m *Manager) Receive(ctx context.Context, case *round.VTXOCreatedNotification: return m.handleVTXOCreated(ctx, req) + case *VTXOsMaterializedNotification: + return m.handleVTXOsMaterialized(ctx, req) + case *round.VTXOTerminatedMsg: return m.handleVTXOTerminated(ctx, req) @@ -207,6 +210,47 @@ func (m *Manager) handleVTXOCreated(ctx context.Context, return fn.Ok[ManagerResp](&VTXOCreatedResp{}) } +// handleVTXOsMaterialized spawns VTXO actors for descriptors that were already +// persisted by another actor, such as the OOR receive flow. +func (m *Manager) handleVTXOsMaterialized(ctx context.Context, + msg *VTXOsMaterializedNotification) fn.Result[ManagerResp] { + + for _, descriptor := range msg.VTXOs { + if descriptor == nil { + continue + } + + outpoint := descriptor.Outpoint + if _, exists := m.actors[outpoint]; exists { + m.logger(ctx).WarnS(ctx, + "VTXO actor already exists", nil, + slog.String("outpoint", outpoint.String()), + ) + + continue + } + + ref, err := m.spawnVTXOActor(ctx, descriptor) + if err != nil { + m.logger(ctx).ErrorS(ctx, + "Failed to spawn VTXO actor", err, + slog.String("outpoint", outpoint.String()), + ) + + continue + } + + m.actors[outpoint] = ref + + m.logger(ctx).InfoS(ctx, "Spawned VTXO actor", + slog.String("outpoint", outpoint.String()), + slog.Int64("amount", int64(descriptor.Amount)), + slog.Int("batch_expiry", int(descriptor.BatchExpiry))) + } + + return fn.Ok[ManagerResp](&VTXOsMaterializedResp{}) +} + // 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 4b31edbcb..7ebc5dda7 100644 --- a/vtxo/messages.go +++ b/vtxo/messages.go @@ -26,11 +26,37 @@ type VTXOCreatedResp struct{} func (r *VTXOCreatedResp) managerRespSealed() {} +// VTXOsMaterializedResp is the response to VTXOsMaterializedNotification. +type VTXOsMaterializedResp struct{} + +func (r *VTXOsMaterializedResp) managerRespSealed() {} + // VTXOTerminatedResp is the response to VTXOTerminatedMsg. type VTXOTerminatedResp struct{} func (r *VTXOTerminatedResp) managerRespSealed() {} +// VTXOsMaterializedNotification notifies the VTXO manager that VTXOs were +// already durably persisted by another actor and only actor activation remains. +// +// The OOR receive path uses this after materializing incoming VTXOs so the +// manager can spawn one VTXO actor per descriptor without performing another +// store write. +type VTXOsMaterializedNotification struct { + actor.BaseMessage + + // VTXOs are the descriptors that were already persisted locally. + VTXOs []*Descriptor +} + +// MessageType returns the message type identifier. +func (m *VTXOsMaterializedNotification) MessageType() string { + return "VTXOsMaterializedNotification" +} + +// VTXOManagerMsg implements actormsg.VTXOManagerMsg marker interface. +func (m *VTXOsMaterializedNotification) VTXOManagerMsg() {} + // GetActiveVTXOCountRequest requests the number of active VTXO actors managed // by the VTXO Manager. This goes through the actor message path to avoid // requiring synchronization. From 20b4982c5ed179d124646f7d5ac9dbe3a90c0a74 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 14:41:13 +0200 Subject: [PATCH 02/10] darepod: wire round and OOR into VTXO manager Start the VTXO manager actor during daemon startup and connect both round completion notifications and OOR incoming materialization to it. The round actor now supports late manager wiring so startup can bring the manager online after the round actor is registered. Tests cover the late binding path so the runtime wiring stays explicit and safe. --- darepod/server.go | 114 ++++++++++++++++++++++++++++++++---- round/actor.go | 9 ++- round/actor_harness_test.go | 6 +- round/actor_test.go | 42 +++++++++++++ 4 files changed, 153 insertions(+), 18 deletions(-) diff --git a/darepod/server.go b/darepod/server.go index a0c10a314..21c040a5c 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -38,6 +38,7 @@ import ( "github.com/lightninglabs/darepo-client/rpc/roundpb" "github.com/lightninglabs/darepo-client/serverconn" "github.com/lightninglabs/darepo-client/timeout" + "github.com/lightninglabs/darepo-client/vtxo" "github.com/lightninglabs/darepo-client/wallet" "github.com/lightninglabs/lndclient" lndbuild "github.com/lightningnetwork/lnd/build" @@ -766,18 +767,34 @@ func (s *Server) startWalletDependentActors(ctx context.Context, s.walletRef = fn.Some(walletRef) // ------------------------------------------------------- - // 10. Register the round client actor. + // 10. Start the VTXO manager before the round actor so + // the manager ref can be passed directly in the round + // config, avoiding a post-Start mutation. // ------------------------------------------------------- - if err := s.initRoundActor( + vtxoManagerRef, err := s.initVTXOManager(ctx, chainSourceRef) + if err != nil { + return err + } + + roundVTXOManager := actor.NewMapInputRef( + vtxoManagerRef, mapRoundVTXOManagerMsg, + ) + + // ------------------------------------------------------- + // 11. Register the round client actor. + // ------------------------------------------------------- + _, err = s.initRoundActor( ctx, chainSourceRef, walletRef, timeoutRef, - ); err != nil { + roundVTXOManager, + ) + if err != nil { return err } // ------------------------------------------------------- - // 11. Register the OOR client actor. + // 12. Register the OOR client actor. // ------------------------------------------------------- - if err := s.initOORActor(ctx); err != nil { + if err := s.initOORActor(ctx, vtxoManagerRef); err != nil { return err } @@ -1514,7 +1531,9 @@ func (s *Server) initRoundActor(ctx context.Context, walletRef actor.ActorRef[ wallet.WalletMsg, wallet.WalletResp, ], - timeoutRef actor.TellOnlyRef[timeout.Msg]) error { + timeoutRef actor.TellOnlyRef[timeout.Msg], + vtxoManager actor.TellOnlyRef[round.VTXOManagerMsg], +) (*round.RoundClientActor, error) { // Select the client wallet (signing) backend based on // wallet type. In lnd mode, signing goes through lnd's @@ -1545,7 +1564,7 @@ func (s *Server) initRoundActor(ctx context.Context, // and other round parameters. operatorTerms, err := s.fetchOperatorTerms(ctx) if err != nil { - return fmt.Errorf("unable to fetch operator "+ + return nil, fmt.Errorf("unable to fetch operator "+ "terms: %w", err) } @@ -1569,6 +1588,7 @@ func (s *Server) initRoundActor(ctx context.Context, ActorSystem: s.actorSystem, TimeoutActor: timeoutRef, MaxOperatorFee: defaultMaxOperatorFee, + VTXOManager: vtxoManager, ForfeitCollectionTimeout: s.cfg. ForfeitCollectionTimeout, } @@ -1577,7 +1597,7 @@ func (s *Server) initRoundActor(ctx context.Context, roundCfg, ).Unpack() if err != nil { - return fmt.Errorf("unable to create round "+ + return nil, fmt.Errorf("unable to create round "+ "actor: %w", err) } @@ -1593,13 +1613,71 @@ func (s *Server) initRoundActor(ctx context.Context, roundCfg.SelfRef = roundRef if err := roundActor.Start(ctx); err != nil { - return fmt.Errorf("unable to start round "+ + return nil, fmt.Errorf("unable to start round "+ "actor: %w", err) } log.InfoS(ctx, "Round actor registered and started") - return nil + return roundActor, nil +} + +// initVTXOManager creates, registers, and starts the VTXO manager actor. +// The manager recovers persisted VTXOs on startup and spawns one VTXO actor +// per live descriptor. +func (s *Server) initVTXOManager(ctx context.Context, + chainSourceRef actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ], +) (actor.ActorRef[vtxo.ManagerMsg, vtxo.ManagerResp], error) { + + var vtxoWallet vtxo.VTXOWallet + switch s.cfg.Wallet.Type { + case WalletTypeLnd: + lndSvc := s.lnd.UnsafeFromSome() + vtxoWallet = lndbackend.NewClientWallet( + lndSvc.Signer, lndSvc.WalletKit, + ) + + case WalletTypeLwwallet: + vtxoWallet = s.lwWallet.UnsafeFromSome() + } + + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + s.db.DB, s.db.Queries, s.db.Backend(), log, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + + manager := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSourceRef, + ActorSystem: s.actorSystem, + ChainParams: s.chainParams, + Log: fn.Some(log), + RoundActor: round.NewServiceKey().Ref(s.actorSystem), + }) + + managerKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + "vtxo-manager", + ) + managerRef := actor.RegisterWithSystem( + s.actorSystem, "vtxo-manager", managerKey, manager, + ) + + err := manager.Start(ctx, managerRef) + if err != nil { + s.actorSystem.StopAndRemoveActor("vtxo-manager") + + var zero actor.ActorRef[vtxo.ManagerMsg, vtxo.ManagerResp] + + return zero, fmt.Errorf("unable to start vtxo manager: %w", err) + } + + log.InfoS(ctx, "VTXO manager registered and started") + + return managerRef, nil } // initOORActor creates and starts the OOR (out-of-round) client actor. @@ -1615,7 +1693,9 @@ func (s *Server) initRoundActor(ctx context.Context, // incoming VTXOs, handles incoming ack. // - SigningOutboxHandler (Next delegate): signs Ark and checkpoint // PSBTs, schedules retries. -func (s *Server) initOORActor(ctx context.Context) error { +func (s *Server) initOORActor(ctx context.Context, + vtxoManagerRef actor.TellOnlyRef[vtxo.ManagerMsg]) error { + clk := clock.NewDefaultClock() dbStore := db.NewStore( s.db.DB, s.db.Queries, s.db.Backend(), log, @@ -1663,6 +1743,7 @@ func (s *Server) initOORActor(ctx context.Context) error { DeliveryStore: s.deliveryStore, ActorSystem: s.actorSystem, ActorID: oor.OORActorServiceKeyName, + VTXOManager: vtxoManagerRef, }) // Wire the timeout callback ref using the registered service @@ -1682,6 +1763,17 @@ func (s *Server) initOORActor(ctx context.Context) error { return nil } +// mapRoundVTXOManagerMsg adapts round-owned manager notifications into the +// concrete message type accepted by the VTXO manager actor. +func mapRoundVTXOManagerMsg(msg round.VTXOManagerMsg) vtxo.ManagerMsg { + mapped, ok := msg.(vtxo.ManagerMsg) + if !ok { + panic(fmt.Sprintf("unexpected VTXO manager msg type: %T", msg)) + } + + return mapped +} + // fetchOperatorTerms retrieves the operator's terms from the Ark // server via the ArkService.GetInfo RPC. The terms include the // operator pubkey, sweep delay, VTXO exit delay, forfeit script, dust diff --git a/round/actor.go b/round/actor.go index 619706928..e627523ab 100644 --- a/round/actor.go +++ b/round/actor.go @@ -247,11 +247,10 @@ type RoundClientConfig struct { MaxOperatorFee btcutil.Amount // VTXOManager receives VTXO creation notifications after rounds - // complete. The round actor forwards VTXOCreatedNotification messages - // to spawn VTXO actors for newly created VTXOs. Uses actor.Message to - // avoid import cycle with vtxo package. Optional - if nil, - // notifications are not forwarded. - VTXOManager actor.TellOnlyRef[actor.Message] + // complete. The round actor forwards VTXOCreatedNotification + // messages so newly created VTXOs get an active VTXO actor. + // Optional - if nil, notifications are not forwarded. + VTXOManager actor.TellOnlyRef[VTXOManagerMsg] // ActorSystem enables direct communication with VTXO actors via service // keys. Used to send PendingForfeitEvent, ForfeitRequestEvent, and diff --git a/round/actor_harness_test.go b/round/actor_harness_test.go index bf2ce6d82..43013be5d 100644 --- a/round/actor_harness_test.go +++ b/round/actor_harness_test.go @@ -380,7 +380,7 @@ func (m *mockTimeoutActor) assertTimeoutCancelled(t *testing.T, id timeout.ID) { } // mockVTXOManagerRef captures messages sent to the VTXO manager for test -// verification, implementing actor.TellOnlyRef[actor.Message]. +// verification, implementing actor.TellOnlyRef[VTXOManagerMsg]. type mockVTXOManagerRef struct { t *testing.T id string @@ -400,7 +400,9 @@ func (m *mockVTXOManagerRef) ID() string { return m.id } -func (m *mockVTXOManagerRef) Tell(_ context.Context, msg actor.Message) error { +func (m *mockVTXOManagerRef) Tell(_ context.Context, + msg VTXOManagerMsg) error { + m.mu.Lock() defer m.mu.Unlock() m.messages = append(m.messages, msg) diff --git a/round/actor_test.go b/round/actor_test.go index d764b8c7f..48080f730 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -1580,6 +1580,48 @@ func TestVTXOCreatedNotificationForwarding(t *testing.T) { t, clientVTXO.Outpoint, receivedNotif.VTXOs[0].Outpoint, ) }) + + t.Run("nil_vtxo_manager_skips_notification", func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + h.actor.cfg.VTXOManager = nil + + err := h.start() + require.NoError(t, err) + + notification := &VTXOCreatedNotification{ + VTXOs: []*ClientVTXO{ + { + Outpoint: wire.OutPoint{ + Hash: chainhash.HashH( + []byte("no-mgr-vtxo"), + ), + Index: 0, + }, + Amount: 100000, + PkScript: []byte{0x51, 0x20}, + ClientKey: h.newKeyDescriptor(), + OperatorKey: h.operatorPubKey, + Expiry: 144, + }, + }, + RoundID: "no-mgr-round", + CommitmentTxID: chainhash.HashH( + []byte("no-mgr-commitment"), + ), + BatchExpiry: 1000, + CreatedHeight: 500, + } + + // With nil VTXOManager, notification is silently + // skipped without error. + _ = h.actor.processOutbox( + h.ctx, []ClientOutMsg{notification}, + ) + require.Empty(t, h.vtxoManager.messages) + }) } // TestActorIntentMapping verifies that the actor correctly maps external From d0f81075bcdbde9aa2fb82fe5992c27fe1614628 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 15:19:12 +0200 Subject: [PATCH 03/10] systest: cover OOR VTXO manager activation Add a systest that drives an incoming OOR receive through materialization and asserts the VTXO manager activates the new VTXO actor. This covers the runtime wiring path that unit tests do not fully exercise, including persistence, manager notification, and actor registration in the live system graph. --- systest/oor_vtxo_manager_test.go | 418 +++++++++++++++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 systest/oor_vtxo_manager_test.go diff --git a/systest/oor_vtxo_manager_test.go b/systest/oor_vtxo_manager_test.go new file mode 100644 index 000000000..22b2a308c --- /dev/null +++ b/systest/oor_vtxo_manager_test.go @@ -0,0 +1,418 @@ +//go:build systest + +package systest + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/db/actordelivery" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/lightninglabs/darepo-client/lib/tree" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/lightninglabs/darepo-client/lndbackend" + "github.com/lightninglabs/darepo-client/oor" + "github.com/lightninglabs/darepo-client/vtxo" + "github.com/lightningnetwork/lnd/clock" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +const ( + oorVTXOManagerEventuallyTimeout = 10 * time.Second + oorVTXOManagerEventuallyPoll = 100 * time.Millisecond +) + +// TestOORIncomingMaterializationSpawnsVTXOActor verifies the OOR receive flow +// materializes an incoming VTXO, notifies the VTXO manager, and results in a +// live VTXO actor registered in the actor system. +func TestOORIncomingMaterializationSpawnsVTXOActor(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), h.Logger(), + ) + vtxoStore := dbStore.NewVTXOStore(clk) + + deliveryStore, err := actordelivery.NewTxAwareDeliveryStoreFromDB( + sqlDB.DB, sqlDB.Backend(), clk, h.Logger(), + ) + require.NoError(t, err) + + chainSourceRef := h.NewChainSourceActor() + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + + manager := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSourceRef, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + managerKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + "systest-vtxo-manager", + ) + managerRef := actor.RegisterWithSystem( + h.ActorSystem(), "systest-vtxo-manager", managerKey, manager, + ) + + err = manager.Start(ctx, managerRef) + require.NoError(t, err) + + arkPSBT, finalCheckpoints, recipients, metadata, recipientKey, + operatorKey := buildSystemTestIncomingMaterialization(t) + sessionID := oor.SessionID(arkPSBT.UnsignedTx.TxHash()) + expectedIndex := recipients[0].OutputIndex + + err = seedIncomingRound( + ctx, sqlDB.Queries, metadata.RoundID, clk.Now().Unix(), + ) + require.NoError(t, err) + + handler := &oor.LocalPersistenceOutboxHandler{ + Store: vtxoStore, + OperatorKey: operatorKey, + ExitDelay: 10, + ResolveIncomingClientKey: func(_ context.Context, + recipient oor.ArkRecipientOutput) ( + keychain.KeyDescriptor, error) { + + require.Equal(t, expectedIndex, recipient.OutputIndex) + + return keychain.KeyDescriptor{ + PubKey: recipientKey.PubKey(), + }, nil + }, + ResolveIncomingMetadata: func(_ context.Context, + gotSessionID oor.SessionID, + recipient oor.ArkRecipientOutput, _ *psbt.Packet, + _ []*psbt.Packet) ( + oor.IncomingVTXOMetadata, error) { + + require.Equal(t, sessionID, gotSessionID) + require.Equal(t, expectedIndex, recipient.OutputIndex) + + return metadata, nil + }, + } + + oorActor := oor.NewOORClientActor(oor.ClientActorCfg{ + Log: fn.Some(h.SubLogger(oor.Subsystem)), + OutboxHandler: handler, + DeliveryStore: deliveryStore, + ActorSystem: h.ActorSystem(), + ActorID: "systest-oor-vtxo-manager", + VTXOManager: managerRef, + }) + defer oorActor.Stop() + + session, outbox, err := oor.DriveIncomingTransferWithCheckpoints( + ctx, sessionID, arkPSBT, finalCheckpoints, + ) + require.NoError(t, err) + + err = driveIncomingOutbox( + ctx, session, handler, sessionID, managerRef, outbox, + ) + require.NoError(t, err) + + outpoint := wire.OutPoint{ + Hash: arkPSBT.UnsignedTx.TxHash(), + Index: recipients[0].OutputIndex, + } + + require.Eventually(t, func() bool { + count, countErr := activeVTXOCount(ctx, managerRef) + if countErr != nil { + return false + } + + return count == 1 + }, oorVTXOManagerEventuallyTimeout, oorVTXOManagerEventuallyPoll) + + require.Eventually(t, func() bool { + refs := actor.FindInReceptionist( + h.ActorSystem().Receptionist(), + vtxo.VTXOActorServiceKey(outpoint), + ) + + return len(refs) == 1 + }, oorVTXOManagerEventuallyTimeout, oorVTXOManagerEventuallyPoll) + + desc, err := vtxoStore.GetVTXO(ctx, outpoint) + require.NoError(t, err) + require.Equal(t, metadata.RoundID, desc.RoundID) + require.Equal(t, metadata.CommitmentTxID, desc.CommitmentTxID) + require.Equal(t, metadata.BatchExpiry, desc.BatchExpiry) + require.Equal(t, metadata.CreatedHeight, desc.CreatedHeight) + + state, err := session.FSM.CurrentState() + require.NoError(t, err) + require.IsType(t, &oor.ReceiveCompleted{}, state) +} + +// driveIncomingOutbox executes the receive-flow outbox through the local +// persistence handler until the FSM reaches its terminal acked state. +// When a VTXOManager ref is provided, materialized VTXOs are forwarded +// to the manager, mirroring the actor's driveOutbox behavior. +func driveIncomingOutbox(ctx context.Context, session *oor.ReceiveSession, + handler *oor.LocalPersistenceOutboxHandler, sessionID oor.SessionID, + managerRef actor.ActorRef[vtxo.ManagerMsg, vtxo.ManagerResp], + outbox []oor.OutboxEvent) error { + + for _, msg := range outbox { + switch typedMsg := msg.(type) { + case *oor.IncomingTransferNotification: + continue + + case *oor.MaterializeIncomingVTXOsRequest: + followUps, err := handler.Handle( + ctx, sessionID, typedMsg, + ) + if err != nil { + return err + } + + for _, followUp := range followUps { + // Mirror actor notification: forward + // materialized VTXOs to the manager. + err = notifyMaterialized( + ctx, followUp, managerRef, + ) + if err != nil { + return err + } + + fut := session.FSM.AskEvent(ctx, followUp) + result := fut.Await(ctx) + if result.IsErr() { + return result.Err() + } + + nextOutbox := result.UnwrapOr(nil) + if err := driveIncomingOutbox( + ctx, session, handler, sessionID, + managerRef, nextOutbox, + ); err != nil { + return err + } + } + + case *oor.SendIncomingAckRequest: + followUps, err := handler.Handle( + ctx, sessionID, typedMsg, + ) + if err != nil { + return err + } + + for _, followUp := range followUps { + fut := session.FSM.AskEvent(ctx, followUp) + result := fut.Await(ctx) + if result.IsErr() { + return result.Err() + } + } + + default: + return fmt.Errorf( + "unexpected outbox event: %T", typedMsg, + ) + } + } + + return nil +} + +// activeVTXOCount queries the VTXO manager actor for its current live count. +func activeVTXOCount(ctx context.Context, + managerRef actor.ActorRef[vtxo.ManagerMsg, vtxo.ManagerResp]) ( + int, error) { + + fut := managerRef.Ask(ctx, &vtxo.GetActiveVTXOCountRequest{}) + result := fut.Await(ctx) + resp, err := result.Unpack() + if err != nil { + return 0, err + } + + countResp, ok := resp.(*vtxo.GetActiveVTXOCountResponse) + if !ok { + return 0, fmt.Errorf("unexpected manager response: %T", resp) + } + + return countResp.Count, nil +} + +// seedIncomingRound inserts the round row referenced by the incoming VTXO +// fixture so the VTXO insert satisfies its foreign-key constraint regardless +// of the active test database backend. +func seedIncomingRound(ctx context.Context, roundStore db.RoundStore, + roundID string, nowUnix int64) error { + + return roundStore.InsertRound(ctx, db.InsertRoundParams{ + RoundID: roundID, + ConfirmationHeight: sql.NullInt32{}, + ConfirmationBlockHash: nil, + CommitmentTx: nil, + CommitmentTxid: nil, + VtxtTree: nil, + Status: "confirmed", + CreationTime: nowUnix, + LastUpdateTime: nowUnix, + StartHeight: 0, + }) +} + +// buildSystemTestIncomingMaterialization constructs a canonical Ark PSBT and +// metadata suitable for exercising the OOR receive materialization path. +func buildSystemTestIncomingMaterialization(t *testing.T) (*psbt.Packet, + []*psbt.Packet, []oor.ArkRecipientOutput, oor.IncomingVTXOMetadata, + *btcec.PrivateKey, *btcec.PublicKey) { + + t.Helper() + + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + policy := scripts.CheckpointPolicy{ + OperatorKey: operatorKey.PubKey(), + CSVDelay: 10, + } + + recipientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + inputValue := btcutil.Amount(10000) + inputs := []oortx.CheckpointInput{ + { + SpentVTXO: oortx.SpentVTXORef{ + Outpoint: wire.OutPoint{ + Hash: [32]byte{0x11}, + Index: 0, + }, + Output: &wire.TxOut{ + Value: int64(inputValue), + PkScript: systemTestTaprootPkScript( + t, operatorKey.PubKey(), + ), + }, + }, + OwnerLeafScript: []byte{0x51}, + }, + } + + vtxoTapKey, err := scripts.VTXOTapKey( + recipientKey.PubKey(), policy.OperatorKey, 10, + ) + require.NoError(t, err) + + recipientPkScript, err := txscript.PayToTaprootScript(vtxoTapKey) + require.NoError(t, err) + + outputs := []oortx.RecipientOutput{ + { + PkScript: recipientPkScript, + Value: inputValue, + }, + } + + checkpoint, err := oortx.BuildCheckpointPSBT(policy, inputs[0]) + require.NoError(t, err) + checkpointTxHash := checkpoint.PSBT.UnsignedTx.TxHash() + checkpointTxOut := checkpoint.PSBT.UnsignedTx.TxOut[0] + + arkPSBT, err := oortx.BuildArkPSBT( + []oortx.CheckpointOutput{ + { + Txid: checkpointTxHash, + Output: checkpointTxOut, + TapTreeEncoded: checkpoint.TapTreeEncoded, + }, + }, + outputs, + ) + require.NoError(t, err) + + recipients, err := oor.ExtractArkRecipients(arkPSBT) + require.NoError(t, err) + + metadata := oor.IncomingVTXOMetadata{ + RoundID: "systest-round", + CommitmentTxID: inputs[0].SpentVTXO.Outpoint.Hash, + BatchExpiry: 1000, + TreeDepth: 1, + CreatedHeight: 700, + TreePath: &tree.Tree{ + BatchOutpoint: wire.OutPoint{ + Hash: inputs[0].SpentVTXO.Outpoint.Hash, + Index: 0, + }, + Root: &tree.Node{ + Input: inputs[0].SpentVTXO.Outpoint, + Outputs: []*wire.TxOut{ + checkpoint.PSBT.UnsignedTx.TxOut[0], + }, + CoSigners: []*btcec.PublicKey{}, + Children: make(map[uint32]*tree.Node), + }, + }, + } + + return arkPSBT, []*psbt.Packet{checkpoint.PSBT}, recipients, metadata, + recipientKey, operatorKey.PubKey() +} + +// systemTestTaprootPkScript returns a valid P2TR pkScript for systest +// fixtures. +func systemTestTaprootPkScript(t *testing.T, + key *btcec.PublicKey) []byte { + + t.Helper() + + pkScript, err := txscript.PayToTaprootScript(key) + require.NoError(t, err) + + return pkScript +} + +// notifyMaterialized forwards materialized VTXOs from an +// IncomingHandledEvent to the VTXO manager, mirroring the +// actor's driveOutbox notification path. +func notifyMaterialized(ctx context.Context, + ev oor.Event, + mgr actor.ActorRef[vtxo.ManagerMsg, vtxo.ManagerResp], +) error { + + handled, ok := ev.(*oor.IncomingHandledEvent) + if !ok || len(handled.MaterializedVTXOs) == 0 { + return nil + } + + if mgr == nil { + return nil + } + + return mgr.Tell(ctx, &vtxo.VTXOsMaterializedNotification{ + VTXOs: handled.MaterializedVTXOs, + }) +} From 55e8f203107d2f8fa49f2642ed3c9291cf6d24ca Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 15:45:44 +0200 Subject: [PATCH 04/10] vtxo+oor: add ChainDepth field to Descriptor and IncomingVTXOMetadata Introduce ChainDepth as a first-class field on vtxo.Descriptor and oor.IncomingVTXOMetadata, distinct from the existing TreeDepth. TreeDepth tracks position within the VTXT (virtual transaction tree), while ChainDepth counts OOR checkpoint hops from the last on-chain commitment. Round-created VTXOs explicitly set ChainDepth to 0. This is Part 2 of issue #124: the field is carried through the domain types so that persistence and RPC layers can expose it in follow-up commits. --- oor/incoming_vtxo.go | 6 +++ oor/incoming_vtxo_test.go | 89 +++++++++++++++++++++++++++++++++++++++ vtxo/interfaces.go | 12 +++++- vtxo/manager.go | 1 + vtxo/manager_test.go | 58 +++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 oor/incoming_vtxo_test.go create mode 100644 vtxo/manager_test.go diff --git a/oor/incoming_vtxo.go b/oor/incoming_vtxo.go index c002249e6..507503c2c 100644 --- a/oor/incoming_vtxo.go +++ b/oor/incoming_vtxo.go @@ -34,6 +34,11 @@ type IncomingVTXOMetadata struct { // TreeDepth is the VTXO depth in the commitment tree. TreeDepth int + // ChainDepth is the number of OOR checkpoint hops between this + // VTXO and the last on-chain commitment. This is distinct from + // TreeDepth, which tracks position in the VTXT. + ChainDepth int + // CreatedHeight is the block height at which the VTXO was created. CreatedHeight int32 @@ -151,6 +156,7 @@ func BuildIncomingVTXODescriptor(ark *psbt.Packet, BatchExpiry: cfg.Metadata.BatchExpiry, RelativeExpiry: cfg.ExitDelay, TreeDepth: cfg.Metadata.TreeDepth, + ChainDepth: cfg.Metadata.ChainDepth, CreatedHeight: cfg.Metadata.CreatedHeight, Status: vtxo.VTXOStatusLive, }, nil diff --git a/oor/incoming_vtxo_test.go b/oor/incoming_vtxo_test.go new file mode 100644 index 000000000..1d6ac202d --- /dev/null +++ b/oor/incoming_vtxo_test.go @@ -0,0 +1,89 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// TestBuildIncomingVTXODescriptorChainDepth verifies that +// BuildIncomingVTXODescriptor propagates ChainDepth from the incoming +// metadata to the resulting descriptor without modification. +func TestBuildIncomingVTXODescriptorChainDepth(t *testing.T) { + t.Parallel() + + arkPSBT, _, recipients, commitHash, recipientKey, + operatorKey := buildTestIncomingMaterialization(t) + + const wantChainDepth = 3 + + desc, err := BuildIncomingVTXODescriptor(arkPSBT, + IncomingVTXOConfig{ + OutputIndex: recipients[0].OutputIndex, + ClientKey: keychain.KeyDescriptor{ + PubKey: recipientKey.PubKey(), + }, + OperatorKey: operatorKey, + ExitDelay: 10, + Metadata: IncomingVTXOMetadata{ + RoundID: "test-round", + CommitmentTxID: commitHash, + BatchExpiry: 1000, + TreeDepth: 2, + ChainDepth: wantChainDepth, + CreatedHeight: 500, + }, + }, + ) + require.NoError(t, err) + require.Equal(t, wantChainDepth, desc.ChainDepth) + require.Equal(t, 2, desc.TreeDepth) +} + +// TestBuildIncomingVTXODescriptorZeroChainDepth verifies that a VTXO +// built with ChainDepth 0 (e.g. first OOR hop from a round VTXO) +// preserves the zero value explicitly. +func TestBuildIncomingVTXODescriptorZeroChainDepth(t *testing.T) { + t.Parallel() + + arkPSBT, _, recipients, commitHash, recipientKey, + operatorKey := buildTestIncomingMaterialization(t) + + desc, err := BuildIncomingVTXODescriptor(arkPSBT, + IncomingVTXOConfig{ + OutputIndex: recipients[0].OutputIndex, + ClientKey: keychain.KeyDescriptor{ + PubKey: recipientKey.PubKey(), + }, + OperatorKey: operatorKey, + ExitDelay: 10, + Metadata: IncomingVTXOMetadata{ + RoundID: "test-round", + CommitmentTxID: commitHash, + BatchExpiry: 1000, + TreeDepth: 1, + ChainDepth: 0, + CreatedHeight: 500, + }, + }, + ) + require.NoError(t, err) + require.Equal(t, 0, desc.ChainDepth) +} + +// TestBuildIncomingVTXODescriptorRejectsNilArk verifies that a nil Ark +// PSBT is rejected early. +func TestBuildIncomingVTXODescriptorRejectsNilArk(t *testing.T) { + t.Parallel() + + _, err := BuildIncomingVTXODescriptor(nil, IncomingVTXOConfig{ + Metadata: IncomingVTXOMetadata{ + RoundID: "test-round", + CommitmentTxID: chainhash.Hash{0x01}, + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "ark psbt must be provided") +} diff --git a/vtxo/interfaces.go b/vtxo/interfaces.go index dbfeea9bd..e05592e22 100644 --- a/vtxo/interfaces.go +++ b/vtxo/interfaces.go @@ -317,10 +317,18 @@ type Descriptor struct { // (blocks from when VTXO is realized on-chain). RelativeExpiry uint32 - // TreeDepth is the depth of this VTXO in the VTXT (used for expiry - // calculation). + // TreeDepth is the depth of this VTXO in the VTXT (virtual + // transaction tree). This is the VTXO's position within the + // commitment tree and is used for expiry calculation. TreeDepth int + // ChainDepth is the number of OOR checkpoint transactions between + // this VTXO and the most recent on-chain commitment. A VTXO + // created directly from a round has ChainDepth 0. Each OOR hop + // adds one to the chain depth. This is distinct from TreeDepth, + // which tracks position within the VTXT. + ChainDepth int + // CreatedHeight is the block height when this VTXO was created. CreatedHeight int32 diff --git a/vtxo/manager.go b/vtxo/manager.go index 66d4d3b39..779a69636 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -366,6 +366,7 @@ func clientVTXOToDescriptor(cv *round.ClientVTXO, BatchExpiry: msg.BatchExpiry, RelativeExpiry: cv.Expiry, TreeDepth: treeDepth, + ChainDepth: 0, CreatedHeight: msg.CreatedHeight, Status: VTXOStatusLive, }) diff --git a/vtxo/manager_test.go b/vtxo/manager_test.go new file mode 100644 index 000000000..280685bbd --- /dev/null +++ b/vtxo/manager_test.go @@ -0,0 +1,58 @@ +package vtxo + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/tree" + "github.com/lightninglabs/darepo-client/round" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// TestClientVTXOToDescriptorChainDepthZero verifies that a round-created +// VTXO descriptor has ChainDepth 0, since round VTXOs are anchored +// directly by the on-chain commitment with no OOR hops. +func TestClientVTXOToDescriptorChainDepthZero(t *testing.T) { + t.Parallel() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + cv := &round.ClientVTXO{ + Outpoint: wire.OutPoint{ + Hash: chainhash.Hash{0x01}, + Index: 0, + }, + Amount: btcutil.Amount(50000), + PkScript: []byte{0x51, 0x20}, + ClientKey: keychain.KeyDescriptor{ + PubKey: clientKey.PubKey(), + }, + OperatorKey: operatorKey.PubKey(), + Expiry: 10, + TreePath: &tree.Tree{ + Root: &tree.Node{}, + }, + } + + msg := &round.VTXOCreatedNotification{ + RoundID: "round-1", + CommitmentTxID: chainhash.Hash{0x02}, + BatchExpiry: 1000, + CreatedHeight: 700, + VTXOs: []*round.ClientVTXO{cv}, + } + + result := clientVTXOToDescriptor(cv, msg) + desc, err := result.Unpack() + require.NoError(t, err) + require.Equal(t, 0, desc.ChainDepth) + require.Equal(t, "round-1", desc.RoundID) +} From 43828746b215ee4811a2a01c93eff30c7858a2e3 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 15:56:48 +0200 Subject: [PATCH 05/10] db: persist ChainDepth in vtxos table Add migration 000005 with a chain_depth column (INTEGER NOT NULL DEFAULT 0) to the vtxos table. Existing rows read as 0, which is the correct value for round-created VTXOs and the safe default for historical OOR VTXOs with unknown lineage. Thread the field through both store implementations: - VTXOPersistenceStore (OOR path): maps Descriptor.ChainDepth on insert and read. - RoundPersistenceStore (round path): explicitly sets ChainDepth to 0. The InsertVTXO ON CONFLICT clause preserves existing chain_depth when the incoming value is 0, matching the pattern used for tree_depth and batch_expiry. --- db/actordelivery/sqlc/models.go | 1 + db/migrations.go | 2 +- db/round_store.go | 1 + .../000005_vtxo_chain_depth.down.sql | 1 + .../migrations/000005_vtxo_chain_depth.up.sql | 5 +++ db/sqlc/models.go | 1 + db/sqlc/queries/round.sql | 7 ++-- db/sqlc/round.sql.go | 21 +++++++---- db/sqlc/schemas/generated_schema.sql | 2 +- db/sqlc/vtxo.sql.go | 6 ++-- db/vtxo_store.go | 2 ++ db/vtxo_store_test.go | 36 +++++++++++++++++++ 12 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 db/sqlc/migrations/000005_vtxo_chain_depth.down.sql create mode 100644 db/sqlc/migrations/000005_vtxo_chain_depth.up.sql diff --git a/db/actordelivery/sqlc/models.go b/db/actordelivery/sqlc/models.go index 3803bff52..07ae70836 100644 --- a/db/actordelivery/sqlc/models.go +++ b/db/actordelivery/sqlc/models.go @@ -252,4 +252,5 @@ type Vtxo struct { ReplacedByIndex sql.NullInt32 CreationTime int64 LastUpdateTime int64 + ChainDepth int32 } diff --git a/db/migrations.go b/db/migrations.go index 2e83c467b..fede14a36 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 = 4 + LatestMigrationVersion uint = 5 ) // MigrationTarget is a functional option that can be passed to applyMigrations diff --git a/db/round_store.go b/db/round_store.go index f107e090b..05980369f 100644 --- a/db/round_store.go +++ b/db/round_store.go @@ -1204,6 +1204,7 @@ func (s *RoundPersistenceStore) domainVTXOToInsertParams( // metadata will update these fields. BatchExpiry: 0, TreeDepth: 0, + ChainDepth: 0, CreatedHeight: 0, CommitmentTxid: []byte{}, Spent: false, diff --git a/db/sqlc/migrations/000005_vtxo_chain_depth.down.sql b/db/sqlc/migrations/000005_vtxo_chain_depth.down.sql new file mode 100644 index 000000000..cbc13110b --- /dev/null +++ b/db/sqlc/migrations/000005_vtxo_chain_depth.down.sql @@ -0,0 +1 @@ +ALTER TABLE vtxos DROP COLUMN chain_depth; diff --git a/db/sqlc/migrations/000005_vtxo_chain_depth.up.sql b/db/sqlc/migrations/000005_vtxo_chain_depth.up.sql new file mode 100644 index 000000000..898ecca36 --- /dev/null +++ b/db/sqlc/migrations/000005_vtxo_chain_depth.up.sql @@ -0,0 +1,5 @@ +-- Add chain_depth to vtxos table. This tracks the number of OOR checkpoint +-- hops between a VTXO and the most recent on-chain commitment. Round-created +-- VTXOs have chain_depth 0. Existing rows default to 0 because they are +-- either round-created or have unknown historical OOR depth. +ALTER TABLE vtxos ADD COLUMN chain_depth INTEGER NOT NULL DEFAULT 0; diff --git a/db/sqlc/models.go b/db/sqlc/models.go index d969a3e41..96ceacefb 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -185,4 +185,5 @@ type Vtxo struct { ReplacedByIndex sql.NullInt32 CreationTime int64 LastUpdateTime int64 + ChainDepth int32 } diff --git a/db/sqlc/queries/round.sql b/db/sqlc/queries/round.sql index 821e2f3d0..cf05aa37f 100644 --- a/db/sqlc/queries/round.sql +++ b/db/sqlc/queries/round.sql @@ -130,15 +130,16 @@ DELETE FROM client_tree_txids WHERE round_id = $1 AND client_key = $2; INSERT INTO vtxos ( outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, - tree_path, batch_expiry, tree_depth, created_height, commitment_txid, - spent, creation_time, last_update_time + tree_path, batch_expiry, tree_depth, chain_depth, created_height, + commitment_txid, spent, creation_time, last_update_time ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - $16, $17, $18 + $16, $17, $18, $19 ) ON CONFLICT (outpoint_hash, outpoint_index) DO UPDATE SET batch_expiry = CASE WHEN excluded.batch_expiry != 0 THEN excluded.batch_expiry ELSE vtxos.batch_expiry END, tree_depth = CASE WHEN excluded.tree_depth != 0 THEN excluded.tree_depth ELSE vtxos.tree_depth END, + chain_depth = CASE WHEN excluded.chain_depth != 0 THEN excluded.chain_depth ELSE vtxos.chain_depth END, created_height = CASE WHEN excluded.created_height != 0 THEN excluded.created_height ELSE vtxos.created_height END, commitment_txid = CASE WHEN excluded.commitment_txid IS NOT NULL AND length(excluded.commitment_txid) > 0 THEN excluded.commitment_txid ELSE vtxos.commitment_txid END, last_update_time = excluded.last_update_time; diff --git a/db/sqlc/round.sql.go b/db/sqlc/round.sql.go index 285c09191..7ff30c0b4 100644 --- a/db/sqlc/round.sql.go +++ b/db/sqlc/round.sql.go @@ -302,7 +302,7 @@ func (q *Queries) GetRoundVtxoRequests(ctx context.Context, roundID string) ([]R } const GetVTXO = `-- name: GetVTXO :one -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time FROM vtxos +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth FROM vtxos WHERE outpoint_hash = $1 AND outpoint_index = $2 ` @@ -339,6 +339,7 @@ func (q *Queries) GetVTXO(ctx context.Context, arg GetVTXOParams) (Vtxo, error) &i.ReplacedByIndex, &i.CreationTime, &i.LastUpdateTime, + &i.ChainDepth, ) return i, err } @@ -520,15 +521,16 @@ const InsertVTXO = `-- name: InsertVTXO :exec INSERT INTO vtxos ( outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, - tree_path, batch_expiry, tree_depth, created_height, commitment_txid, - spent, creation_time, last_update_time + tree_path, batch_expiry, tree_depth, chain_depth, created_height, + commitment_txid, spent, creation_time, last_update_time ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - $16, $17, $18 + $16, $17, $18, $19 ) ON CONFLICT (outpoint_hash, outpoint_index) DO UPDATE SET batch_expiry = CASE WHEN excluded.batch_expiry != 0 THEN excluded.batch_expiry ELSE vtxos.batch_expiry END, tree_depth = CASE WHEN excluded.tree_depth != 0 THEN excluded.tree_depth ELSE vtxos.tree_depth END, + chain_depth = CASE WHEN excluded.chain_depth != 0 THEN excluded.chain_depth ELSE vtxos.chain_depth END, created_height = CASE WHEN excluded.created_height != 0 THEN excluded.created_height ELSE vtxos.created_height END, commitment_txid = CASE WHEN excluded.commitment_txid IS NOT NULL AND length(excluded.commitment_txid) > 0 THEN excluded.commitment_txid ELSE vtxos.commitment_txid END, last_update_time = excluded.last_update_time @@ -548,6 +550,7 @@ type InsertVTXOParams struct { TreePath []byte BatchExpiry int32 TreeDepth int32 + ChainDepth int32 CreatedHeight int32 CommitmentTxid []byte Spent bool @@ -575,6 +578,7 @@ func (q *Queries) InsertVTXO(ctx context.Context, arg InsertVTXOParams) error { arg.TreePath, arg.BatchExpiry, arg.TreeDepth, + arg.ChainDepth, arg.CreatedHeight, arg.CommitmentTxid, arg.Spent, @@ -623,7 +627,7 @@ func (q *Queries) ListActiveRounds(ctx context.Context) ([]Round, error) { } const ListAllVTXOs = `-- name: ListAllVTXOs :many -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time FROM vtxos ORDER BY creation_time DESC +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth FROM vtxos ORDER BY creation_time DESC ` func (q *Queries) ListAllVTXOs(ctx context.Context) ([]Vtxo, error) { @@ -660,6 +664,7 @@ func (q *Queries) ListAllVTXOs(ctx context.Context) ([]Vtxo, error) { &i.ReplacedByIndex, &i.CreationTime, &i.LastUpdateTime, + &i.ChainDepth, ); err != nil { return nil, err } @@ -761,7 +766,7 @@ func (q *Queries) ListRoundsPaginated(ctx context.Context, arg ListRoundsPaginat } const ListUnspentVTXOs = `-- name: ListUnspentVTXOs :many -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time FROM vtxos +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth FROM vtxos WHERE spent = FALSE AND status != 4 ORDER BY creation_time DESC @@ -802,6 +807,7 @@ func (q *Queries) ListUnspentVTXOs(ctx context.Context) ([]Vtxo, error) { &i.ReplacedByIndex, &i.CreationTime, &i.LastUpdateTime, + &i.ChainDepth, ); err != nil { return nil, err } @@ -817,7 +823,7 @@ func (q *Queries) ListUnspentVTXOs(ctx context.Context) ([]Vtxo, error) { } const ListVTXOsByRound = `-- name: ListVTXOsByRound :many -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time FROM vtxos WHERE round_id = $1 ORDER BY creation_time DESC +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth FROM vtxos WHERE round_id = $1 ORDER BY creation_time DESC ` func (q *Queries) ListVTXOsByRound(ctx context.Context, roundID string) ([]Vtxo, error) { @@ -854,6 +860,7 @@ func (q *Queries) ListVTXOsByRound(ctx context.Context, roundID string) ([]Vtxo, &i.ReplacedByIndex, &i.CreationTime, &i.LastUpdateTime, + &i.ChainDepth, ); err != nil { return nil, err } diff --git a/db/sqlc/schemas/generated_schema.sql b/db/sqlc/schemas/generated_schema.sql index 2b7e494bc..97f29e33c 100644 --- a/db/sqlc/schemas/generated_schema.sql +++ b/db/sqlc/schemas/generated_schema.sql @@ -760,7 +760,7 @@ CREATE TABLE vtxos ( -- last_update_time is the unix epoch timestamp when this VTXO was last -- modified, such as when it was marked as spent. - last_update_time BIGINT NOT NULL, + last_update_time BIGINT NOT NULL, chain_depth INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (outpoint_hash, outpoint_index), FOREIGN KEY (round_id) REFERENCES rounds(round_id) diff --git a/db/sqlc/vtxo.sql.go b/db/sqlc/vtxo.sql.go index 829531948..5cd96a16f 100644 --- a/db/sqlc/vtxo.sql.go +++ b/db/sqlc/vtxo.sql.go @@ -88,7 +88,7 @@ func (q *Queries) GetVTXOReplacement(ctx context.Context, arg GetVTXOReplacement } const ListLiveVTXOs = `-- name: ListLiveVTXOs :many -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time FROM vtxos +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth FROM vtxos WHERE status < 3 AND spent = FALSE ORDER BY creation_time DESC ` @@ -132,6 +132,7 @@ func (q *Queries) ListLiveVTXOs(ctx context.Context) ([]Vtxo, error) { &i.ReplacedByIndex, &i.CreationTime, &i.LastUpdateTime, + &i.ChainDepth, ); err != nil { return nil, err } @@ -148,7 +149,7 @@ func (q *Queries) ListLiveVTXOs(ctx context.Context) ([]Vtxo, error) { const ListVTXOsByStatus = `-- name: ListVTXOsByStatus :many -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time FROM vtxos +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth FROM vtxos WHERE status = $1 ORDER BY creation_time DESC ` @@ -191,6 +192,7 @@ func (q *Queries) ListVTXOsByStatus(ctx context.Context, status int32) ([]Vtxo, &i.ReplacedByIndex, &i.CreationTime, &i.LastUpdateTime, + &i.ChainDepth, ); err != nil { return nil, err } diff --git a/db/vtxo_store.go b/db/vtxo_store.go index 0bb3129b8..84007a37a 100644 --- a/db/vtxo_store.go +++ b/db/vtxo_store.go @@ -350,6 +350,7 @@ func (s *VTXOPersistenceStore) descriptorToInsertParams( TreePath: treePathBytes, BatchExpiry: desc.BatchExpiry, TreeDepth: int32(desc.TreeDepth), + ChainDepth: int32(desc.ChainDepth), CreatedHeight: desc.CreatedHeight, CommitmentTxid: desc.CommitmentTxID[:], Spent: false, @@ -447,6 +448,7 @@ func (s *VTXOPersistenceStore) rowToDescriptor( BatchExpiry: row.BatchExpiry, RelativeExpiry: uint32(row.Expiry), TreeDepth: int(row.TreeDepth), + ChainDepth: int(row.ChainDepth), CreatedHeight: row.CreatedHeight, Status: vtxo.VTXOStatus(row.Status), }, nil diff --git a/db/vtxo_store_test.go b/db/vtxo_store_test.go index 079f14eda..e5965940e 100644 --- a/db/vtxo_store_test.go +++ b/db/vtxo_store_test.go @@ -167,6 +167,42 @@ func TestVTXOPersistenceStoreSaveAndGet(t *testing.T) { ) } +// TestVTXOPersistenceStoreChainDepthRoundTrip verifies that a non-zero +// ChainDepth survives a save/load cycle through the database. +func TestVTXOPersistenceStoreChainDepthRoundTrip(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + roundID := testRoundIDDB("test-round-chain-depth") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + err := roundStore.CommitState(ctx, testRound, state) + require.NoError(t, err) + + // Create a descriptor with a non-zero chain depth (simulating an + // OOR VTXO that is 3 hops from the on-chain commitment). + desc := createTestVTXODescriptor(t, roundID, 99) + desc.ChainDepth = 3 + + err = vtxoStore.SaveVTXO(ctx, desc) + require.NoError(t, err) + + fetched, err := vtxoStore.GetVTXO(ctx, desc.Outpoint) + require.NoError(t, err) + require.Equal(t, 3, fetched.ChainDepth) + + // Also verify via ListLiveVTXOs. + live, err := vtxoStore.ListLiveVTXOs(ctx) + require.NoError(t, err) + require.Len(t, live, 1) + require.Equal(t, 3, live[0].ChainDepth) +} + // TestVTXOPersistenceStoreListLiveVTXOs tests that ListLiveVTXOs returns only // VTXOs in non-terminal states. func TestVTXOPersistenceStoreListLiveVTXOs(t *testing.T) { From 94efb6c072e727888a66e28fdad394fdf4e1d8ef Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 16:03:22 +0200 Subject: [PATCH 06/10] rpc: expose chain_depth on daemon and indexer VTXO messages Add chain_depth to the daemon VTXO message (field 10) and the indexer VTXOInfo message (field 15). The daemon RPC server maps Descriptor.ChainDepth into the new proto field so ListVTXOs callers can inspect the OOR hop count. This enables future tooling and metadata resolvers to consume chain depth without another wire-format break. --- arkrpc/indexer.pb.go | 21 +++++++++++++++++---- arkrpc/indexer.proto | 5 +++++ daemonrpc/daemon.pb.go | 22 ++++++++++++++++++---- daemonrpc/daemon.proto | 5 +++++ darepod/rpc_server.go | 1 + 5 files changed, 46 insertions(+), 8 deletions(-) diff --git a/arkrpc/indexer.pb.go b/arkrpc/indexer.pb.go index 2e77eea34..0028d654d 100644 --- a/arkrpc/indexer.pb.go +++ b/arkrpc/indexer.pb.go @@ -1192,8 +1192,12 @@ type VTXO struct { // oor_final_checkpoint_psbts are serialized finalized checkpoint PSBTs for // virtual/OOR VTXOs. OorFinalCheckpointPsbts [][]byte `protobuf:"bytes,14,rep,name=oor_final_checkpoint_psbts,json=oorFinalCheckpointPsbts,proto3" json:"oor_final_checkpoint_psbts,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // chain_depth is the number of OOR checkpoint hops between this VTXO + // and the most recent on-chain commitment. Round-created VTXOs have + // chain_depth 0. + ChainDepth uint32 `protobuf:"varint,15,opt,name=chain_depth,json=chainDepth,proto3" json:"chain_depth,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *VTXO) Reset() { @@ -1324,6 +1328,13 @@ func (x *VTXO) GetOorFinalCheckpointPsbts() [][]byte { return nil } +func (x *VTXO) GetChainDepth() uint32 { + if x != nil { + return x.ChainDepth + } + return 0 +} + type ListVTXOsByScriptsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Scripts []*ScriptScope `protobuf:"bytes,1,rep,name=scripts,proto3" json:"scripts,omitempty"` @@ -2034,7 +2045,7 @@ const file_indexer_proto_rawDesc = "" + "\x0ftaproot_schnorr\x18\n" + " \x01(\v2\x1b.arkrpc.TaprootSchnorrProofH\x00R\x0etaprootSchnorr\x12-\n" + "\x06bip322\x18\v \x01(\v2\x13.arkrpc.BIP322ProofH\x00R\x06bip322B\a\n" + - "\x05proof\"\xa3\x04\n" + + "\x05proof\"\xc4\x04\n" + "\x04VTXO\x12,\n" + "\boutpoint\x18\x01 \x01(\v2\x10.arkrpc.OutPointR\boutpoint\x12\x1b\n" + "\tvalue_sat\x18\x02 \x01(\x04R\bvalueSat\x12\x1b\n" + @@ -2052,7 +2063,9 @@ const file_indexer_proto_rawDesc = "" + "\aleaf_tx\x18\f \x01(\fR\x06leafTx\x12 \n" + "\foor_ark_psbt\x18\r \x01(\fR\n" + "oorArkPsbt\x12;\n" + - "\x1aoor_final_checkpoint_psbts\x18\x0e \x03(\fR\x17oorFinalCheckpointPsbts\"\xb1\x01\n" + + "\x1aoor_final_checkpoint_psbts\x18\x0e \x03(\fR\x17oorFinalCheckpointPsbts\x12\x1f\n" + + "\vchain_depth\x18\x0f \x01(\rR\n" + + "chainDepth\"\xb1\x01\n" + "\x19ListVTXOsByScriptsRequest\x12-\n" + "\ascripts\x18\x01 \x03(\v2\x13.arkrpc.ScriptScopeR\ascripts\x127\n" + "\rstatus_filter\x18\x02 \x03(\x0e2\x12.arkrpc.VTXOStatusR\fstatusFilter\x12\x16\n" + diff --git a/arkrpc/indexer.proto b/arkrpc/indexer.proto index 410587209..819e900c1 100644 --- a/arkrpc/indexer.proto +++ b/arkrpc/indexer.proto @@ -273,6 +273,11 @@ message VTXO { // oor_final_checkpoint_psbts are serialized finalized checkpoint PSBTs for // virtual/OOR VTXOs. repeated bytes oor_final_checkpoint_psbts = 14; + + // chain_depth is the number of OOR checkpoint hops between this VTXO + // and the most recent on-chain commitment. Round-created VTXOs have + // chain_depth 0. + uint32 chain_depth = 15; } message ListVTXOsByScriptsRequest { diff --git a/daemonrpc/daemon.pb.go b/daemonrpc/daemon.pb.go index 3853618bc..c048afb1b 100644 --- a/daemonrpc/daemon.pb.go +++ b/daemonrpc/daemon.pb.go @@ -825,8 +825,12 @@ type VTXO struct { // commitment_txid is the hex-encoded txid of the on-chain commitment // transaction anchoring this VTXO's tree. CommitmentTxid string `protobuf:"bytes,9,opt,name=commitment_txid,json=commitmentTxid,proto3" json:"commitment_txid,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // chain_depth is the number of OOR checkpoint hops between this VTXO + // and the most recent on-chain commitment. Round-created VTXOs have + // chain_depth 0. + ChainDepth uint32 `protobuf:"varint,10,opt,name=chain_depth,json=chainDepth,proto3" json:"chain_depth,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *VTXO) Reset() { @@ -922,6 +926,13 @@ func (x *VTXO) GetCommitmentTxid() string { return "" } +func (x *VTXO) GetChainDepth() uint32 { + if x != nil { + return x.ChainDepth + } + return 0 +} + type ListVTXOsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // status_filter restricts the response to VTXOs matching this status. @@ -2104,7 +2115,7 @@ const file_daemon_proto_rawDesc = "" + "\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\"\xc4\x02\n" + + "\x13total_confirmed_sat\x18\x04 \x01(\x03R\x11totalConfirmedSat\"\xe5\x02\n" + "\x04VTXO\x12\x1a\n" + "\boutpoint\x18\x01 \x01(\tR\boutpoint\x12\x1d\n" + "\n" + @@ -2115,7 +2126,10 @@ const file_daemon_proto_rawDesc = "" + "\x0ecreated_height\x18\x06 \x01(\x05R\rcreatedHeight\x12'\n" + "\x0frelative_expiry\x18\a \x01(\rR\x0erelativeExpiry\x12\x1b\n" + "\tpk_script\x18\b \x01(\tR\bpkScript\x12'\n" + - "\x0fcommitment_txid\x18\t \x01(\tR\x0ecommitmentTxid\"t\n" + + "\x0fcommitment_txid\x18\t \x01(\tR\x0ecommitmentTxid\x12\x1f\n" + + "\vchain_depth\x18\n" + + " \x01(\rR\n" + + "chainDepth\"t\n" + "\x10ListVTXOsRequest\x12:\n" + "\rstatus_filter\x18\x01 \x01(\x0e2\x15.daemonrpc.VTXOStatusR\fstatusFilter\x12$\n" + "\x0emin_amount_sat\x18\x02 \x01(\x03R\fminAmountSat\":\n" + diff --git a/daemonrpc/daemon.proto b/daemonrpc/daemon.proto index 7b0ab7f88..6d67598b0 100644 --- a/daemonrpc/daemon.proto +++ b/daemonrpc/daemon.proto @@ -246,6 +246,11 @@ message VTXO { // commitment_txid is the hex-encoded txid of the on-chain commitment // transaction anchoring this VTXO's tree. string commitment_txid = 9; + + // chain_depth is the number of OOR checkpoint hops between this VTXO + // and the most recent on-chain commitment. Round-created VTXOs have + // chain_depth 0. + uint32 chain_depth = 10; } message ListVTXOsRequest { diff --git a/darepod/rpc_server.go b/darepod/rpc_server.go index 48066cd81..c26946c3c 100644 --- a/darepod/rpc_server.go +++ b/darepod/rpc_server.go @@ -344,6 +344,7 @@ func descriptorToProto(v *vtxo.Descriptor) *daemonrpc.VTXO { RelativeExpiry: v.RelativeExpiry, PkScript: hex.EncodeToString(v.PkScript), CommitmentTxid: v.CommitmentTxID.String(), + ChainDepth: uint32(v.ChainDepth), } } From d804b895b3336683d696d7ecf9929d27a2df756a Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 11 Mar 2026 16:05:50 +0200 Subject: [PATCH 07/10] systest: assert ChainDepth survives OOR materialization Set ChainDepth to 2 in the systest incoming metadata fixture and assert the persisted descriptor retains the value. This proves the field flows through the OOR receive path and database round-trip in the full actor-system integration test. --- systest/oor_vtxo_manager_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/systest/oor_vtxo_manager_test.go b/systest/oor_vtxo_manager_test.go index 22b2a308c..9aaa420dc 100644 --- a/systest/oor_vtxo_manager_test.go +++ b/systest/oor_vtxo_manager_test.go @@ -164,6 +164,7 @@ func TestOORIncomingMaterializationSpawnsVTXOActor(t *testing.T) { require.Equal(t, metadata.CommitmentTxID, desc.CommitmentTxID) require.Equal(t, metadata.BatchExpiry, desc.BatchExpiry) require.Equal(t, metadata.CreatedHeight, desc.CreatedHeight) + require.Equal(t, metadata.ChainDepth, desc.ChainDepth) state, err := session.FSM.CurrentState() require.NoError(t, err) @@ -361,6 +362,7 @@ func buildSystemTestIncomingMaterialization(t *testing.T) (*psbt.Packet, CommitmentTxID: inputs[0].SpentVTXO.Outpoint.Hash, BatchExpiry: 1000, TreeDepth: 1, + ChainDepth: 2, CreatedHeight: 700, TreePath: &tree.Tree{ BatchOutpoint: wire.OutPoint{ From 2a9bf4feeb23ccb3793a3a5db8706d475a096059 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 13 Mar 2026 16:21:39 -0500 Subject: [PATCH 08/10] darepod: replace panic with compile-time assertions in mapRoundVTXOManagerMsg Replace the panic() in mapRoundVTXOManagerMsg with compile-time type assertions that guarantee all round.VTXOManagerMsg implementors also satisfy vtxo.ManagerMsg. This eliminates the runtime panic risk while keeping the type assertion infallible. --- darepod/server.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/darepod/server.go b/darepod/server.go index 21c040a5c..ef5584721 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -1763,12 +1763,25 @@ func (s *Server) initOORActor(ctx context.Context, return nil } +// Compile-time assertions: every round.VTXOManagerMsg implementor must +// also satisfy vtxo.ManagerMsg. This makes the runtime assertion in +// mapRoundVTXOManagerMsg infallible. +var _ vtxo.ManagerMsg = (*round.VTXOCreatedNotification)(nil) +var _ vtxo.ManagerMsg = (*round.VTXOTerminatedMsg)(nil) + // mapRoundVTXOManagerMsg adapts round-owned manager notifications into the // concrete message type accepted by the VTXO manager actor. func mapRoundVTXOManagerMsg(msg round.VTXOManagerMsg) vtxo.ManagerMsg { + // The compile-time assertions above guarantee this succeeds for + // all concrete types that implement round.VTXOManagerMsg. mapped, ok := msg.(vtxo.ManagerMsg) if !ok { - panic(fmt.Sprintf("unexpected VTXO manager msg type: %T", msg)) + log.ErrorS(context.TODO(), + "Unexpected VTXO manager msg type, dropping", + nil, slog.String("type", + fmt.Sprintf("%T", msg))) + + return nil } return mapped From 2fac9d33c4066c5dca9984976ffb27a990e44fb7 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 13 Mar 2026 16:21:44 -0500 Subject: [PATCH 09/10] oor: validate ChainDepth is non-negative in BuildIncomingVTXODescriptor Add an early validation check rejecting negative ChainDepth values in the incoming VTXO descriptor builder. ChainDepth represents OOR checkpoint hop count and is semantically non-negative. --- oor/incoming_vtxo.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/oor/incoming_vtxo.go b/oor/incoming_vtxo.go index 507503c2c..e2418a5cd 100644 --- a/oor/incoming_vtxo.go +++ b/oor/incoming_vtxo.go @@ -91,6 +91,10 @@ func BuildIncomingVTXODescriptor(ark *psbt.Packet, case cfg.Metadata.RoundID == "": return nil, fmt.Errorf("round id must be provided") + + case cfg.Metadata.ChainDepth < 0: + return nil, fmt.Errorf("chain depth must be "+ + "non-negative, got %d", cfg.Metadata.ChainDepth) } if cfg.Metadata.CommitmentTxID == (chainhash.Hash{}) { From ef534bede84b15cf9a851ac47b7744afcf087b51 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 13 Mar 2026 16:23:28 -0500 Subject: [PATCH 10/10] docs: update per-package docs for OOR VTXO manager and ChainDepth Update CLAUDE.md and AGENTS.md for vtxo, oor, darepod, and db packages to reflect new VTXOsMaterializedNotification message flow, ChainDepth field on Descriptor, actor startup ordering invariants, and migration 000005 chain_depth column. --- darepod/AGENTS.md | 2 ++ darepod/CLAUDE.md | 2 ++ db/AGENTS.md | 3 ++- db/CLAUDE.md | 3 ++- oor/AGENTS.md | 4 +++- oor/CLAUDE.md | 4 +++- vtxo/AGENTS.md | 8 +++++--- vtxo/CLAUDE.md | 8 +++++--- 8 files changed, 24 insertions(+), 10 deletions(-) diff --git a/darepod/AGENTS.md b/darepod/AGENTS.md index 2b67d8dfc..fbd5fcfa6 100644 --- a/darepod/AGENTS.md +++ b/darepod/AGENTS.md @@ -26,6 +26,8 @@ gRPC API. - Board RPC is non-blocking: delegates to wallet actor and returns immediately. - ListRounds splits pending (in-memory from actor) and persisted (SQL with cursor pagination) rounds. - Server holds a `roundStore` reference for direct SQL queries from the RPC layer. +- Actor startup order: VTXO manager starts before round actor and OOR actor, so the manager ref is available for both. The round actor ref in the VTXO manager is lazy (service-key-based, resolved at Tell time). +- `mapRoundVTXOManagerMsg` bridges `round.VTXOManagerMsg` → `vtxo.ManagerMsg` via `MapInputRef`. Compile-time assertions enforce that all `round.VTXOManagerMsg` implementors satisfy `vtxo.ManagerMsg`. ## Deep Docs diff --git a/darepod/CLAUDE.md b/darepod/CLAUDE.md index 2b67d8dfc..fbd5fcfa6 100644 --- a/darepod/CLAUDE.md +++ b/darepod/CLAUDE.md @@ -26,6 +26,8 @@ gRPC API. - Board RPC is non-blocking: delegates to wallet actor and returns immediately. - ListRounds splits pending (in-memory from actor) and persisted (SQL with cursor pagination) rounds. - Server holds a `roundStore` reference for direct SQL queries from the RPC layer. +- Actor startup order: VTXO manager starts before round actor and OOR actor, so the manager ref is available for both. The round actor ref in the VTXO manager is lazy (service-key-based, resolved at Tell time). +- `mapRoundVTXOManagerMsg` bridges `round.VTXOManagerMsg` → `vtxo.ManagerMsg` via `MapInputRef`. Compile-time assertions enforce that all `round.VTXOManagerMsg` implementors satisfy `vtxo.ManagerMsg`. ## Deep Docs diff --git a/db/AGENTS.md b/db/AGENTS.md index 4ab6bbe70..1dd44566c 100644 --- a/db/AGENTS.md +++ b/db/AGENTS.md @@ -13,7 +13,7 @@ Supports SQLite and PostgreSQL backends. - `RoundStore` — Interface for round state persistence (CommitState, FetchState, ListRoundsPaginated). - `RoundPersistenceStore` — Concrete implementation wrapping `BatchedTx[RoundStore]` with domain conversion. - `RoundSummary` / `VTXOSummary` — Lightweight descriptors for paginated round listing (avoids deserializing full trees). -- `VTXOPersistenceStore` — Persistent store for VTXO descriptors (InsertClientVTXO, FetchByOutpoint). +- `VTXOPersistenceStore` — Persistent store for VTXO descriptors (InsertClientVTXO, FetchByOutpoint). Persists `ChainDepth` (OOR hop count) alongside other VTXO metadata. - `OORArtifactStore` — Interface for OOR session state persistence. ## Relationships @@ -28,6 +28,7 @@ Supports SQLite and PostgreSQL backends. - Round checkpoints include commitment tx, VTXO tree, client sub-trees, boarding signatures, and every intent with updated status. - 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`. +- Latest migration: `000005_vtxo_chain_depth` adds `chain_depth INTEGER NOT NULL DEFAULT 0` to `vtxos` table. UPSERT uses zero-value sentinel pattern (same as `tree_depth`, `batch_expiry`): zero means "not yet populated", non-zero overwrites. ## Deep Docs diff --git a/db/CLAUDE.md b/db/CLAUDE.md index 4ab6bbe70..1dd44566c 100644 --- a/db/CLAUDE.md +++ b/db/CLAUDE.md @@ -13,7 +13,7 @@ Supports SQLite and PostgreSQL backends. - `RoundStore` — Interface for round state persistence (CommitState, FetchState, ListRoundsPaginated). - `RoundPersistenceStore` — Concrete implementation wrapping `BatchedTx[RoundStore]` with domain conversion. - `RoundSummary` / `VTXOSummary` — Lightweight descriptors for paginated round listing (avoids deserializing full trees). -- `VTXOPersistenceStore` — Persistent store for VTXO descriptors (InsertClientVTXO, FetchByOutpoint). +- `VTXOPersistenceStore` — Persistent store for VTXO descriptors (InsertClientVTXO, FetchByOutpoint). Persists `ChainDepth` (OOR hop count) alongside other VTXO metadata. - `OORArtifactStore` — Interface for OOR session state persistence. ## Relationships @@ -28,6 +28,7 @@ Supports SQLite and PostgreSQL backends. - Round checkpoints include commitment tx, VTXO tree, client sub-trees, boarding signatures, and every intent with updated status. - 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`. +- Latest migration: `000005_vtxo_chain_depth` adds `chain_depth INTEGER NOT NULL DEFAULT 0` to `vtxos` table. UPSERT uses zero-value sentinel pattern (same as `tree_depth`, `batch_expiry`): zero means "not yet populated", non-zero overwrites. ## Deep Docs diff --git a/oor/AGENTS.md b/oor/AGENTS.md index e1faa0fce..5233f12cd 100644 --- a/oor/AGENTS.md +++ b/oor/AGENTS.md @@ -11,7 +11,8 @@ resume semantics. - `SessionID` — Stable session identifier (Ark txid hash in v0). - `Environment` — FSM environment providing SessionID and external system access. - `OutboxHandler` — Interface for executing FSM outbox requests (RPC, signing, persistence). -- `ClientActorCfg` — Configuration for OORClientActor (OutboxHandler, ServerConn, PackageStore, DeliveryStore). +- `ClientActorCfg` — Configuration for OORClientActor (OutboxHandler, ServerConn, PackageStore, DeliveryStore, VTXOManager). +- `IncomingVTXOMetadata` — Lineage metadata for incoming OOR VTXOs including `ChainDepth` (OOR checkpoint hop count). - `OORClientActor` — Durable actor wrapping per-session state machines. ## Relationships @@ -22,6 +23,7 @@ resume semantics. - → `serverconn`: `SendSubmitPackageRequest`, `SendFinalizePackageRequest`, `SendIncomingAckRequest` - → `db` (via outbox): `MarkInputsSpentRequest` - → `wallet`: `MaterializeIncomingVTXOsRequest` + - → `vtxo` manager: `VTXOsMaterializedNotification` (after incoming VTXOs are durably materialized) - **Receives**: - ← `serverconn` (via EventRouter): `SubmitAcceptedEvent`, `FinalizeAcceptedEvent`, `IncomingTransferEvent` - ← API: `StartTransferRequest`, `DriveEventRequest`, `RestoreSessionRequest`, `ResumeSessionRequest` diff --git a/oor/CLAUDE.md b/oor/CLAUDE.md index e1faa0fce..5233f12cd 100644 --- a/oor/CLAUDE.md +++ b/oor/CLAUDE.md @@ -11,7 +11,8 @@ resume semantics. - `SessionID` — Stable session identifier (Ark txid hash in v0). - `Environment` — FSM environment providing SessionID and external system access. - `OutboxHandler` — Interface for executing FSM outbox requests (RPC, signing, persistence). -- `ClientActorCfg` — Configuration for OORClientActor (OutboxHandler, ServerConn, PackageStore, DeliveryStore). +- `ClientActorCfg` — Configuration for OORClientActor (OutboxHandler, ServerConn, PackageStore, DeliveryStore, VTXOManager). +- `IncomingVTXOMetadata` — Lineage metadata for incoming OOR VTXOs including `ChainDepth` (OOR checkpoint hop count). - `OORClientActor` — Durable actor wrapping per-session state machines. ## Relationships @@ -22,6 +23,7 @@ resume semantics. - → `serverconn`: `SendSubmitPackageRequest`, `SendFinalizePackageRequest`, `SendIncomingAckRequest` - → `db` (via outbox): `MarkInputsSpentRequest` - → `wallet`: `MaterializeIncomingVTXOsRequest` + - → `vtxo` manager: `VTXOsMaterializedNotification` (after incoming VTXOs are durably materialized) - **Receives**: - ← `serverconn` (via EventRouter): `SubmitAcceptedEvent`, `FinalizeAcceptedEvent`, `IncomingTransferEvent` - ← API: `StartTransferRequest`, `DriveEventRequest`, `RestoreSessionRequest`, `ResumeSessionRequest` diff --git a/vtxo/AGENTS.md b/vtxo/AGENTS.md index c13432af2..83e0814fc 100644 --- a/vtxo/AGENTS.md +++ b/vtxo/AGENTS.md @@ -10,8 +10,9 @@ refresh vs leave); intent composition is handled by the wallet. ## Key Types - `VTXOState` — Sealed interface for all states (Live, PendingForfeit, Forfeiting, Forfeited, UnilateralExit, Failed). -- `Descriptor` — Complete VTXO metadata: outpoint, amount, taproot key, CSV expiry, tree path to root. -- `Manager` — Actor managing per-VTXO FSM instances and their lifecycle. +- `Descriptor` — Complete VTXO metadata: outpoint, amount, taproot key, CSV expiry, tree path to root, `ChainDepth` (OOR hop count from on-chain commitment). +- `Manager` — Actor managing per-VTXO FSM instances and their lifecycle. Handles both round-created (`VTXOCreatedNotification`) and OOR-materialized (`VTXOsMaterializedNotification`) VTXOs. +- `VTXOsMaterializedNotification` — Notifies the manager that VTXOs were already persisted by another actor (OOR receive) and only actor activation is needed. - `VTXOEvent` — Inbound events (BlockEpochEvent, PendingForfeitEvent, ForfeitRequestEvent, ForfeitConfirmedEvent, ResumeVTXOEvent). - `VTXOOutMsg` — Outbound messages (ForfeitRequest, ForfeitSignatureSubmission, ExpiringNotification, VTXOStatusUpdate, VTXOTerminatedNotification). @@ -24,7 +25,8 @@ refresh vs leave); intent composition is handled by the wallet. - → `db` (via outbox): `VTXOStatusUpdate` - → `vtxo` manager: `VTXOTerminatedNotification`, `RelayToRoundMsg` - **Receives**: - - ← `round`: `PendingForfeitEvent`, `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `BlockEpochEvent` + - ← `round`: `VTXOCreatedNotification`, `PendingForfeitEvent`, `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `BlockEpochEvent` + - ← `oor`: `VTXOsMaterializedNotification` (already-persisted VTXOs needing actor activation) - ← `chainsource` (via Manager): `BlockEpochEvent` - ← API: `ResumeVTXOEvent` diff --git a/vtxo/CLAUDE.md b/vtxo/CLAUDE.md index c13432af2..83e0814fc 100644 --- a/vtxo/CLAUDE.md +++ b/vtxo/CLAUDE.md @@ -10,8 +10,9 @@ refresh vs leave); intent composition is handled by the wallet. ## Key Types - `VTXOState` — Sealed interface for all states (Live, PendingForfeit, Forfeiting, Forfeited, UnilateralExit, Failed). -- `Descriptor` — Complete VTXO metadata: outpoint, amount, taproot key, CSV expiry, tree path to root. -- `Manager` — Actor managing per-VTXO FSM instances and their lifecycle. +- `Descriptor` — Complete VTXO metadata: outpoint, amount, taproot key, CSV expiry, tree path to root, `ChainDepth` (OOR hop count from on-chain commitment). +- `Manager` — Actor managing per-VTXO FSM instances and their lifecycle. Handles both round-created (`VTXOCreatedNotification`) and OOR-materialized (`VTXOsMaterializedNotification`) VTXOs. +- `VTXOsMaterializedNotification` — Notifies the manager that VTXOs were already persisted by another actor (OOR receive) and only actor activation is needed. - `VTXOEvent` — Inbound events (BlockEpochEvent, PendingForfeitEvent, ForfeitRequestEvent, ForfeitConfirmedEvent, ResumeVTXOEvent). - `VTXOOutMsg` — Outbound messages (ForfeitRequest, ForfeitSignatureSubmission, ExpiringNotification, VTXOStatusUpdate, VTXOTerminatedNotification). @@ -24,7 +25,8 @@ refresh vs leave); intent composition is handled by the wallet. - → `db` (via outbox): `VTXOStatusUpdate` - → `vtxo` manager: `VTXOTerminatedNotification`, `RelayToRoundMsg` - **Receives**: - - ← `round`: `PendingForfeitEvent`, `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `BlockEpochEvent` + - ← `round`: `VTXOCreatedNotification`, `PendingForfeitEvent`, `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `BlockEpochEvent` + - ← `oor`: `VTXOsMaterializedNotification` (already-persisted VTXOs needing actor activation) - ← `chainsource` (via Manager): `BlockEpochEvent` - ← API: `ResumeVTXOEvent`