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) +} diff --git a/oor/actor.go b/oor/actor.go new file mode 100644 index 000000000..2da052d98 --- /dev/null +++ b/oor/actor.go @@ -0,0 +1,243 @@ +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) + } + + // 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 + + 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) +} 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 +} diff --git a/oor/events.go b/oor/events.go new file mode 100644 index 000000000..7cf1bcda9 --- /dev/null +++ b/oor/events.go @@ -0,0 +1,126 @@ +package oor + +import ( + "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 { + // 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 +} + +// eventSealed marks this as implementing the sealed Event interface. +func (e *StartTransferEvent) eventSealed() {} + +// 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 { + // SessionID is the session identifier (Ark txid). + SessionID SessionID + + // ArkPSBT is the canonical session artifact for consistency checks and + // stateless finalize retries. + 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() {} + +// 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. +// +// 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. +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/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..96442609b --- /dev/null +++ b/oor/outbox_messages.go @@ -0,0 +1,217 @@ +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 + // 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 +} + +// IncomingTransferNotification is emitted when an incoming transfer has been +// 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 + + // 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 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. +type MaterializeIncomingVTXOsRequest struct { + 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 { + 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..46a351185 --- /dev/null +++ b/oor/receive_transitions.go @@ -0,0 +1,167 @@ +package oor + +import ( + "context" + "fmt" + + "github.com/lightninglabs/darepo-client/lib/tx/oor" + 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 { + _ = 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. + // + // 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 + } + + // 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 +} 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..d1b283bc0 --- /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 local wallet + // state still needs to be updated to reflect spent inputs. + + // 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..6df68be6e --- /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.VTXOInputs)) + for i := range evt.VTXOInputs { + inputOutpoints = append( + inputOutpoints, evt.VTXOInputs[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.VTXOInputs, + 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 +}