From 999cb51f3a01bd1907d44458b78867be75a2cd39 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Sat, 20 Dec 2025 09:40:29 +0100 Subject: [PATCH 1/9] lib: add OOR PSBT builders Add deterministic builders for v0 OOR checkpoint and Ark PSBTs. BuildCheckpointPSBT constructs an unsigned checkpoint tx that pays the full input value to a checkpoint P2TR output derived from the draft checkpoint script helpers. BuildArkPSBT constructs a fee-less Ark tx spending checkpoint outputs, sorts inputs/outputs canonically, appends the anchor output last, and attaches per-input `taptree` metadata. Unit tests assert the resulting submit package passes ValidateSubmitPackage. (cherry picked from commit f3615c50f7561eef4038cc0a823713a46cd802d6) --- lib/tx/oor/build.go | 304 +++++++++++++++++++++++++++++++++++++++ lib/tx/oor/build_test.go | 90 ++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 lib/tx/oor/build.go create mode 100644 lib/tx/oor/build_test.go diff --git a/lib/tx/oor/build.go b/lib/tx/oor/build.go new file mode 100644 index 000000000..35c214d2a --- /dev/null +++ b/lib/tx/oor/build.go @@ -0,0 +1,304 @@ +package oor + +import ( + "bytes" + "fmt" + "sort" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" +) + +// CheckpointInput describes the VTXO input being transformed into a checkpoint +// output for an OOR transfer. +type CheckpointInput struct { + // Outpoint is the outpoint of the VTXO output being spent. + Outpoint wire.OutPoint + + // WitnessUtxo is the previous output being spent (value + pkScript). + // + // This must match the server's stored VTXO descriptor later, but at the + // primitive level we only need it so PSBT has enough material to be + // signed and validated structurally. + WitnessUtxo *wire.TxOut + + // OwnerLeafScript is the VTXO-owner collaborative leaf script. + // + // "Owner" here means owner of the spent VTXO input, not owner of the + // checkpoint CSV timeout path. + // + // The script should be committed to in the checkpoint output tap tree. + // + // This is deliberately a raw script for the draft implementation. Once + // the closure system is canonical, higher layers should construct this + // leaf using closure helpers and pass the resulting script bytes here. + OwnerLeafScript []byte +} + +// CheckpointResult is the result of building a checkpoint PSBT. +type CheckpointResult struct { + // PSBT is the unsigned checkpoint transaction. + PSBT *psbt.Packet + + // TapTreeEncoded is the v0 tap tree encoding for the checkpoint output. + // + // This is intended to be attached to the Ark tx PSBT inputs under the + // `taptree` unknown key so finalization can later copy it onto the + // checkpoint output metadata. + TapTreeEncoded []byte +} + +// RecipientOutput describes an Ark tx recipient output. +type RecipientOutput struct { + // PkScript is the destination script. + PkScript []byte + + // Value is the amount to send in satoshis. + Value btcutil.Amount +} + +// BuildCheckpointPSBT constructs an unsigned checkpoint PSBT that spends a VTXO +// input and pays the entire input value to a checkpoint P2TR output. +// +// The checkpoint output pkScript is derived deterministically from: +// +// - the operator checkpoint policy, and +// - the caller-provided VTXO-owner collaborative leaf script. +// +// This function does not attempt to sign the checkpoint tx. It also does not +// validate that the owner leaf is a canonical Ark closure (draft phase). +func BuildCheckpointPSBT(policy scripts.CheckpointPolicy, + in CheckpointInput) (*CheckpointResult, error) { + + switch { + case in.WitnessUtxo == nil: + return nil, fmt.Errorf("witness utxo must be provided") + + case in.WitnessUtxo.Value <= 0: + return nil, fmt.Errorf("witness utxo value must be " + + "positive") + + case len(in.WitnessUtxo.PkScript) == 0: + return nil, fmt.Errorf("witness utxo pkScript must be " + + "provided") + } + + tapscript, err := scripts.CheckpointTapScript( + policy, in.OwnerLeafScript, + ) + if err != nil { + return nil, err + } + + encodedTapTree, err := EncodeTapTree(tapLeafScripts(tapscript.Leaves)) + if err != nil { + return nil, err + } + + checkpointPkScript, err := scripts.CheckpointPkScript( + policy, in.OwnerLeafScript, + ) + if err != nil { + return nil, err + } + + // Use v3 to be compatible with package relay policies (TRUC-style + // constraints) when these txs are eventually submitted as a package. + tx := wire.NewMsgTx(3) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: in.Outpoint, + Sequence: wire.MaxTxInSequenceNum, + }) + tx.AddTxOut(&wire.TxOut{ + Value: in.WitnessUtxo.Value, + PkScript: checkpointPkScript, + }) + + pkt, err := psbt.NewFromUnsignedTx(tx) + if err != nil { + return nil, fmt.Errorf("unable to create checkpoint psbt: %w", + err) + } + + pkt.Inputs[0].WitnessUtxo = in.WitnessUtxo + + return &CheckpointResult{ + PSBT: pkt, + TapTreeEncoded: encodedTapTree, + }, nil +} + +// CheckpointOutput describes a checkpoint output that will be spent by the Ark +// transaction. +type CheckpointOutput struct { + // Txid is the txid of the checkpoint transaction. + Txid chainhash.Hash + + // Output is the checkpoint output being spent (value + pkScript). + Output *wire.TxOut + + // TapTreeEncoded is the v0 tap tree encoding for the checkpoint output. + TapTreeEncoded []byte +} + +// BuildArkPSBT constructs a deterministic Ark tx PSBT spending the set of +// checkpoint outputs and producing the requested recipient outputs plus an +// anchor output. +// +// This is a v0 builder and enforces: +// +// - fee-less transfers (sum(inputs) == sum(outputs excluding anchor)), +// - anchor output is last output (P2A, value 0), and +// - canonical ordering rules for inputs/outputs (BIP69), +// +// It also attaches per-input `taptree` metadata using TapTreePSBTKey so the +// finalize step can later bind tap tree data onto checkpoint PSBT outputs. +func BuildArkPSBT(checkpoints []CheckpointOutput, + recipients []RecipientOutput) (*psbt.Packet, error) { + + if len(checkpoints) == 0 { + return nil, fmt.Errorf("checkpoint outputs must be provided") + } + + if len(recipients) == 0 { + return nil, fmt.Errorf("recipient outputs must be provided") + } + + var sumInputs btcutil.Amount + for _, cp := range checkpoints { + if cp.Output == nil { + return nil, fmt.Errorf( + "checkpoint output must be provided", + ) + } + + if len(cp.Output.PkScript) == 0 { + return nil, fmt.Errorf("checkpoint pkScript must be " + + "provided") + } + + if cp.Output.Value <= 0 { + return nil, fmt.Errorf("checkpoint output value must " + + "be positive") + } + + sumInputs += btcutil.Amount(cp.Output.Value) + } + + var sumOutputs btcutil.Amount + for _, out := range recipients { + if len(out.PkScript) == 0 { + return nil, fmt.Errorf("recipient pkScript must be " + + "provided") + } + + if out.Value <= 0 { + return nil, fmt.Errorf("recipient value must be " + + "positive") + } + + sumOutputs += out.Value + } + + if sumInputs != sumOutputs { + return nil, fmt.Errorf("fee-less ark tx requires equal " + + "input/output sums") + } + + // Sort checkpoint inputs by outpoint (BIP69-style) to ensure + // deterministic input order. + checkpointsSorted := make([]CheckpointOutput, len(checkpoints)) + copy(checkpointsSorted, checkpoints) + sort.SliceStable(checkpointsSorted, func(i, j int) bool { + a := checkpointsSorted[i] + b := checkpointsSorted[j] + + cmp := bytes.Compare(a.Txid[:], b.Txid[:]) + if cmp != 0 { + return cmp < 0 + } + + // v0 always spends vout=0. + return false + }) + + recipientOuts := make([]RecipientOutput, len(recipients)) + copy(recipientOuts, recipients) + sort.SliceStable(recipientOuts, func(i, j int) bool { + a := recipientOuts[i] + b := recipientOuts[j] + + if a.Value != b.Value { + return a.Value < b.Value + } + + return bytes.Compare(a.PkScript, b.PkScript) < 0 + }) + + // Use v3 to be compatible with package relay policies (TRUC-style + // constraints) when this tx is submitted as part of a package. + tx := wire.NewMsgTx(3) + for _, cp := range checkpointsSorted { + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: cp.Txid, + Index: 0, + }, + Sequence: wire.MaxTxInSequenceNum, + }) + } + + for _, out := range recipientOuts { + tx.AddTxOut(&wire.TxOut{ + Value: int64(out.Value), + PkScript: out.PkScript, + }) + } + + tx.AddTxOut(scripts.AnchorOutput()) + + err := ValidateCanonicalArkTx(tx) + if err != nil { + return nil, fmt.Errorf("internal: built ark tx is not "+ + "canonical: %w", err) + } + + pkt, err := psbt.NewFromUnsignedTx(tx) + if err != nil { + return nil, fmt.Errorf("unable to create ark psbt: %w", err) + } + + // Attach witness UTXOs and tap tree metadata in the same order as + // inputs. + for i := range checkpointsSorted { + cp := checkpointsSorted[i] + + pkt.Inputs[i].WitnessUtxo = cp.Output + + if len(cp.TapTreeEncoded) == 0 { + return nil, fmt.Errorf("checkpoint tap tree must be " + + "provided") + } + + err := PutTapTreePSBTInput(pkt, i, cp.TapTreeEncoded) + if err != nil { + return nil, err + } + } + + return pkt, nil +} + +// tapLeafScripts extracts raw script bytes from a list of tap leaves. +func tapLeafScripts(leaves []txscript.TapLeaf) [][]byte { + scripts := make([][]byte, 0, len(leaves)) + for _, leaf := range leaves { + scripts = append(scripts, leaf.Script) + } + + return scripts +} diff --git a/lib/tx/oor/build_test.go b/lib/tx/oor/build_test.go new file mode 100644 index 000000000..04e4fac48 --- /dev/null +++ b/lib/tx/oor/build_test.go @@ -0,0 +1,90 @@ +package oor + +import ( + "crypto/rand" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/stretchr/testify/require" +) + +// randomP2TRScript returns a P2TR pkScript with a random key. +func randomP2TRScript(t *testing.T) []byte { + t.Helper() + + var key [32]byte + _, err := rand.Read(key[:]) + require.NoError(t, err) + + return append([]byte{txscript.OP_1, 0x20}, key[:]...) +} + +// TestBuildCheckpointAndArkPSBT asserts the builders produce a submit package +// that passes the shared submit validator. +func TestBuildCheckpointAndArkPSBT(t *testing.T) { + t.Parallel() + + // This is an integration-style unit test over the tx builder layer: + // - BuildCheckpointPSBT produces a checkpoint spend for a single VTXO. + // - BuildArkPSBT consumes the checkpoint output and adds recipients + + // anchor output. + // - ValidateSubmitPackage then enforces the shared structural rules. + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + policy := scripts.CheckpointPolicy{ + OperatorKey: operatorKey.PubKey(), + CSVDelay: 10, + } + + vtxoWitness := &wire.TxOut{ + Value: 5000, + PkScript: randomP2TRScript(t), + } + + ownerLeafScript := []byte{ + txscript.OP_1, + txscript.OP_1, + txscript.OP_ADD, + txscript.OP_2, + txscript.OP_EQUAL, + } + + cpResult, err := BuildCheckpointPSBT(policy, CheckpointInput{ + Outpoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + WitnessUtxo: vtxoWitness, + OwnerLeafScript: ownerLeafScript, + }) + require.NoError(t, err) + require.NotNil(t, cpResult) + + checkpointTx := cpResult.PSBT.UnsignedTx + require.NotNil(t, checkpointTx) + require.Len(t, checkpointTx.TxOut, 1) + + arkPsbt, err := BuildArkPSBT([]CheckpointOutput{ + { + Txid: checkpointTx.TxHash(), + Output: checkpointTx.TxOut[0], + TapTreeEncoded: cpResult.TapTreeEncoded, + }, + }, []RecipientOutput{ + { + PkScript: randomP2TRScript(t), + Value: 5000, + }, + }) + require.NoError(t, err) + require.NotNil(t, arkPsbt) + + _, err = ValidateSubmitPackage(arkPsbt, []*psbt.Packet{cpResult.PSBT}) + require.NoError(t, err) +} From 904688dbfec90c1ccef855e8582968f20e5a513a Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Sun, 21 Dec 2025 09:07:04 +0100 Subject: [PATCH 2/9] oor: Add client transfer FSM Add a client-side protofsm workflow for out-of-round transfers. The FSM builds a canonical submit package via lib/tx/oor, emits transport outbox requests, and validates finalize packages after client signature attachment and tap-tree metadata binding. (cherry picked from commit e850abe49afc4576a527faf9196304de1aa1d629) --- oor/events.go | 106 +++++++++++++ oor/interfaces.go | 62 ++++++++ oor/log.go | 54 +++++++ oor/outbox_messages.go | 133 +++++++++++++++++ oor/session.go | 91 +++++++++++ oor/session_test.go | 111 ++++++++++++++ oor/states.go | 202 +++++++++++++++++++++++++ oor/transitions.go | 332 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1091 insertions(+) create mode 100644 oor/events.go create mode 100644 oor/interfaces.go create mode 100644 oor/log.go create mode 100644 oor/outbox_messages.go create mode 100644 oor/session.go create mode 100644 oor/session_test.go create mode 100644 oor/states.go create mode 100644 oor/transitions.go diff --git a/oor/events.go b/oor/events.go new file mode 100644 index 000000000..1dc780614 --- /dev/null +++ b/oor/events.go @@ -0,0 +1,106 @@ +package oor + +import ( + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/lightninglabs/darepo-client/lib/scripts" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" +) + +// Event is a sealed interface for all events that can drive the OOR transfer +// client FSM. +// +// The outgoing transfer FSM is intentionally deterministic: +// - events are the only inputs to transitions; and +// - outbox requests are the only way to do I/O (RPC, signing, timers). +// +// This is a foundational design choice for mobile safety: after a crash, the +// application can restore a persisted snapshot and re-drive the outbox implied +// by the current state. +type Event interface { + eventSealed() +} + +// StartTransferEvent requests starting an OOR transfer by building a submit +// package (checkpoint PSBTs + Ark PSBT). +type StartTransferEvent struct { + // StartTransferEvent is the one-and-only "kick off a session" event. + // After this, the FSM should have a deterministic submit package so + // retries produce a stable session id (Ark txid). + + // CheckpointInputs is the set of VTXO inputs to convert into + // checkpoints. + CheckpointInputs []oortx.CheckpointInput + + // RecipientOutputs are the Ark tx outputs to produce. + RecipientOutputs []oortx.RecipientOutput + + // Policy defines the checkpoint output tap tree policy. + Policy scripts.CheckpointPolicy + + // AnchorAmount is reserved for future extensions. v0 uses a fixed P2A + // anchor output with 0 sats. + AnchorAmount btcutil.Amount +} + +// eventSealed marks this as implementing the sealed Event interface. +func (e *StartTransferEvent) eventSealed() {} + +// SubmitAcceptedEvent is emitted when the server accepts and co-signs the +// submit package. +type SubmitAcceptedEvent struct { + // SubmitAcceptedEvent is the client's view of the "point-of-no-return". + // + // Once the operator co-signs the checkpoint PSBTs, the client must be + // able to resume and obtain the same co-signed artifacts even if it + // did not receive the response due to a crash or transport loss. + + // SessionID is the session identifier (Ark txid). + SessionID SessionID + + // ArkPSBT is echoed back for convenience and to allow stateless + // finalization (tap tree metadata is bound to it). + ArkPSBT *psbt.Packet + + // CoSignedCheckpointPSBTs are checkpoint PSBTs co-signed by the + // operator. + CoSignedCheckpointPSBTs []*psbt.Packet +} + +// eventSealed marks this as implementing the sealed Event interface. +func (e *SubmitAcceptedEvent) eventSealed() {} + +// CheckpointsSignedEvent is emitted after the client has attached signature +// material to the co-signed checkpoint PSBTs. +type CheckpointsSignedEvent struct { + // FinalCheckpointPSBTs are the finalized checkpoint PSBTs. + FinalCheckpointPSBTs []*psbt.Packet +} + +// eventSealed marks this as implementing the sealed Event interface. +func (e *CheckpointsSignedEvent) eventSealed() {} + +// FinalizeAcceptedEvent is emitted once the server has accepted the finalize +// package and updated its VTXO set. +type FinalizeAcceptedEvent struct{} + +// eventSealed marks this as implementing the sealed Event interface. +func (e *FinalizeAcceptedEvent) eventSealed() {} + +// InputsMarkedSpentEvent is emitted once the local wallet state has been +// updated to reflect that the input VTXOs were spent by this OOR session. +// +// This is an off-chain bookkeeping step: the OOR protocol does not imply any +// on-chain confirmation in the happy path. +type InputsMarkedSpentEvent struct{} + +// eventSealed marks this as implementing the sealed Event interface. +func (e *InputsMarkedSpentEvent) eventSealed() {} + +// FailEvent forces the session to enter a terminal failure state. +type FailEvent struct { + Reason string +} + +// eventSealed marks this as implementing the sealed Event interface. +func (e *FailEvent) eventSealed() {} diff --git a/oor/interfaces.go b/oor/interfaces.go new file mode 100644 index 000000000..8ca433009 --- /dev/null +++ b/oor/interfaces.go @@ -0,0 +1,62 @@ +package oor + +import ( + "encoding/hex" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/darepo-client/baselib/protofsm" +) + +// StateTransition is a type alias for the verbose protofsm.StateTransition type +// used throughout the OOR client transfer FSM. +type StateTransition = protofsm.StateTransition[ + Event, OutboxEvent, *Environment, +] + +// EmittedEvent is a type alias for the verbose protofsm.EmittedEvent type used +// when state transitions emit new events or outbox messages. +type EmittedEvent = protofsm.EmittedEvent[Event, OutboxEvent] + +// StateMachine is a type alias for the OOR client transfer FSM. +type StateMachine = protofsm.StateMachine[ + Event, OutboxEvent, *Environment, +] + +// StateMachineCfg is a type alias for the OOR client transfer FSM +// configuration. +type StateMachineCfg = protofsm.StateMachineCfg[ + Event, OutboxEvent, *Environment, +] + +// SessionID uniquely identifies an out-of-round transfer session. +// +// In v0, we use the Ark txid as the stable session identifier. +type SessionID chainhash.Hash + +// String returns the full string representation of the session id. +func (id SessionID) String() string { + hash := chainhash.Hash(id) + return hash.String() +} + +// LogPrefix returns a short string representation of the session id for logs. +func (id SessionID) LogPrefix() string { + hash := chainhash.Hash(id) + return fmt.Sprintf("oor(%s)", hex.EncodeToString(hash[:4])) +} + +// Environment provides the transfer FSM with access to external systems and +// storage. +// +// The FSM itself should remain mostly pure: it emits outbox requests and +// expects the actor boundary to translate them into follow-up events. +type Environment struct { + // SessionID identifies this FSM instance. + SessionID SessionID +} + +// Name returns the unique identifier for this FSM instance. +func (e *Environment) Name() string { + return fmt.Sprintf("oor_transfer_fsm_%s", e.SessionID) +} diff --git a/oor/log.go b/oor/log.go new file mode 100644 index 000000000..fe6603702 --- /dev/null +++ b/oor/log.go @@ -0,0 +1,54 @@ +package oor + +import ( + "context" + + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/protofsm" + "github.com/lightninglabs/darepo-client/build" +) + +// Subsystem defines the logging code for this subsystem. +const Subsystem = "OORC" + +// log is a logger that is initialized with no output filters. This means the +// package will not perform any logging by default until the caller requests +// it. +var log = btclog.Disabled + +// DisableLog disables all library log output. Logging output is disabled by +// default until UseLogger is called. +func DisableLog() { + UseLogger(btclog.Disabled) +} + +// UseLogger uses a specified Logger to output package logging info. +func UseLogger(logger btclog.Logger) { + log = logger +} + +// contextErrorReporter implements protofsm.ErrorReporter by logging errors +// using a logger from the context with a specific prefix. +// +//nolint:containedctx +type contextErrorReporter struct { + ctx context.Context + prefix string +} + +// newContextErrorReporter creates an error reporter that logs using the logger +// from the given context with the specified prefix. +func newContextErrorReporter(ctx context.Context, + prefix string) *contextErrorReporter { + + return &contextErrorReporter{ctx: ctx, prefix: prefix} +} + +// ReportError logs the error using the context logger. +func (r *contextErrorReporter) ReportError(err error) { + logger := build.LoggerFromContext(r.ctx).WithPrefix(r.prefix) + logger.Errorf("FSM error: %v", err) +} + +// Compile-time check that contextErrorReporter implements ErrorReporter. +var _ protofsm.ErrorReporter = (*contextErrorReporter)(nil) diff --git a/oor/outbox_messages.go b/oor/outbox_messages.go new file mode 100644 index 000000000..fb6801d5c --- /dev/null +++ b/oor/outbox_messages.go @@ -0,0 +1,133 @@ +package oor + +import ( + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/baselib/actor" + "google.golang.org/protobuf/proto" +) + +// OutboxEvent is a sealed interface for side-effect requests emitted by the +// OOR transfer FSM. +// +// Outbox messages are the explicit I/O boundary for the FSM: +// - transport (submit/finalize/ack) lives behind this interface +// - wallet signing lives behind this interface +// - chain confirmation monitoring lives behind this interface +// +// Keeping these side effects out of the FSM makes transitions deterministic +// and testable, and it makes it possible to implement restart-safe behavior by +// re-emitting the outbox implied by the current state. +type OutboxEvent interface { + outboxType() string + outboxSealed() +} + +// SendSubmitPackageRequest asks the transport layer to send the submit package +// (Ark PSBT + checkpoint PSBTs) to the server. +type SendSubmitPackageRequest struct { + actor.BaseMessage + + // ArkPSBT is the canonical unsigned Ark transfer PSBT. + ArkPSBT *psbt.Packet + + // CheckpointPSBTs are unsigned checkpoint PSBTs for the submit phase. + // + // In v0, client signing happens only after the server returns operator + // co-signed checkpoints. + CheckpointPSBTs []*psbt.Packet +} + +// outboxType returns a stable identifier for this outbox message. +func (m *SendSubmitPackageRequest) outboxType() string { + return "SendSubmitPackageRequest" +} + +// outboxSealed marks this as implementing the sealed OutboxEvent interface. +func (m *SendSubmitPackageRequest) outboxSealed() {} + +// ToProto converts SendSubmitPackageRequest to a protobuf message. +// +// TODO: Implement once OOR RPC definitions exist. +func (m *SendSubmitPackageRequest) ToProto() proto.Message { + return nil +} + +// RequestCheckpointSignatures asks the signing layer to add client signature +// material to the co-signed checkpoint PSBTs. +type RequestCheckpointSignatures struct { + actor.BaseMessage + + // ArkPSBT is the canonical Ark PSBT used to derive signing metadata. + ArkPSBT *psbt.Packet + + // CoSignedCheckpointPSBTs are operator-co-signed checkpoint PSBTs. + // + // The signer should append client signature material directly in the PSBT + // input witness/signature fields and return finalized checkpoint PSBTs. + CoSignedCheckpointPSBTs []*psbt.Packet +} + +// outboxType returns a stable identifier for this outbox message. +func (m *RequestCheckpointSignatures) outboxType() string { + return "RequestCheckpointSignatures" +} + +// outboxSealed marks this as implementing the sealed OutboxEvent interface. +func (m *RequestCheckpointSignatures) outboxSealed() {} + +// SendFinalizePackageRequest asks the transport layer to send finalized +// checkpoint PSBTs back to the server. +type SendFinalizePackageRequest struct { + actor.BaseMessage + + // ArkPSBT is the canonical Ark tx PSBT for this session. + ArkPSBT *psbt.Packet + + // FinalCheckpointPSBTs are fully signed checkpoint PSBTs. + FinalCheckpointPSBTs []*psbt.Packet +} + +// outboxType returns a stable identifier for this outbox message. +func (m *SendFinalizePackageRequest) outboxType() string { + return "SendFinalizePackageRequest" +} + +// outboxSealed marks this as implementing the sealed OutboxEvent interface. +func (m *SendFinalizePackageRequest) outboxSealed() {} + +// ToProto converts SendFinalizePackageRequest to a protobuf message. +// +// TODO: Implement once OOR RPC definitions exist. +func (m *SendFinalizePackageRequest) ToProto() proto.Message { + return nil +} + +// MarkInputsSpentRequest asks the persistence layer to mark the OOR inputs as +// spent in the local VTXO store. +// +// This outbox request exists to make the FSM crash-resilient: after a crash, +// the application can re-emit the outbox implied by the current state and +// retry local persistence until it succeeds. +type MarkInputsSpentRequest struct { + actor.BaseMessage + + // Outpoints are the VTXO outpoints that were consumed as inputs to this + // OOR session. + Outpoints []wire.OutPoint +} + +// outboxType returns a stable identifier for this outbox message. +func (m *MarkInputsSpentRequest) outboxType() string { + return "MarkInputsSpentRequest" +} + +// outboxSealed marks this as implementing the sealed OutboxEvent interface. +func (m *MarkInputsSpentRequest) outboxSealed() {} + +// ToProto converts MarkInputsSpentRequest to a protobuf message. +// +// TODO: Implement once OOR RPC definitions exist. +func (m *MarkInputsSpentRequest) ToProto() proto.Message { + return nil +} diff --git a/oor/session.go b/oor/session.go new file mode 100644 index 000000000..e31d5e28b --- /dev/null +++ b/oor/session.go @@ -0,0 +1,91 @@ +package oor + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/baselib/protofsm" + "github.com/lightninglabs/darepo-client/lib/scripts" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" +) + +// Session groups a running OOR transfer FSM with its stable identifier. +type Session struct { + // ID is the stable v0 session identifier (Ark txid). + ID SessionID + + // FSM is the running state machine for this session. + FSM *StateMachine +} + +// NewSession builds a submit package and creates a new OOR transfer session +// FSM that is ready to send the submit package to the server. +// +// This helper exists to ensure the FSM environment name is stable and derived +// from the Ark txid, which is only known after building the Ark PSBT. +// +// The returned outbox contains the submit request and should be treated as the +// only place where the caller performs I/O (transport, signing, timers). The +// caller is expected to: +// 1. execute outbox requests and turn results into follow-up events; and +// 2. feed those events back into the session FSM. +func NewSession(ctx context.Context, policy scripts.CheckpointPolicy, + inputs []oortx.CheckpointInput, + outputs []oortx.RecipientOutput) (*Session, []OutboxEvent, error) { + + inputOutpoints := make([]wire.OutPoint, 0, len(inputs)) + for i := range inputs { + inputOutpoints = append(inputOutpoints, inputs[i].Outpoint) + } + + ark, checkpoints, err := buildSubmitPackage(policy, inputs, outputs) + if err != nil { + return nil, nil, err + } + + sessionID, err := sessionIDFromArk(ark) + if err != nil { + return nil, nil, err + } + + env := &Environment{SessionID: sessionID} + + fsmCfg := StateMachineCfg{ + Logger: log.WithPrefix(sessionID.LogPrefix()), + ErrorReporter: newContextErrorReporter(ctx, sessionID.LogPrefix()), + InitialState: &AwaitingSubmitAccepted{ + InputOutpoints: inputOutpoints, + ArkPSBT: ark, + CheckpointPSBTs: checkpoints, + }, + Env: env, + } + + sm := protofsm.NewStateMachine(fsmCfg) + sm.Start(ctx) + + outbox := []OutboxEvent{ + &SendSubmitPackageRequest{ + ArkPSBT: ark, + CheckpointPSBTs: checkpoints, + }, + } + + return &Session{ + ID: sessionID, + FSM: &sm, + }, outbox, nil +} + +// sessionIDFromArk derives the v0 session identifier from an Ark PSBT. +func sessionIDFromArk(ark *psbt.Packet) (SessionID, error) { + if ark == nil || ark.UnsignedTx == nil { + return SessionID{}, fmt.Errorf("ark psbt must be provided") + } + + txid := ark.UnsignedTx.TxHash() + + return SessionID(txid), nil +} diff --git a/oor/session_test.go b/oor/session_test.go new file mode 100644 index 000000000..0edf0adb6 --- /dev/null +++ b/oor/session_test.go @@ -0,0 +1,111 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/stretchr/testify/require" +) + +func TestSessionHappyPath(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + policy := scripts.CheckpointPolicy{ + OperatorKey: operatorKey.PubKey(), + CSVDelay: 10, + } + + inputValue := btcutil.Amount(10000) + + inputs := []oortx.CheckpointInput{{ + Outpoint: wire.OutPoint{ + Hash: [32]byte{0x01}, + Index: 0, + }, + WitnessUtxo: &wire.TxOut{ + Value: int64(inputValue), + PkScript: []byte{0x51}, + }, + OwnerLeafScript: []byte{0x51}, + }} + + outputs := []oortx.RecipientOutput{{ + PkScript: []byte{0x51}, + Value: inputValue, + }} + + session, outbox, err := NewSession(ctx, policy, inputs, outputs) + require.NoError(t, err) + require.NotNil(t, session) + require.NotEmpty(t, outbox) + + require.Len(t, outbox, 1) + submit, ok := outbox[0].(*SendSubmitPackageRequest) + require.True(t, ok) + require.NotNil(t, submit.ArkPSBT) + require.NotEmpty(t, submit.CheckpointPSBTs) + + state, err := session.FSM.CurrentState() + require.NoError(t, err) + _, ok = state.(*AwaitingSubmitAccepted) + require.True(t, ok) + + // Step 1: Server accepts submit and returns co-signed checkpoints. + fut := session.FSM.AskEvent(ctx, &SubmitAcceptedEvent{ + SessionID: session.ID, + ArkPSBT: submit.ArkPSBT, + CoSignedCheckpointPSBTs: submit.CheckpointPSBTs, + }) + result := fut.Await(ctx) + require.False(t, result.IsErr()) + + submitOutbox := result.UnwrapOr(nil) + require.Len(t, submitOutbox, 1) + _, ok = submitOutbox[0].(*RequestCheckpointSignatures) + require.True(t, ok) + + // Step 2: Wallet attaches client signatures to checkpoints. + finalCheckpoints := submit.CheckpointPSBTs + finalCheckpoints[0].Inputs[0].TaprootKeySpendSig = []byte{0x01} + + fut = session.FSM.AskEvent(ctx, &CheckpointsSignedEvent{ + FinalCheckpointPSBTs: finalCheckpoints, + }) + result = fut.Await(ctx) + require.False(t, result.IsErr()) + + finalizeOutbox := result.UnwrapOr(nil) + require.Len(t, finalizeOutbox, 1) + _, ok = finalizeOutbox[0].(*SendFinalizePackageRequest) + require.True(t, ok) + + // Step 3: Server accepts finalize and updates VTXO set. + fut = session.FSM.AskEvent(ctx, &FinalizeAcceptedEvent{}) + result = fut.Await(ctx) + require.False(t, result.IsErr()) + + markOutbox := result.UnwrapOr(nil) + require.Len(t, markOutbox, 1) + _, ok = markOutbox[0].(*MarkInputsSpentRequest) + require.True(t, ok) + + // Step 4: Client persists that inputs are spent. + fut = session.FSM.AskEvent(ctx, &InputsMarkedSpentEvent{}) + result = fut.Await(ctx) + require.False(t, result.IsErr()) + require.Empty(t, result.UnwrapOr(nil)) + + state, err = session.FSM.CurrentState() + require.NoError(t, err) + _, ok = state.(*Completed) + require.True(t, ok) +} diff --git a/oor/states.go b/oor/states.go new file mode 100644 index 000000000..66b117712 --- /dev/null +++ b/oor/states.go @@ -0,0 +1,202 @@ +package oor + +import ( + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/baselib/protofsm" +) + +// State is a sealed interface for all states in the OOR client transfer FSM. +// +// States are protocol stages, not just implementation details. In particular, +// the outgoing transfer FSM is designed so that: +// - the submit package is deterministic (stable Ark txid); and +// - the client can resume by re-sending the outbox implied by the state. +type State interface { + protofsm.State[Event, OutboxEvent, *Environment] + stateSealed() +} + +// Idle is the initial state of a client-side OOR transfer session. +type Idle struct{} + +// String returns a human-readable representation of Idle. +func (s *Idle) String() string { + return "Idle" +} + +// IsTerminal returns false as Idle is not terminal. +func (s *Idle) IsTerminal() bool { + return false +} + +// stateSealed marks Idle as implementing the sealed State interface. +func (s *Idle) stateSealed() {} + +// AwaitingSubmitAccepted is reached after the client has built a submit +// package and emitted an outbox request to send it to the server. +type AwaitingSubmitAccepted struct { + // AwaitingSubmitAccepted is the crash-sensitive phase where a submit + // request may have been sent, while the client has not yet observed + // the server's co-sign response. + + // InputOutpoints are the VTXO outpoints consumed by this OOR session. + // + // The FSM carries these through to the terminal state so it can emit a + // crash-resilient local persistence step after the server accepts + // finalize. + InputOutpoints []wire.OutPoint + + // ArkPSBT is the Ark tx PSBT for this session. + ArkPSBT *psbt.Packet + + // CheckpointPSBTs are the checkpoint tx PSBTs for this session. + CheckpointPSBTs []*psbt.Packet +} + +// String returns a human-readable representation of AwaitingSubmitAccepted. +func (s *AwaitingSubmitAccepted) String() string { + return "AwaitingSubmitAccepted" +} + +// IsTerminal returns false as AwaitingSubmitAccepted is not terminal. +func (s *AwaitingSubmitAccepted) IsTerminal() bool { + return false +} + +// stateSealed marks AwaitingSubmitAccepted as implementing the sealed State +// interface. +func (s *AwaitingSubmitAccepted) stateSealed() {} + +// AwaitingCheckpointSignatures indicates the server has accepted and co-signed +// the package and the client must attach its own signature material to +// checkpoints. +type AwaitingCheckpointSignatures struct { + // AwaitingCheckpointSignatures means the server has accepted submit and + // returned operator co-signed checkpoint PSBTs. The next step is to + // attach the client's signature material and build a finalize package. + + // SessionID is the stable session identifier (Ark txid). + SessionID SessionID + + // InputOutpoints are the VTXO outpoints consumed by this OOR session. + InputOutpoints []wire.OutPoint + + // ArkPSBT is the Ark tx PSBT, needed to finalize checkpoint metadata. + ArkPSBT *psbt.Packet + + // CoSignedCheckpointPSBTs are the operator co-signed checkpoint PSBTs. + CoSignedCheckpointPSBTs []*psbt.Packet +} + +// String returns a human-readable representation of +// AwaitingCheckpointSignatures. +func (s *AwaitingCheckpointSignatures) String() string { + return "AwaitingCheckpointSignatures" +} + +// IsTerminal returns false as AwaitingCheckpointSignatures is not terminal. +func (s *AwaitingCheckpointSignatures) IsTerminal() bool { + return false +} + +// stateSealed marks AwaitingCheckpointSignatures as implementing the sealed +// State interface. +func (s *AwaitingCheckpointSignatures) stateSealed() {} + +// AwaitingFinalizeAccepted indicates the client has produced finalized +// checkpoint PSBTs and is waiting for the server to accept the finalize +// package. +type AwaitingFinalizeAccepted struct { + // AwaitingFinalizeAccepted means the client has sent fully signed + // checkpoint PSBTs back to the server and is waiting for ack. + + // SessionID is the stable session identifier (Ark txid). + SessionID SessionID + + // InputOutpoints are the VTXO outpoints consumed by this OOR session. + InputOutpoints []wire.OutPoint + + // ArkPSBT is the Ark tx PSBT. + ArkPSBT *psbt.Packet + + // FinalCheckpointPSBTs are the final checkpoint PSBTs sent to the + // server. + FinalCheckpointPSBTs []*psbt.Packet +} + +// String returns a human-readable representation of AwaitingFinalizeAccepted. +func (s *AwaitingFinalizeAccepted) String() string { + return "AwaitingFinalizeAccepted" +} + +// IsTerminal returns false as AwaitingFinalizeAccepted is not terminal. +func (s *AwaitingFinalizeAccepted) IsTerminal() bool { + return false +} + +// stateSealed marks AwaitingFinalizeAccepted as implementing the sealed State +// interface. +func (s *AwaitingFinalizeAccepted) stateSealed() {} + +// AwaitingLocalVTXOUpdate indicates the server has accepted the finalize +// package and the client must update its local VTXO persistence state. +type AwaitingLocalVTXOUpdate struct { + // AwaitingLocalVTXOUpdate means the off-chain OOR protocol has completed + // successfully at the server boundary, but the local wallet state still + // needs to be updated to reflect that the inputs are spent. + + // SessionID is the stable session identifier (Ark txid). + SessionID SessionID + + // InputOutpoints are the VTXO outpoints consumed by this OOR session. + InputOutpoints []wire.OutPoint +} + +// String returns a human-readable representation of AwaitingLocalVTXOUpdate. +func (s *AwaitingLocalVTXOUpdate) String() string { + return "AwaitingLocalVTXOUpdate" +} + +// IsTerminal returns false as AwaitingLocalVTXOUpdate is not terminal. +func (s *AwaitingLocalVTXOUpdate) IsTerminal() bool { + return false +} + +// stateSealed marks AwaitingLocalVTXOUpdate as implementing the sealed State +// interface. +func (s *AwaitingLocalVTXOUpdate) stateSealed() {} + +// Completed is the terminal success state for the OOR client transfer session. +type Completed struct{} + +// String returns a human-readable representation of Completed. +func (s *Completed) String() string { + return "Completed" +} + +// IsTerminal returns true as Completed is terminal. +func (s *Completed) IsTerminal() bool { + return true +} + +// stateSealed marks Completed as implementing the sealed State interface. +func (s *Completed) stateSealed() {} + +// Failed is the terminal failure state for the OOR client transfer session. +type Failed struct { + Reason string +} + +// String returns a human-readable representation of Failed. +func (s *Failed) String() string { + return "Failed" +} + +// IsTerminal returns true as Failed is terminal. +func (s *Failed) IsTerminal() bool { + return true +} + +// stateSealed marks Failed as implementing the sealed State interface. +func (s *Failed) stateSealed() {} diff --git a/oor/transitions.go b/oor/transitions.go new file mode 100644 index 000000000..1091de207 --- /dev/null +++ b/oor/transitions.go @@ -0,0 +1,332 @@ +package oor + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// unexpectedEvent returns a transition that stays in the current state and +// emits no outbox work for an unexpected event. +// +// This makes the FSM resilient to retries and late deliveries at the actor +// boundary. +func unexpectedEvent(state State, event Event) *StateTransition { + _ = event + + return &StateTransition{ + NextState: state, + NewEvents: fn.None[EmittedEvent](), + } +} + +// ProcessEvent handles events for Idle. +func (s *Idle) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*StateTransition, error) { + + _ = ctx + _ = env + + switch evt := event.(type) { + case *StartTransferEvent: + inputOutpoints := make([]wire.OutPoint, 0, len(evt.CheckpointInputs)) + for i := range evt.CheckpointInputs { + inputOutpoints = append( + inputOutpoints, evt.CheckpointInputs[i].Outpoint, + ) + } + + // Build a deterministic submit package: + // - checkpoint txs convert VTXOs into checkpoints + // - an Ark tx spends checkpoints and pays recipients + // + // The Ark txid becomes the stable session identifier. + ark, checkpoints, err := buildSubmitPackage( + evt.Policy, + evt.CheckpointInputs, + evt.RecipientOutputs, + ) + if err != nil { + return nil, err + } + + return &StateTransition{ + NextState: &AwaitingSubmitAccepted{ + InputOutpoints: inputOutpoints, + ArkPSBT: ark, + CheckpointPSBTs: checkpoints, + }, + NewEvents: fn.Some(EmittedEvent{ + Outbox: []OutboxEvent{ + &SendSubmitPackageRequest{ + ArkPSBT: ark, + CheckpointPSBTs: checkpoints, + }, + }, + }), + }, nil + + case *FailEvent: + return &StateTransition{ + NextState: &Failed{Reason: evt.Reason}, + NewEvents: fn.None[EmittedEvent](), + }, nil + + default: + return unexpectedEvent(s, event), nil + } +} + +// ProcessEvent handles events for AwaitingSubmitAccepted. +func (s *AwaitingSubmitAccepted) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*StateTransition, error) { + + _ = ctx + _ = env + + switch evt := event.(type) { + case *SubmitAcceptedEvent: + // Point-of-no-return: operator has co-signed checkpoints. + // + // After a crash, client must re-obtain co-signed bytes. + if evt.ArkPSBT == nil || evt.ArkPSBT.UnsignedTx == nil { + return nil, fmt.Errorf("ark psbt must be provided") + } + + if s.ArkPSBT == nil || s.ArkPSBT.UnsignedTx == nil { + return nil, fmt.Errorf("internal: missing ark psbt") + } + + stateTxid := s.ArkPSBT.UnsignedTx.TxHash() + evTxid := evt.ArkPSBT.UnsignedTx.TxHash() + if stateTxid != evTxid { + return nil, fmt.Errorf("ark txid mismatch") + } + + if len(evt.CoSignedCheckpointPSBTs) == 0 { + return nil, fmt.Errorf("co-signed checkpoints required") + } + + checkpoints := evt.CoSignedCheckpointPSBTs + + // Signature material is produced outside the FSM. + // The actor boundary uses a wallet to finalize checkpoints. + return &StateTransition{ + NextState: &AwaitingCheckpointSignatures{ + SessionID: evt.SessionID, + InputOutpoints: s.InputOutpoints, + ArkPSBT: evt.ArkPSBT, + CoSignedCheckpointPSBTs: checkpoints, + }, + NewEvents: fn.Some(EmittedEvent{ + Outbox: []OutboxEvent{ + &RequestCheckpointSignatures{ + ArkPSBT: evt.ArkPSBT, + CoSignedCheckpointPSBTs: evt. + CoSignedCheckpointPSBTs, + }, + }, + }), + }, nil + + case *FailEvent: + return &StateTransition{ + NextState: &Failed{Reason: evt.Reason}, + NewEvents: fn.None[EmittedEvent](), + }, nil + + default: + return unexpectedEvent(s, event), nil + } +} + +// ProcessEvent handles events for AwaitingCheckpointSignatures. +func (s *AwaitingCheckpointSignatures) ProcessEvent(ctx context.Context, + event Event, env *Environment) (*StateTransition, error) { + + _ = ctx + _ = env + + switch evt := event.(type) { + case *CheckpointsSignedEvent: + if s.ArkPSBT == nil { + return nil, fmt.Errorf("internal: missing ark psbt") + } + + if len(evt.FinalCheckpointPSBTs) == 0 { + return nil, fmt.Errorf("final checkpoints required") + } + + // Finalize binds tap tree metadata onto checkpoints. + err := oortx.ApplyFinalizeData( + s.ArkPSBT, evt.FinalCheckpointPSBTs, + ) + if err != nil { + return nil, err + } + + // Validate finalize package before emitting request. + err = oortx.ValidateFinalizePackage( + s.ArkPSBT, evt.FinalCheckpointPSBTs, + ) + if err != nil { + return nil, err + } + + return &StateTransition{ + NextState: &AwaitingFinalizeAccepted{ + SessionID: s.SessionID, + InputOutpoints: s.InputOutpoints, + ArkPSBT: s.ArkPSBT, + FinalCheckpointPSBTs: evt.FinalCheckpointPSBTs, + }, + NewEvents: fn.Some(EmittedEvent{ + Outbox: []OutboxEvent{ + &SendFinalizePackageRequest{ + ArkPSBT: s.ArkPSBT, + FinalCheckpointPSBTs: evt. + FinalCheckpointPSBTs, + }, + }, + }), + }, nil + + case *FailEvent: + return &StateTransition{ + NextState: &Failed{Reason: evt.Reason}, + NewEvents: fn.None[EmittedEvent](), + }, nil + + default: + return unexpectedEvent(s, event), nil + } +} + +// ProcessEvent handles events for AwaitingFinalizeAccepted. +func (s *AwaitingFinalizeAccepted) ProcessEvent(ctx context.Context, + event Event, env *Environment) (*StateTransition, error) { + + _ = ctx + _ = env + + switch evt := event.(type) { + case *FinalizeAcceptedEvent: + _ = evt + + return &StateTransition{ + NextState: &AwaitingLocalVTXOUpdate{ + SessionID: s.SessionID, + InputOutpoints: s.InputOutpoints, + }, + NewEvents: fn.Some(EmittedEvent{ + Outbox: []OutboxEvent{ + &MarkInputsSpentRequest{ + Outpoints: s.InputOutpoints, + }, + }, + }), + }, nil + + case *FailEvent: + return &StateTransition{ + NextState: &Failed{Reason: evt.Reason}, + NewEvents: fn.None[EmittedEvent](), + }, nil + + default: + return unexpectedEvent(s, event), nil + } +} + +// ProcessEvent handles events for AwaitingLocalVTXOUpdate. +func (s *AwaitingLocalVTXOUpdate) ProcessEvent(ctx context.Context, + event Event, env *Environment) (*StateTransition, error) { + + _ = ctx + _ = env + + switch evt := event.(type) { + case *InputsMarkedSpentEvent: + _ = evt + + return &StateTransition{ + NextState: &Completed{}, + NewEvents: fn.None[EmittedEvent](), + }, nil + + case *FailEvent: + return &StateTransition{ + NextState: &Failed{Reason: evt.Reason}, + NewEvents: fn.None[EmittedEvent](), + }, nil + + default: + return unexpectedEvent(s, event), nil + } +} + +// ProcessEvent handles events for Completed. +func (s *Completed) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*StateTransition, error) { + + _ = ctx + _ = env + + return unexpectedEvent(s, event), nil +} + +// ProcessEvent handles events for Failed. +func (s *Failed) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*StateTransition, error) { + + _ = ctx + _ = env + + return unexpectedEvent(s, event), nil +} + +// buildSubmitPackage constructs a v0 OOR submit package using the shared +// darepo-client lib/tx/oor primitives. +func buildSubmitPackage(policy scripts.CheckpointPolicy, + inputs []oortx.CheckpointInput, + outputs []oortx.RecipientOutput) (*psbt.Packet, []*psbt.Packet, error) { + + if len(inputs) == 0 { + return nil, nil, fmt.Errorf("checkpoint inputs required") + } + + checkpoints := make([]*psbt.Packet, 0, len(inputs)) + checkpointOuts := make([]oortx.CheckpointOutput, 0, len(inputs)) + + for i := range inputs { + result, err := oortx.BuildCheckpointPSBT(policy, inputs[i]) + if err != nil { + return nil, nil, err + } + + checkpoints = append(checkpoints, result.PSBT) + + txid := result.PSBT.UnsignedTx.TxHash() + cpOut := result.PSBT.UnsignedTx.TxOut[0] + + checkpointOuts = append(checkpointOuts, + oortx.CheckpointOutput{ + Txid: txid, + Output: cpOut, + TapTreeEncoded: result.TapTreeEncoded, + }, + ) + } + + ark, err := oortx.BuildArkPSBT(checkpointOuts, outputs) + if err != nil { + return nil, nil, err + } + + return ark, checkpoints, nil +} From b01a74cb52a388a59f88bbab83b323e90f092eec Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Sat, 20 Dec 2025 11:13:55 +0100 Subject: [PATCH 3/9] oor: Add recipient materialization hook Extract non-anchor recipient outputs from incoming Ark PSBTs and include them in the incoming notification. Also emit a dedicated outbox request that higher layers can implement to materialize/persist received VTXOs once closure/script semantics are finalized. This keeps the receive path restart-friendly and allows end-to-end harnesses to wire in mock materialization before durable storage exists. (cherry picked from commit ca1918727f26a44c335def7ef60ae72e81dbfb29) --- oor/ark_recipients.go | 62 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 oor/ark_recipients.go diff --git a/oor/ark_recipients.go b/oor/ark_recipients.go new file mode 100644 index 000000000..5ca6369aa --- /dev/null +++ b/oor/ark_recipients.go @@ -0,0 +1,62 @@ +package oor + +import ( + "fmt" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" +) + +// ArkRecipientOutput is a non-anchor Ark tx output intended for the receiver. +type ArkRecipientOutput struct { + // OutputIndex is the index of this output in the Ark tx. + OutputIndex uint32 + + // Value is the output amount in satoshis. + Value btcutil.Amount + + // PkScript is the raw pkScript bytes. + PkScript []byte +} + +// ExtractArkRecipients returns the non-anchor outputs from a canonical Ark +// PSBT, preserving their transaction indices. +// +// This helper is intentionally structural. It does not attempt to map outputs +// into VTXO descriptors (that requires closure/script semantics). +// +// Canonical ordering is required so output indices are stable: recipients can +// reference outputs by index without ambiguity (e.g. for event logs and +// materialization). +func ExtractArkRecipients(ark *psbt.Packet) ([]ArkRecipientOutput, error) { + if ark == nil || ark.UnsignedTx == nil { + return nil, fmt.Errorf("ark psbt must be provided") + } + + err := oortx.ValidateCanonicalArkPSBT(ark) + if err != nil { + return nil, err + } + + tx := ark.UnsignedTx + + recipients := make([]ArkRecipientOutput, 0, len(tx.TxOut)) + for idx, out := range tx.TxOut { + if oortx.IsAnchorOutput(out) { + continue + } + + recipients = append(recipients, ArkRecipientOutput{ + OutputIndex: uint32(idx), + Value: btcutil.Amount(out.Value), + PkScript: out.PkScript, + }) + } + + if len(recipients) == 0 { + return nil, fmt.Errorf("ark tx has no recipient outputs") + } + + return recipients, nil +} From b5377d2b8e027d234da69e2d38a2791ad6219e01 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Sat, 20 Dec 2025 11:11:10 +0100 Subject: [PATCH 4/9] oor: Add incoming transfer FSM Add a minimal recipient-side OOR transfer FSM that validates an incoming Ark PSBT structurally, emits an application notification, and requests an ack to the server via an outbox boundary. This keeps sender/receiver flows symmetric and provides a place to later attach full VTXO materialization once scripts/closures and the notification payload are finalized. (cherry picked from commit a9ca5f7356f40713ec80c44df4d50be6546e1661) --- oor/events.go | 27 +++++++ oor/outbox_messages.go | 87 ++++++++++++++++++++++ oor/receive_session.go | 79 ++++++++++++++++++++ oor/receive_session_test.go | 71 ++++++++++++++++++ oor/receive_states.go | 69 ++++++++++++++++++ oor/receive_transitions.go | 140 ++++++++++++++++++++++++++++++++++++ 6 files changed, 473 insertions(+) create mode 100644 oor/receive_session.go create mode 100644 oor/receive_session_test.go create mode 100644 oor/receive_states.go create mode 100644 oor/receive_transitions.go diff --git a/oor/events.go b/oor/events.go index 1dc780614..f2680b013 100644 --- a/oor/events.go +++ b/oor/events.go @@ -104,3 +104,30 @@ type FailEvent struct { // eventSealed marks this as implementing the sealed Event interface. func (e *FailEvent) eventSealed() {} + +// IncomingTransferEvent notifies the client about an incoming OOR transfer. +// +// This event is intended to be delivered by some higher layer (RPC push, +// polling, or push-notification wakeup) once the server has accepted and +// finalized the transfer. +// +// The incoming transfer FSM is intentionally separate from the outgoing FSM so +// applications can handle notifications and acknowledgements independently of +// initiating transfers. +type IncomingTransferEvent struct { + // SessionID is the stable v0 session identifier (Ark txid). + SessionID SessionID + + // ArkPSBT is the canonical Ark tx PSBT for this transfer. + ArkPSBT *psbt.Packet +} + +// eventSealed marks this as implementing the sealed Event interface. +func (e *IncomingTransferEvent) eventSealed() {} + +// IncomingHandledEvent indicates the application/wallet has processed the +// incoming transfer notification. +type IncomingHandledEvent struct{} + +// eventSealed marks this as implementing the sealed Event interface. +func (e *IncomingHandledEvent) eventSealed() {} diff --git a/oor/outbox_messages.go b/oor/outbox_messages.go index fb6801d5c..28bdcc981 100644 --- a/oor/outbox_messages.go +++ b/oor/outbox_messages.go @@ -131,3 +131,90 @@ func (m *MarkInputsSpentRequest) outboxSealed() {} func (m *MarkInputsSpentRequest) ToProto() proto.Message { return nil } + +// IncomingTransferNotification is emitted when an incoming transfer has been +// validated structurally and should be surfaced to the application layer. +type IncomingTransferNotification struct { + // IncomingTransferNotification is an application-facing notification. + // It carries the canonical Ark PSBT and the derived recipients so the + // UI can display a stable summary of the transfer. + + actor.BaseMessage + + // SessionID is the stable v0 session identifier (Ark txid). + SessionID SessionID + + // ArkPSBT is the canonical Ark tx PSBT. + ArkPSBT *psbt.Packet + + // Recipients are the non-anchor recipient outputs in the Ark tx. + Recipients []ArkRecipientOutput +} + +// outboxType returns a stable identifier for this outbox message. +func (m *IncomingTransferNotification) outboxType() string { + return "IncomingTransferNotification" +} + +// outboxSealed marks this as implementing the sealed OutboxEvent interface. +func (m *IncomingTransferNotification) outboxSealed() {} + +// MaterializeIncomingVTXOsRequest asks the application/wallet layer to +// materialize the incoming transfer into local VTXO records. +// +// This is the interface boundary where we eventually construct full VTXO +// descriptors and hand them to the vtxo.Manager for lifecycle tracking. +type MaterializeIncomingVTXOsRequest struct { + // MaterializeIncomingVTXOsRequest is the outbox boundary for updating + // local wallet/VTXO state as a result of an incoming transfer. + + actor.BaseMessage + + // SessionID identifies the incoming transfer session. + SessionID SessionID + + // ArkPSBT is the canonical Ark tx PSBT. + ArkPSBT *psbt.Packet + + // Recipients are the non-anchor recipient outputs in the Ark tx. + Recipients []ArkRecipientOutput +} + +// outboxType returns a stable identifier for this outbox message. +func (m *MaterializeIncomingVTXOsRequest) outboxType() string { + return "MaterializeIncomingVTXOsRequest" +} + +// outboxSealed marks this as implementing the sealed OutboxEvent interface. +func (m *MaterializeIncomingVTXOsRequest) outboxSealed() {} + +// SendIncomingAckRequest requests the transport layer to ack receipt of the +// incoming transfer to the server. +// +// In the future this becomes an RPC call. For now it is left as an interface +// boundary so client-side FSMs can be tested without a transport. +type SendIncomingAckRequest struct { + // SendIncomingAckRequest is best-effort: an app may retry it until the + // server confirms the ack was recorded, depending on the chosen + // transport semantics. + + actor.BaseMessage + + // SessionID identifies the transfer being acknowledged. + SessionID SessionID +} + +// outboxType returns a stable identifier for this outbox message. +func (m *SendIncomingAckRequest) outboxType() string { + return "SendIncomingAckRequest" +} + +// outboxSealed marks this as implementing the sealed OutboxEvent interface. +func (m *SendIncomingAckRequest) outboxSealed() {} + +// ToProto converts SendIncomingAckRequest to a protobuf message. +// +// TODO: Implement once OOR RPC definitions exist. +func (m *SendIncomingAckRequest) ToProto() proto.Message { + return nil +} diff --git a/oor/receive_session.go b/oor/receive_session.go new file mode 100644 index 000000000..af8facac8 --- /dev/null +++ b/oor/receive_session.go @@ -0,0 +1,79 @@ +package oor + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/lightninglabs/darepo-client/baselib/protofsm" +) + +// ReceiveSession groups a running incoming-transfer FSM with its stable +// identifier. +type ReceiveSession struct { + ID SessionID + + FSM *StateMachine +} + +// NewReceiveSession creates a new incoming-transfer FSM session for the given +// Ark PSBT. +// +// The caller should drive the returned FSM by sending an IncomingTransferEvent +// (or by using DriveIncomingTransfer). +func NewReceiveSession(ctx context.Context, ark *psbt.Packet, + sessionID SessionID) (*ReceiveSession, error) { + + if ark == nil || ark.UnsignedTx == nil { + return nil, fmt.Errorf("ark psbt must be provided") + } + + if sessionID == (SessionID{}) { + return nil, fmt.Errorf("session id must be provided") + } + + env := &Environment{SessionID: sessionID} + + fsmCfg := StateMachineCfg{ + Logger: log.WithPrefix(sessionID.LogPrefix()), + ErrorReporter: newContextErrorReporter(ctx, sessionID.LogPrefix()), + InitialState: &ReceiveIdle{}, + Env: env, + } + + sm := protofsm.NewStateMachine(fsmCfg) + sm.Start(ctx) + + return &ReceiveSession{ + ID: sessionID, + FSM: &sm, + }, nil +} + +// DriveIncomingTransfer is a small helper that constructs a receive session +// and feeds it the incoming-transfer event. +// +// This is intended for tests and early harnesses. In an app, the incoming +// event would typically be delivered to an already-running durable actor. +func DriveIncomingTransfer(ctx context.Context, sessionID SessionID, + ark *psbt.Packet) (*ReceiveSession, []OutboxEvent, error) { + + sess, err := NewReceiveSession(ctx, ark, sessionID) + if err != nil { + return nil, nil, err + } + + fut := sess.FSM.AskEvent(ctx, &IncomingTransferEvent{ + SessionID: sessionID, + ArkPSBT: ark, + }) + + result := fut.Await(ctx) + if result.IsErr() { + return nil, nil, result.Err() + } + + outbox := result.UnwrapOr(nil) + + return sess, outbox, nil +} diff --git a/oor/receive_session_test.go b/oor/receive_session_test.go new file mode 100644 index 000000000..f92882585 --- /dev/null +++ b/oor/receive_session_test.go @@ -0,0 +1,71 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/stretchr/testify/require" +) + +func TestReceiveSessionNotifiesAndAcks(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + policy := scripts.CheckpointPolicy{ + OperatorKey: operatorKey.PubKey(), + CSVDelay: 10, + } + + inputValue := btcutil.Amount(10000) + + inputs := []oortx.CheckpointInput{{ + Outpoint: wire.OutPoint{ + Hash: [32]byte{0x01}, + Index: 0, + }, + WitnessUtxo: &wire.TxOut{ + Value: int64(inputValue), + PkScript: []byte{0x51}, + }, + OwnerLeafScript: []byte{0x51}, + }} + + outputs := []oortx.RecipientOutput{{ + PkScript: []byte{0x51}, + Value: inputValue, + }} + + // Build a canonical Ark PSBT for the receive notification. + cp, err := oortx.BuildCheckpointPSBT(policy, inputs[0]) + require.NoError(t, err) + + arkPSBT, err := oortx.BuildArkPSBT([]oortx.CheckpointOutput{{ + Txid: cp.PSBT.UnsignedTx.TxHash(), + Output: cp.PSBT.UnsignedTx.TxOut[0], + TapTreeEncoded: cp.TapTreeEncoded, + }}, outputs) + require.NoError(t, err) + + sessionID := SessionID(arkPSBT.UnsignedTx.TxHash()) + + _, outbox, err := DriveIncomingTransfer(ctx, sessionID, arkPSBT) + require.NoError(t, err) + require.Len(t, outbox, 3) + + _, ok := outbox[0].(*IncomingTransferNotification) + require.True(t, ok) + + _, ok = outbox[1].(*MaterializeIncomingVTXOsRequest) + require.True(t, ok) + + _, ok = outbox[2].(*SendIncomingAckRequest) + require.True(t, ok) +} diff --git a/oor/receive_states.go b/oor/receive_states.go new file mode 100644 index 000000000..237b1fea2 --- /dev/null +++ b/oor/receive_states.go @@ -0,0 +1,69 @@ +package oor + +import ( + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/lightninglabs/darepo-client/baselib/protofsm" +) + +// ReceiveState is a sealed interface for all states in the incoming transfer +// FSM. +// +// This FSM is separate from the sender transfer FSM. It exists so a client can +// validate and acknowledge incoming transfers in a restart-friendly way. +type ReceiveState interface { + protofsm.State[Event, OutboxEvent, *Environment] + receiveStateSealed() +} + +// ReceiveIdle is the initial state for handling incoming transfers. +type ReceiveIdle struct{} + +// String returns a human-readable representation of ReceiveIdle. +func (s *ReceiveIdle) String() string { + return "ReceiveIdle" +} + +// IsTerminal returns false as ReceiveIdle is not terminal. +func (s *ReceiveIdle) IsTerminal() bool { + return false +} + +// receiveStateSealed marks ReceiveIdle as implementing ReceiveState. +func (s *ReceiveIdle) receiveStateSealed() {} + +// ReceiveNotified indicates the client has validated and surfaced an incoming +// transfer and is waiting to ack it. +type ReceiveNotified struct { + SessionID SessionID + + ArkPSBT *psbt.Packet +} + +// String returns a human-readable representation of ReceiveNotified. +func (s *ReceiveNotified) String() string { + return "ReceiveNotified" +} + +// IsTerminal returns false as ReceiveNotified is not terminal. +func (s *ReceiveNotified) IsTerminal() bool { + return false +} + +// receiveStateSealed marks ReceiveNotified as implementing ReceiveState. +func (s *ReceiveNotified) receiveStateSealed() {} + +// ReceiveCompleted is the terminal success state for an incoming transfer. +type ReceiveCompleted struct{} + +// String returns a human-readable representation of ReceiveCompleted. +func (s *ReceiveCompleted) String() string { + return "ReceiveCompleted" +} + +// IsTerminal returns true as ReceiveCompleted is terminal. +func (s *ReceiveCompleted) IsTerminal() bool { + return true +} + +// receiveStateSealed marks ReceiveCompleted as implementing ReceiveState. +func (s *ReceiveCompleted) receiveStateSealed() {} diff --git a/oor/receive_transitions.go b/oor/receive_transitions.go new file mode 100644 index 000000000..2cb8550ef --- /dev/null +++ b/oor/receive_transitions.go @@ -0,0 +1,140 @@ +package oor + +import ( + "context" + "fmt" + + "github.com/lightninglabs/darepo-client/lib/tx/oor" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// unexpectedReceiveEvent returns a transition that keeps the current state and +// emits no outbox work for an unexpected event. +func unexpectedReceiveEvent(state ReceiveState, event Event) *StateTransition { + _ = event + + return &StateTransition{ + NextState: state, + NewEvents: fn.None[EmittedEvent](), + } +} + +// ProcessEvent handles events for ReceiveIdle. +func (s *ReceiveIdle) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*StateTransition, error) { + + _ = ctx + _ = env + + switch evt := event.(type) { + case *IncomingTransferEvent: + // Basic sanity checks: we only accept an incoming transfer + // notification if it is self-consistent (Ark txid matches + // SessionID) and structurally valid. + if evt.ArkPSBT == nil || evt.ArkPSBT.UnsignedTx == nil { + return nil, fmt.Errorf("ark psbt must be provided") + } + + if evt.SessionID == (SessionID{}) { + return nil, fmt.Errorf("session id must be provided") + } + + txid := evt.ArkPSBT.UnsignedTx.TxHash() + if SessionID(txid) != evt.SessionID { + return nil, fmt.Errorf("ark txid mismatch") + } + + // Canonical ordering checks prevent subtle malleability in how + // the recipients are extracted and displayed. + // + // The goal is that all parties derive identical semantics from + // identical bytes. + err := oor.ValidateCanonicalArkPSBT(evt.ArkPSBT) + if err != nil { + return nil, err + } + + // Extract recipients and surface the notification to the + // application layer. + recipients, err := ExtractArkRecipients(evt.ArkPSBT) + if err != nil { + return nil, err + } + + // The outbox is intentionally ordered: + // 1) notify the app/UI so it can show the transfer; + // 2) materialize incoming VTXOs into local state; and + // 3) ack receipt to the server (best-effort, idempotent). + return &StateTransition{ + NextState: &ReceiveNotified{ + SessionID: evt.SessionID, + ArkPSBT: evt.ArkPSBT, + }, + NewEvents: fn.Some(EmittedEvent{ + Outbox: []OutboxEvent{ + &IncomingTransferNotification{ + SessionID: evt.SessionID, + ArkPSBT: evt.ArkPSBT, + Recipients: recipients, + }, + &MaterializeIncomingVTXOsRequest{ + SessionID: evt.SessionID, + ArkPSBT: evt.ArkPSBT, + Recipients: recipients, + }, + &SendIncomingAckRequest{ + SessionID: evt.SessionID, + }, + }, + }), + }, nil + + case *FailEvent: + return &StateTransition{ + NextState: &Failed{Reason: evt.Reason}, + NewEvents: fn.None[EmittedEvent](), + }, nil + + default: + return unexpectedReceiveEvent(s, event), nil + } +} + +// ProcessEvent handles events for ReceiveNotified. +func (s *ReceiveNotified) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*StateTransition, error) { + + _ = ctx + _ = env + + switch evt := event.(type) { + case *IncomingHandledEvent: + // The application signals it has processed the notification. + // Wallet state has been updated (materialization complete). + _ = evt + + return &StateTransition{ + NextState: &ReceiveCompleted{}, + NewEvents: fn.None[EmittedEvent](), + }, nil + + case *FailEvent: + return &StateTransition{ + NextState: &Failed{Reason: evt.Reason}, + NewEvents: fn.None[EmittedEvent](), + }, nil + + default: + return unexpectedReceiveEvent(s, event), nil + } +} + +// ProcessEvent handles events for ReceiveCompleted. +func (s *ReceiveCompleted) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*StateTransition, error) { + + _ = ctx + _ = env + + return unexpectedReceiveEvent(s, event), nil +} From 0a35ed30a2155d616e27d2530b95bd78df40869b Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Sat, 20 Dec 2025 12:19:14 +0100 Subject: [PATCH 5/9] oor: add client actor wrapper Introduce an OORClientActor that owns per-session protofsm instances and drives side effects through an explicit outbox handler interface. This mirrors the server-side coordinator pattern and enables fully in-process end-to-end tests without requiring RPC or durable actors yet. A new unit test exercises the happy path by stubbing out server/wallet behavior in an in-memory outbox handler. (cherry picked from commit 989cbdf8011b8af1625100017c33c876da204538) --- oor/actor.go | 234 ++++++++++++++++++++++++++++++++++++++++++ oor/actor_messages.go | 123 ++++++++++++++++++++++ oor/actor_test.go | 115 +++++++++++++++++++++ 3 files changed, 472 insertions(+) create mode 100644 oor/actor.go create mode 100644 oor/actor_messages.go create mode 100644 oor/actor_test.go diff --git a/oor/actor.go b/oor/actor.go new file mode 100644 index 000000000..6caaadf7f --- /dev/null +++ b/oor/actor.go @@ -0,0 +1,234 @@ +package oor + +import ( + "context" + "fmt" + + "github.com/btcsuite/btclog/v2" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// OutboxHandler executes FSM outbox requests and returns follow-up events. +// +// This mirrors the server-side OOR coordinator approach. The goal is to keep +// the FSM pure and move I/O (RPC, signing, persistence) behind an explicit +// boundary that can later be implemented by durable actors. +type OutboxHandler interface { + // Handle executes the outbox request and returns follow-up events. + Handle(ctx context.Context, sessionID SessionID, + outbox OutboxEvent) ([]Event, error) +} + +// ClientActorCfg configures the OORClientActor. +type ClientActorCfg struct { + // Logger is used for actor and FSM logging. + Logger btclog.Logger + + // OutboxHandler executes side effects emitted by the FSM. + OutboxHandler OutboxHandler +} + +// OORClientActor wraps the outgoing-transfer client FSM in an actor interface. +// +// The actor owns a set of per-session protofsm state machines and drives them +// by executing outbox requests via an OutboxHandler. +type OORClientActor struct { + cfg ClientActorCfg + + // sessions holds all currently active transfer sessions keyed by the v0 + // session id (Ark txid). + sessions map[SessionID]*sessionHandle +} + +// NewOORClientActor creates a new outgoing-transfer OOR client actor. +func NewOORClientActor(cfg ClientActorCfg) *OORClientActor { + if cfg.Logger == nil { + cfg.Logger = btclog.Disabled + } + + return &OORClientActor{ + cfg: cfg, + sessions: make(map[SessionID]*sessionHandle), + } +} + +// Receive processes a client actor message and returns a response. +func (a *OORClientActor) Receive(ctx context.Context, + msg ActorMsg) fn.Result[ActorResp] { + + switch m := msg.(type) { + case *StartTransferRequest: + return a.handleStartTransfer(ctx, m) + + case *DriveEventRequest: + return a.handleDriveEvent(ctx, m) + + case *GetStateRequest: + return a.handleGetState(ctx, m) + + default: + return fn.Err[ActorResp](fmt.Errorf("unknown message type: %T", + m)) + } +} + +// handleStartTransfer starts a new outgoing transfer session. +func (a *OORClientActor) handleStartTransfer(ctx context.Context, + req *StartTransferRequest) fn.Result[ActorResp] { + + if req == nil { + return fn.Err[ActorResp](fmt.Errorf("request must be provided")) + } + + // Build the deterministic submit package and start the session FSM. + // I/O is emitted as outbox messages. + session, outbox, err := NewSession( + ctx, req.Policy, req.Inputs, req.Recipients, + ) + if err != nil { + return fn.Err[ActorResp](err) + } + + handle := &sessionHandle{FSM: session.FSM} + a.sessions[session.ID] = handle + + err = a.driveOutbox(ctx, session.ID, handle.FSM, outbox) + if err != nil { + return fn.Err[ActorResp](err) + } + + return fn.Ok[ActorResp](&StartTransferResponse{ + SessionID: session.ID, + }) +} + +// handleDriveEvent feeds a follow-up event into an existing session. +func (a *OORClientActor) handleDriveEvent(ctx context.Context, + req *DriveEventRequest) fn.Result[ActorResp] { + + if req == nil { + return fn.Err[ActorResp](fmt.Errorf("request must be provided")) + } + + if req.Event == nil { + return fn.Err[ActorResp](fmt.Errorf("event must be provided")) + } + + handle, ok := a.sessions[req.SessionID] + if !ok { + return fn.Err[ActorResp](fmt.Errorf("unknown session: %s", + req.SessionID)) + } + + outbox, err := a.askEvent(ctx, handle.FSM, req.Event) + if err != nil { + return fn.Err[ActorResp](err) + } + + err = a.driveOutbox(ctx, req.SessionID, handle.FSM, outbox) + if err != nil { + return fn.Err[ActorResp](err) + } + + return fn.Ok[ActorResp](&DriveEventResponse{}) +} + +// handleGetState returns the current state for the requested session. +func (a *OORClientActor) handleGetState(ctx context.Context, + req *GetStateRequest) fn.Result[ActorResp] { + + _ = ctx + + if req == nil { + return fn.Err[ActorResp](fmt.Errorf("request must be provided")) + } + + handle, ok := a.sessions[req.SessionID] + if !ok { + return fn.Err[ActorResp](fmt.Errorf("unknown session: %s", + req.SessionID)) + } + + state, err := handle.currentState() + if err != nil { + return fn.Err[ActorResp](err) + } + + return fn.Ok[ActorResp](&GetStateResponse{ + State: state, + }) +} + +// askEvent asks an event on the FSM and returns any outbox produced. +func (a *OORClientActor) askEvent(ctx context.Context, fsm *StateMachine, + event Event) ([]OutboxEvent, error) { + + if fsm == nil { + return nil, fmt.Errorf("fsm must be provided") + } + + fut := fsm.AskEvent(ctx, event) + result := fut.Await(ctx) + if result.IsErr() { + return nil, result.Err() + } + + return result.UnwrapOr(nil), nil +} + +// driveOutbox executes outbox work using the configured handler and feeds any +// follow-up events back into the FSM. +func (a *OORClientActor) driveOutbox(ctx context.Context, sessionID SessionID, + fsm *StateMachine, outbox []OutboxEvent) error { + + handler := a.cfg.OutboxHandler + if handler == nil { + return nil + } + + for _, msg := range outbox { + // The outbox boundary is the only place where I/O is allowed. + // The handler returns follow-up events for the FSM. + followUps, err := handler.Handle(ctx, sessionID, msg) + if err != nil { + return err + } + + for _, followUp := range followUps { + // Feed follow-up events into the FSM. + // Recursively execute any emitted outbox work. + // Stop when none remains. + nextOutbox, err := a.askEvent(ctx, fsm, followUp) + if err != nil { + return err + } + + err = a.driveOutbox(ctx, sessionID, fsm, nextOutbox) + if err != nil { + return err + } + } + } + + return nil +} + +// sessionHandle ties a session ID to its running state machine instance. +type sessionHandle struct { + FSM *StateMachine +} + +// currentState returns the current concrete OOR session state. +func (h *sessionHandle) currentState() (State, error) { + current, err := h.FSM.CurrentState() + if err != nil { + return nil, err + } + + state, ok := current.(State) + if !ok { + return nil, fmt.Errorf("unexpected state type: %T", current) + } + + return state, nil +} diff --git a/oor/actor_messages.go b/oor/actor_messages.go new file mode 100644 index 000000000..22a02e6bf --- /dev/null +++ b/oor/actor_messages.go @@ -0,0 +1,123 @@ +package oor + +import ( + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/lib/scripts" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" +) + +// ActorMsg is a sealed interface for messages that can be sent to the +// OORClientActor. +type ActorMsg interface { + actor.Message + actorMsgSealed() +} + +// ActorResp is a sealed interface for responses produced by the OORClientActor. +type ActorResp interface { + actor.Message + actorRespSealed() +} + +// StartTransferRequest asks the actor to start a new outgoing OOR transfer +// session by building a submit package and sending it via the outbox boundary. +type StartTransferRequest struct { + actor.BaseMessage + + // Policy defines the checkpoint output tap tree policy. + Policy scripts.CheckpointPolicy + + // Inputs are the VTXO inputs to convert into checkpoint txs. + Inputs []oortx.CheckpointInput + + // Recipients are the Ark tx recipient outputs. + Recipients []oortx.RecipientOutput +} + +// MessageType returns the type of this message. +func (m *StartTransferRequest) MessageType() string { + return "StartTransferRequest" +} + +// actorMsgSealed marks this as implementing the sealed ActorMsg interface. +func (m *StartTransferRequest) actorMsgSealed() {} + +// StartTransferResponse returns the created session identifier. +type StartTransferResponse struct { + actor.BaseMessage + + SessionID SessionID +} + +// MessageType returns the type of this message. +func (m *StartTransferResponse) MessageType() string { + return "StartTransferResponse" +} + +// actorRespSealed marks this as implementing the sealed ActorResp interface. +func (m *StartTransferResponse) actorRespSealed() {} + +// DriveEventRequest asks the actor to feed an event into an existing session. +// +// This is the generic adapter boundary for future RPC/server notifications. +type DriveEventRequest struct { + actor.BaseMessage + + // SessionID selects the session to drive. + SessionID SessionID + + // Event is the follow-up event produced by an outbox handler, or by a + // higher-level notification mechanism. + Event Event +} + +// MessageType returns the type of this message. +func (m *DriveEventRequest) MessageType() string { + return "DriveEventRequest" +} + +// actorMsgSealed marks this as implementing the sealed ActorMsg interface. +func (m *DriveEventRequest) actorMsgSealed() {} + +// DriveEventResponse acknowledges the event was processed. +type DriveEventResponse struct { + actor.BaseMessage +} + +// MessageType returns the type of this message. +func (m *DriveEventResponse) MessageType() string { + return "DriveEventResponse" +} + +// actorRespSealed marks this as implementing the sealed ActorResp interface. +func (m *DriveEventResponse) actorRespSealed() {} + +// GetStateRequest asks the actor for the current state of a session. +type GetStateRequest struct { + actor.BaseMessage + + SessionID SessionID +} + +// MessageType returns the type of this message. +func (m *GetStateRequest) MessageType() string { + return "GetStateRequest" +} + +// actorMsgSealed marks this as implementing the sealed ActorMsg interface. +func (m *GetStateRequest) actorMsgSealed() {} + +// GetStateResponse returns the current session FSM state. +type GetStateResponse struct { + actor.BaseMessage + + State State +} + +// MessageType returns the type of this message. +func (m *GetStateResponse) MessageType() string { + return "GetStateResponse" +} + +// actorRespSealed marks this as implementing the sealed ActorResp interface. +func (m *GetStateResponse) actorRespSealed() {} diff --git a/oor/actor_test.go b/oor/actor_test.go new file mode 100644 index 000000000..34a4d3b58 --- /dev/null +++ b/oor/actor_test.go @@ -0,0 +1,115 @@ +package oor + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/stretchr/testify/require" +) + +// testOutboxHandler is a minimal in-process outbox handler for client actor +// tests. It simulates a server and wallet by returning follow-up events that +// drive the FSM forward. +type testOutboxHandler struct { + t *testing.T +} + +// Handle processes the outbox request and returns follow-up events. +func (h *testOutboxHandler) Handle(_ context.Context, sessionID SessionID, + outbox OutboxEvent) ([]Event, error) { + + h.t.Helper() + + switch msg := outbox.(type) { + case *SendSubmitPackageRequest: + txid := msg.ArkPSBT.UnsignedTx.TxHash() + require.Equal(h.t, SessionID(txid), sessionID) + + return []Event{&SubmitAcceptedEvent{ + SessionID: sessionID, + ArkPSBT: msg.ArkPSBT, + CoSignedCheckpointPSBTs: msg.CheckpointPSBTs, + }}, nil + + case *RequestCheckpointSignatures: + finalCheckpoints := msg.CoSignedCheckpointPSBTs + finalCheckpoints[0].Inputs[0].TaprootKeySpendSig = []byte{0x01} + + return []Event{&CheckpointsSignedEvent{ + FinalCheckpointPSBTs: finalCheckpoints, + }}, nil + + case *SendFinalizePackageRequest: + _ = msg + return []Event{&FinalizeAcceptedEvent{}}, nil + + default: + return nil, nil + } +} + +var _ OutboxHandler = (*testOutboxHandler)(nil) + +// TestOORClientActorHappyPath exercises the outgoing transfer flow end-to-end +// using the client actor wrapper and a stub outbox handler. +func TestOORClientActorHappyPath(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + policy := scripts.CheckpointPolicy{ + OperatorKey: operatorKey.PubKey(), + CSVDelay: 10, + } + + inputValue := btcutil.Amount(10000) + + inputs := []oortx.CheckpointInput{{ + Outpoint: wire.OutPoint{ + Hash: [32]byte{0x01}, + Index: 0, + }, + WitnessUtxo: &wire.TxOut{ + Value: int64(inputValue), + PkScript: []byte{0x51}, + }, + OwnerLeafScript: []byte{0x51}, + }} + + recipients := []oortx.RecipientOutput{{ + PkScript: []byte{0x51}, + Value: inputValue, + }} + + actor := NewOORClientActor(ClientActorCfg{ + OutboxHandler: &testOutboxHandler{t: t}, + }) + + startResp := actor.Receive(ctx, &StartTransferRequest{ + Policy: policy, + Inputs: inputs, + Recipients: recipients, + }) + require.True(t, startResp.IsOk()) + + startMsg, ok := startResp.UnwrapOr(nil).(*StartTransferResponse) + require.True(t, ok) + require.NotEqual(t, SessionID{}, startMsg.SessionID) + + stateResp := actor.Receive(ctx, &GetStateRequest{ + SessionID: startMsg.SessionID, + }) + require.True(t, stateResp.IsOk()) + + stateMsg, ok := stateResp.UnwrapOr(nil).(*GetStateResponse) + require.True(t, ok) + require.IsType(t, &AwaitingLocalVTXOUpdate{}, stateMsg.State) +} From 35e56507ded73370b19d37c7a02c5151196eb72c Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Thu, 12 Feb 2026 18:41:33 +0100 Subject: [PATCH 6/9] oor: tighten start-event contract and naming Simplify StartTransferEvent by removing the unused AnchorAmount field and renaming CheckpointInputs to VTXOInputs so the input role is explicit. This keeps the event contract aligned with actual v0 behavior and avoids carrying dead data. Also clean up SubmitAcceptedEvent documentation to describe point-of-no-return semantics and Ark artifact usage more clearly. Transitions are updated to use the renamed field without behavioral changes. --- oor/events.go | 31 +++++++++---------------------- oor/outbox_messages.go | 5 +++-- oor/states.go | 6 +++--- oor/transitions.go | 8 ++++---- 4 files changed, 19 insertions(+), 31 deletions(-) diff --git a/oor/events.go b/oor/events.go index f2680b013..e1713b316 100644 --- a/oor/events.go +++ b/oor/events.go @@ -1,7 +1,6 @@ package oor import ( - "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/lightninglabs/darepo-client/lib/scripts" oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" @@ -24,42 +23,30 @@ type Event interface { // StartTransferEvent requests starting an OOR transfer by building a submit // package (checkpoint PSBTs + Ark PSBT). type StartTransferEvent struct { - // StartTransferEvent is the one-and-only "kick off a session" event. - // After this, the FSM should have a deterministic submit package so - // retries produce a stable session id (Ark txid). - - // CheckpointInputs is the set of VTXO inputs to convert into - // checkpoints. - CheckpointInputs []oortx.CheckpointInput + // VTXOInputs is the set of VTXO inputs to convert into checkpoints. + VTXOInputs []oortx.CheckpointInput // RecipientOutputs are the Ark tx outputs to produce. RecipientOutputs []oortx.RecipientOutput // Policy defines the checkpoint output tap tree policy. Policy scripts.CheckpointPolicy - - // AnchorAmount is reserved for future extensions. v0 uses a fixed P2A - // anchor output with 0 sats. - AnchorAmount btcutil.Amount } // eventSealed marks this as implementing the sealed Event interface. func (e *StartTransferEvent) eventSealed() {} -// SubmitAcceptedEvent is emitted when the server accepts and co-signs the -// submit package. +// SubmitAcceptedEvent is emitted when the server accepts the submit package and +// co-signs checkpoint PSBTs (point-of-no-return for the outgoing flow). +// +// After this event, the client must be able to resume and obtain the same +// co-signed checkpoint artifacts even if the submit response was lost. type SubmitAcceptedEvent struct { - // SubmitAcceptedEvent is the client's view of the "point-of-no-return". - // - // Once the operator co-signs the checkpoint PSBTs, the client must be - // able to resume and obtain the same co-signed artifacts even if it - // did not receive the response due to a crash or transport loss. - // SessionID is the session identifier (Ark txid). SessionID SessionID - // ArkPSBT is echoed back for convenience and to allow stateless - // finalization (tap tree metadata is bound to it). + // ArkPSBT is the canonical session artifact for consistency checks and + // stateless finalize retries. ArkPSBT *psbt.Packet // CoSignedCheckpointPSBTs are checkpoint PSBTs co-signed by the diff --git a/oor/outbox_messages.go b/oor/outbox_messages.go index 28bdcc981..a4b6697fc 100644 --- a/oor/outbox_messages.go +++ b/oor/outbox_messages.go @@ -63,8 +63,9 @@ type RequestCheckpointSignatures struct { // CoSignedCheckpointPSBTs are operator-co-signed checkpoint PSBTs. // - // The signer should append client signature material directly in the PSBT - // input witness/signature fields and return finalized checkpoint PSBTs. + // The signer should append client signature material directly in + // PSBT input witness/signature fields and return finalized + // checkpoint PSBTs. CoSignedCheckpointPSBTs []*psbt.Packet } diff --git a/oor/states.go b/oor/states.go index 66b117712..d1b283bc0 100644 --- a/oor/states.go +++ b/oor/states.go @@ -142,9 +142,9 @@ func (s *AwaitingFinalizeAccepted) stateSealed() {} // AwaitingLocalVTXOUpdate indicates the server has accepted the finalize // package and the client must update its local VTXO persistence state. type AwaitingLocalVTXOUpdate struct { - // AwaitingLocalVTXOUpdate means the off-chain OOR protocol has completed - // successfully at the server boundary, but the local wallet state still - // needs to be updated to reflect that the inputs are spent. + // AwaitingLocalVTXOUpdate means the off-chain OOR protocol has + // completed successfully at the server boundary, but local wallet + // state still needs to be updated to reflect spent inputs. // SessionID is the stable session identifier (Ark txid). SessionID SessionID diff --git a/oor/transitions.go b/oor/transitions.go index 1091de207..6df68be6e 100644 --- a/oor/transitions.go +++ b/oor/transitions.go @@ -34,10 +34,10 @@ func (s *Idle) ProcessEvent(ctx context.Context, event Event, switch evt := event.(type) { case *StartTransferEvent: - inputOutpoints := make([]wire.OutPoint, 0, len(evt.CheckpointInputs)) - for i := range evt.CheckpointInputs { + inputOutpoints := make([]wire.OutPoint, 0, len(evt.VTXOInputs)) + for i := range evt.VTXOInputs { inputOutpoints = append( - inputOutpoints, evt.CheckpointInputs[i].Outpoint, + inputOutpoints, evt.VTXOInputs[i].Outpoint, ) } @@ -48,7 +48,7 @@ func (s *Idle) ProcessEvent(ctx context.Context, event Event, // The Ark txid becomes the stable session identifier. ark, checkpoints, err := buildSubmitPackage( evt.Policy, - evt.CheckpointInputs, + evt.VTXOInputs, evt.RecipientOutputs, ) if err != nil { From 23dfdaef5dca4d2bb5eb05033b5191aad4779f89 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Mon, 16 Feb 2026 08:27:37 +0100 Subject: [PATCH 7/9] oor: make StartTransfer idempotent Treat StartTransferRequest as idempotent by returning the existing session ID when the same deterministic transfer is submitted twice. This keeps the actor resilient to retries/replay and avoids accidentally replacing an in-flight session FSM. --- oor/actor.go | 9 +++++++++ oor/outbox_messages.go | 11 ----------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/oor/actor.go b/oor/actor.go index 6caaadf7f..2da052d98 100644 --- a/oor/actor.go +++ b/oor/actor.go @@ -89,6 +89,15 @@ func (a *OORClientActor) handleStartTransfer(ctx context.Context, return fn.Err[ActorResp](err) } + // StartTransferRequest is treated as idempotent: if the same + // deterministic transfer is submitted twice (e.g. due to retries or + // durable replay), we keep the existing session and return its ID. + if _, exists := a.sessions[session.ID]; exists { + return fn.Ok[ActorResp](&StartTransferResponse{ + SessionID: session.ID, + }) + } + handle := &sessionHandle{FSM: session.FSM} a.sessions[session.ID] = handle diff --git a/oor/outbox_messages.go b/oor/outbox_messages.go index a4b6697fc..63d1178ef 100644 --- a/oor/outbox_messages.go +++ b/oor/outbox_messages.go @@ -136,10 +136,6 @@ func (m *MarkInputsSpentRequest) ToProto() proto.Message { // IncomingTransferNotification is emitted when an incoming transfer has been // validated structurally and should be surfaced to the application layer. type IncomingTransferNotification struct { - // IncomingTransferNotification is an application-facing notification. - // It carries the canonical Ark PSBT and the derived recipients so the - // UI can display a stable summary of the transfer. - actor.BaseMessage // SessionID is the stable v0 session identifier (Ark txid). @@ -166,9 +162,6 @@ func (m *IncomingTransferNotification) outboxSealed() {} // This is the interface boundary where we eventually construct full VTXO // descriptors and hand them to the vtxo.Manager for lifecycle tracking. type MaterializeIncomingVTXOsRequest struct { - // MaterializeIncomingVTXOsRequest is the outbox boundary for updating - // local wallet/VTXO state as a result of an incoming transfer. - actor.BaseMessage // SessionID identifies the incoming transfer session. @@ -195,10 +188,6 @@ func (m *MaterializeIncomingVTXOsRequest) outboxSealed() {} // In the future this becomes an RPC call. For now it is left as an interface // boundary so client-side FSMs can be tested without a transport. type SendIncomingAckRequest struct { - // SendIncomingAckRequest is best-effort: an app may retry it until the - // server confirms the ack was recorded, depending on the chosen - // transport semantics. - actor.BaseMessage // SessionID identifies the transfer being acknowledged. From fce3f1c4df8f227cba079e0f0f6282afc3a011fb Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Mon, 16 Feb 2026 08:38:02 +0100 Subject: [PATCH 8/9] oor: document incoming transfer receive flow Add a human-readable receive FSM diagram and clarify the intended responsibilities for incoming transfers. The FSM performs structural/canonical validation and emits outbox requests in a fixed order: - notify app/UI - materialize wallet state - ack the server Wallet-ownership filtering happens behind the outbox boundary. --- oor/events.go | 6 ++++++ oor/receive_transitions.go | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/oor/events.go b/oor/events.go index e1713b316..7cf1bcda9 100644 --- a/oor/events.go +++ b/oor/events.go @@ -98,6 +98,12 @@ func (e *FailEvent) eventSealed() {} // polling, or push-notification wakeup) once the server has accepted and // finalized the transfer. // +// NOTE: This event is expected to be delivered only for transfers where the +// server believes the client is a recipient. The client-side receive FSM still +// performs structural/canonical validation, and the application/wallet layer is +// responsible for filtering/materializing only the outputs that belong to the +// local wallet. +// // The incoming transfer FSM is intentionally separate from the outgoing FSM so // applications can handle notifications and acknowledgements independently of // initiating transfers. diff --git a/oor/receive_transitions.go b/oor/receive_transitions.go index 2cb8550ef..46a351185 100644 --- a/oor/receive_transitions.go +++ b/oor/receive_transitions.go @@ -8,6 +8,28 @@ import ( fn "github.com/lightningnetwork/lnd/fn/v2" ) +// Incoming transfer receive flow (human-readable): +// +// IncomingTransferEvent (delivered by transport; server believes we are a +// recipient) +// | +// v +// ReceiveIdle +// - structural checks (SessionID/txid consistency) +// - canonical Ark PSBT validation (stable recipient extraction) +// - extract recipients (for UI summary and wallet materialization) +// emits outbox (in order): +// 1) IncomingTransferNotification: app/UI summary of the transfer +// 2) MaterializeIncomingVTXOsRequest: wallet/state update (filter + persist) +// 3) SendIncomingAckRequest: best-effort ack to server +// | +// v +// ReceiveNotified +// - waits for IncomingHandledEvent (app confirms it processed the transfer) +// | +// v +// ReceiveCompleted + // unexpectedReceiveEvent returns a transition that keeps the current state and // emits no outbox work for an unexpected event. func unexpectedReceiveEvent(state ReceiveState, event Event) *StateTransition { @@ -56,6 +78,11 @@ func (s *ReceiveIdle) ProcessEvent(ctx context.Context, event Event, // Extract recipients and surface the notification to the // application layer. + // + // Note: the FSM intentionally does not decide which of these + // outputs belong to the local wallet. That check depends on + // local wallet keys and policy, so it lives behind the outbox + // boundary in the materialization step. recipients, err := ExtractArkRecipients(evt.ArkPSBT) if err != nil { return nil, err From b98398d732c1e656e732a3c067c81ff28f14c778 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Mon, 16 Feb 2026 08:41:59 +0100 Subject: [PATCH 9/9] oor: clarify receive outbox responsibilities Make the boundary between incoming-transfer outbox messages explicit. IncomingTransferNotification is UI/app-facing (display/notify). MaterializeIncomingVTXOsRequest is wallet/state-facing (filter + persist). --- oor/outbox_messages.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/oor/outbox_messages.go b/oor/outbox_messages.go index 63d1178ef..96442609b 100644 --- a/oor/outbox_messages.go +++ b/oor/outbox_messages.go @@ -134,7 +134,11 @@ func (m *MarkInputsSpentRequest) ToProto() proto.Message { } // IncomingTransferNotification is emitted when an incoming transfer has been -// validated structurally and should be surfaced to the application layer. +// validated structurally and should be surfaced to the application/UI layer. +// +// This message is meant for "show/notify" semantics (eg. display a summary, +// badge a notification, or queue a UX flow). It is not expected to persist +// wallet state. type IncomingTransferNotification struct { actor.BaseMessage @@ -156,8 +160,11 @@ func (m *IncomingTransferNotification) outboxType() string { // outboxSealed marks this as implementing the sealed OutboxEvent interface. func (m *IncomingTransferNotification) outboxSealed() {} -// MaterializeIncomingVTXOsRequest asks the application/wallet layer to -// materialize the incoming transfer into local VTXO records. +// MaterializeIncomingVTXOsRequest asks the wallet/state layer to materialize +// the incoming transfer into local VTXO records. +// +// This message is meant for "persist/track" semantics: decide which recipient +// outputs belong to the local wallet and persist the corresponding VTXO state. // // This is the interface boundary where we eventually construct full VTXO // descriptors and hand them to the vtxo.Manager for lifecycle tracking.