diff --git a/oor/service.go b/oor/service.go new file mode 100644 index 000000000..4daf6aadc --- /dev/null +++ b/oor/service.go @@ -0,0 +1,718 @@ +package oor + +import ( + "bytes" + "context" + "fmt" + "math/rand" + "sync" + "time" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/db" +) + +// oorService is the default OORService implementation. +type oorService struct { + outgoingRef actor.ActorRef[ActorMsg, ActorResp] + outgoingActor *OORClientActor + + incomingSource IncomingEventSource + cursorStore IncomingCursorStore + unrollResolver UnrollPackageResolver + + incomingOutboxHandler OutboxHandler + + incomingPageSize int32 + incomingPollInterval time.Duration + incomingPollJitter time.Duration + jitterRand *rand.Rand + jitterRandMu sync.Mutex + + workerMu sync.Mutex + workerCancel context.CancelFunc + workerDone chan struct{} + + incomingRunMu sync.Mutex + + statusMu sync.RWMutex + status IncomingSyncStatus +} + +// NewOORService constructs a high-level OOR service for outgoing and incoming +// flow orchestration. +// +// The constructor wires local persistence handling in front of the provided +// transport/signing handler so both outgoing and incoming paths share one +// consistent outbox boundary. +func NewOORService(cfg ServiceConfig) (OORService, error) { + if cfg.VTXOStore == nil { + return nil, fmt.Errorf("vtxo store must be provided") + } + + if cfg.OperatorKey == nil { + return nil, fmt.Errorf("operator key must be provided") + } + + if cfg.ResolveIncomingClientKey == nil { + return nil, fmt.Errorf("incoming client key resolver must be " + + "provided") + } + + if cfg.ResolveIncomingMetadata == nil { + return nil, fmt.Errorf("incoming metadata resolver must be " + + "provided") + } + + actorID := cfg.ActorID + if actorID == "" { + actorID = DefaultActorServiceKeyName + } + + pageSize := cfg.IncomingPageSize + if pageSize <= 0 { + pageSize = DefaultIncomingPageSize + } + + pollInterval := cfg.IncomingPollInterval + if pollInterval <= 0 { + pollInterval = DefaultIncomingPollInterval + } + + localHandler := &LocalPersistenceOutboxHandler{ + Next: cfg.TransportOutboxHandler, + Store: cfg.VTXOStore, + PackageStore: cfg.PackageStore, + OperatorKey: cfg.OperatorKey, + ExitDelay: cfg.ExitDelay, + ResolveIncomingClientKey: cfg.ResolveIncomingClientKey, + ResolveIncomingMetadata: cfg.ResolveIncomingMetadata, + } + + outgoingRef, outgoingActor, err := buildOutgoingActorClient( + cfg, actorID, localHandler, + ) + if err != nil { + return nil, err + } + + return &oorService{ + outgoingRef: outgoingRef, + outgoingActor: outgoingActor, + incomingSource: cfg.IncomingSource, + cursorStore: cfg.IncomingCursorStore, + unrollResolver: cfg.UnrollResolver, + incomingOutboxHandler: localHandler, + incomingPageSize: pageSize, + incomingPollInterval: pollInterval, + incomingPollJitter: cfg.IncomingPollJitter, + // #nosec G404 -- non-crypto jitter for polling. + jitterRand: rand.New(rand.NewSource(time.Now().UnixNano())), + }, nil +} + +// StartOutgoing starts one outgoing transfer via the configured outgoing actor. +func (s *oorService) StartOutgoing(ctx context.Context, + req StartOutgoingRequest) (SessionID, error) { + + if s == nil { + return SessionID{}, fmt.Errorf("service must be provided") + } + + result, err := s.askOutgoing(ctx, &StartTransferRequest{ + Policy: req.Policy, + Inputs: req.Inputs, + Recipients: req.Recipients, + }) + if err != nil { + return SessionID{}, err + } + + resp, ok := result.(*StartTransferResponse) + if !ok || resp == nil { + return SessionID{}, fmt.Errorf("unexpected response type: %T", + result) + } + + return resp.SessionID, nil +} + +// GetOutgoingState returns a caller-facing state summary for one session. +func (s *oorService) GetOutgoingState(ctx context.Context, + sessionID SessionID) (OutgoingStateView, error) { + + if s == nil { + return OutgoingStateView{}, fmt.Errorf( + "service must be provided", + ) + } + + result, err := s.askOutgoing( + ctx, &GetStateRequest{SessionID: sessionID}, + ) + if err != nil { + return OutgoingStateView{}, err + } + + resp, ok := result.(*GetStateResponse) + if !ok || resp == nil || resp.State == nil { + return OutgoingStateView{}, fmt.Errorf( + "unexpected response type: %T", + result, + ) + } + + return outgoingStateViewFromState(sessionID, resp.State), nil +} + +// SyncIncomingOnce executes one incoming-sync cycle. +func (s *oorService) SyncIncomingOnce(ctx context.Context) error { + if s == nil { + return fmt.Errorf("service must be provided") + } + + _, _, err := s.runIncomingCycle(ctx) + + return err +} + +// StartIncomingSync starts the background incoming-sync loop. +func (s *oorService) StartIncomingSync(ctx context.Context) error { + if s == nil { + return fmt.Errorf("service must be provided") + } + + if ctx == nil { + ctx = context.Background() + } + + s.workerMu.Lock() + defer s.workerMu.Unlock() + + if s.workerCancel != nil { + return nil + } + + loopCtx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + + s.workerCancel = cancel + s.workerDone = done + + s.setWorkerRunning(true) + + go s.runIncomingWorker(loopCtx, done) + + return nil +} + +// StopIncomingSync stops the background incoming-sync loop. +func (s *oorService) StopIncomingSync(ctx context.Context) error { + if s == nil { + return nil + } + + s.workerMu.Lock() + cancel := s.workerCancel + done := s.workerDone + + s.workerCancel = nil + s.workerDone = nil + if cancel != nil { + cancel() + } + + s.workerMu.Unlock() + + if done == nil { + s.setWorkerRunning(false) + return nil + } + + select { + case <-done: + return nil + + case <-ctx.Done(): + return ctx.Err() + } +} + +// GetIncomingSyncStatus returns the latest incoming-sync status snapshot. +func (s *oorService) GetIncomingSyncStatus() IncomingSyncStatus { + if s == nil { + return IncomingSyncStatus{} + } + + s.statusMu.RLock() + defer s.statusMu.RUnlock() + + return s.status +} + +// ResolveUnrollPackages resolves locally persisted package artifacts for one +// outpoint. +func (s *oorService) ResolveUnrollPackages(ctx context.Context, + outpoint wire.OutPoint) (*db.OORUnrollPackages, error) { + + if s == nil || s.unrollResolver == nil { + return nil, fmt.Errorf("unroll resolver must be provided") + } + + return s.unrollResolver.ResolveUnrollPackages(ctx, outpoint) +} + +// Stop stops the service and all managed workers. +func (s *oorService) Stop(ctx context.Context) error { + if s == nil { + return nil + } + + if err := s.StopIncomingSync(ctx); err != nil { + return err + } + + if s.outgoingActor != nil { + s.outgoingActor.Stop() + } + + return nil +} + +// runIncomingWorker runs the background incoming-sync loop until canceled. +func (s *oorService) runIncomingWorker( + ctx context.Context, done chan struct{}, +) { + + defer close(done) + defer s.setWorkerRunning(false) + + for { + if ctx.Err() != nil { + return + } + + _, _, _ = s.runIncomingCycle(ctx) + + delay := s.nextIncomingPollDelay() + timer := time.NewTimer(delay) + + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + + return + + case <-timer.C: + } + } +} + +// runIncomingCycle executes one tracked incoming-sync cycle and updates status. +func (s *oorService) runIncomingCycle(ctx context.Context) (int, int, error) { + s.incomingRunMu.Lock() + defer s.incomingRunMu.Unlock() + + start := time.Now() + s.setCycleStart(start) + + scripts, events, err := s.syncIncomingOnce(ctx) + s.setCycleFinish(time.Now(), scripts, events, err) + + return scripts, events, err +} + +// syncIncomingOnce performs one full poll/process pass across tracked scripts. +func (s *oorService) syncIncomingOnce(ctx context.Context) (int, int, error) { + if s.incomingSource == nil { + return 0, 0, fmt.Errorf("incoming source must be provided") + } + + if s.cursorStore == nil { + return 0, 0, fmt.Errorf( + "incoming cursor store must be provided", + ) + } + + if s.incomingOutboxHandler == nil { + return 0, 0, fmt.Errorf( + "incoming outbox handler must be provided", + ) + } + + scripts, err := s.cursorStore.ListOwnedReceiveScripts(ctx) + if err != nil { + return 0, 0, err + } + + totalEvents := 0 + for i := range scripts { + processed, err := s.syncIncomingScript(ctx, scripts[i]) + if err != nil { + return i, totalEvents, err + } + + totalEvents += processed + } + + return len(scripts), totalEvents, nil +} + +// syncIncomingScript processes all available events for one recipient script. +func (s *oorService) syncIncomingScript(ctx context.Context, + script OwnedReceiveScript) (int, error) { + + if len(script.PkScript) == 0 { + return 0, fmt.Errorf("owned receive script must be provided") + } + + cursor, err := s.cursorStore.GetRecipientCursor(ctx, script.PkScript) + if err != nil { + return 0, err + } + + lastEventID := int64(0) + if cursor != nil { + lastEventID = cursor.LastEventID + } + + processed := 0 + + for { + events, err := s.incomingSource.ListRecipientEvents( + ctx, script.PkScript, lastEventID, s.incomingPageSize, + ) + if err != nil { + return processed, err + } + + if len(events) == 0 { + return processed, nil + } + + for i := range events { + event := events[i] + if event == nil { + return processed, fmt.Errorf( + "incoming event must be provided", + ) + } + + if err := validateIncomingEvent(script.PkScript, + lastEventID, event); err != nil { + return processed, err + } + + if err := s.processIncomingEvent( + ctx, event, + ); err != nil { + return processed, err + } + + sessionID := event.SessionID + err := s.cursorStore.UpsertRecipientCursor( + ctx, script.PkScript, event.EventID, &sessionID, + ) + if err != nil { + return processed, err + } + + lastEventID = event.EventID + processed++ + } + + if int32(len(events)) < s.incomingPageSize { + return processed, nil + } + } +} + +// processIncomingEvent drives one incoming event through the receive FSM. +func (s *oorService) processIncomingEvent(ctx context.Context, + event *IncomingRecipientEvent) error { + + // Incoming receive processing intentionally uses a short-lived FSM per + // event. Restart safety is provided by the persisted recipient cursor, + // plus idempotent local materialization in the outbox boundary. + session, err := NewReceiveSession(ctx, event.ArkPSBT, event.SessionID) + if err != nil { + return err + } + defer session.FSM.Stop() + + outbox, err := askFSMEvent(ctx, session.FSM, &IncomingTransferEvent{ + SessionID: event.SessionID, + ArkPSBT: event.ArkPSBT, + FinalCheckpointPSBTs: event.FinalCheckpointPSBTs, + }) + if err != nil { + return err + } + + if err := s.driveReceiveOutbox(ctx, event.SessionID, + session.FSM, outbox); err != nil { + return err + } + + state, err := currentReceiveState(session.FSM) + if err != nil { + return err + } + + if _, ok := state.(*ReceiveCompleted); !ok { + return fmt.Errorf( + "incoming session did not reach completion: %s", + state.String(), + ) + } + + return nil +} + +// driveReceiveOutbox executes receive-side outbox requests and feeds follow-up +// events back into the receive FSM until no outbox remains. +func (s *oorService) driveReceiveOutbox(ctx context.Context, + sessionID SessionID, fsm *StateMachine, outbox []OutboxEvent) error { + + for _, msg := range outbox { + followUps, err := s.incomingOutboxHandler.Handle( + ctx, sessionID, msg, + ) + if err != nil { + return fmt.Errorf("handle incoming outbox %s: %w", + msg.outboxType(), err) + } + + for _, followUp := range followUps { + nextOutbox, err := askFSMEvent(ctx, fsm, followUp) + if err != nil { + return err + } + + if err := s.driveReceiveOutbox(ctx, + sessionID, fsm, nextOutbox); err != nil { + return err + } + } + } + + return nil +} + +// currentReceiveState returns the concrete receive FSM state type. +func currentReceiveState(fsm *StateMachine) (ReceiveState, error) { + if fsm == nil { + return nil, fmt.Errorf("fsm must be provided") + } + + state, err := fsm.CurrentState() + if err != nil { + return nil, err + } + + receiveState, ok := state.(ReceiveState) + if !ok { + return nil, fmt.Errorf( + "unexpected receive state type: %T", state, + ) + } + + return receiveState, nil +} + +// askFSMEvent sends one event into an FSM and returns emitted outbox requests. +func askFSMEvent(ctx context.Context, fsm *StateMachine, + event Event) ([]OutboxEvent, error) { + + if fsm == nil { + return nil, fmt.Errorf("fsm must be provided") + } + + future := fsm.AskEvent(ctx, event) + result := future.Await(ctx) + if result.IsErr() { + return nil, result.Err() + } + + return result.UnwrapOr(nil), nil +} + +// validateIncomingEvent validates cursor ordering and script targeting. +func validateIncomingEvent(expectedScript []byte, lastEventID int64, + event *IncomingRecipientEvent) error { + + if event.EventID <= lastEventID { + return fmt.Errorf( + "incoming event id %d is not strictly after %d", + event.EventID, lastEventID, + ) + } + + if len(event.RecipientPkScript) > 0 && + !bytes.Equal(event.RecipientPkScript, expectedScript) { + + return fmt.Errorf("incoming event recipient script mismatch") + } + + if event.ArkPSBT == nil || event.ArkPSBT.UnsignedTx == nil { + return fmt.Errorf("incoming event ark psbt must be provided") + } + + if len(event.FinalCheckpointPSBTs) == 0 { + return fmt.Errorf("incoming event checkpoints must be provided") + } + + return nil +} + +// outgoingStateViewFromState maps a concrete outgoing state to a stable view. +func outgoingStateViewFromState(sessionID SessionID, + state State) OutgoingStateView { + + view := OutgoingStateView{ + SessionID: sessionID, + StateName: state.String(), + Terminal: state.IsTerminal(), + } + + if failedState, ok := state.(*Failed); ok { + view.FailedReason = failedState.Reason + } + + if retryState, ok := state.(*RetryBackoff); ok { + view.RetryAfter = retryState.RetryAfter + view.RetryReason = retryState.Reason + } + + return view +} + +// nextIncomingPollDelay calculates one worker sleep interval including jitter. +func (s *oorService) nextIncomingPollDelay() time.Duration { + delay := s.incomingPollInterval + if s.incomingPollJitter <= 0 { + return delay + } + + jitterMax := int64(s.incomingPollJitter) + 1 + if jitterMax <= 0 { + return delay + } + + s.jitterRandMu.Lock() + jitter := time.Duration(s.jitterRand.Int63n(jitterMax)) + s.jitterRandMu.Unlock() + + return delay + jitter +} + +// askOutgoing sends one command to the configured outgoing actor endpoint. +func (s *oorService) askOutgoing(ctx context.Context, + msg ActorMsg) (ActorResp, error) { + + if s.outgoingRef != nil { + future := s.outgoingRef.Ask(ctx, msg) + result := future.Await(ctx) + if result.IsErr() { + return nil, result.Err() + } + + return result.UnwrapOr(nil), nil + } + + if s.outgoingActor != nil { + result := s.outgoingActor.Receive(ctx, msg) + if result.IsErr() { + return nil, result.Err() + } + + return result.UnwrapOr(nil), nil + } + + return nil, fmt.Errorf("outgoing actor must be configured") +} + +// buildOutgoingActorClient resolves or constructs outgoing actor wiring. +func buildOutgoingActorClient(cfg ServiceConfig, actorID string, + localHandler OutboxHandler) (actor.ActorRef[ActorMsg, ActorResp], + *OORClientActor, error) { + + if cfg.OutgoingRef != nil { + return cfg.OutgoingRef, nil, nil + } + + if cfg.ActorSystem != nil { + serviceKey := ActorServiceKey(actorID) + if cfg.OutgoingServiceKey != nil { + serviceKey = *cfg.OutgoingServiceKey + } + + refs := actor.FindInReceptionist( + cfg.ActorSystem.Receptionist(), serviceKey, + ) + if len(refs) == 0 { + return nil, nil, fmt.Errorf( + "no outgoing actor registered for service key", + ) + } + + return serviceKey.Ref(cfg.ActorSystem), nil, nil + } + + if cfg.DeliveryStore == nil { + return nil, nil, fmt.Errorf("delivery store must be provided") + } + + localActor := NewOORClientActor(ClientActorCfg{ + ActorID: actorID, + DeliveryStore: cfg.DeliveryStore, + OutboxHandler: localHandler, + PackageStore: cfg.PackageStore, + }) + if localActor.startupErr != nil { + return nil, nil, localActor.startupErr + } + + return nil, localActor, nil +} + +// setWorkerRunning updates the running flag in status. +func (s *oorService) setWorkerRunning(running bool) { + s.statusMu.Lock() + defer s.statusMu.Unlock() + + s.status.Running = running +} + +// setCycleStart records start metadata for one sync cycle. +func (s *oorService) setCycleStart(start time.Time) { + s.statusMu.Lock() + defer s.statusMu.Unlock() + + s.status.LastRunStartedAt = start +} + +// setCycleFinish records completion metadata for one sync cycle. +func (s *oorService) setCycleFinish(finished time.Time, + processedScripts int, processedEvents int, err error) { + + s.statusMu.Lock() + defer s.statusMu.Unlock() + + s.status.LastRunFinishedAt = finished + s.status.LastRunProcessedScripts = processedScripts + s.status.LastRunProcessedEvents = processedEvents + s.status.TotalProcessedScripts += int64(processedScripts) + s.status.TotalProcessedEvents += int64(processedEvents) + + if err != nil { + s.status.LastError = err.Error() + } else { + s.status.LastError = "" + } +} + +var _ OORService = (*oorService)(nil) diff --git a/oor/service_db_adapters.go b/oor/service_db_adapters.go new file mode 100644 index 000000000..86f879f0a --- /dev/null +++ b/oor/service_db_adapters.go @@ -0,0 +1,120 @@ +package oor + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/darepo-client/db" +) + +// DBIncomingCursorStore adapts db.OORArtifactPersistenceStore to the +// IncomingCursorStore interface expected by OORService. +type DBIncomingCursorStore struct { + store *db.OORArtifactPersistenceStore +} + +// NewDBIncomingCursorStore constructs a cursor-store adapter from the DB OOR +// artifact store. +func NewDBIncomingCursorStore( + store *db.OORArtifactPersistenceStore) *DBIncomingCursorStore { + + return &DBIncomingCursorStore{store: store} +} + +// ListOwnedReceiveScripts returns all tracked receive scripts. +func (s *DBIncomingCursorStore) ListOwnedReceiveScripts( + ctx context.Context) ([]OwnedReceiveScript, error) { + + if s == nil || s.store == nil { + return nil, fmt.Errorf("store must be provided") + } + + rows, err := s.store.ListOwnedReceiveScripts(ctx) + if err != nil { + return nil, err + } + + scripts := make([]OwnedReceiveScript, 0, len(rows)) + for i := range rows { + scripts = append(scripts, OwnedReceiveScript{ + PkScript: rows[i].PkScript, + }) + } + + return scripts, nil +} + +// GetRecipientCursor returns one script cursor if present. +func (s *DBIncomingCursorStore) GetRecipientCursor(ctx context.Context, + recipientPkScript []byte) (*RecipientCursor, error) { + + if s == nil || s.store == nil { + return nil, fmt.Errorf("store must be provided") + } + + row, err := s.store.GetRecipientCursor(ctx, recipientPkScript) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + return nil, err + } + + var sessionID *SessionID + if len(row.LastSessionID) > 0 { + hash, err := parseSessionHash(row.LastSessionID) + if err != nil { + return nil, err + } + + session := SessionID(*hash) + sessionID = &session + } + + return &RecipientCursor{ + RecipientPkScript: row.RecipientPkScript, + LastEventID: row.LastEventID, + LastSessionID: sessionID, + }, nil +} + +// UpsertRecipientCursor stores the latest processed cursor for one script. +func (s *DBIncomingCursorStore) UpsertRecipientCursor(ctx context.Context, + recipientPkScript []byte, lastEventID int64, + lastSessionID *SessionID) error { + + if s == nil || s.store == nil { + return fmt.Errorf("store must be provided") + } + + var sessionHash *chainhash.Hash + if lastSessionID != nil { + hash := chainhash.Hash(*lastSessionID) + sessionHash = &hash + } + + return s.store.UpsertRecipientCursor(ctx, recipientPkScript, + lastEventID, sessionHash) +} + +// parseSessionHash validates and converts a session-id byte slice to a hash. +func parseSessionHash(raw []byte) (*chainhash.Hash, error) { + if len(raw) != chainhash.HashSize { + return nil, fmt.Errorf( + "invalid session id length: %d", len(raw), + ) + } + + hash, err := chainhash.NewHash(raw) + if err != nil { + return nil, err + } + + return hash, nil +} + +var _ IncomingCursorStore = (*DBIncomingCursorStore)(nil) diff --git a/oor/service_key.go b/oor/service_key.go new file mode 100644 index 000000000..89c6b4615 --- /dev/null +++ b/oor/service_key.go @@ -0,0 +1,20 @@ +package oor + +import "github.com/lightninglabs/darepo-client/baselib/actor" + +const ( + // DefaultActorServiceKeyName is the default OOR actor service-key name + // used for actor-system lookup. + DefaultActorServiceKeyName = "oor-service" +) + +// ActorServiceKey returns the actor-system key for the outgoing OOR actor. +// +// When actorID is empty, the default service-key name is used. +func ActorServiceKey(actorID string) actor.ServiceKey[ActorMsg, ActorResp] { + if actorID == "" { + actorID = DefaultActorServiceKeyName + } + + return actor.NewServiceKey[ActorMsg, ActorResp](actorID) +} diff --git a/oor/service_test.go b/oor/service_test.go new file mode 100644 index 000000000..026de6439 --- /dev/null +++ b/oor/service_test.go @@ -0,0 +1,750 @@ +package oor + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/lib/scripts" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// testIncomingSource is an in-memory incoming event source used by service +// tests. +type testIncomingSource struct { + mu sync.Mutex + events map[string][]*IncomingRecipientEvent +} + +// ListRecipientEvents returns events after a cursor in ascending event-id +// order. +func (s *testIncomingSource) ListRecipientEvents(_ context.Context, + recipientPkScript []byte, afterEventID int64, + limit int32) ([]*IncomingRecipientEvent, error) { + + s.mu.Lock() + defer s.mu.Unlock() + + items := s.events[string(recipientPkScript)] + out := make([]*IncomingRecipientEvent, 0) + + for i := range items { + if items[i].EventID <= afterEventID { + continue + } + + out = append(out, items[i]) + if int32(len(out)) == limit { + break + } + } + + return out, nil +} + +// testIncomingCursorStore stores scripts/cursors in memory for service tests. +type testIncomingCursorStore struct { + mu sync.Mutex + scripts []OwnedReceiveScript + cursors map[string]RecipientCursor +} + +// newTestIncomingCursorStore constructs an in-memory cursor store. +func newTestIncomingCursorStore( + scripts []OwnedReceiveScript) *testIncomingCursorStore { + + return &testIncomingCursorStore{ + scripts: scripts, + cursors: make(map[string]RecipientCursor), + } +} + +// ListOwnedReceiveScripts returns the configured script set. +func (s *testIncomingCursorStore) ListOwnedReceiveScripts(_ context.Context) ( + []OwnedReceiveScript, error) { + + s.mu.Lock() + defer s.mu.Unlock() + + out := make([]OwnedReceiveScript, 0, len(s.scripts)) + out = append(out, s.scripts...) + + return out, nil +} + +// GetRecipientCursor returns the current cursor for one script. +func (s *testIncomingCursorStore) GetRecipientCursor(_ context.Context, + recipientPkScript []byte) (*RecipientCursor, error) { + + s.mu.Lock() + defer s.mu.Unlock() + + cursor, ok := s.cursors[string(recipientPkScript)] + if !ok { + return nil, nil + } + + copyCursor := cursor + + return ©Cursor, nil +} + +// UpsertRecipientCursor updates one script cursor row. +func (s *testIncomingCursorStore) UpsertRecipientCursor(_ context.Context, + recipientPkScript []byte, lastEventID int64, + lastSessionID *SessionID) error { + + s.mu.Lock() + defer s.mu.Unlock() + + var session *SessionID + if lastSessionID != nil { + copySession := *lastSessionID + session = ©Session + } + + s.cursors[string(recipientPkScript)] = RecipientCursor{ + RecipientPkScript: recipientPkScript, + LastEventID: lastEventID, + LastSessionID: session, + } + + return nil +} + +// cursor returns one stored cursor by script. +func (s *testIncomingCursorStore) cursor( + recipientPkScript []byte) (*RecipientCursor, bool) { + + s.mu.Lock() + defer s.mu.Unlock() + + cursor, ok := s.cursors[string(recipientPkScript)] + if !ok { + return nil, false + } + + copyCursor := cursor + + return ©Cursor, true +} + +// testUnrollResolver is a deterministic resolver stub for service tests. +type testUnrollResolver struct { + wantOutpoint wire.OutPoint + result *db.OORUnrollPackages +} + +// ResolveUnrollPackages checks the target outpoint and returns the fixture. +func (r *testUnrollResolver) ResolveUnrollPackages(_ context.Context, + outpoint wire.OutPoint) (*db.OORUnrollPackages, error) { + + if outpoint != r.wantOutpoint { + return nil, fmt.Errorf("unexpected outpoint") + } + + return r.result, nil +} + +// testAckFailOutboxHandler fails incoming ack requests and passes through other +// outbox messages. +type testAckFailOutboxHandler struct{} + +// Handle fails only SendIncomingAckRequest to test cursor ordering guarantees. +func (h *testAckFailOutboxHandler) Handle(_ context.Context, + _ SessionID, outbox OutboxEvent) ([]Event, error) { + + if _, ok := outbox.(*SendIncomingAckRequest); ok { + return nil, fmt.Errorf("ack failed") + } + + return nil, nil +} + +// testAckFailOnceOutboxHandler fails the first incoming ack request and +// succeeds afterwards. +type testAckFailOnceOutboxHandler struct { + mu sync.Mutex + failed bool + ackCalls int +} + +// Handle fails the first SendIncomingAckRequest and succeeds on later calls. +func (h *testAckFailOnceOutboxHandler) Handle(_ context.Context, + _ SessionID, outbox OutboxEvent) ([]Event, error) { + + if _, ok := outbox.(*SendIncomingAckRequest); !ok { + return nil, nil + } + + h.mu.Lock() + defer h.mu.Unlock() + + h.ackCalls++ + if h.failed { + return nil, nil + } + + h.failed = true + + return nil, fmt.Errorf("ack failed once") +} + +// newTestServiceConfig builds a service config fixture with injectable source, +// cursor store, and transport handler. +func newTestServiceConfig(t *testing.T, operatorKey *btcec.PublicKey, + recipientKey *btcec.PrivateKey, source IncomingEventSource, + cursors IncomingCursorStore, transport OutboxHandler, + packageStore PackagePersistence, vtxoStore *testVTXOStore, + resolver UnrollPackageResolver) ServiceConfig { + + t.Helper() + + return ServiceConfig{ + ActorID: fmt.Sprintf("oor-service-%s", t.Name()), + DeliveryStore: newTestDeliveryStore(t), + TransportOutboxHandler: transport, + VTXOStore: vtxoStore, + PackageStore: packageStore, + OperatorKey: operatorKey, + ExitDelay: 10, + IncomingSource: source, + IncomingCursorStore: cursors, + IncomingPageSize: 20, + IncomingPollInterval: 5 * time.Millisecond, + IncomingPollJitter: 0, + UnrollResolver: resolver, + ResolveIncomingClientKey: func(context.Context, + ArkRecipientOutput) (keychain.KeyDescriptor, error) { + + return keychain.KeyDescriptor{ + PubKey: recipientKey.PubKey(), + }, nil + }, + ResolveIncomingMetadata: func(context.Context, SessionID, + ArkRecipientOutput, *psbt.Packet, + []*psbt.Packet) (IncomingVTXOMetadata, error) { + + return IncomingVTXOMetadata{ + RoundID: "round-service-test", + CommitmentTxID: [32]byte{0x22}, + BatchExpiry: 100, + TreeDepth: 1, + CreatedHeight: 1, + }, nil + }, + } +} + +// registerOutgoingActorInSystem registers a test OOR outgoing actor behind the +// service key expected by ServiceConfig. +func registerOutgoingActorInSystem(t *testing.T, system *actor.ActorSystem, + cfg ServiceConfig) { + + t.Helper() + + localHandler := &LocalPersistenceOutboxHandler{ + Next: cfg.TransportOutboxHandler, + Store: cfg.VTXOStore, + PackageStore: cfg.PackageStore, + OperatorKey: cfg.OperatorKey, + ExitDelay: cfg.ExitDelay, + ResolveIncomingClientKey: cfg.ResolveIncomingClientKey, + ResolveIncomingMetadata: cfg.ResolveIncomingMetadata, + } + + actorID := cfg.ActorID + if actorID == "" { + actorID = DefaultActorServiceKeyName + } + + outgoingActor := NewOORClientActor(ClientActorCfg{ + ActorID: actorID, + DeliveryStore: cfg.DeliveryStore, + OutboxHandler: localHandler, + PackageStore: cfg.PackageStore, + }) + require.NoError(t, outgoingActor.startupErr) + + serviceKey := ActorServiceKey(actorID) + bridge := actor.NewFunctionBehavior( + func(ctx context.Context, msg ActorMsg) fn.Result[ActorResp] { + return outgoingActor.Receive(ctx, msg) + }, + ) + serviceKey.Spawn(system, actorID+"-bridge", bridge) + + t.Cleanup(func() { + outgoingActor.Stop() + }) +} + +// TestOORServiceOutgoingFlow verifies outgoing start/state APIs through the +// service facade. +func TestOORServiceOutgoingFlow(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + signer := input.NewMockSigner([]*btcec.PrivateKey{clientKey}, nil) + operatorSigner := input.NewMockSigner( + []*btcec.PrivateKey{operatorPriv}, nil, + ) + + policy := scripts.CheckpointPolicy{ + OperatorKey: operatorPriv.PubKey(), + CSVDelay: 10, + } + + inputAmount := btcutil.Amount(10_000) + inputs := []TransferInput{ + newTestTransferInput(t, clientKey, operatorPriv.PubKey(), + wire.OutPoint{ + Hash: [32]byte{0x01}, + Index: 0, + }, inputAmount), + } + + recipients := []oortx.RecipientOutput{{ + PkScript: newTestTaprootPkScript(t, clientKey.PubKey()), + Value: inputAmount, + }} + + source := &testIncomingSource{} + cursors := newTestIncomingCursorStore(nil) + vtxoStore := newTestVTXOStore() + for i := range inputs { + require.NoError(t, vtxoStore.SaveVTXO(ctx, inputs[i].VTXO)) + } + + cfg := newTestServiceConfig(t, operatorPriv.PubKey(), clientKey, + source, cursors, &testOutboxHandler{ + t: t, + clientSigner: signer, + operatorSigner: operatorSigner, + }, &testPackageStore{}, vtxoStore, nil) + + svcIface, err := NewOORService(cfg) + require.NoError(t, err) + defer func() { + require.NoError(t, svcIface.Stop(t.Context())) + }() + + svc, ok := svcIface.(*oorService) + require.True(t, ok) + + sessionID, err := svc.StartOutgoing(ctx, StartOutgoingRequest{ + Policy: policy, + Inputs: inputs, + Recipients: recipients, + }) + require.NoError(t, err) + require.NotEqual(t, SessionID{}, sessionID) + + view, err := svc.GetOutgoingState(ctx, sessionID) + require.NoError(t, err) + require.Equal(t, sessionID, view.SessionID) + require.Equal(t, "Completed", view.StateName) + require.True(t, view.Terminal) +} + +// TestOORServiceSyncIncomingOnce verifies one incoming cycle processes events +// and advances recipient cursors only after successful ack. +func TestOORServiceSyncIncomingOnce(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + arkPSBT, checkpoints, recipients, _, recipientKey, operatorKey := + buildTestIncomingMaterialization(t) + sessionID := SessionID(arkPSBT.UnsignedTx.TxHash()) + + recipientScript := recipients[0].PkScript + event := &IncomingRecipientEvent{ + EventID: 10, + SessionID: sessionID, + RecipientPkScript: recipientScript, + ArkPSBT: arkPSBT, + FinalCheckpointPSBTs: checkpoints, + CreatedAt: time.Now(), + } + + source := &testIncomingSource{ + events: map[string][]*IncomingRecipientEvent{ + string(recipientScript): {event}, + }, + } + + cursors := newTestIncomingCursorStore([]OwnedReceiveScript{{ + PkScript: recipientScript, + }}) + + packageStore := &testPackageStore{} + vtxoStore := newTestVTXOStore() + cfg := newTestServiceConfig(t, operatorKey, recipientKey, + source, cursors, nil, packageStore, vtxoStore, nil) + + svcIface, err := NewOORService(cfg) + require.NoError(t, err) + defer func() { + require.NoError(t, svcIface.Stop(t.Context())) + }() + + err = svcIface.SyncIncomingOnce(ctx) + require.NoError(t, err) + + cursor, ok := cursors.cursor(recipientScript) + require.True(t, ok) + require.Equal(t, int64(10), cursor.LastEventID) + require.NotNil(t, cursor.LastSessionID) + require.Equal(t, sessionID, *cursor.LastSessionID) + + outpoint := wire.OutPoint{ + Hash: arkPSBT.UnsignedTx.TxHash(), + Index: recipients[0].OutputIndex, + } + + desc, err := vtxoStore.GetVTXO(ctx, outpoint) + require.NoError(t, err) + require.Equal(t, recipients[0].Value, desc.Amount) + + require.Equal(t, 1, packageStore.packageCalls) + require.Equal(t, 1, packageStore.bindingCalls) + + status := svcIface.GetIncomingSyncStatus() + require.Equal(t, 1, status.LastRunProcessedScripts) + require.Equal(t, 1, status.LastRunProcessedEvents) + require.Equal(t, int64(1), status.TotalProcessedEvents) + require.Empty(t, status.LastError) +} + +// TestOORServiceSyncIncomingAckFailureDoesNotAdvanceCursor verifies a failing +// ack path leaves cursors unchanged. +func TestOORServiceSyncIncomingAckFailureDoesNotAdvanceCursor(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + arkPSBT, checkpoints, recipients, _, recipientKey, operatorKey := + buildTestIncomingMaterialization(t) + sessionID := SessionID(arkPSBT.UnsignedTx.TxHash()) + + recipientScript := recipients[0].PkScript + event := &IncomingRecipientEvent{ + EventID: 20, + SessionID: sessionID, + RecipientPkScript: recipientScript, + ArkPSBT: arkPSBT, + FinalCheckpointPSBTs: checkpoints, + } + + source := &testIncomingSource{ + events: map[string][]*IncomingRecipientEvent{ + string(recipientScript): {event}, + }, + } + + cursors := newTestIncomingCursorStore([]OwnedReceiveScript{{ + PkScript: recipientScript, + }}) + + cfg := newTestServiceConfig(t, operatorKey, recipientKey, + source, cursors, &testAckFailOutboxHandler{}, + &testPackageStore{}, newTestVTXOStore(), nil) + + svcIface, err := NewOORService(cfg) + require.NoError(t, err) + defer func() { + require.NoError(t, svcIface.Stop(t.Context())) + }() + + err = svcIface.SyncIncomingOnce(ctx) + require.Error(t, err) + + _, ok := cursors.cursor(recipientScript) + require.False(t, ok) +} + +// TestOORServiceSyncIncomingAckRetryIsIdempotent verifies incoming replay is +// safe when a cycle fails after materialization but before cursor persistence. +func TestOORServiceSyncIncomingAckRetryIsIdempotent(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + arkPSBT, checkpoints, recipients, _, recipientKey, operatorKey := + buildTestIncomingMaterialization(t) + sessionID := SessionID(arkPSBT.UnsignedTx.TxHash()) + + recipientScript := recipients[0].PkScript + event := &IncomingRecipientEvent{ + EventID: 30, + SessionID: sessionID, + RecipientPkScript: recipientScript, + ArkPSBT: arkPSBT, + FinalCheckpointPSBTs: checkpoints, + } + + source := &testIncomingSource{ + events: map[string][]*IncomingRecipientEvent{ + string(recipientScript): {event}, + }, + } + + cursors := newTestIncomingCursorStore([]OwnedReceiveScript{{ + PkScript: recipientScript, + }}) + transport := &testAckFailOnceOutboxHandler{} + vtxoStore := newTestVTXOStore() + + cfg := newTestServiceConfig(t, operatorKey, recipientKey, + source, cursors, transport, &testPackageStore{}, vtxoStore, nil) + + svcIface, err := NewOORService(cfg) + require.NoError(t, err) + defer func() { + require.NoError(t, svcIface.Stop(t.Context())) + }() + + err = svcIface.SyncIncomingOnce(ctx) + require.ErrorContains(t, err, "ack failed once") + + _, ok := cursors.cursor(recipientScript) + require.False(t, ok) + + err = svcIface.SyncIncomingOnce(ctx) + require.NoError(t, err) + + cursor, ok := cursors.cursor(recipientScript) + require.True(t, ok) + require.Equal(t, int64(30), cursor.LastEventID) + require.NotNil(t, cursor.LastSessionID) + require.Equal(t, sessionID, *cursor.LastSessionID) + + live, err := vtxoStore.ListLiveVTXOs(ctx) + require.NoError(t, err) + require.Len(t, live, 1) + + transport.mu.Lock() + require.Equal(t, 2, transport.ackCalls) + transport.mu.Unlock() +} + +// TestOORServiceOutgoingFlowViaActorSystem verifies outgoing orchestration can +// route through actor-system service-key lookup instead of direct actor calls. +func TestOORServiceOutgoingFlowViaActorSystem(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + signer := input.NewMockSigner([]*btcec.PrivateKey{clientKey}, nil) + operatorSigner := input.NewMockSigner( + []*btcec.PrivateKey{operatorPriv}, nil, + ) + + policy := scripts.CheckpointPolicy{ + OperatorKey: operatorPriv.PubKey(), + CSVDelay: 10, + } + + inputAmount := btcutil.Amount(10_000) + inputs := []TransferInput{ + newTestTransferInput(t, clientKey, operatorPriv.PubKey(), + wire.OutPoint{ + Hash: [32]byte{0x0A}, + Index: 0, + }, inputAmount), + } + + recipients := []oortx.RecipientOutput{{ + PkScript: newTestTaprootPkScript(t, clientKey.PubKey()), + Value: inputAmount, + }} + + source := &testIncomingSource{} + cursors := newTestIncomingCursorStore(nil) + vtxoStore := newTestVTXOStore() + for i := range inputs { + require.NoError(t, vtxoStore.SaveVTXO(ctx, inputs[i].VTXO)) + } + + cfg := newTestServiceConfig(t, operatorPriv.PubKey(), clientKey, + source, cursors, &testOutboxHandler{ + t: t, + clientSigner: signer, + operatorSigner: operatorSigner, + }, &testPackageStore{}, vtxoStore, nil) + + actorSystem := actor.NewActorSystem() + t.Cleanup(func() { + baseCtx := context.WithoutCancel(t.Context()) + shutdownCtx, cancel := context.WithTimeout( + baseCtx, time.Second, + ) + defer cancel() + + require.NoError(t, actorSystem.Shutdown(shutdownCtx)) + }) + + registerOutgoingActorInSystem(t, actorSystem, cfg) + cfg.ActorSystem = actorSystem + cfg.DeliveryStore = nil + + svcIface, err := NewOORService(cfg) + require.NoError(t, err) + defer func() { + require.NoError(t, svcIface.Stop(t.Context())) + }() + + sessionID, err := svcIface.StartOutgoing(ctx, StartOutgoingRequest{ + Policy: policy, + Inputs: inputs, + Recipients: recipients, + }) + require.NoError(t, err) + require.NotEqual(t, SessionID{}, sessionID) + + view, err := svcIface.GetOutgoingState(ctx, sessionID) + require.NoError(t, err) + require.Equal(t, sessionID, view.SessionID) + require.Equal(t, "Completed", view.StateName) + require.True(t, view.Terminal) +} + +// TestNewOORServiceActorSystemRequiresOutgoingActor verifies constructor +// validation when actor-system lookup is enabled without a registered actor. +func TestNewOORServiceActorSystemRequiresOutgoingActor(t *testing.T) { + t.Parallel() + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + recipientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + cfg := newTestServiceConfig(t, operatorPriv.PubKey(), recipientKey, + &testIncomingSource{}, newTestIncomingCursorStore(nil), nil, + &testPackageStore{}, newTestVTXOStore(), nil) + + actorSystem := actor.NewActorSystem() + t.Cleanup(func() { + baseCtx := context.WithoutCancel(t.Context()) + shutdownCtx, cancel := context.WithTimeout( + baseCtx, time.Second, + ) + defer cancel() + + require.NoError(t, actorSystem.Shutdown(shutdownCtx)) + }) + + cfg.ActorSystem = actorSystem + cfg.DeliveryStore = nil + + _, err = NewOORService(cfg) + require.ErrorContains(t, err, "no outgoing actor registered") +} + +// TestOORServiceResolveUnrollPackages verifies resolver passthrough. +func TestOORServiceResolveUnrollPackages(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + recipientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{0x55}, + Index: 3, + } + + result := &db.OORUnrollPackages{TargetOutpoint: outpoint} + resolver := &testUnrollResolver{ + wantOutpoint: outpoint, + result: result, + } + + cfg := newTestServiceConfig(t, operatorPriv.PubKey(), recipientKey, + &testIncomingSource{}, newTestIncomingCursorStore(nil), nil, + &testPackageStore{}, newTestVTXOStore(), resolver) + + svcIface, err := NewOORService(cfg) + require.NoError(t, err) + defer func() { + require.NoError(t, svcIface.Stop(t.Context())) + }() + + resolved, err := svcIface.ResolveUnrollPackages(ctx, outpoint) + require.NoError(t, err) + require.Equal(t, result, resolved) +} + +// TestOORServiceIncomingWorkerLifecycle verifies the background worker can be +// started and stopped cleanly. +func TestOORServiceIncomingWorkerLifecycle(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + recipientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + cfg := newTestServiceConfig(t, operatorPriv.PubKey(), recipientKey, + &testIncomingSource{}, newTestIncomingCursorStore(nil), nil, + &testPackageStore{}, newTestVTXOStore(), nil) + + svcIface, err := NewOORService(cfg) + require.NoError(t, err) + defer func() { + require.NoError(t, svcIface.Stop(t.Context())) + }() + + err = svcIface.StartIncomingSync(ctx) + require.NoError(t, err) + + require.Eventually(t, func() bool { + return svcIface.GetIncomingSyncStatus().Running + }, time.Second, 10*time.Millisecond) + + err = svcIface.StopIncomingSync(ctx) + require.NoError(t, err) + + require.False(t, svcIface.GetIncomingSyncStatus().Running) +} + +var _ IncomingEventSource = (*testIncomingSource)(nil) +var _ IncomingCursorStore = (*testIncomingCursorStore)(nil) +var _ UnrollPackageResolver = (*testUnrollResolver)(nil) +var _ OutboxHandler = (*testAckFailOutboxHandler)(nil) +var _ OutboxHandler = (*testAckFailOnceOutboxHandler)(nil) diff --git a/oor/service_types.go b/oor/service_types.go new file mode 100644 index 000000000..01fca302e --- /dev/null +++ b/oor/service_types.go @@ -0,0 +1,286 @@ +package oor + +import ( + "context" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/lib/scripts" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/lightninglabs/darepo-client/vtxo" +) + +const ( + // DefaultIncomingPageSize is the default event count fetched per script + // poll in one source call. + DefaultIncomingPageSize int32 = 100 + + // DefaultIncomingPollInterval is the default delay between background + // incoming-sync cycles. + DefaultIncomingPollInterval = 2 * time.Second +) + +// OORService is the high-level client API for outgoing transfer orchestration, +// incoming transfer ingestion, and local unroll package resolution. +// +// The service hides low-level actor/FSM/outbox plumbing from application code. +// Callers interact with typed request/response methods while the service keeps +// ordering and idempotency guarantees for incoming cursor progression. +type OORService interface { + // StartOutgoing starts one outgoing OOR transfer session and returns + // the stable session identifier. + StartOutgoing(ctx context.Context, req StartOutgoingRequest) ( + SessionID, error, + ) + + // GetOutgoingState returns the current state summary for one outgoing + // session. + GetOutgoingState(ctx context.Context, sessionID SessionID) ( + OutgoingStateView, error, + ) + + // SyncIncomingOnce runs one full incoming-sync cycle across all tracked + // receive scripts. + SyncIncomingOnce(ctx context.Context) error + + // StartIncomingSync starts the background incoming-sync loop. + StartIncomingSync(ctx context.Context) error + + // StopIncomingSync stops the background incoming-sync loop. + // The call waits for shutdown. + StopIncomingSync(ctx context.Context) error + + // GetIncomingSyncStatus returns incoming-sync runtime status. + GetIncomingSyncStatus() IncomingSyncStatus + + // ResolveUnrollPackages resolves stored OOR packages for one + // outpoint. + ResolveUnrollPackages(ctx context.Context, + outpoint wire.OutPoint) (*db.OORUnrollPackages, error) + + // Stop stops the service and all managed workers. + Stop(ctx context.Context) error +} + +// StartOutgoingRequest carries parameters for creating one outgoing transfer. +type StartOutgoingRequest struct { + // Policy defines the checkpoint policy used to construct the submit + // package. + Policy scripts.CheckpointPolicy + + // Inputs are the local VTXOs to spend in the outgoing transfer. + Inputs []TransferInput + + // Recipients are the Ark transfer outputs. + Recipients []oortx.RecipientOutput +} + +// OutgoingStateView is a stable, caller-facing summary of one session state. +type OutgoingStateView struct { + // SessionID is the queried outgoing session identifier. + SessionID SessionID + + // StateName is the concrete FSM state name. + StateName string + + // Terminal reports whether the current state is terminal. + Terminal bool + + // FailedReason is populated for terminal failure states. + FailedReason string + + // RetryAfter is populated when the state is retry-backoff. + RetryAfter time.Duration + + // RetryReason is populated when the state is retry-backoff. + RetryReason string +} + +// IncomingRecipientEvent is one incoming recipient notification returned by a +// polling source. +// +// EventID is expected to be monotonically increasing per recipient script. +type IncomingRecipientEvent struct { + // EventID is the per-script cursor value used for resume. + EventID int64 + + // SessionID identifies the transfer session this event belongs to. + SessionID SessionID + + // RecipientPkScript identifies the script this event targets. + RecipientPkScript []byte + + // ArkPSBT is the finalized Ark transaction package for the transfer. + ArkPSBT *psbt.Packet + + // FinalCheckpointPSBTs is the finalized checkpoint package set. + FinalCheckpointPSBTs []*psbt.Packet + + // CreatedAt is the server-side creation time when available. + CreatedAt time.Time +} + +// IncomingEventSource lists incoming recipient events after a cursor. +type IncomingEventSource interface { + // ListRecipientEvents returns recipient events for one script + // strictly after afterEventID. Results must be sorted by EventID + // ascending. + ListRecipientEvents( + ctx context.Context, + recipientPkScript []byte, + afterEventID int64, + limit int32, + ) ([]*IncomingRecipientEvent, error) +} + +// OwnedReceiveScript is one locally tracked receive script entry. +type OwnedReceiveScript struct { + // PkScript is the raw script used for recipient polling. + PkScript []byte +} + +// RecipientCursor is the local processing cursor for one recipient script. +type RecipientCursor struct { + // RecipientPkScript identifies the script this cursor belongs to. + RecipientPkScript []byte + + // LastEventID is the highest successfully processed event ID. + LastEventID int64 + + // LastSessionID is the last processed session ID when available. + LastSessionID *SessionID +} + +// IncomingCursorStore persists and loads recipient cursors and tracked scripts. +type IncomingCursorStore interface { + // ListOwnedReceiveScripts returns all scripts that should be polled for + // incoming events. + ListOwnedReceiveScripts( + ctx context.Context, + ) ([]OwnedReceiveScript, error) + + // GetRecipientCursor returns the current cursor for one script. + // Nil means no cursor row exists yet. + GetRecipientCursor(ctx context.Context, + recipientPkScript []byte) (*RecipientCursor, error) + + // UpsertRecipientCursor stores the latest processed cursor for + // one script. + UpsertRecipientCursor(ctx context.Context, + recipientPkScript []byte, lastEventID int64, + lastSessionID *SessionID) error +} + +// UnrollPackageResolver resolves locally persisted OOR package chains by +// outpoint. +type UnrollPackageResolver interface { + // ResolveUnrollPackages returns the known package chain + // needed to unroll the target outpoint. + ResolveUnrollPackages(ctx context.Context, + outpoint wire.OutPoint) (*db.OORUnrollPackages, error) +} + +// IncomingSyncStatus reports runtime state of incoming-sync processing. +type IncomingSyncStatus struct { + // Running reports whether the background worker is currently active. + Running bool + + // LastRunStartedAt is when the last sync cycle started. + LastRunStartedAt time.Time + + // LastRunFinishedAt is when the last sync cycle completed. + LastRunFinishedAt time.Time + + // LastRunProcessedScripts is how many scripts were + // processed in the last cycle. + LastRunProcessedScripts int + + // LastRunProcessedEvents is how many events were processed in + // the last cycle. + LastRunProcessedEvents int + + // TotalProcessedScripts is the cumulative number of processed scripts. + TotalProcessedScripts int64 + + // TotalProcessedEvents is the cumulative number of processed events. + TotalProcessedEvents int64 + + // LastError is the last cycle error text, when present. + LastError string +} + +// ServiceConfig configures a concrete OORService implementation. +type ServiceConfig struct { + // ActorID identifies the outgoing actor instance. + // + // When the service constructs the actor locally, this value is used as + // the durable mailbox ID. + // + // When resolving through ActorSystem and no explicit OutgoingServiceKey + // is provided, this value is used as the service-key name. + ActorID string + + // ActorSystem provides actor lookup for outgoing OOR commands. + ActorSystem *actor.ActorSystem + + // OutgoingServiceKey overrides actor-system lookup key for the outgoing + // actor. + OutgoingServiceKey *actor.ServiceKey[ActorMsg, ActorResp] + + // OutgoingRef directly injects the outgoing actor reference. This takes + // precedence over ActorSystem lookup. + OutgoingRef actor.ActorRef[ActorMsg, ActorResp] + + // DeliveryStore backs durable actor mailbox/checkpoint persistence. + // Required only when the service constructs the outgoing actor locally. + DeliveryStore actor.DeliveryStore + + // TransportOutboxHandler handles protocol/network/signing + // outbox requests not handled by local persistence. + TransportOutboxHandler OutboxHandler + + // VTXOStore stores local VTXO state updates emitted by OOR + // outbox handlers. + VTXOStore vtxo.VTXOStore + + // PackageStore persists outgoing and incoming OOR package artifacts. + PackageStore PackagePersistence + + // OperatorKey is used to reconstruct incoming VTXO tapscripts. + OperatorKey *btcec.PublicKey + + // ExitDelay is the unilateral CSV delay used for incoming descriptors. + ExitDelay uint32 + + // ResolveIncomingClientKey resolves wallet key ownership for incoming + // recipients. + ResolveIncomingClientKey IncomingClientKeyResolver + + // ResolveIncomingMetadata resolves lineage metadata for + // incoming recipients. + ResolveIncomingMetadata IncomingMetadataResolver + + // IncomingSource provides recipient events for incoming polling. + IncomingSource IncomingEventSource + + // IncomingCursorStore persists owned receive scripts and + // recipient cursors. + IncomingCursorStore IncomingCursorStore + + // IncomingPageSize is the per-script page size for event polling. + IncomingPageSize int32 + + // IncomingPollInterval controls background incoming worker cadence. + IncomingPollInterval time.Duration + + // IncomingPollJitter adds random jitter to poll interval + // per cycle. + IncomingPollJitter time.Duration + + // UnrollResolver resolves stored unroll package chains by outpoint. + UnrollResolver UnrollPackageResolver +}