diff --git a/baselib/actor/restart.go b/baselib/actor/restart.go index b44b50494..e1704e321 100644 --- a/baselib/actor/restart.go +++ b/baselib/actor/restart.go @@ -178,7 +178,9 @@ func PrependRestartMessage( MessageType: msg.MessageType(), Payload: payload, Priority: RestartPriority, - AvailableAt: time.Now(), + // Use epoch so restart delivery never depends on wall-clock skew + // versus a test/fake delivery-store clock. + AvailableAt: time.Unix(0, 0), MaxAttempts: 1, // Restart message should only be delivered once. }) } diff --git a/lib/tx/oor/canonical.go b/lib/tx/arktx/canonical.go similarity index 67% rename from lib/tx/oor/canonical.go rename to lib/tx/arktx/canonical.go index 0086fbb04..6f1748779 100644 --- a/lib/tx/oor/canonical.go +++ b/lib/tx/arktx/canonical.go @@ -1,4 +1,4 @@ -package oor +package arktx import ( "bytes" @@ -10,6 +10,12 @@ import ( "github.com/lightninglabs/darepo-client/lib/scripts" ) +const ( + // TxVersion is the canonical transaction version used for v0 Ark + // transfers. We use v3 to support package relay. + TxVersion = 3 +) + // IsAnchorOutput returns true if the output is the v0 Ark anchor output (P2A, // value 0). func IsAnchorOutput(out *wire.TxOut) bool { @@ -24,21 +30,14 @@ func IsAnchorOutput(out *wire.TxOut) bool { return bytes.Equal(out.PkScript, scripts.AnchorPkScript) } -// ValidateCanonicalArkTx validates the canonical ordering rules for an Ark tx -// (as a raw transaction). -// -// The v0 rule set is based on BIP-0069-style sorting: -// - inputs are ordered by previous outpoint (txid, then vout); and -// - non-anchor outputs are ordered lexicographically by raw pkScript bytes, -// with output value used only as a tie-breaker. -// -// Ark txs additionally include exactly one anchor output (P2A, value 0), and -// the anchor output must be the final output. +// ValidateCanonicalTx validates canonical ordering rules for an Ark tx +// (as a raw transaction), including that it contains exactly one anchor output +// and that the anchor output is the last output. // // This is a structural validator only. It does not validate signatures, script // satisfaction, or VTXO ownership. Those checks belong in higher-level // validators that have access to policy and VTXO set state. -func ValidateCanonicalArkTx(tx *wire.MsgTx) error { +func ValidateCanonicalTx(tx *wire.MsgTx) error { if tx == nil { return fmt.Errorf("ark tx must be provided") } @@ -65,97 +64,31 @@ func ValidateCanonicalArkTx(tx *wire.MsgTx) error { "output") } - err := validateCanonicalArkOutputs(tx) + err := validateCanonicalOutputs(tx) if err != nil { return err } - return validateCanonicalArkInputs(tx) + return validateCanonicalInputs(tx) } -// ValidateCanonicalArkPSBT validates canonical ordering for an Ark tx PSBT. -func ValidateCanonicalArkPSBT(pkt *psbt.Packet) error { +// ValidateCanonicalPSBT validates canonical ordering for an Ark tx PSBT. +func ValidateCanonicalPSBT(pkt *psbt.Packet) error { if pkt == nil || pkt.UnsignedTx == nil { return fmt.Errorf("ark psbt must include unsigned tx") } - return ValidateCanonicalArkTx(pkt.UnsignedTx) + return ValidateCanonicalTx(pkt.UnsignedTx) } -// validateCanonicalArkInputs validates BIP-0069-style ordering of Ark tx -// inputs. -func validateCanonicalArkInputs(tx *wire.MsgTx) error { - for i := 1; i < len(tx.TxIn); i++ { - prev := tx.TxIn[i-1].PreviousOutPoint - cur := tx.TxIn[i].PreviousOutPoint - - cmp := bytes.Compare(prev.Hash[:], cur.Hash[:]) - switch { - case cmp < 0: - continue - - case cmp > 0: - return fmt.Errorf("ark tx inputs are not canonical " + - "(outpoint hash order)") - } - - if prev.Index > cur.Index { - return fmt.Errorf("ark tx inputs are not canonical " + - "(outpoint index order)") - } - } - - return nil -} - -// validateCanonicalArkOutputs validates recipient output ordering rules and -// anchor placement for v0 OOR transfers. -// -// Recipient outputs exclude the anchor output. Their ordering is compatible -// with BIP-0069 output ordering, using raw pkScript bytes as the primary sort -// key. -func validateCanonicalArkOutputs(tx *wire.MsgTx) error { - if len(tx.TxOut) == 0 { - return fmt.Errorf("ark tx has no outputs") - } - - recipientOuts := tx.TxOut[:len(tx.TxOut)-1] - - for i := 1; i < len(recipientOuts); i++ { - prev := recipientOuts[i-1] - cur := recipientOuts[i] - - if IsAnchorOutput(prev) || IsAnchorOutput(cur) { - return fmt.Errorf("anchor output must be last") - } - - cmp := bytes.Compare(prev.PkScript, cur.PkScript) - switch { - case cmp < 0: - continue - - case cmp > 0: - return fmt.Errorf("ark tx outputs are not canonical " + - "(pkScript order)") - } - - if prev.Value > cur.Value { - return fmt.Errorf("ark tx outputs are not canonical " + - "(value tie-break)") - } - } - - return nil -} - -// CanonicalizeArkTxOrdering sorts the transaction inputs and outputs in-place +// CanonicalizeOrdering sorts the transaction inputs and outputs in-place // according to the v0 rules. // -// CanonicalizeArkTxOrdering does not insert or remove outputs. It assumes the +// CanonicalizeOrdering does not insert or remove outputs. It assumes the // caller has already constructed an Ark tx that includes exactly one anchor // output of value 0. If the anchor output is missing or invalid, this returns // an error rather than guessing what to do. -func CanonicalizeArkTxOrdering(tx *wire.MsgTx) error { +func CanonicalizeOrdering(tx *wire.MsgTx) error { if tx == nil { return fmt.Errorf("ark tx must be provided") } @@ -194,12 +127,13 @@ func CanonicalizeArkTxOrdering(tx *wire.MsgTx) error { a := recipientOuts[i] b := recipientOuts[j] - cmp := bytes.Compare(a.PkScript, b.PkScript) - if cmp != 0 { - return cmp < 0 + // We order recipient outputs using BIP69 output ordering + // (amount, then pkScript bytes). + if a.Value != b.Value { + return a.Value < b.Value } - return a.Value < b.Value + return bytes.Compare(a.PkScript, b.PkScript) < 0 }) recipientOuts = append(recipientOuts, anchorOut) @@ -219,3 +153,63 @@ func CanonicalizeArkTxOrdering(tx *wire.MsgTx) error { return nil } + +// validateCanonicalInputs validates BIP69 ordering of Ark tx inputs. +func validateCanonicalInputs(tx *wire.MsgTx) error { + for i := 1; i < len(tx.TxIn); i++ { + prev := tx.TxIn[i-1].PreviousOutPoint + cur := tx.TxIn[i].PreviousOutPoint + + cmp := bytes.Compare(prev.Hash[:], cur.Hash[:]) + switch { + case cmp < 0: + continue + + case cmp > 0: + return fmt.Errorf("ark tx inputs are not canonical " + + "(outpoint hash order)") + } + + if prev.Index > cur.Index { + return fmt.Errorf("ark tx inputs are not canonical " + + "(outpoint index order)") + } + } + + return nil +} + +// validateCanonicalOutputs validates recipient output ordering rules and anchor +// placement for v0 Ark transfers. +func validateCanonicalOutputs(tx *wire.MsgTx) error { + if len(tx.TxOut) == 0 { + return fmt.Errorf("ark tx has no outputs") + } + + recipientOuts := tx.TxOut[:len(tx.TxOut)-1] + + for i := 1; i < len(recipientOuts); i++ { + prev := recipientOuts[i-1] + cur := recipientOuts[i] + + if IsAnchorOutput(prev) || IsAnchorOutput(cur) { + return fmt.Errorf("anchor output must be last") + } + + if prev.Value < cur.Value { + continue + } + + if prev.Value > cur.Value { + return fmt.Errorf("ark tx outputs are not canonical " + + "(value order)") + } + + if bytes.Compare(prev.PkScript, cur.PkScript) > 0 { + return fmt.Errorf("ark tx outputs are not canonical " + + "(pkScript order)") + } + } + + return nil +} diff --git a/lib/tx/arktx/canonical_test.go b/lib/tx/arktx/canonical_test.go new file mode 100644 index 000000000..1c8a01d7e --- /dev/null +++ b/lib/tx/arktx/canonical_test.go @@ -0,0 +1,51 @@ +package arktx + +import ( + "testing" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/stretchr/testify/require" +) + +// TestCanonicalizeOrderingSortsAndValidates asserts CanonicalizeOrdering +// produces a transaction that passes ValidateCanonicalTx, even if the input tx +// is not canonical. +func TestCanonicalizeOrderingSortsAndValidates(t *testing.T) { + t.Parallel() + + tx := wire.NewMsgTx(TxVersion) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: [32]byte{2}, + Index: 1, + }, + }) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: [32]byte{1}, + Index: 0, + }, + }) + + // Add outputs in non-canonical order and with anchor not last. + tx.TxOut = append(tx.TxOut, + &wire.TxOut{ + Value: 1, + PkScript: []byte{0x52}, + }, + scripts.AnchorOutput(), + &wire.TxOut{ + Value: 2, + PkScript: []byte{0x51}, + }, + ) + + err := CanonicalizeOrdering(tx) + require.NoError(t, err) + + err = ValidateCanonicalTx(tx) + require.NoError(t, err) + + require.True(t, IsAnchorOutput(tx.TxOut[len(tx.TxOut)-1])) +} diff --git a/lib/tx/arktx/doc.go b/lib/tx/arktx/doc.go new file mode 100644 index 000000000..e8a8ce57a --- /dev/null +++ b/lib/tx/arktx/doc.go @@ -0,0 +1,9 @@ +package arktx + +// Package arktx contains helpers for constructing and validating Ark +// transactions that represent the virtual-chain step following checkpoints. +// +// Canonical output ordering is critical because multiple subsystems rely on +// byte-identical transaction construction (client retries, server validation, +// and persisted snapshots). This package provides a single, shared definition +// of that canonical ordering. diff --git a/lib/tx/checkpoint/build.go b/lib/tx/checkpoint/build.go new file mode 100644 index 000000000..cca6b3a0c --- /dev/null +++ b/lib/tx/checkpoint/build.go @@ -0,0 +1,145 @@ +package checkpoint + +import ( + "fmt" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/lightninglabs/darepo-client/lib/tx/arktx" +) + +// MinCheckpointCSVDelay is the minimum acceptable checkpoint CSV delay for v0 +// OOR checkpoint policies. +const MinCheckpointCSVDelay = uint32(10) + +// Input describes the VTXO input being transformed into a checkpoint output for +// an OOR transfer. +type Input struct { + // SpentVTXO identifies and describes the VTXO output being spent. + SpentVTXO SpentVTXORef + + // OwnerLeafScript is the spent VTXO's collaborative leaf script. + // + // It is committed into the checkpoint output tap tree together with + // the operator timeout leaf. + // + // 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 +} + +// SpentVTXORef groups the spent VTXO outpoint and output data in one value so +// callers cannot accidentally mismatch identity and witness material. +type SpentVTXORef struct { + // Outpoint is the outpoint of the VTXO output being spent. + Outpoint wire.OutPoint + + // Output 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. + Output *wire.TxOut +} + +// Result is the result of building a checkpoint PSBT. +type Result 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 +} + +// BuildPSBT 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 owner 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 BuildPSBT(policy scripts.CheckpointPolicy, in Input) (*Result, error) { + switch { + case policy.CSVDelay < MinCheckpointCSVDelay: + return nil, fmt.Errorf( + "checkpoint csv delay %d below minimum %d", + policy.CSVDelay, MinCheckpointCSVDelay, + ) + + case in.SpentVTXO.Output == nil: + return nil, fmt.Errorf("spent output must be provided") + + case in.SpentVTXO.Output.Value <= 0: + return nil, fmt.Errorf("spent output value must be " + + "positive") + + case len(in.SpentVTXO.Output.PkScript) == 0: + return nil, fmt.Errorf("spent output 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 + } + + tapKey, err := tapscript.TaprootKey() + if err != nil { + return nil, fmt.Errorf("unable to compute taproot key: %w", err) + } + + checkpointPkScript, err := txscript.PayToTaprootScript(tapKey) + if err != nil { + return nil, fmt.Errorf("unable to create p2tr script: %w", err) + } + + tx := wire.NewMsgTx(arktx.TxVersion) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: in.SpentVTXO.Outpoint, + Sequence: wire.MaxTxInSequenceNum, + }) + tx.AddTxOut(&wire.TxOut{ + Value: in.SpentVTXO.Output.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.SpentVTXO.Output + + return &Result{ + PSBT: pkt, + TapTreeEncoded: encodedTapTree, + }, 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/checkpoint/build_test.go b/lib/tx/checkpoint/build_test.go new file mode 100644 index 000000000..93d57fe29 --- /dev/null +++ b/lib/tx/checkpoint/build_test.go @@ -0,0 +1,113 @@ +package checkpoint + +import ( + "crypto/rand" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/lightninglabs/darepo-client/lib/tx/arktx" + "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[:]...) +} + +// TestBuildPSBTHappyPath asserts BuildPSBT creates a signable checkpoint PSBT +// and returns the corresponding tap tree encoding. +func TestBuildPSBTHappyPath(t *testing.T) { + t.Parallel() + + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + policy := scripts.CheckpointPolicy{ + OperatorKey: operatorKey.PubKey(), + CSVDelay: 10, + } + + witnessUtxo := &wire.TxOut{ + Value: 5000, + PkScript: randomP2TRScript(t), + } + + in := Input{ + SpentVTXO: SpentVTXORef{ + Outpoint: wire.OutPoint{ + Hash: [32]byte{1}, + Index: 0, + }, + Output: witnessUtxo, + }, + OwnerLeafScript: []byte{ + txscript.OP_1, + txscript.OP_1, + txscript.OP_ADD, + txscript.OP_2, + txscript.OP_EQUAL, + }, + } + + result, err := BuildPSBT(policy, in) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.PSBT) + require.NotNil(t, result.PSBT.UnsignedTx) + + tx := result.PSBT.UnsignedTx + require.Equal(t, int32(arktx.TxVersion), tx.Version) + require.Len(t, tx.TxIn, 1) + require.Equal(t, in.SpentVTXO.Outpoint, tx.TxIn[0].PreviousOutPoint) + require.Len(t, tx.TxOut, 1) + require.Equal(t, witnessUtxo.Value, tx.TxOut[0].Value) + + expectedPkScript, err := scripts.CheckpointPkScript( + policy, in.OwnerLeafScript, + ) + require.NoError(t, err) + require.Equal(t, expectedPkScript, tx.TxOut[0].PkScript) + + require.NotNil(t, result.PSBT.Inputs[0].WitnessUtxo) + require.Equal(t, witnessUtxo, result.PSBT.Inputs[0].WitnessUtxo) + + decoded, err := DecodeTapTree(result.TapTreeEncoded) + require.NoError(t, err) + + tapscript, err := scripts.CheckpointTapScript( + policy, in.OwnerLeafScript, + ) + require.NoError(t, err) + + expectedLeaves := make([][]byte, 0, len(tapscript.Leaves)) + for _, leaf := range tapscript.Leaves { + expectedLeaves = append(expectedLeaves, leaf.Script) + } + + require.Equal(t, expectedLeaves, decoded) +} + +// TestBuildPSBTRejectsMissingWitness asserts missing witness data is rejected. +func TestBuildPSBTRejectsMissingWitness(t *testing.T) { + t.Parallel() + + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + policy := scripts.CheckpointPolicy{ + OperatorKey: operatorKey.PubKey(), + CSVDelay: 10, + } + + _, err = BuildPSBT(policy, Input{}) + require.Error(t, err) +} diff --git a/lib/tx/checkpoint/doc.go b/lib/tx/checkpoint/doc.go new file mode 100644 index 000000000..d83e3e78e --- /dev/null +++ b/lib/tx/checkpoint/doc.go @@ -0,0 +1,9 @@ +package checkpoint + +// Package checkpoint contains helpers for constructing and validating Ark +// checkpoint transactions. +// +// Checkpoints are taproot transactions that spend one or more VTXOs into a +// new on-chain output with defined closure semantics. They are used as the +// on-chain "checkpoint" for out-of-round transfers and, later, for other Ark +// lifecycle operations. diff --git a/lib/tx/checkpoint/taptree.go b/lib/tx/checkpoint/taptree.go new file mode 100644 index 000000000..cd23cb496 --- /dev/null +++ b/lib/tx/checkpoint/taptree.go @@ -0,0 +1,227 @@ +package checkpoint + +import ( + "bytes" + "errors" + "io" + + "github.com/btcsuite/btcd/txscript" + "github.com/lightningnetwork/lnd/tlv" +) + +const ( + typeTapscriptType tlv.Type = 1 + typeTapscriptLeaves tlv.Type = 3 + + typeTapLeafVersion tlv.Type = 1 + typeTapLeafScript tlv.Type = 2 +) + +// EncodeTapTree encodes a set of tapscript leaves into a single byte blob. +// +// EncodeTapTree uses the same TLV leaf encoding as waddrmgr.Tapscript, so +// durability and persistence layers can share a single representation. For v0, +// we only populate the full tree leaves with the base leaf version. +func EncodeTapTree(leaves [][]byte) ([]byte, error) { + tapscriptLeaves := make([]txscript.TapLeaf, 0, len(leaves)) + for _, script := range leaves { + tapscriptLeaves = append(tapscriptLeaves, txscript.TapLeaf{ + LeafVersion: txscript.BaseLeafVersion, + Script: script, + }) + } + + typ := uint8(0) + tlvRecords := []tlv.Record{ + tlv.MakePrimitiveRecord(typeTapscriptType, &typ), + tlv.MakeDynamicRecord( + typeTapscriptLeaves, + &tapscriptLeaves, + func() uint64 { + return recordSize( + leavesEncoder, &tapscriptLeaves, + ) + }, + leavesEncoder, + leavesDecoder, + ), + } + + tlvStream, err := tlv.NewStream(tlvRecords...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := tlvStream.Encode(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// DecodeTapTree decodes a tap tree encoding produced by EncodeTapTree. +// +// DecodeTapTree is intentionally lenient about leaf version in v0: it ignores +// the version and returns the raw script bytes for each leaf. +func DecodeTapTree(data []byte) ([][]byte, error) { + var ( + typ uint8 + leaves []txscript.TapLeaf + ) + + tlvStream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(typeTapscriptType, &typ), + tlv.MakeDynamicRecord( + typeTapscriptLeaves, + &leaves, + func() uint64 { + return recordSize(leavesEncoder, &leaves) + }, + leavesEncoder, + leavesDecoder, + ), + ) + if err != nil { + return nil, err + } + + _, err = tlvStream.DecodeWithParsedTypes(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + scripts := make([][]byte, 0, len(leaves)) + for _, leaf := range leaves { + scripts = append(scripts, leaf.Script) + } + + return scripts, nil +} + +// leavesEncoder encodes a slice of tap leaves using the waddrmgr TLV format. +func leavesEncoder(w io.Writer, val interface{}, buf *[8]byte) error { + if v, ok := val.(*[]txscript.TapLeaf); ok { + for _, leaf := range *v { + leafVersion := uint8(leaf.LeafVersion) + tlvRecords := []tlv.Record{ + tlv.MakePrimitiveRecord( + typeTapLeafVersion, &leafVersion, + ), + } + + if len(leaf.Script) > 0 { + tlvRecords = append( + tlvRecords, tlv.MakePrimitiveRecord( + typeTapLeafScript, &leaf.Script, + ), + ) + } + + tlvStream, err := tlv.NewStream(tlvRecords...) + if err != nil { + return err + } + + var leafTLVBytes bytes.Buffer + if err := tlvStream.Encode(&leafTLVBytes); err != nil { + return err + } + + tlvLen := uint64(len(leafTLVBytes.Bytes())) + if err := tlv.WriteVarInt(w, tlvLen, buf); err != nil { + return err + } + + if _, err := w.Write(leafTLVBytes.Bytes()); err != nil { + return err + } + } + + return nil + } + + return tlv.NewTypeForEncodingErr(val, "[]txscript.TapLeaf") +} + +// leavesDecoder decodes a slice of tap leaves using the waddrmgr TLV format. +func leavesDecoder(r io.Reader, val interface{}, buf *[8]byte, l uint64) error { + if v, ok := val.(*[]txscript.TapLeaf); ok { + var leaves []txscript.TapLeaf + + innerTlvReader := io.LimitedReader{ + R: r, + N: int64(l), + } + + for { + blobSize, err := tlv.ReadVarInt(&innerTlvReader, buf) + if errors.Is(err, io.EOF) { + break + } else if err != nil { + return err + } + + innerInnerTlvReader := io.LimitedReader{ + R: &innerTlvReader, + N: int64(blobSize), + } + + var ( + leafVersion uint8 + script []byte + ) + tlvStream, err := tlv.NewStream( + tlv.MakePrimitiveRecord( + typeTapLeafVersion, &leafVersion, + ), + tlv.MakePrimitiveRecord( + typeTapLeafScript, &script, + ), + ) + if err != nil { + return err + } + + parsedTypes, err := tlvStream.DecodeWithParsedTypes( + &innerInnerTlvReader, + ) + if err != nil { + return err + } + + leaf := txscript.TapLeaf{ + LeafVersion: txscript.TapscriptLeafVersion( + leafVersion, + ), + } + + if _, ok := parsedTypes[typeTapLeafScript]; ok { + leaf.Script = script + } + + leaves = append(leaves, leaf) + } + + *v = leaves + + return nil + } + + return tlv.NewTypeForDecodingErr(val, "[]txscript.TapLeaf", l, l) +} + +// recordSize returns the amount of bytes this TLV record will occupy when +// encoded. +func recordSize(encoder tlv.Encoder, v interface{}) uint64 { + var ( + b bytes.Buffer + buf [8]byte + ) + + if err := encoder(&b, v, &buf); err != nil { + return 0 + } + + return uint64(len(b.Bytes())) +} diff --git a/lib/tx/checkpoint/taptree_test.go b/lib/tx/checkpoint/taptree_test.go new file mode 100644 index 000000000..f0c3dbed5 --- /dev/null +++ b/lib/tx/checkpoint/taptree_test.go @@ -0,0 +1,61 @@ +package checkpoint + +import ( + "bytes" + "testing" + + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestTapTreeRoundTrip asserts our v0 tap tree encoding is stable and +// round-trippable. +func TestTapTreeRoundTrip(t *testing.T) { + t.Parallel() + + leaves := [][]byte{ + {0x51, 0x51, 0x51}, + {0x6a}, + {0x00, 0x01, 0x02, 0x03}, + } + + encoded, err := EncodeTapTree(leaves) + require.NoError(t, err) + + decoded, err := DecodeTapTree(encoded) + require.NoError(t, err) + + require.Equal(t, leaves, decoded) +} + +// TestTapTreeDecodeIgnoresTrailingUnknownRecord verifies decode stays within +// the tapscript-leaves record boundary and ignores unknown odd records in the +// enclosing TLV stream. +func TestTapTreeDecodeIgnoresTrailingUnknownRecord(t *testing.T) { + t.Parallel() + + encoded, err := EncodeTapTree([][]byte{{0x51}}) + require.NoError(t, err) + + var ( + buf bytes.Buffer + scratch [8]byte + ) + + _, err = buf.Write(encoded) + require.NoError(t, err) + + // Append an unknown odd TLV record after the known records. + err = tlv.WriteVarInt(&buf, 9, &scratch) + require.NoError(t, err) + + err = tlv.WriteVarInt(&buf, 1, &scratch) + require.NoError(t, err) + + _, err = buf.Write([]byte{0x99}) + require.NoError(t, err) + + decoded, err := DecodeTapTree(buf.Bytes()) + require.NoError(t, err) + require.Equal(t, [][]byte{{0x51}}, decoded) +} diff --git a/lib/tx/oor/build.go b/lib/tx/oor/build.go index 35c214d2a..ea1906a5f 100644 --- a/lib/tx/oor/build.go +++ b/lib/tx/oor/build.go @@ -8,50 +8,57 @@ import ( "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" + "github.com/lightninglabs/darepo-client/lib/tx/arktx" + "github.com/lightninglabs/darepo-client/lib/tx/checkpoint" ) // 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 +// +// "Owner" in the nested leaf script naming means owner of the spent VTXO +// input, not owner of the checkpoint CSV timeout path. +type CheckpointInput = checkpoint.Input - // 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 +// SpentVTXORef groups the spent VTXO outpoint and output data used to build a +// checkpoint input. +type SpentVTXORef = checkpoint.SpentVTXORef - // 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. +// CheckpointArtifact is the submit-phase checkpoint artifact. +// +// The checkpoint tap tree metadata is carried as sidecar bytes in this phase. +// During finalize, it is attached to checkpoint PSBT output metadata. +type CheckpointArtifact struct { + // PSBT is the checkpoint transaction PSBT. 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 } +// ToCheckpointOutput projects this artifact into the Ark-builder checkpoint +// input shape. +func (a *CheckpointArtifact) ToCheckpointOutput() (CheckpointOutput, error) { + if a == nil || a.PSBT == nil || a.PSBT.UnsignedTx == nil { + return CheckpointOutput{}, fmt.Errorf( + "checkpoint psbt must be provided", + ) + } + + if len(a.PSBT.UnsignedTx.TxOut) == 0 { + return CheckpointOutput{}, fmt.Errorf( + "checkpoint output must be provided", + ) + } + + return CheckpointOutput{ + Txid: a.PSBT.UnsignedTx.TxHash(), + Output: a.PSBT.UnsignedTx.TxOut[0], + TapTreeEncoded: a.TapTreeEncoded, + }, nil +} + // RecipientOutput describes an Ark tx recipient output. type RecipientOutput struct { // PkScript is the destination script. @@ -72,63 +79,16 @@ type RecipientOutput struct { // 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 - } + in CheckpointInput) (*CheckpointArtifact, error) { - encodedTapTree, err := EncodeTapTree(tapLeafScripts(tapscript.Leaves)) + result, err := checkpoint.BuildPSBT(policy, in) 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, + return &CheckpointArtifact{ + PSBT: result.PSBT, + TapTreeEncoded: result.TapTreeEncoded, }, nil } @@ -168,6 +128,12 @@ func BuildArkPSBT(checkpoints []CheckpointOutput, return nil, fmt.Errorf("recipient outputs must be provided") } + // Sum inputs/outputs and enforce a fee-less OOR transfer in v0. + // + // For now we intentionally do not support operator fee outputs because + // they complicate minimum relay/dust constraints and also shift policy + // questions into the transfer flow. Fees can be introduced elsewhere + // (for example, cooperative exit). var sumInputs btcutil.Amount for _, cp := range checkpoints { if cp.Output == nil { @@ -209,8 +175,8 @@ func BuildArkPSBT(checkpoints []CheckpointOutput, "input/output sums") } - // Sort checkpoint inputs by outpoint (BIP69-style) to ensure - // deterministic input order. + // Sort checkpoint inputs by outpoint (BIP69-style) to ensure a stable + // txid and stable session id across restarts and retries. checkpointsSorted := make([]CheckpointOutput, len(checkpoints)) copy(checkpointsSorted, checkpoints) sort.SliceStable(checkpointsSorted, func(i, j int) bool { @@ -231,7 +197,7 @@ func BuildArkPSBT(checkpoints []CheckpointOutput, sort.SliceStable(recipientOuts, func(i, j int) bool { a := recipientOuts[i] b := recipientOuts[j] - + // Output ordering follows BIP69 (amount, then pkScript). if a.Value != b.Value { return a.Value < b.Value } @@ -239,9 +205,7 @@ func BuildArkPSBT(checkpoints []CheckpointOutput, 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) + tx := wire.NewMsgTx(arktx.TxVersion) for _, cp := range checkpointsSorted { tx.AddTxIn(&wire.TxIn{ PreviousOutPoint: wire.OutPoint{ @@ -261,7 +225,7 @@ func BuildArkPSBT(checkpoints []CheckpointOutput, tx.AddTxOut(scripts.AnchorOutput()) - err := ValidateCanonicalArkTx(tx) + err := arktx.ValidateCanonicalTx(tx) if err != nil { return nil, fmt.Errorf("internal: built ark tx is not "+ "canonical: %w", err) @@ -292,13 +256,3 @@ func BuildArkPSBT(checkpoints []CheckpointOutput, 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 index 04e4fac48..e7262bf6d 100644 --- a/lib/tx/oor/build_test.go +++ b/lib/tx/oor/build_test.go @@ -56,11 +56,13 @@ func TestBuildCheckpointAndArkPSBT(t *testing.T) { } cpResult, err := BuildCheckpointPSBT(policy, CheckpointInput{ - Outpoint: wire.OutPoint{ - Hash: chainhash.Hash{1}, - Index: 0, + SpentVTXO: SpentVTXORef{ + Outpoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + Output: vtxoWitness, }, - WitnessUtxo: vtxoWitness, OwnerLeafScript: ownerLeafScript, }) require.NoError(t, err) diff --git a/lib/tx/oor/doc.go b/lib/tx/oor/doc.go new file mode 100644 index 000000000..2bd2e573e --- /dev/null +++ b/lib/tx/oor/doc.go @@ -0,0 +1,16 @@ +package oor + +// Package oor defines the transaction-level primitives for v0 out-of-round +// transfers. +// +// These primitives are shared by both client and server to avoid validation +// drift. The package intentionally focuses on deterministic construction and +// canonical encoding: +// - PSBT is used as the transport and storage envelope. +// - Output ordering follows BIP69 ordering (amount, then pkScript) for +// recipient outputs. +// - The Ark anchor output is always last. +// +// The submit flow builds a package containing the Ark PSBT plus one or more +// checkpoint PSBTs. The finalize flow attaches TapTree metadata binding each +// checkpoint to the Ark transaction, then validates the finalized package. diff --git a/lib/tx/oor/finalize_validate.go b/lib/tx/oor/finalize_validate.go index eee8aa899..d54735a99 100644 --- a/lib/tx/oor/finalize_validate.go +++ b/lib/tx/oor/finalize_validate.go @@ -6,6 +6,7 @@ import ( "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/tx/arktx" ) // ValidateFinalizePackage validates a v0 OOR finalize package. @@ -34,7 +35,7 @@ func ValidateFinalizePackage(ark *psbt.Packet, return fmt.Errorf("final checkpoint psbts must be provided") } - err := ValidateCanonicalArkPSBT(ark) + err := arktx.ValidateCanonicalPSBT(ark) if err != nil { return err } diff --git a/lib/tx/oor/package.go b/lib/tx/oor/package.go new file mode 100644 index 000000000..de220435d --- /dev/null +++ b/lib/tx/oor/package.go @@ -0,0 +1,261 @@ +package oor + +import ( + "bytes" + "fmt" + "io" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/darepo-client/lib/tx/psbtutil" + "github.com/lightningnetwork/lnd/tlv" +) + +// PackageVersion identifies the wire/storage encoding version for OOR packages. +type PackageVersion uint32 + +const ( + // PackageVersionV1 is the first versioned encoding of OOR + // submit/finalize packages. + PackageVersionV1 PackageVersion = 1 +) + +const ( + submitPackageVersionRecordType tlv.Type = 1 + submitPackageArkPSBTRecordType tlv.Type = 2 + submitPackageCheckpointsRecordType tlv.Type = 3 +) + +// SubmitPackage is the v0 OOR submit payload: an Ark tx PSBT plus checkpoint +// PSBTs. +// +// This is the shape we expect to send over RPC later. Today it is also the +// natural unit to persist durably and to hash/deduplicate. +type SubmitPackage struct { + // ArkPSBT is the canonical Ark transaction PSBT. + // + // The v0 session identifier is derived from its unsigned txid. + ArkPSBT *psbt.Packet + + // CheckpointPSBTs are the checkpoint transactions that the Ark tx + // spends. + CheckpointPSBTs []*psbt.Packet +} + +// SessionID derives the stable v0 session identifier from the Ark txid. +func (p *SubmitPackage) SessionID() (chainhash.Hash, error) { + if p == nil || p.ArkPSBT == nil || p.ArkPSBT.UnsignedTx == nil { + return chainhash.Hash{}, fmt.Errorf("ark psbt must be provided") + } + + return p.ArkPSBT.UnsignedTx.TxHash(), nil +} + +// Validate applies the shared structural submit validation rules. +func (p *SubmitPackage) Validate() (*ValidatedSubmitPackage, error) { + if p == nil { + return nil, fmt.Errorf("submit package must be provided") + } + + return ValidateSubmitPackage(p.ArkPSBT, p.CheckpointPSBTs) +} + +// FinalizePackage is the v0 OOR finalize payload: the Ark tx PSBT plus +// finalized checkpoint PSBTs. +type FinalizePackage struct { + // ArkPSBT is the canonical Ark transaction PSBT. + ArkPSBT *psbt.Packet + + // FinalCheckpointPSBTs are the finalized checkpoint PSBTs. + // They match the Ark tx inputs and include tap tree metadata. + FinalCheckpointPSBTs []*psbt.Packet +} + +// Validate applies the shared structural finalize validation rules. +func (p *FinalizePackage) Validate() error { + if p == nil { + return fmt.Errorf("finalize package must be provided") + } + + return ValidateFinalizePackage(p.ArkPSBT, p.FinalCheckpointPSBTs) +} + +// MarshalSubmitPackage encodes a submit package as versioned TLV with binary +// PSBT payloads. +func MarshalSubmitPackage(pkg *SubmitPackage) ([]byte, error) { + if pkg == nil { + return nil, fmt.Errorf("submit package must be provided") + } + + arkRaw, err := psbtutil.Serialize(pkg.ArkPSBT) + if err != nil { + return nil, err + } + + checkpoints := make([][]byte, 0, len(pkg.CheckpointPSBTs)) + for _, cp := range pkg.CheckpointPSBTs { + cpRaw, err := psbtutil.Serialize(cp) + if err != nil { + return nil, err + } + + checkpoints = append(checkpoints, cpRaw) + } + + checkpointBlob, err := encodeBlobList(checkpoints) + if err != nil { + return nil, err + } + + version := uint64(PackageVersionV1) + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + submitPackageVersionRecordType, &version, + ), + tlv.MakePrimitiveRecord( + submitPackageArkPSBTRecordType, &arkRaw, + ), + tlv.MakePrimitiveRecord( + submitPackageCheckpointsRecordType, &checkpointBlob, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + var b bytes.Buffer + if err := stream.Encode(&b); err != nil { + return nil, err + } + + return b.Bytes(), nil +} + +// UnmarshalSubmitPackage decodes a submit package previously encoded by +// MarshalSubmitPackage. +func UnmarshalSubmitPackage(b []byte) (*SubmitPackage, error) { + if len(b) == 0 { + return nil, fmt.Errorf("submit package bytes must be provided") + } + + var ( + version uint64 + arkRaw []byte + checkpointBlob []byte + ) + + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + submitPackageVersionRecordType, &version, + ), + tlv.MakePrimitiveRecord( + submitPackageArkPSBTRecordType, &arkRaw, + ), + tlv.MakePrimitiveRecord( + submitPackageCheckpointsRecordType, &checkpointBlob, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + reader := bytes.NewReader(b) + if _, err := stream.DecodeWithParsedTypes(reader); err != nil { + return nil, err + } + if reader.Len() != 0 { + return nil, fmt.Errorf("trailing package bytes") + } + + if version != uint64(PackageVersionV1) { + return nil, fmt.Errorf("unknown package version: %d", + version) + } + + ark, err := psbtutil.Parse(arkRaw) + if err != nil { + return nil, err + } + + checkpointRaws, err := decodeBlobList(checkpointBlob) + if err != nil { + return nil, err + } + + checkpoints := make([]*psbt.Packet, 0, len(checkpointRaws)) + for _, cpRaw := range checkpointRaws { + cp, err := psbtutil.Parse(cpRaw) + if err != nil { + return nil, err + } + + checkpoints = append(checkpoints, cp) + } + + return &SubmitPackage{ + ArkPSBT: ark, + CheckpointPSBTs: checkpoints, + }, nil +} + +func encodeBlobList(blobs [][]byte) ([]byte, error) { + var ( + buf bytes.Buffer + scratch [8]byte + ) + + if err := tlv.WriteVarInt( + &buf, uint64(len(blobs)), &scratch, + ); err != nil { + return nil, err + } + + for i := range blobs { + if err := tlv.WriteVarInt( + &buf, uint64(len(blobs[i])), &scratch, + ); err != nil { + return nil, err + } + + if _, err := buf.Write(blobs[i]); err != nil { + return nil, err + } + } + + return buf.Bytes(), nil +} + +func decodeBlobList(raw []byte) ([][]byte, error) { + var scratch [8]byte + + reader := bytes.NewReader(raw) + count, err := tlv.ReadVarInt(reader, &scratch) + if err != nil { + return nil, err + } + + blobs := make([][]byte, 0, count) + for i := uint64(0); i < count; i++ { + size, err := tlv.ReadVarInt(reader, &scratch) + if err != nil { + return nil, err + } + + blob := make([]byte, size) + if _, err := io.ReadFull(reader, blob); err != nil { + return nil, err + } + + blobs = append(blobs, blob) + } + + if reader.Len() != 0 { + return nil, fmt.Errorf("trailing package bytes") + } + + return blobs, nil +} diff --git a/lib/tx/oor/package_methods_test.go b/lib/tx/oor/package_methods_test.go new file mode 100644 index 000000000..f574b7758 --- /dev/null +++ b/lib/tx/oor/package_methods_test.go @@ -0,0 +1,152 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/stretchr/testify/require" +) + +// TestSubmitPackageValidateHappyPath asserts the SubmitPackage method wrapper +// applies the shared structural submit validator. +func TestSubmitPackageValidateHappyPath(t *testing.T) { + t.Parallel() + + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + policy := scripts.CheckpointPolicy{ + OperatorKey: operatorKey.PubKey(), + CSVDelay: 10, + } + + cpResult, err := BuildCheckpointPSBT(policy, CheckpointInput{ + SpentVTXO: SpentVTXORef{ + Outpoint: wire.OutPoint{ + Hash: [32]byte{1}, + Index: 0, + }, + Output: &wire.TxOut{ + Value: 5000, + PkScript: randomP2TRScript(t), + }, + }, + OwnerLeafScript: []byte{ + txscript.OP_1, + txscript.OP_1, + txscript.OP_ADD, + txscript.OP_2, + txscript.OP_EQUAL, + }, + }) + require.NoError(t, err) + + checkpointTx := cpResult.PSBT.UnsignedTx + require.NotNil(t, checkpointTx) + + arkPSBT, err := BuildArkPSBT([]CheckpointOutput{ + { + Txid: checkpointTx.TxHash(), + Output: checkpointTx.TxOut[0], + TapTreeEncoded: cpResult.TapTreeEncoded, + }, + }, []RecipientOutput{ + { + PkScript: randomP2TRScript(t), + Value: 5000, + }, + }) + require.NoError(t, err) + + pkg := &SubmitPackage{ + ArkPSBT: arkPSBT, + CheckpointPSBTs: []*psbt.Packet{cpResult.PSBT}, + } + + validated, err := pkg.Validate() + require.NoError(t, err) + require.NotNil(t, validated) + require.Len(t, validated.CheckpointOutpoints, 1) +} + +// TestSubmitPackageValidateRejectsNil asserts nil receivers are rejected. +func TestSubmitPackageValidateRejectsNil(t *testing.T) { + t.Parallel() + + var pkg *SubmitPackage + _, err := pkg.Validate() + require.Error(t, err) +} + +// TestFinalizePackageValidateHappyPath asserts the FinalizePackage method +// wrapper applies the shared structural finalize validator. +func TestFinalizePackageValidateHappyPath(t *testing.T) { + t.Parallel() + + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + policy := scripts.CheckpointPolicy{ + OperatorKey: operatorKey.PubKey(), + CSVDelay: 10, + } + + cpResult, err := BuildCheckpointPSBT(policy, CheckpointInput{ + SpentVTXO: SpentVTXORef{ + Outpoint: wire.OutPoint{ + Hash: [32]byte{1}, + Index: 0, + }, + Output: &wire.TxOut{ + Value: 5000, + PkScript: randomP2TRScript(t), + }, + }, + OwnerLeafScript: []byte{txscript.OP_1}, + }) + require.NoError(t, err) + + checkpointTx := cpResult.PSBT.UnsignedTx + require.NotNil(t, checkpointTx) + + arkPSBT, err := BuildArkPSBT([]CheckpointOutput{ + { + Txid: checkpointTx.TxHash(), + Output: checkpointTx.TxOut[0], + TapTreeEncoded: cpResult.TapTreeEncoded, + }, + }, []RecipientOutput{ + { + PkScript: randomP2TRScript(t), + Value: 5000, + }, + }) + require.NoError(t, err) + + finalCheckpoint := cpResult.PSBT + finalCheckpoint.Inputs[0].TaprootScriptSpendSig = + []*psbt.TaprootScriptSpendSig{ + {}, + } + + pkg := &FinalizePackage{ + ArkPSBT: arkPSBT, + FinalCheckpointPSBTs: []*psbt.Packet{finalCheckpoint}, + } + + err = pkg.Validate() + require.NoError(t, err) +} + +// TestFinalizePackageValidateRejectsNil asserts nil receivers are rejected. +func TestFinalizePackageValidateRejectsNil(t *testing.T) { + t.Parallel() + + var pkg *FinalizePackage + err := pkg.Validate() + require.Error(t, err) +} diff --git a/lib/tx/oor/package_test.go b/lib/tx/oor/package_test.go new file mode 100644 index 000000000..a82872c90 --- /dev/null +++ b/lib/tx/oor/package_test.go @@ -0,0 +1,85 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/stretchr/testify/require" +) + +// TestSubmitPackageMarshalRoundTrip asserts the submit package TLV encoding is +// stable and round-trippable. +func TestSubmitPackageMarshalRoundTrip(t *testing.T) { + t.Parallel() + + checkpointTx := wire.NewMsgTx(3) + checkpointTx.AddTxIn(&wire.TxIn{}) + checkpointTx.AddTxOut(&wire.TxOut{Value: 5, PkScript: []byte{0x51}}) + checkpointPSBT, err := psbt.NewFromUnsignedTx(checkpointTx) + require.NoError(t, err) + + arkTx := wire.NewMsgTx(3) + arkTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: checkpointTx.TxHash(), + Index: 0, + }, + }) + arkTx.AddTxOut(&wire.TxOut{Value: 5, PkScript: []byte{0x51}}) + arkTx.AddTxOut(scripts.AnchorOutput()) + arkPSBT, err := psbt.NewFromUnsignedTx(arkTx) + require.NoError(t, err) + + pkg := &SubmitPackage{ + ArkPSBT: arkPSBT, + CheckpointPSBTs: []*psbt.Packet{checkpointPSBT}, + } + + b, err := MarshalSubmitPackage(pkg) + require.NoError(t, err) + + parsed, err := UnmarshalSubmitPackage(b) + require.NoError(t, err) + require.NotNil(t, parsed) + + id1, err := pkg.SessionID() + require.NoError(t, err) + + id2, err := parsed.SessionID() + require.NoError(t, err) + require.Equal(t, id1, id2) +} + +func TestSubmitPackageUnmarshalRejectsMalformedTrailingBytes(t *testing.T) { + t.Parallel() + + checkpointTx := wire.NewMsgTx(3) + checkpointTx.AddTxIn(&wire.TxIn{}) + checkpointTx.AddTxOut(&wire.TxOut{Value: 5, PkScript: []byte{0x51}}) + checkpointPSBT, err := psbt.NewFromUnsignedTx(checkpointTx) + require.NoError(t, err) + + arkTx := wire.NewMsgTx(3) + arkTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: checkpointTx.TxHash(), + Index: 0, + }, + }) + arkTx.AddTxOut(&wire.TxOut{Value: 5, PkScript: []byte{0x51}}) + arkTx.AddTxOut(scripts.AnchorOutput()) + arkPSBT, err := psbt.NewFromUnsignedTx(arkTx) + require.NoError(t, err) + + raw, err := MarshalSubmitPackage(&SubmitPackage{ + ArkPSBT: arkPSBT, + CheckpointPSBTs: []*psbt.Packet{checkpointPSBT}, + }) + require.NoError(t, err) + + raw = append(raw, 0xff) + _, err = UnmarshalSubmitPackage(raw) + require.Error(t, err) +} diff --git a/lib/tx/oor/submit.go b/lib/tx/oor/submit.go index 2b01a17cf..e66dc71c3 100644 --- a/lib/tx/oor/submit.go +++ b/lib/tx/oor/submit.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/tx/arktx" ) // ValidatedSubmitPackage contains derived facts from a submit package that are @@ -54,7 +55,7 @@ func ValidateSubmitPackage(ark *psbt.Packet, return nil, fmt.Errorf("checkpoint psbts must be provided") } - err := ValidateCanonicalArkPSBT(ark) + err := arktx.ValidateCanonicalPSBT(ark) if err != nil { return nil, err } diff --git a/lib/tx/oor/taptree.go b/lib/tx/oor/taptree.go index 3995cea30..f0f50d3fb 100644 --- a/lib/tx/oor/taptree.go +++ b/lib/tx/oor/taptree.go @@ -5,8 +5,7 @@ import ( "fmt" "github.com/btcsuite/btcd/btcutil/psbt" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/tx/checkpoint" ) var ( @@ -16,109 +15,23 @@ var ( // We treat this as part of the OOR PSBT profile so client and server // implementations can deterministically attach, validate, and later use // the same metadata during finalization. + // + // NOTE: PSBT unknown keys are a shared namespace. We use a short, + // stable key here for v0 tests and in-process wiring. + // + // A future version should consider namespacing this (for example, + // `ark/taptree`) to reduce collision risk with other PSBT extensions. TapTreePSBTKey = []byte("taptree") ) // EncodeTapTree encodes a set of tapscript leaves into a single byte blob. -// -// EncodeTapTree intentionally uses a simple leaf list representation that is -// sufficient for v0 OOR transfers. Each leaf is encoded at depth 1 with the -// base tapscript leaf version. The encoding uses Bitcoin varint (compact size) -// lengths and is compatible with how many BIP-371 encodings represent tap -// trees. -// -// This encoding is part of the PSBT profile for OOR transfers. If we ever need -// to support richer trees (multiple depths), this function must become -// versioned rather than changing behavior silently. func EncodeTapTree(leaves [][]byte) ([]byte, error) { - var buf bytes.Buffer - - err := wire.WriteVarInt(&buf, 0, uint64(len(leaves))) - if err != nil { - return nil, fmt.Errorf("unable to write leaf count: %w", - err) - } - - for _, leaf := range leaves { - err := buf.WriteByte(1) - if err != nil { - return nil, fmt.Errorf("unable to write depth: %w", - err) - } - - err = buf.WriteByte(byte(txscript.BaseLeafVersion)) - if err != nil { - return nil, fmt.Errorf("unable to write leaf "+ - "version: %w", err) - } - - err = wire.WriteVarInt(&buf, 0, uint64(len(leaf))) - if err != nil { - return nil, fmt.Errorf("unable to write leaf "+ - "length: %w", err) - } - - _, err = buf.Write(leaf) - if err != nil { - return nil, fmt.Errorf("unable to write leaf "+ - "script: %w", err) - } - } - - return buf.Bytes(), nil + return checkpoint.EncodeTapTree(leaves) } // DecodeTapTree decodes a tap tree encoding produced by EncodeTapTree. -// -// DecodeTapTree is intentionally lenient about leaf depth and version in v0: -// it reads and ignores them. The returned value is the list of raw script -// bytes for each leaf. func DecodeTapTree(data []byte) ([][]byte, error) { - buf := bytes.NewReader(data) - - leafCount, err := wire.ReadVarInt(buf, 0) - if err != nil { - return nil, fmt.Errorf("unable to read leaf count: %w", - err) - } - - leaves := make([][]byte, 0, leafCount) - for i := uint64(0); i < leafCount; i++ { - _, err := buf.ReadByte() - if err != nil { - return nil, fmt.Errorf("unable to read depth: %w", - err) - } - - _, err = buf.ReadByte() - if err != nil { - return nil, fmt.Errorf("unable to read leaf "+ - "version: %w", err) - } - - scriptLen, err := wire.ReadVarInt(buf, 0) - if err != nil { - return nil, fmt.Errorf("unable to read script "+ - "length: %w", err) - } - - scriptBytes := make([]byte, scriptLen) - _, err = buf.Read(scriptBytes) - if err != nil { - return nil, fmt.Errorf( - "unable to read script bytes: %w", err, - ) - } - - leaves = append(leaves, scriptBytes) - } - - if buf.Len() != 0 { - return nil, fmt.Errorf("trailing bytes in tap tree "+ - "encoding (%d bytes)", buf.Len()) - } - - return leaves, nil + return checkpoint.DecodeTapTree(data) } // PutTapTreePSBTInput stores an encoded tap tree blob into the given PSBT input diff --git a/lib/tx/psbtutil/psbt.go b/lib/tx/psbtutil/psbt.go new file mode 100644 index 000000000..1163f8dee --- /dev/null +++ b/lib/tx/psbtutil/psbt.go @@ -0,0 +1,72 @@ +package psbtutil + +import ( + "bytes" + "encoding/base64" + "fmt" + + "github.com/btcsuite/btcd/btcutil/psbt" +) + +// Package psbtutil contains small helpers for encoding and decoding PSBTs. +// +// These helpers are intentionally "dumb": they only serialize/parse bytes and +// do not attempt to validate the transaction's semantics (ordering rules, +// script correctness, etc). Callers should run the appropriate validators for +// the protocol they are implementing. + +// Serialize encodes a PSBT packet into raw bytes. +func Serialize(pkt *psbt.Packet) ([]byte, error) { + if pkt == nil { + return nil, fmt.Errorf("psbt must be provided") + } + + var b bytes.Buffer + err := pkt.Serialize(&b) + if err != nil { + return nil, fmt.Errorf("serialize psbt: %w", err) + } + + return b.Bytes(), nil +} + +// Parse decodes a PSBT packet from raw bytes. +func Parse(raw []byte) (*psbt.Packet, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("psbt bytes must be provided") + } + + pkt, err := psbt.NewFromRawBytes(bytes.NewReader(raw), false) + if err != nil { + return nil, fmt.Errorf("parse psbt: %w", err) + } + + return pkt, nil +} + +// EncodeBase64 serializes and base64-encodes a PSBT packet. +func EncodeBase64(pkt *psbt.Packet) (string, error) { + raw, err := Serialize(pkt) + if err != nil { + return "", err + } + + // We use standard base64 (not URL base64) to match common PSBT + // tooling and to make it easy to copy/paste values into other + // PSBT-aware utilities. + return base64.StdEncoding.EncodeToString(raw), nil +} + +// DecodeBase64 base64-decodes and parses a PSBT packet. +func DecodeBase64(b64 string) (*psbt.Packet, error) { + if b64 == "" { + return nil, fmt.Errorf("base64 string must be provided") + } + + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, fmt.Errorf("decode base64: %w", err) + } + + return Parse(raw) +} diff --git a/lib/tx/psbtutil/psbt_test.go b/lib/tx/psbtutil/psbt_test.go new file mode 100644 index 000000000..9d5535133 --- /dev/null +++ b/lib/tx/psbtutil/psbt_test.go @@ -0,0 +1,78 @@ +package psbtutil + +import ( + "encoding/base64" + "testing" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// TestSerializeParseRoundTrip asserts PSBT serialization is reversible. +func TestSerializeParseRoundTrip(t *testing.T) { + t.Parallel() + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + tx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) + + pkt, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + raw, err := Serialize(pkt) + require.NoError(t, err) + + parsed, err := Parse(raw) + require.NoError(t, err) + require.NotNil(t, parsed) + require.Equal(t, tx.TxHash(), parsed.UnsignedTx.TxHash()) +} + +// TestEncodeDecodeBase64RoundTrip asserts base64 encode/decode is reversible. +func TestEncodeDecodeBase64RoundTrip(t *testing.T) { + t.Parallel() + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + tx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) + + pkt, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + b64, err := EncodeBase64(pkt) + require.NoError(t, err) + require.NotEmpty(t, b64) + + decoded, err := DecodeBase64(b64) + require.NoError(t, err) + require.NotNil(t, decoded) + require.Equal(t, tx.TxHash(), decoded.UnsignedTx.TxHash()) +} + +// TestEncodeBase64NilPSBT asserts nil PSBTs are rejected. +func TestEncodeBase64NilPSBT(t *testing.T) { + t.Parallel() + + _, err := EncodeBase64(nil) + require.Error(t, err) +} + +// TestDecodeBase64EmptyString asserts empty base64 strings are rejected. +func TestDecodeBase64EmptyString(t *testing.T) { + t.Parallel() + + _, err := DecodeBase64("") + require.Error(t, err) +} + +// TestDecodeBase64InvalidEncoding asserts invalid base64 is rejected. +func TestDecodeBase64InvalidEncoding(t *testing.T) { + t.Parallel() + + invalid := base64.StdEncoding.EncodeToString([]byte{0x00, 0x01}) + invalid = invalid[:len(invalid)-2] + + _, err := DecodeBase64(invalid) + require.Error(t, err) +} diff --git a/lib/tx/psbtutil/taproot.go b/lib/tx/psbtutil/taproot.go new file mode 100644 index 000000000..d0e54099f --- /dev/null +++ b/lib/tx/psbtutil/taproot.go @@ -0,0 +1,101 @@ +package psbtutil + +import ( + "bytes" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/darepo-client/lib/scripts" +) + +// AddTapLeafScript ensures the PSBT input includes the leaf script and +// control block for the collaborative VTXO leaf. If the leaf is already +// present the function is a no-op. +func AddTapLeafScript(in *psbt.PInput, + spendInfo *scripts.VTXOSpendData) error { + + if in == nil { + return fmt.Errorf("psbt input must be provided") + } + + if spendInfo == nil { + return fmt.Errorf("spend info must be provided") + } + + needle := &psbt.TaprootTapLeafScript{ + ControlBlock: spendInfo.ControlBlock, + Script: spendInfo.WitnessScript, + LeafVersion: txscript.BaseLeafVersion, + } + + for i := range in.TaprootLeafScript { + existing := in.TaprootLeafScript[i] + if existing == nil { + continue + } + + if bytes.Equal(existing.ControlBlock, needle.ControlBlock) && + bytes.Equal(existing.Script, needle.Script) && + existing.LeafVersion == needle.LeafVersion { + + return nil + } + } + + in.TaprootLeafScript = append(in.TaprootLeafScript, needle) + + return nil +} + +// AddTaprootScriptSpendSig adds or replaces a taproot script-path spend +// signature in the PSBT input, keyed by (x-only pubkey, leaf hash). +func AddTaprootScriptSpendSig(in *psbt.PInput, pubKey *btcec.PublicKey, + leafScript []byte, sig []byte, + sigHash txscript.SigHashType) error { + + switch { + case in == nil: + return fmt.Errorf("psbt input must be provided") + + case pubKey == nil: + return fmt.Errorf("pubkey must be provided") + + case len(leafScript) == 0: + return fmt.Errorf("leaf script must be provided") + + case len(sig) == 0: + return fmt.Errorf("signature must be provided") + } + + leafHash := txscript.NewBaseTapLeaf(leafScript).TapHash() + leafHashBytes := make([]byte, 0, len(leafHash)) + leafHashBytes = append(leafHashBytes, leafHash[:]...) + + needle := &psbt.TaprootScriptSpendSig{ + XOnlyPubKey: schnorr.SerializePubKey(pubKey), + LeafHash: leafHashBytes, + Signature: sig, + SigHash: sigHash, + } + + for i := range in.TaprootScriptSpendSig { + existing := in.TaprootScriptSpendSig[i] + if existing == nil { + continue + } + + if existing.EqualKey(needle) { + in.TaprootScriptSpendSig[i] = needle + return nil + } + } + + in.TaprootScriptSpendSig = append( + in.TaprootScriptSpendSig, needle, + ) + + return nil +} diff --git a/oor/actor.go b/oor/actor.go index 2da052d98..2ff926027 100644 --- a/oor/actor.go +++ b/oor/actor.go @@ -3,11 +3,19 @@ package oor import ( "context" "fmt" + "sort" "github.com/btcsuite/btclog/v2" + "github.com/google/uuid" + "github.com/lightninglabs/darepo-client/baselib/actor" fn "github.com/lightningnetwork/lnd/fn/v2" ) +const ( + oorCheckpointStateType = "oor.outgoing.sessions" + oorCheckpointVersion = 1 +) + // OutboxHandler executes FSM outbox requests and returns follow-up events. // // This mirrors the server-side OOR coordinator approach. The goal is to keep @@ -26,18 +34,27 @@ type ClientActorCfg struct { // OutboxHandler executes side effects emitted by the FSM. OutboxHandler OutboxHandler + + // DeliveryStore backs the durable actor mailbox/checkpoint operations. + DeliveryStore actor.DeliveryStore + + // ActorID is the durable mailbox id used for this actor instance. + // Re-using the same ActorID across restarts enables checkpoint restore. + ActorID string } -// OORClientActor wraps the outgoing-transfer client FSM in an actor interface. +// OORClientActor wraps the outgoing-transfer client FSM in a durable 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 + ref actor.ActorRef[actor.TLVMessage, ActorResp] + durable *actor.DurableActor[actor.TLVMessage, ActorResp] + + startupErr error } // NewOORClientActor creates a new outgoing-transfer OOR client actor. @@ -46,25 +63,156 @@ func NewOORClientActor(cfg ClientActorCfg) *OORClientActor { cfg.Logger = btclog.Disabled } - return &OORClientActor{ + if cfg.ActorID == "" { + cfg.ActorID = fmt.Sprintf("oor-client-%s", uuid.NewString()) + } + + actorRef := &OORClientActor{cfg: cfg} + + if cfg.DeliveryStore == nil { + actorRef.startupErr = fmt.Errorf( + "delivery store must be provided", + ) + + return actorRef + } + + codec := actor.NewMessageCodec() + codec.MustRegister(oorDurableCommandTLVType, + func() actor.TLVMessage { + return &durableActorCommandMessage{} + }, + ) + codec.MustRegister(actor.RestartTLVType, + func() actor.TLVMessage { + return &actor.RestartMessage{} + }, + ) + + behavior := &oorDurableBehavior{ cfg: cfg, sessions: make(map[SessionID]*sessionHandle), } + + durableCfg := actor.DefaultDurableActorConfig[actor.TLVMessage, + ActorResp]( + cfg.ActorID, + behavior, + cfg.DeliveryStore, + codec, + ) + + durable := actor.NewDurableActor(durableCfg) + actorRef.durable = durable + actorRef.ref = durable.Ref() + + checkpoint, err := cfg.DeliveryStore.LoadCheckpoint( + context.Background(), cfg.ActorID, + ) + if err != nil { + actorRef.startupErr = err + return actorRef + } + + err = actor.PrependRestartMessage( + context.Background(), + cfg.DeliveryStore, + codec, + cfg.ActorID, + checkpoint, + ) + if err != nil { + actorRef.startupErr = err + return actorRef + } + + durable.Start() + + return actorRef } // Receive processes a client actor message and returns a response. func (a *OORClientActor) Receive(ctx context.Context, msg ActorMsg) fn.Result[ActorResp] { + if a.startupErr != nil { + return fn.Err[ActorResp](a.startupErr) + } + + if a.ref == nil { + return fn.Err[ActorResp]( + fmt.Errorf("durable actor not initialized"), + ) + } + + cmd, err := durableCommandFromActorMsg(msg) + if err != nil { + return fn.Err[ActorResp](err) + } + + fut := a.ref.Ask(ctx, cmd) + + return fut.Await(ctx) +} + +// Stop shuts down the underlying durable actor. +func (a *OORClientActor) Stop() { + if a.durable != nil { + a.durable.Stop() + } +} + +// oorDurableBehavior implements the durable actor behavior for the OOR +// client. It dispatches decoded TLV messages to per-session FSMs and +// persists a combined checkpoint after every state mutation. +type oorDurableBehavior struct { + cfg ClientActorCfg + + sessions map[SessionID]*sessionHandle +} + +// Receive dispatches decoded TLV messages to the appropriate handler +// method based on message type. +func (b *oorDurableBehavior) Receive(ctx context.Context, + msg actor.TLVMessage) fn.Result[ActorResp] { + switch m := msg.(type) { - case *StartTransferRequest: - return a.handleStartTransfer(ctx, m) + case *actor.RestartMessage: + return b.handleRestart(ctx, m) + + case *durableActorCommandMessage: + request, err := actorMsgFromDurableCommand(m) + if err != nil { + return fn.Err[ActorResp](err) + } + + switch typedReq := request.(type) { + case *StartTransferRequest: + return b.handleStartTransfer(ctx, typedReq) - case *DriveEventRequest: - return a.handleDriveEvent(ctx, m) + case *DriveEventRequest: + return b.handleDriveEvent(ctx, typedReq) - case *GetStateRequest: - return a.handleGetState(ctx, m) + case *GetStateRequest: + return b.handleGetState(ctx, typedReq) + + case *RestoreSessionRequest: + return b.handleRestoreSession(ctx, typedReq) + + case *ResumeSessionRequest: + return b.handleResumeSession(ctx, typedReq) + + case *ExportSnapshotRequest: + return b.handleExportSnapshot(ctx, typedReq) + + default: + return fn.Err[ActorResp]( + fmt.Errorf( + "unknown message type: %T", + typedReq, + ), + ) + } default: return fn.Err[ActorResp](fmt.Errorf("unknown message type: %T", @@ -72,8 +220,42 @@ func (a *OORClientActor) Receive(ctx context.Context, } } +// handleRestart restores all sessions from the durable checkpoint (if +// present) and re-drives their outbox side effects. +func (b *oorDurableBehavior) handleRestart(ctx context.Context, + msg *actor.RestartMessage) fn.Result[ActorResp] { + + if msg == nil { + return fn.Err[ActorResp](fmt.Errorf("restart message must be " + + "provided")) + } + + b.sessions = make(map[SessionID]*sessionHandle) + + if msg.HasCheckpoint() { + checkpoint := msg.Checkpoint.UnsafeFromSome() + + err := b.restoreFromCheckpoint(ctx, checkpoint.StateData) + if err != nil { + return fn.Err[ActorResp](err) + } + } + + err := b.resumeRestoredSessions(ctx) + if err != nil { + return fn.Err[ActorResp](err) + } + + err = b.persistCheckpoint(ctx) + if err != nil { + return fn.Err[ActorResp](err) + } + + return fn.Ok[ActorResp](&DriveEventResponse{}) +} + // handleStartTransfer starts a new outgoing transfer session. -func (a *OORClientActor) handleStartTransfer(ctx context.Context, +func (b *oorDurableBehavior) handleStartTransfer(ctx context.Context, req *StartTransferRequest) fn.Result[ActorResp] { if req == nil { @@ -92,16 +274,21 @@ func (a *OORClientActor) handleStartTransfer(ctx context.Context, // 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 { + if _, exists := b.sessions[session.ID]; exists { return fn.Ok[ActorResp](&StartTransferResponse{ SessionID: session.ID, }) } handle := &sessionHandle{FSM: session.FSM} - a.sessions[session.ID] = handle + b.sessions[session.ID] = handle + + err = b.persistCheckpoint(ctx) + if err != nil { + return fn.Err[ActorResp](err) + } - err = a.driveOutbox(ctx, session.ID, handle.FSM, outbox) + err = b.driveOutbox(ctx, session.ID, handle.FSM, outbox) if err != nil { return fn.Err[ActorResp](err) } @@ -112,7 +299,7 @@ func (a *OORClientActor) handleStartTransfer(ctx context.Context, } // handleDriveEvent feeds a follow-up event into an existing session. -func (a *OORClientActor) handleDriveEvent(ctx context.Context, +func (b *oorDurableBehavior) handleDriveEvent(ctx context.Context, req *DriveEventRequest) fn.Result[ActorResp] { if req == nil { @@ -123,18 +310,32 @@ func (a *OORClientActor) handleDriveEvent(ctx context.Context, return fn.Err[ActorResp](fmt.Errorf("event must be provided")) } - handle, ok := a.sessions[req.SessionID] + if submitAccepted, ok := req.Event.(*SubmitAcceptedEvent); ok { + err := validateSubmitAcceptedIdentity( + req.SessionID, submitAccepted, + ) + if err != nil { + return fn.Err[ActorResp](err) + } + } + + handle, ok := b.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) + outbox, err := b.askEvent(ctx, handle.FSM, req.Event) + if err != nil { + return fn.Err[ActorResp](err) + } + + err = b.persistCheckpoint(ctx) if err != nil { return fn.Err[ActorResp](err) } - err = a.driveOutbox(ctx, req.SessionID, handle.FSM, outbox) + err = b.driveOutbox(ctx, req.SessionID, handle.FSM, outbox) if err != nil { return fn.Err[ActorResp](err) } @@ -142,8 +343,110 @@ func (a *OORClientActor) handleDriveEvent(ctx context.Context, return fn.Ok[ActorResp](&DriveEventResponse{}) } +// handleRestoreSession restores a session from an exported snapshot. +func (b *oorDurableBehavior) handleRestoreSession(ctx context.Context, + req *RestoreSessionRequest) fn.Result[ActorResp] { + + if req == nil { + return fn.Err[ActorResp](fmt.Errorf("request must be provided")) + } + + if req.Snapshot == nil { + return fn.Err[ActorResp]( + fmt.Errorf("snapshot must be provided"), + ) + } + + if _, exists := b.sessions[req.Snapshot.SessionID]; exists { + return fn.Err[ActorResp](fmt.Errorf( + "duplicate session id during restore: %s", + req.Snapshot.SessionID, + )) + } + + session, err := NewSessionFromSnapshot(ctx, req.Snapshot) + if err != nil { + return fn.Err[ActorResp](err) + } + + b.sessions[session.ID] = &sessionHandle{FSM: session.FSM} + + err = b.persistCheckpoint(ctx) + if err != nil { + return fn.Err[ActorResp](err) + } + + return fn.Ok[ActorResp](&RestoreSessionResponse{ + SessionID: session.ID, + }) +} + +// handleResumeSession re-emits the outbox implied by the session's current +// state. +func (b *oorDurableBehavior) handleResumeSession(ctx context.Context, + req *ResumeSessionRequest) fn.Result[ActorResp] { + + if req == nil { + return fn.Err[ActorResp](fmt.Errorf("request must be provided")) + } + + handle, ok := b.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) + } + + outbox, err := OutboxForState(state) + if err != nil { + return fn.Err[ActorResp](err) + } + + err = b.driveOutbox(ctx, req.SessionID, handle.FSM, outbox) + if err != nil { + return fn.Err[ActorResp](err) + } + + return fn.Ok[ActorResp](&ResumeSessionResponse{}) +} + +// handleExportSnapshot exports a snapshot for the requested session. +func (b *oorDurableBehavior) handleExportSnapshot(ctx context.Context, + req *ExportSnapshotRequest) fn.Result[ActorResp] { + + _ = ctx + + if req == nil { + return fn.Err[ActorResp](fmt.Errorf("request must be provided")) + } + + handle, ok := b.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) + } + + snapshot, err := NewOutgoingSnapshot(req.SessionID, state) + if err != nil { + return fn.Err[ActorResp](err) + } + + return fn.Ok[ActorResp](&ExportSnapshotResponse{ + Snapshot: snapshot, + }) +} + // handleGetState returns the current state for the requested session. -func (a *OORClientActor) handleGetState(ctx context.Context, +func (b *oorDurableBehavior) handleGetState(ctx context.Context, req *GetStateRequest) fn.Result[ActorResp] { _ = ctx @@ -152,7 +455,7 @@ func (a *OORClientActor) handleGetState(ctx context.Context, return fn.Err[ActorResp](fmt.Errorf("request must be provided")) } - handle, ok := a.sessions[req.SessionID] + handle, ok := b.sessions[req.SessionID] if !ok { return fn.Err[ActorResp](fmt.Errorf("unknown session: %s", req.SessionID)) @@ -168,8 +471,87 @@ func (a *OORClientActor) handleGetState(ctx context.Context, }) } +// restoreFromCheckpoint decodes a TLV checkpoint blob and rebuilds +// per-session FSMs from the embedded outgoing snapshots. +func (b *oorDurableBehavior) restoreFromCheckpoint(ctx context.Context, + raw []byte) error { + + _ = ctx + + if len(raw) == 0 { + return nil + } + + var checkpoint outgoingSessionsCheckpoint + checkpoint, err := decodeOutgoingSessionsCheckpoint(raw) + if err != nil { + return err + } + + if checkpoint.Version != oorCheckpointVersion { + return fmt.Errorf("unknown checkpoint version: %d", + checkpoint.Version) + } + + for i := range checkpoint.Snapshots { + snapshot := checkpoint.Snapshots[i] + + if _, exists := b.sessions[snapshot.SessionID]; exists { + return fmt.Errorf( + "duplicate session id in checkpoint: %s", + snapshot.SessionID, + ) + } + + session, err := NewSessionFromSnapshot(ctx, snapshot) + if err != nil { + return err + } + + b.sessions[session.ID] = &sessionHandle{FSM: session.FSM} + } + + return nil +} + +// resumeRestoredSessions iterates all restored sessions in deterministic +// order and re-drives their outbox side effects so that in-flight +// transfers resume from where they left off. +func (b *oorDurableBehavior) resumeRestoredSessions(ctx context.Context) error { + sessionIDs := make([]SessionID, 0, len(b.sessions)) + for sessionID := range b.sessions { + sessionIDs = append(sessionIDs, sessionID) + } + + sort.SliceStable(sessionIDs, func(i, j int) bool { + return sessionIDs[i].String() < sessionIDs[j].String() + }) + + for i := range sessionIDs { + sessionID := sessionIDs[i] + handle := b.sessions[sessionID] + + state, err := handle.currentState() + if err != nil { + return err + } + + outbox, err := OutboxForState(state) + if err != nil { + return err + } + + err = b.driveOutbox(ctx, sessionID, handle.FSM, outbox) + if err != nil { + return err + } + } + + return nil +} + // askEvent asks an event on the FSM and returns any outbox produced. -func (a *OORClientActor) askEvent(ctx context.Context, fsm *StateMachine, +func (b *oorDurableBehavior) askEvent(ctx context.Context, fsm *StateMachine, event Event) ([]OutboxEvent, error) { if fsm == nil { @@ -187,17 +569,16 @@ func (a *OORClientActor) askEvent(ctx context.Context, fsm *StateMachine, // 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 { +func (b *oorDurableBehavior) driveOutbox(ctx context.Context, + sessionID SessionID, fsm *StateMachine, outbox []OutboxEvent) error { - handler := a.cfg.OutboxHandler + handler := b.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. + // Outbox handler is the I/O boundary. followUps, err := handler.Handle(ctx, sessionID, msg) if err != nil { return err @@ -207,12 +588,17 @@ func (a *OORClientActor) driveOutbox(ctx context.Context, sessionID SessionID, // Feed follow-up events into the FSM. // Recursively execute any emitted outbox work. // Stop when none remains. - nextOutbox, err := a.askEvent(ctx, fsm, followUp) + nextOutbox, err := b.askEvent(ctx, fsm, followUp) if err != nil { return err } - err = a.driveOutbox(ctx, sessionID, fsm, nextOutbox) + err = b.persistCheckpoint(ctx) + if err != nil { + return err + } + + err = b.driveOutbox(ctx, sessionID, fsm, nextOutbox) if err != nil { return err } @@ -222,6 +608,61 @@ func (a *OORClientActor) driveOutbox(ctx context.Context, sessionID SessionID, return nil } +// persistCheckpoint snapshots every active session into a single TLV +// blob and writes it to the durable delivery store. +func (b *oorDurableBehavior) persistCheckpoint(ctx context.Context) error { + if b.cfg.DeliveryStore == nil { + return fmt.Errorf("delivery store must be provided") + } + + sessionIDs := make([]SessionID, 0, len(b.sessions)) + for sessionID := range b.sessions { + sessionIDs = append(sessionIDs, sessionID) + } + + sort.SliceStable(sessionIDs, func(i, j int) bool { + return sessionIDs[i].String() < sessionIDs[j].String() + }) + + snapshots := make([]*OutgoingSnapshot, 0, len(sessionIDs)) + for i := range sessionIDs { + sessionID := sessionIDs[i] + handle := b.sessions[sessionID] + + state, err := handle.currentState() + if err != nil { + return err + } + + snapshot, err := NewOutgoingSnapshot(sessionID, state) + if err != nil { + return err + } + + snapshots = append(snapshots, snapshot) + } + + raw, err := encodeOutgoingSessionsCheckpoint(outgoingSessionsCheckpoint{ + Version: oorCheckpointVersion, + Snapshots: snapshots, + }) + if err != nil { + return err + } + + return b.cfg.DeliveryStore.SaveCheckpoint(ctx, actor.CheckpointParams{ + ActorID: b.cfg.ActorID, + StateType: oorCheckpointStateType, + StateData: raw, + Version: oorCheckpointVersion, + }) +} + +type outgoingSessionsCheckpoint struct { + Version int + Snapshots []*OutgoingSnapshot +} + // sessionHandle ties a session ID to its running state machine instance. type sessionHandle struct { FSM *StateMachine @@ -241,3 +682,9 @@ func (h *sessionHandle) currentState() (State, error) { return state, nil } + +type durableBehaviorIface = actor.ActorBehavior[ + actor.TLVMessage, ActorResp, +] + +var _ durableBehaviorIface = (*oorDurableBehavior)(nil) diff --git a/oor/actor_drive_event_identity_test.go b/oor/actor_drive_event_identity_test.go new file mode 100644 index 000000000..3b9f257dc --- /dev/null +++ b/oor/actor_drive_event_identity_test.go @@ -0,0 +1,70 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/darepo-client/lib/tx/psbtutil" + "github.com/stretchr/testify/require" +) + +func TestDriveEventCommandRejectsSubmitAcceptedSessionMismatch(t *testing.T) { + t.Parallel() + + ark, checkpoints := testOutboxPSBTPair(t) + sessionID, err := sessionIDFromArk(ark) + require.NoError(t, err) + + otherSessionID := SessionID(chainhash.Hash{4, 4, 4}) + msg := &DriveEventRequest{ + SessionID: sessionID, + Event: &SubmitAcceptedEvent{ + SessionID: otherSessionID, + ArkPSBT: ark, + CoSignedCheckpointPSBTs: checkpoints, + }, + } + + _, err = durableCommandFromActorMsg(msg) + require.ErrorContains( + t, err, "submit accepted event session id mismatch", + ) +} + +func TestDriveEventCommandRejectsSubmitAcceptedArkMismatch(t *testing.T) { + t.Parallel() + + ark, checkpoints := testOutboxPSBTPair(t) + sessionID, err := sessionIDFromArk(ark) + require.NoError(t, err) + + tamperedArk := clonePSBTForDriveEventTest(t, ark) + tamperedArk.UnsignedTx.TxOut[0].Value++ + + msg := &DriveEventRequest{ + SessionID: sessionID, + Event: &SubmitAcceptedEvent{ + SessionID: sessionID, + ArkPSBT: tamperedArk, + CoSignedCheckpointPSBTs: checkpoints, + }, + } + + _, err = durableCommandFromActorMsg(msg) + require.ErrorContains( + t, err, "submit accepted event ark txid mismatch", + ) +} + +func clonePSBTForDriveEventTest(t *testing.T, pkt *psbt.Packet) *psbt.Packet { + t.Helper() + + raw, err := psbtutil.Serialize(pkt) + require.NoError(t, err) + + cloned, err := psbtutil.Parse(raw) + require.NoError(t, err) + + return cloned +} diff --git a/oor/actor_durable_message.go b/oor/actor_durable_message.go new file mode 100644 index 000000000..36ff728ef --- /dev/null +++ b/oor/actor_durable_message.go @@ -0,0 +1,1236 @@ +package oor + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "math" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/lib/scripts" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/lightninglabs/darepo-client/lib/tx/psbtutil" + "github.com/lightningnetwork/lnd/tlv" +) + +const ( + // oorDurableCommandTLVType is the top-level TLV type for OOR + // durable actor command messages. The high range (0x7xxx) avoids + // collisions with the actor framework's reserved types + // (e.g. actor.RestartTLVType). + oorDurableCommandTLVType tlv.Type = 0x7003 + + oorDurableCommandRecordType tlv.Type = 1 + oorDurablePayloadRecordType tlv.Type = 2 +) + +const ( + oorCommandStartTransfer uint64 = 1 + oorCommandGetState uint64 = 2 + oorCommandResumeSession uint64 = 3 + oorCommandExportSession uint64 = 4 + oorCommandRestore uint64 = 5 + oorCommandDriveEvent uint64 = 6 +) + +const ( + startPayloadOperatorKeyRecordType tlv.Type = 1 + startPayloadCSVDelayRecordType tlv.Type = 2 + startPayloadInputsRecordType tlv.Type = 3 + startPayloadRecipientsRecordType tlv.Type = 4 +) + +const ( + sessionPayloadSessionIDRecordType tlv.Type = 1 +) + +const ( + restorePayloadSnapshotRecordType tlv.Type = 1 +) + +const ( + driveEventPayloadSessionIDRecordType tlv.Type = 1 + driveEventPayloadEventRecordType tlv.Type = 3 +) + +const ( + eventPayloadKindRecordType tlv.Type = 1 + eventPayloadSubmitSessionIDRecordType tlv.Type = 3 + eventPayloadArkPSBTRecordType tlv.Type = 5 + eventPayloadCheckpointPSBTsRecordType tlv.Type = 7 + eventPayloadReasonRecordType tlv.Type = 9 +) + +const ( + eventKindSubmitAccepted uint64 = 1 + eventKindCheckpointsSigned uint64 = 2 + eventKindFinalizeAccepted uint64 = 3 + eventKindInputsMarkedSpent uint64 = 4 + eventKindFail uint64 = 5 +) + +const ( + transferInputOutpointRecordType tlv.Type = 1 + transferInputAmountSatRecordType tlv.Type = 2 + transferInputClientFamilyRecordType tlv.Type = 3 + transferInputClientIndexRecordType tlv.Type = 4 + transferInputClientPubKeyRecordType tlv.Type = 5 + transferInputOperatorPubKeyRecordType tlv.Type = 6 + transferInputExitDelayRecordType tlv.Type = 7 + transferInputOwnerLeafScriptRecordType tlv.Type = 8 +) + +const ( + recipientPkScriptRecordType tlv.Type = 1 + recipientValueSatRecordType tlv.Type = 2 +) + +type durableActorCommandMessage struct { + actor.BaseMessage + + Command uint64 + Payload []byte +} + +func (m *durableActorCommandMessage) MessageType() string { + return "oor.DurableCommand" +} + +func (m *durableActorCommandMessage) TLVType() tlv.Type { + return oorDurableCommandTLVType +} + +func (m *durableActorCommandMessage) Encode(w io.Writer) error { + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + oorDurableCommandRecordType, &m.Command, + ), + tlv.MakePrimitiveRecord( + oorDurablePayloadRecordType, &m.Payload, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return err + } + + return stream.Encode(w) +} + +func (m *durableActorCommandMessage) Decode(r io.Reader) error { + var ( + command uint64 + payload []byte + ) + + records := []tlv.Record{ + tlv.MakePrimitiveRecord(oorDurableCommandRecordType, &command), + tlv.MakePrimitiveRecord(oorDurablePayloadRecordType, &payload), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return err + } + + if _, err := stream.DecodeWithParsedTypes(r); err != nil { + return err + } + + m.Command = command + m.Payload = payload + + return nil +} + +type startTransferPayload struct { + OperatorPubKey []byte + CSVDelay uint32 + Inputs []*TransferInputSnapshot + Recipients []recipientPayload +} + +type recipientPayload struct { + PkScript []byte + ValueSat int64 +} + +func durableCommandFromActorMsg(msg ActorMsg) (*durableActorCommandMessage, + error) { + + if msg == nil { + return nil, fmt.Errorf("message must be provided") + } + + switch req := msg.(type) { + case *StartTransferRequest: + payload := startTransferPayload{ + CSVDelay: req.Policy.CSVDelay, + Recipients: make( + []recipientPayload, 0, len(req.Recipients), + ), + Inputs: make( + []*TransferInputSnapshot, 0, len(req.Inputs), + ), + } + + if req.Policy.OperatorKey == nil { + return nil, fmt.Errorf("operator key must be provided") + } + + payload.OperatorPubKey = req.Policy.OperatorKey. + SerializeCompressed() + + for i := range req.Inputs { + snap, err := req.Inputs[i].ToSnapshot() + if err != nil { + return nil, err + } + + payload.Inputs = append(payload.Inputs, snap) + } + + for i := range req.Recipients { + payload.Recipients = append( + payload.Recipients, recipientPayload{ + PkScript: req.Recipients[i].PkScript, + ValueSat: int64( + req.Recipients[i].Value, + ), + }, + ) + } + + raw, err := encodeStartTransferPayload(payload) + if err != nil { + return nil, err + } + + return &durableActorCommandMessage{ + Command: oorCommandStartTransfer, + Payload: raw, + }, nil + + case *GetStateRequest: + raw, err := encodeSessionPayload(req.SessionID) + if err != nil { + return nil, err + } + + return &durableActorCommandMessage{ + Command: oorCommandGetState, + Payload: raw, + }, nil + + case *ResumeSessionRequest: + raw, err := encodeSessionPayload(req.SessionID) + if err != nil { + return nil, err + } + + return &durableActorCommandMessage{ + Command: oorCommandResumeSession, + Payload: raw, + }, nil + + case *ExportSnapshotRequest: + raw, err := encodeSessionPayload(req.SessionID) + if err != nil { + return nil, err + } + + return &durableActorCommandMessage{ + Command: oorCommandExportSession, + Payload: raw, + }, nil + + case *RestoreSessionRequest: + raw, err := encodeRestoreSnapshotPayload(req.Snapshot) + if err != nil { + return nil, err + } + + return &durableActorCommandMessage{ + Command: oorCommandRestore, + Payload: raw, + }, nil + + case *DriveEventRequest: + raw, err := encodeDriveEventRequestPayload( + req.SessionID, req.Event, + ) + if err != nil { + return nil, err + } + + return &durableActorCommandMessage{ + Command: oorCommandDriveEvent, + Payload: raw, + }, nil + + default: + return nil, fmt.Errorf("unknown actor message type: %T", req) + } +} + +func actorMsgFromDurableCommand(cmd *durableActorCommandMessage) (ActorMsg, + error) { + + if cmd == nil { + return nil, fmt.Errorf("command must be provided") + } + + switch cmd.Command { + case oorCommandStartTransfer: + payload, err := decodeStartTransferPayload(cmd.Payload) + if err != nil { + return nil, err + } + + operatorKey, err := btcec.ParsePubKey(payload.OperatorPubKey) + if err != nil { + return nil, err + } + + inputs := make([]TransferInput, 0, len(payload.Inputs)) + for i := range payload.Inputs { + in, err := TransferInputFromSnapshot(payload.Inputs[i]) + if err != nil { + return nil, err + } + + inputs = append(inputs, in) + } + + recipients := make( + []oortx.RecipientOutput, 0, len(payload.Recipients), + ) + for i := range payload.Recipients { + recipient := payload.Recipients[i] + recipients = append(recipients, oortx.RecipientOutput{ + PkScript: recipient.PkScript, + Value: btcutil.Amount(recipient.ValueSat), + }) + } + + return &StartTransferRequest{ + Policy: scripts.CheckpointPolicy{ + OperatorKey: operatorKey, + CSVDelay: payload.CSVDelay, + }, + Inputs: inputs, + Recipients: recipients, + }, nil + + case oorCommandGetState: + sessionID, err := decodeSessionPayload(cmd.Payload) + if err != nil { + return nil, err + } + + return &GetStateRequest{SessionID: sessionID}, nil + + case oorCommandResumeSession: + sessionID, err := decodeSessionPayload(cmd.Payload) + if err != nil { + return nil, err + } + + return &ResumeSessionRequest{SessionID: sessionID}, nil + + case oorCommandExportSession: + sessionID, err := decodeSessionPayload(cmd.Payload) + if err != nil { + return nil, err + } + + return &ExportSnapshotRequest{SessionID: sessionID}, nil + + case oorCommandRestore: + snapshot, err := decodeRestoreSnapshotPayload(cmd.Payload) + if err != nil { + return nil, err + } + + return &RestoreSessionRequest{Snapshot: snapshot}, nil + + case oorCommandDriveEvent: + sessionID, event, err := decodeDriveEventRequestPayload( + cmd.Payload, + ) + if err != nil { + return nil, err + } + + return &DriveEventRequest{ + SessionID: sessionID, + Event: event, + }, nil + + default: + return nil, fmt.Errorf("unknown command kind: %d", cmd.Command) + } +} + +func encodeStartTransferPayload(payload startTransferPayload) ([]byte, error) { + inputs, err := encodeTransferInputSnapshots(payload.Inputs) + if err != nil { + return nil, err + } + + recipients, err := encodeRecipientPayloads(payload.Recipients) + if err != nil { + return nil, err + } + + operatorKey := payload.OperatorPubKey + csvDelay := payload.CSVDelay + + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + startPayloadOperatorKeyRecordType, &operatorKey, + ), + tlv.MakePrimitiveRecord( + startPayloadCSVDelayRecordType, &csvDelay, + ), + tlv.MakePrimitiveRecord( + startPayloadInputsRecordType, &inputs, + ), + tlv.MakePrimitiveRecord( + startPayloadRecipientsRecordType, &recipients, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func decodeStartTransferPayload(raw []byte) (startTransferPayload, error) { + var ( + operatorKey []byte + csvDelay uint32 + inputsRaw []byte + recipients []byte + ) + + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + startPayloadOperatorKeyRecordType, &operatorKey, + ), + tlv.MakePrimitiveRecord( + startPayloadCSVDelayRecordType, &csvDelay, + ), + tlv.MakePrimitiveRecord( + startPayloadInputsRecordType, &inputsRaw, + ), + tlv.MakePrimitiveRecord( + startPayloadRecipientsRecordType, &recipients, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return startTransferPayload{}, err + } + + reader := bytes.NewReader(raw) + if _, err := stream.DecodeWithParsedTypes(reader); err != nil { + return startTransferPayload{}, err + } + + inputs, err := decodeTransferInputSnapshots(inputsRaw) + if err != nil { + return startTransferPayload{}, err + } + + recipientsPayload, err := decodeRecipientPayloads(recipients) + if err != nil { + return startTransferPayload{}, err + } + + return startTransferPayload{ + OperatorPubKey: operatorKey, + CSVDelay: csvDelay, + Inputs: inputs, + Recipients: recipientsPayload, + }, nil +} + +func encodeRecipientPayloads(payloads []recipientPayload) ([]byte, error) { + blobs := make([][]byte, 0, len(payloads)) + for i := range payloads { + raw, err := encodeRecipientPayload(payloads[i]) + if err != nil { + return nil, err + } + + blobs = append(blobs, raw) + } + + return encodeLengthPrefixedBlobList(blobs) +} + +func decodeRecipientPayloads(raw []byte) ([]recipientPayload, error) { + blobs, err := decodeLengthPrefixedBlobList(raw) + if err != nil { + return nil, err + } + + payloads := make([]recipientPayload, 0, len(blobs)) + for i := range blobs { + payload, err := decodeRecipientPayload(blobs[i]) + if err != nil { + return nil, err + } + + payloads = append(payloads, payload) + } + + return payloads, nil +} + +func encodeRecipientPayload(payload recipientPayload) ([]byte, error) { + pkScript := payload.PkScript + if payload.ValueSat < 0 { + return nil, fmt.Errorf("recipient value must be non-negative") + } + valueSat := uint64(payload.ValueSat) + + records := []tlv.Record{ + tlv.MakePrimitiveRecord(recipientPkScriptRecordType, &pkScript), + tlv.MakePrimitiveRecord(recipientValueSatRecordType, &valueSat), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func decodeRecipientPayload(raw []byte) (recipientPayload, error) { + var ( + pkScript []byte + valueSat uint64 + ) + + records := []tlv.Record{ + tlv.MakePrimitiveRecord(recipientPkScriptRecordType, &pkScript), + tlv.MakePrimitiveRecord(recipientValueSatRecordType, &valueSat), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return recipientPayload{}, err + } + + reader := bytes.NewReader(raw) + if _, err := stream.DecodeWithParsedTypes(reader); err != nil { + return recipientPayload{}, err + } + + decodedValueSat, err := uint64ToInt64( + valueSat, "recipient value sat", + ) + if err != nil { + return recipientPayload{}, err + } + + return recipientPayload{ + PkScript: pkScript, + ValueSat: decodedValueSat, + }, nil +} + +func encodeTransferInputSnapshots(inputs []*TransferInputSnapshot) ([]byte, + error) { + + blobs := make([][]byte, 0, len(inputs)) + for i := range inputs { + raw, err := encodeTransferInputSnapshot(inputs[i]) + if err != nil { + return nil, err + } + + blobs = append(blobs, raw) + } + + return encodeLengthPrefixedBlobList(blobs) +} + +func decodeTransferInputSnapshots(raw []byte) ([]*TransferInputSnapshot, + error) { + + blobs, err := decodeLengthPrefixedBlobList(raw) + if err != nil { + return nil, err + } + + inputs := make([]*TransferInputSnapshot, 0, len(blobs)) + for i := range blobs { + input, err := decodeTransferInputSnapshot(blobs[i]) + if err != nil { + return nil, err + } + + inputs = append(inputs, input) + } + + return inputs, nil +} + +func encodeTransferInputSnapshot(input *TransferInputSnapshot) ([]byte, error) { + if input == nil { + return nil, fmt.Errorf( + "transfer input snapshot must be provided", + ) + } + + outpoint := outPointBytes(input.Outpoint) + amountSat := uint64(input.AmountSat) + clientFamily := uint32(input.ClientKeyFamily) + clientIndex := input.ClientKeyIndex + clientPubKey := input.ClientPubKey + operatorPubKey := input.OperatorPubKey + exitDelay := input.ExitDelay + ownerLeafScript := input.OwnerLeafScript + + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + transferInputOutpointRecordType, &outpoint, + ), + tlv.MakePrimitiveRecord( + transferInputAmountSatRecordType, &amountSat, + ), + tlv.MakePrimitiveRecord( + transferInputClientFamilyRecordType, &clientFamily, + ), + tlv.MakePrimitiveRecord( + transferInputClientIndexRecordType, &clientIndex, + ), + tlv.MakePrimitiveRecord( + transferInputClientPubKeyRecordType, &clientPubKey, + ), + tlv.MakePrimitiveRecord( + transferInputOperatorPubKeyRecordType, &operatorPubKey, + ), + tlv.MakePrimitiveRecord( + transferInputExitDelayRecordType, &exitDelay, + ), + tlv.MakePrimitiveRecord( + transferInputOwnerLeafScriptRecordType, + &ownerLeafScript, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func decodeTransferInputSnapshot(raw []byte) (*TransferInputSnapshot, error) { + var ( + outpointRaw []byte + amountSat uint64 + clientFamily uint32 + clientIndex uint32 + clientPubKey []byte + operatorPubKey []byte + exitDelay uint32 + ownerLeafScript []byte + ) + + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + transferInputOutpointRecordType, &outpointRaw, + ), + tlv.MakePrimitiveRecord( + transferInputAmountSatRecordType, &amountSat, + ), + tlv.MakePrimitiveRecord( + transferInputClientFamilyRecordType, &clientFamily, + ), + tlv.MakePrimitiveRecord( + transferInputClientIndexRecordType, &clientIndex, + ), + tlv.MakePrimitiveRecord( + transferInputClientPubKeyRecordType, &clientPubKey, + ), + tlv.MakePrimitiveRecord( + transferInputOperatorPubKeyRecordType, &operatorPubKey, + ), + tlv.MakePrimitiveRecord( + transferInputExitDelayRecordType, &exitDelay, + ), + tlv.MakePrimitiveRecord( + transferInputOwnerLeafScriptRecordType, + &ownerLeafScript, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + reader := bytes.NewReader(raw) + if _, err := stream.DecodeWithParsedTypes(reader); err != nil { + return nil, err + } + + outpoint, err := parseOutPointBytes(outpointRaw) + if err != nil { + return nil, err + } + + decodedAmountSat, err := uint64ToInt64(amountSat, "amount sat") + if err != nil { + return nil, err + } + + decodedClientFamily, err := uint32ToInt32( + clientFamily, "client key family", + ) + if err != nil { + return nil, err + } + + return &TransferInputSnapshot{ + Outpoint: outpoint, + AmountSat: decodedAmountSat, + ClientKeyFamily: decodedClientFamily, + ClientKeyIndex: clientIndex, + ClientPubKey: clientPubKey, + OperatorPubKey: operatorPubKey, + ExitDelay: exitDelay, + OwnerLeafScript: ownerLeafScript, + }, nil +} + +func encodeSessionPayload(sessionID SessionID) ([]byte, error) { + sessionBytes := sessionIDBytes(sessionID) + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + sessionPayloadSessionIDRecordType, &sessionBytes, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func decodeSessionPayload(raw []byte) (SessionID, error) { + var sessionBytes []byte + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + sessionPayloadSessionIDRecordType, &sessionBytes, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return SessionID{}, err + } + + reader := bytes.NewReader(raw) + if _, err := stream.DecodeWithParsedTypes(reader); err != nil { + return SessionID{}, err + } + + return parseSessionID(sessionBytes) +} + +func encodeRestoreSnapshotPayload(snapshot *OutgoingSnapshot) ([]byte, error) { + snapshotRaw, err := encodeOutgoingSnapshot(snapshot) + if err != nil { + return nil, err + } + + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + restorePayloadSnapshotRecordType, &snapshotRaw, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func decodeRestoreSnapshotPayload(raw []byte) (*OutgoingSnapshot, error) { + var snapshotRaw []byte + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + restorePayloadSnapshotRecordType, &snapshotRaw, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + reader := bytes.NewReader(raw) + if _, err := stream.DecodeWithParsedTypes(reader); err != nil { + return nil, err + } + + return decodeOutgoingSnapshot(snapshotRaw) +} + +func encodeDriveEventRequestPayload(sessionID SessionID, event Event) ([]byte, + error) { + + if event == nil { + return nil, fmt.Errorf("event must be provided") + } + + if submitAccepted, ok := event.(*SubmitAcceptedEvent); ok { + if err := validateSubmitAcceptedIdentity( + sessionID, submitAccepted, + ); err != nil { + return nil, err + } + } + + sessionBytes := sessionIDBytes(sessionID) + eventPayload, err := encodeEventPayload(event) + if err != nil { + return nil, err + } + + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + driveEventPayloadSessionIDRecordType, &sessionBytes, + ), + tlv.MakePrimitiveRecord( + driveEventPayloadEventRecordType, &eventPayload, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func decodeDriveEventRequestPayload(raw []byte) (SessionID, Event, error) { + var ( + sessionBytes []byte + eventPayload []byte + ) + + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + driveEventPayloadSessionIDRecordType, &sessionBytes, + ), + tlv.MakePrimitiveRecord( + driveEventPayloadEventRecordType, &eventPayload, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return SessionID{}, nil, err + } + + reader := bytes.NewReader(raw) + if _, err := stream.DecodeWithParsedTypes(reader); err != nil { + return SessionID{}, nil, err + } + + sessionID, err := parseSessionID(sessionBytes) + if err != nil { + return SessionID{}, nil, err + } + + event, err := decodeEventPayload(eventPayload) + if err != nil { + return SessionID{}, nil, err + } + + if submitAccepted, ok := event.(*SubmitAcceptedEvent); ok { + if err := validateSubmitAcceptedIdentity( + sessionID, submitAccepted, + ); err != nil { + return SessionID{}, nil, err + } + } + + return sessionID, event, nil +} + +func encodeEventPayload(event Event) ([]byte, error) { + var ( + eventKind uint64 + submitSession []byte + arkPSBT []byte + checkpointPSBT []byte + reason []byte + err error + ) + + switch evt := event.(type) { + case *SubmitAcceptedEvent: + eventKind = eventKindSubmitAccepted + submitSession = sessionIDBytes(evt.SessionID) + arkPSBT, err = psbtutil.Serialize(evt.ArkPSBT) + if err != nil { + return nil, err + } + + checkpoints, err := serializePSBTSlice( + evt.CoSignedCheckpointPSBTs, + ) + if err != nil { + return nil, err + } + checkpointPSBT, err = encodeLengthPrefixedBlobList(checkpoints) + if err != nil { + return nil, err + } + + case *CheckpointsSignedEvent: + eventKind = eventKindCheckpointsSigned + checkpoints, err := serializePSBTSlice(evt.FinalCheckpointPSBTs) + if err != nil { + return nil, err + } + checkpointPSBT, err = encodeLengthPrefixedBlobList(checkpoints) + if err != nil { + return nil, err + } + + case *FinalizeAcceptedEvent: + eventKind = eventKindFinalizeAccepted + + case *InputsMarkedSpentEvent: + eventKind = eventKindInputsMarkedSpent + + case *FailEvent: + eventKind = eventKindFail + reason = []byte(evt.Reason) + + default: + return nil, fmt.Errorf("unsupported event type: %T", event) + } + + records := []tlv.Record{ + tlv.MakePrimitiveRecord(eventPayloadKindRecordType, &eventKind), + tlv.MakePrimitiveRecord( + eventPayloadSubmitSessionIDRecordType, &submitSession, + ), + tlv.MakePrimitiveRecord( + eventPayloadArkPSBTRecordType, &arkPSBT, + ), + tlv.MakePrimitiveRecord( + eventPayloadCheckpointPSBTsRecordType, &checkpointPSBT, + ), + tlv.MakePrimitiveRecord(eventPayloadReasonRecordType, &reason), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func decodeEventPayload(raw []byte) (Event, error) { + var ( + eventKind uint64 + submitSession []byte + arkPSBT []byte + checkpointPSBT []byte + reason []byte + ) + + records := []tlv.Record{ + tlv.MakePrimitiveRecord(eventPayloadKindRecordType, &eventKind), + tlv.MakePrimitiveRecord( + eventPayloadSubmitSessionIDRecordType, &submitSession, + ), + tlv.MakePrimitiveRecord( + eventPayloadArkPSBTRecordType, &arkPSBT, + ), + tlv.MakePrimitiveRecord( + eventPayloadCheckpointPSBTsRecordType, &checkpointPSBT, + ), + tlv.MakePrimitiveRecord(eventPayloadReasonRecordType, &reason), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + reader := bytes.NewReader(raw) + if _, err := stream.DecodeWithParsedTypes(reader); err != nil { + return nil, err + } + + switch eventKind { + case eventKindSubmitAccepted: + sessionID, err := parseSessionID(submitSession) + if err != nil { + return nil, err + } + + ark, err := psbtutil.Parse(arkPSBT) + if err != nil { + return nil, err + } + + checkpointRaw, err := decodeLengthPrefixedBlobList( + checkpointPSBT, + ) + if err != nil { + return nil, err + } + + checkpoints, err := parsePSBTSlice(checkpointRaw) + if err != nil { + return nil, err + } + + return &SubmitAcceptedEvent{ + SessionID: sessionID, + ArkPSBT: ark, + CoSignedCheckpointPSBTs: checkpoints, + }, nil + + case eventKindCheckpointsSigned: + checkpointRaw, err := decodeLengthPrefixedBlobList( + checkpointPSBT, + ) + if err != nil { + return nil, err + } + + checkpoints, err := parsePSBTSlice(checkpointRaw) + if err != nil { + return nil, err + } + + return &CheckpointsSignedEvent{ + FinalCheckpointPSBTs: checkpoints, + }, nil + + case eventKindFinalizeAccepted: + return &FinalizeAcceptedEvent{}, nil + + case eventKindInputsMarkedSpent: + return &InputsMarkedSpentEvent{}, nil + + case eventKindFail: + return &FailEvent{Reason: string(reason)}, nil + + default: + return nil, fmt.Errorf("unknown event kind: %d", eventKind) + } +} + +func validateSubmitAcceptedIdentity(sessionID SessionID, + event *SubmitAcceptedEvent) error { + + if event == nil { + return fmt.Errorf("submit accepted event must be provided") + } + + if event.SessionID != sessionID { + return fmt.Errorf("submit accepted event session id mismatch") + } + + if event.ArkPSBT == nil || event.ArkPSBT.UnsignedTx == nil { + return fmt.Errorf( + "submit accepted event ark psbt must be provided", + ) + } + + arkSessionID, err := sessionIDFromArk(event.ArkPSBT) + if err != nil { + return err + } + + if arkSessionID != sessionID { + return fmt.Errorf("submit accepted event ark txid mismatch") + } + + return nil +} + +func outPointBytes(out wire.OutPoint) []byte { + raw := make([]byte, chainhash.HashSize+4) + copy(raw[:chainhash.HashSize], out.Hash[:]) + binary.LittleEndian.PutUint32(raw[chainhash.HashSize:], out.Index) + + return raw +} + +func parseOutPointBytes(raw []byte) (wire.OutPoint, error) { + if len(raw) != chainhash.HashSize+4 { + return wire.OutPoint{}, fmt.Errorf("invalid outpoint length") + } + + var out wire.OutPoint + copy(out.Hash[:], raw[:chainhash.HashSize]) + out.Index = binary.LittleEndian.Uint32(raw[chainhash.HashSize:]) + + return out, nil +} + +func encodeLengthPrefixedBlobList(blobs [][]byte) ([]byte, error) { + var ( + buf bytes.Buffer + scratch [8]byte + ) + + if err := tlv.WriteVarInt( + &buf, uint64(len(blobs)), &scratch, + ); err != nil { + return nil, err + } + + for i := range blobs { + element := blobs[i] + + if err := tlv.WriteVarInt( + &buf, uint64(len(element)), &scratch, + ); err != nil { + return nil, err + } + + if _, err := buf.Write(element); err != nil { + return nil, err + } + } + + return buf.Bytes(), nil +} + +func decodeLengthPrefixedBlobList(raw []byte) ([][]byte, error) { + var scratch [8]byte + + reader := bytes.NewReader(raw) + count, err := tlv.ReadVarInt(reader, &scratch) + if err != nil { + return nil, err + } + + blobs := make([][]byte, 0, count) + for i := uint64(0); i < count; i++ { + elementLen, err := tlv.ReadVarInt(reader, &scratch) + if err != nil { + return nil, err + } + + element := make([]byte, elementLen) + if _, err := io.ReadFull(reader, element); err != nil { + return nil, err + } + + blobs = append(blobs, element) + } + + if reader.Len() != 0 { + return nil, fmt.Errorf("trailing payload bytes") + } + + return blobs, nil +} + +func sessionIDBytes(sessionID SessionID) []byte { + h := [32]byte(sessionID) + raw := make([]byte, len(h)) + copy(raw, h[:]) + + return raw +} + +func parseSessionID(raw []byte) (SessionID, error) { + if len(raw) != chainhash.HashSize { + return SessionID{}, fmt.Errorf("invalid session id length") + } + + var hash chainhash.Hash + copy(hash[:], raw) + + return SessionID(hash), nil +} + +func uint64ToInt64(value uint64, field string) (int64, error) { + if value > math.MaxInt64 { + return 0, fmt.Errorf("%s overflows int64: %d", field, value) + } + + return int64(value), nil +} + +func uint32ToInt32(value uint32, field string) (int32, error) { + if value > math.MaxInt32 { + return 0, fmt.Errorf("%s overflows int32: %d", field, value) + } + + return int32(value), nil +} + +var _ actor.TLVMessage = (*durableActorCommandMessage)(nil) diff --git a/oor/actor_durable_message_test.go b/oor/actor_durable_message_test.go new file mode 100644 index 000000000..b7016a89e --- /dev/null +++ b/oor/actor_durable_message_test.go @@ -0,0 +1,140 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +func TestStartTransferPayloadTLVRoundTrip(t *testing.T) { + t.Parallel() + + payload := startTransferPayload{ + OperatorPubKey: []byte{2, 1, 2, 3}, + CSVDelay: 144, + Inputs: []*TransferInputSnapshot{ + { + Outpoint: wire.OutPoint{ + Hash: chainhash.Hash{1, 2, 3}, + Index: 7, + }, + AmountSat: 1000, + ClientKeyFamily: 1, + ClientKeyIndex: 9, + ClientPubKey: []byte{2, 4, 6}, + OperatorPubKey: []byte{3, 5, 7}, + ExitDelay: 72, + OwnerLeafScript: []byte{0x51}, + }, + }, + Recipients: []recipientPayload{ + { + PkScript: []byte{0x51, 0x20}, + ValueSat: 321, + }, + }, + } + + raw, err := encodeStartTransferPayload(payload) + require.NoError(t, err) + + decoded, err := decodeStartTransferPayload(raw) + require.NoError(t, err) + + require.Equal(t, payload.OperatorPubKey, decoded.OperatorPubKey) + require.Equal(t, payload.CSVDelay, decoded.CSVDelay) + require.Equal(t, payload.Recipients, decoded.Recipients) + require.Len(t, decoded.Inputs, 1) + require.Equal(t, payload.Inputs[0], decoded.Inputs[0]) +} + +func TestSessionPayloadTLVRoundTrip(t *testing.T) { + t.Parallel() + + id := SessionID(chainhash.Hash{9, 8, 7, 6}) + raw, err := encodeSessionPayload(id) + require.NoError(t, err) + + decoded, err := decodeSessionPayload(raw) + require.NoError(t, err) + require.Equal(t, id, decoded) +} + +func TestDecodeLengthPrefixedBlobListRejectsTrailingBytes(t *testing.T) { + t.Parallel() + + raw, err := encodeLengthPrefixedBlobList( + [][]byte{{1, 2, 3}}, + ) + require.NoError(t, err) + + raw = append(raw, 0xff) + _, err = decodeLengthPrefixedBlobList(raw) + require.ErrorContains(t, err, "trailing payload bytes") +} + +func TestDriveEventCommandRoundTripFailEvent(t *testing.T) { + t.Parallel() + + sessionID := SessionID(chainhash.Hash{7, 7, 7}) + msg := &DriveEventRequest{ + SessionID: sessionID, + Event: &FailEvent{ + Reason: "transport timeout", + }, + } + + cmd, err := durableCommandFromActorMsg(msg) + require.NoError(t, err) + require.Equal(t, oorCommandDriveEvent, cmd.Command) + + decoded, err := actorMsgFromDurableCommand(cmd) + require.NoError(t, err) + + decodedReq, ok := decoded.(*DriveEventRequest) + require.True(t, ok) + require.Equal(t, sessionID, decodedReq.SessionID) + + failEvt, ok := decodedReq.Event.(*FailEvent) + require.True(t, ok) + require.Equal(t, "transport timeout", failEvt.Reason) +} + +func TestDriveEventCommandRoundTripSubmitAcceptedEvent(t *testing.T) { + t.Parallel() + + ark, checkpoints := testOutboxPSBTPair(t) + sessionID, err := sessionIDFromArk(ark) + require.NoError(t, err) + + msg := &DriveEventRequest{ + SessionID: sessionID, + Event: &SubmitAcceptedEvent{ + SessionID: sessionID, + ArkPSBT: ark, + CoSignedCheckpointPSBTs: checkpoints, + }, + } + + cmd, err := durableCommandFromActorMsg(msg) + require.NoError(t, err) + require.Equal(t, oorCommandDriveEvent, cmd.Command) + + decoded, err := actorMsgFromDurableCommand(cmd) + require.NoError(t, err) + + decodedReq, ok := decoded.(*DriveEventRequest) + require.True(t, ok) + require.Equal(t, sessionID, decodedReq.SessionID) + + submitEvt, ok := decodedReq.Event.(*SubmitAcceptedEvent) + require.True(t, ok) + require.Equal(t, sessionID, submitEvt.SessionID) + require.NotNil(t, submitEvt.ArkPSBT) + require.Len(t, submitEvt.CoSignedCheckpointPSBTs, 1) + + decodedTxID := submitEvt.ArkPSBT.UnsignedTx.TxHash() + require.Equal(t, chainhash.Hash(sessionID), decodedTxID) +} diff --git a/oor/actor_messages.go b/oor/actor_messages.go index 22a02e6bf..2e5ab3fe1 100644 --- a/oor/actor_messages.go +++ b/oor/actor_messages.go @@ -24,13 +24,17 @@ type ActorResp interface { type StartTransferRequest struct { actor.BaseMessage - // Policy defines the checkpoint output tap tree policy. + // Policy defines the operator checkpoint policy used to build the + // transfer package. Policy scripts.CheckpointPolicy - // Inputs are the VTXO inputs to convert into checkpoint txs. - Inputs []oortx.CheckpointInput + // Inputs are the VTXOs to transfer. + // + // Each input includes enough context for the outbox boundary to request + // wallet signatures deterministically. + Inputs []TransferInput - // Recipients are the Ark tx recipient outputs. + // Recipients are the Ark tx output scripts/amounts. Recipients []oortx.RecipientOutput } @@ -46,6 +50,7 @@ func (m *StartTransferRequest) actorMsgSealed() {} type StartTransferResponse struct { actor.BaseMessage + // SessionID is the stable v0 session identifier (Ark txid). SessionID SessionID } @@ -63,7 +68,7 @@ func (m *StartTransferResponse) actorRespSealed() {} type DriveEventRequest struct { actor.BaseMessage - // SessionID selects the session to drive. + // SessionID identifies the session to drive. SessionID SessionID // Event is the follow-up event produced by an outbox handler, or by a @@ -96,6 +101,7 @@ func (m *DriveEventResponse) actorRespSealed() {} type GetStateRequest struct { actor.BaseMessage + // SessionID identifies the session to query. SessionID SessionID } @@ -111,6 +117,7 @@ func (m *GetStateRequest) actorMsgSealed() {} type GetStateResponse struct { actor.BaseMessage + // State is the current session state machine state. State State } @@ -121,3 +128,103 @@ func (m *GetStateResponse) MessageType() string { // actorRespSealed marks this as implementing the sealed ActorResp interface. func (m *GetStateResponse) actorRespSealed() {} + +// RestoreSessionRequest asks the actor to restore an outgoing transfer session +// from a previously exported snapshot. +type RestoreSessionRequest struct { + actor.BaseMessage + + // Snapshot is the durable-ish client-side snapshot for an outgoing + // transfer. + Snapshot *OutgoingSnapshot +} + +// MessageType returns the type of this message. +func (m *RestoreSessionRequest) MessageType() string { + return "RestoreSessionRequest" +} + +// actorMsgSealed marks this as implementing the sealed ActorMsg interface. +func (m *RestoreSessionRequest) actorMsgSealed() {} + +// RestoreSessionResponse returns the restored session identifier. +type RestoreSessionResponse struct { + actor.BaseMessage + + // SessionID is the restored session identifier. + SessionID SessionID +} + +// MessageType returns the type of this message. +func (m *RestoreSessionResponse) MessageType() string { + return "RestoreSessionResponse" +} + +// actorRespSealed marks this as implementing the sealed ActorResp interface. +func (m *RestoreSessionResponse) actorRespSealed() {} + +// ResumeSessionRequest asks the actor to re-emit the outbox request implied by +// the current session state. +// +// This supports retries after app restart or temporary transport failures (for +// example, re-sending submit/finalize requests). +type ResumeSessionRequest struct { + actor.BaseMessage + + // SessionID identifies the session to resume. + SessionID SessionID +} + +// MessageType returns the type of this message. +func (m *ResumeSessionRequest) MessageType() string { + return "ResumeSessionRequest" +} + +// actorMsgSealed marks this as implementing the sealed ActorMsg interface. +func (m *ResumeSessionRequest) actorMsgSealed() {} + +// ResumeSessionResponse acknowledges the resume request. +type ResumeSessionResponse struct { + actor.BaseMessage +} + +// MessageType returns the type of this message. +func (m *ResumeSessionResponse) MessageType() string { + return "ResumeSessionResponse" +} + +// actorRespSealed marks this as implementing the sealed ActorResp interface. +func (m *ResumeSessionResponse) actorRespSealed() {} + +// ExportSnapshotRequest asks the actor to export a snapshot for the requested +// session. +type ExportSnapshotRequest struct { + actor.BaseMessage + + // SessionID identifies the session to snapshot. + SessionID SessionID +} + +// MessageType returns the type of this message. +func (m *ExportSnapshotRequest) MessageType() string { + return "ExportSnapshotRequest" +} + +// actorMsgSealed marks this as implementing the sealed ActorMsg interface. +func (m *ExportSnapshotRequest) actorMsgSealed() {} + +// ExportSnapshotResponse returns an exported outgoing session snapshot. +type ExportSnapshotResponse struct { + actor.BaseMessage + + // Snapshot is the exported outgoing snapshot. + Snapshot *OutgoingSnapshot +} + +// MessageType returns the type of this message. +func (m *ExportSnapshotResponse) MessageType() string { + return "ExportSnapshotResponse" +} + +// actorRespSealed marks this as implementing the sealed ActorResp interface. +func (m *ExportSnapshotResponse) actorRespSealed() {} diff --git a/oor/actor_restore_duplicates_test.go b/oor/actor_restore_duplicates_test.go new file mode 100644 index 000000000..f02384120 --- /dev/null +++ b/oor/actor_restore_duplicates_test.go @@ -0,0 +1,69 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/stretchr/testify/require" +) + +func TestHandleRestoreSessionRejectsDuplicateSessionID(t *testing.T) { + t.Parallel() + + behavior := &oorDurableBehavior{ + cfg: ClientActorCfg{ + ActorID: "oor-duplicate-restore-test", + DeliveryStore: newTestDeliveryStore(t), + }, + sessions: make(map[SessionID]*sessionHandle), + } + + snapshot := &OutgoingSnapshot{ + Version: 2, + SessionID: SessionID(chainhash.Hash{1, 2, 3}), + Phase: OutgoingPhaseCompleted, + } + + first := behavior.handleRestoreSession(t.Context(), + &RestoreSessionRequest{ + Snapshot: snapshot, + }, + ) + require.True(t, first.IsOk()) + + second := behavior.handleRestoreSession(t.Context(), + &RestoreSessionRequest{ + Snapshot: snapshot, + }, + ) + require.True(t, second.IsErr()) + require.ErrorContains(t, second.Err(), "duplicate session id") +} + +func TestRestoreFromCheckpointRejectsDuplicateSessionID(t *testing.T) { + t.Parallel() + + behavior := &oorDurableBehavior{ + sessions: make(map[SessionID]*sessionHandle), + } + + snapshot := &OutgoingSnapshot{ + Version: 2, + SessionID: SessionID(chainhash.Hash{9, 8, 7}), + Phase: OutgoingPhaseCompleted, + } + + raw, err := encodeOutgoingSessionsCheckpoint( + outgoingSessionsCheckpoint{ + Version: oorCheckpointVersion, + Snapshots: []*OutgoingSnapshot{ + snapshot, + snapshot, + }, + }, + ) + require.NoError(t, err) + + err = behavior.restoreFromCheckpoint(t.Context(), raw) + require.ErrorContains(t, err, "duplicate session id") +} diff --git a/oor/actor_resume_test.go b/oor/actor_resume_test.go new file mode 100644 index 000000000..6f0068bd7 --- /dev/null +++ b/oor/actor_resume_test.go @@ -0,0 +1,861 @@ +package oor + +import ( + "context" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "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" + "github.com/lightninglabs/darepo-client/lib/tx/psbtutil" + "github.com/lightningnetwork/lnd/input" + "github.com/stretchr/testify/require" +) + +// pausedFinalizeHandler simulates a transport that drops the finalize response +// the first time finalize is sent, requiring an explicit resume/retry. +type pausedFinalizeHandler struct { + t *testing.T + + clientSigner input.Signer + + finalizePaused bool +} + +// Handle processes the outbox request and returns follow-up events. +func (h *pausedFinalizeHandler) 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: + err := SignCheckpointPSBTs( + h.clientSigner, msg.TransferInputs, + msg.CoSignedCheckpointPSBTs, + ) + require.NoError(h.t, err) + + return []Event{ + &CheckpointsSignedEvent{ + FinalCheckpointPSBTs: msg. + CoSignedCheckpointPSBTs, + }, + }, nil + + case *SendFinalizePackageRequest: + if !h.finalizePaused { + h.finalizePaused = true + return nil, nil + } + + return []Event{ + &FinalizeAcceptedEvent{}, + }, nil + + default: + return nil, nil + } +} + +var _ OutboxHandler = (*pausedFinalizeHandler)(nil) + +// pausedSubmitHandler simulates a transport that drops the submit response the +// first time submit is sent, requiring an explicit resume/retry. +type pausedSubmitHandler struct { + t *testing.T + + clientSigner input.Signer + + submitPaused bool +} + +// Handle processes the outbox request and returns follow-up events. +func (h *pausedSubmitHandler) 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) + + if !h.submitPaused { + h.submitPaused = true + return nil, nil + } + + return []Event{ + &SubmitAcceptedEvent{ + SessionID: sessionID, + ArkPSBT: msg.ArkPSBT, + CoSignedCheckpointPSBTs: msg.CheckpointPSBTs, + }, + }, nil + + case *RequestCheckpointSignatures: + err := SignCheckpointPSBTs( + h.clientSigner, msg.TransferInputs, + msg.CoSignedCheckpointPSBTs, + ) + require.NoError(h.t, err) + + return []Event{ + &CheckpointsSignedEvent{ + FinalCheckpointPSBTs: msg. + CoSignedCheckpointPSBTs, + }, + }, nil + + case *SendFinalizePackageRequest: + _ = msg + + return []Event{ + &FinalizeAcceptedEvent{}, + }, nil + + default: + return nil, nil + } +} + +var _ OutboxHandler = (*pausedSubmitHandler)(nil) + +// pausedCoSignedHandler simulates a wallet/UI environment where checkpoint +// signing is not completed the first time it is requested (for example, the +// app is backgrounded), requiring resume. +type pausedCoSignedHandler struct { + t *testing.T + + clientSigner input.Signer + + signPaused bool +} + +// Handle processes the outbox request and returns follow-up events. +func (h *pausedCoSignedHandler) 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: + if !h.signPaused { + h.signPaused = true + return nil, nil + } + + err := SignCheckpointPSBTs( + h.clientSigner, msg.TransferInputs, + msg.CoSignedCheckpointPSBTs, + ) + require.NoError(h.t, err) + + return []Event{ + &CheckpointsSignedEvent{ + FinalCheckpointPSBTs: msg. + CoSignedCheckpointPSBTs, + }, + }, nil + + case *SendFinalizePackageRequest: + _ = msg + + return []Event{ + &FinalizeAcceptedEvent{}, + }, nil + + default: + return nil, nil + } +} + +var _ OutboxHandler = (*pausedCoSignedHandler)(nil) + +// cosignedButDroppedHandler simulates the point-of-no-return edge case: +// the server accepted and co-signed the submit package, but the client did not +// receive the SubmitAccepted response. +// +// On retry, the client must re-send the exact same submit package, and the +// server must return the original co-signed artifacts (not new ones). +type cosignedButDroppedHandler struct { + t *testing.T + + clientSigner input.Signer + + firstSubmitDropped bool + + firstArkRaw []byte + firstCheckpointRaws [][]byte + + cosignedCheckpoints []*psbt.Packet +} + +// Handle processes the outbox request and returns follow-up events. +func (h *cosignedButDroppedHandler) 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) + + arkRaw, err := psbtutil.Serialize(msg.ArkPSBT) + require.NoError(h.t, err) + + cpRaws, err := serializePSBTSlice(msg.CheckpointPSBTs) + require.NoError(h.t, err) + + // First submit: simulate server co-signing and persisting, but + // drop the response back to the client. + if !h.firstSubmitDropped { + h.firstSubmitDropped = true + h.firstArkRaw = arkRaw + h.firstCheckpointRaws = cpRaws + + // In v0 tests we don't need a real operator signature. + // We only need stable "co-signed" artifacts for the + // client to resume with. + // + // We model this by deep-copying the checkpoint PSBTs. + // Then we hold them for the eventual retry response. + h.cosignedCheckpoints, err = parsePSBTSlice(cpRaws) + require.NoError(h.t, err) + + return nil, nil + } + + // Retry: client must resend the exact same package. + require.Equal(h.t, h.firstArkRaw, arkRaw, + "ark psbt differs across submit retries") + require.Equal(h.t, h.firstCheckpointRaws, cpRaws, + "checkpoint psbts differ across submit retries") + + return []Event{ + &SubmitAcceptedEvent{ + SessionID: sessionID, + ArkPSBT: msg.ArkPSBT, + CoSignedCheckpointPSBTs: h. + cosignedCheckpoints, + }, + }, nil + + case *RequestCheckpointSignatures: + err := SignCheckpointPSBTs( + h.clientSigner, msg.TransferInputs, + msg.CoSignedCheckpointPSBTs, + ) + require.NoError(h.t, err) + + return []Event{ + &CheckpointsSignedEvent{ + FinalCheckpointPSBTs: msg. + CoSignedCheckpointPSBTs, + }, + }, nil + + case *SendFinalizePackageRequest: + _ = msg + + return []Event{ + &FinalizeAcceptedEvent{}, + }, nil + + default: + return nil, nil + } +} + +var _ OutboxHandler = (*cosignedButDroppedHandler)(nil) + +// TestOORClientActorResumeFromSnapshot verifies the client actor can export a +// snapshot, restore it into a new actor, and resume the workflow to completion. +func TestOORClientActorResumeFromSnapshot(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) + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + clientSigner := input.NewMockSigner([]*btcec.PrivateKey{clientKey}, nil) + + inputs := []TransferInput{ + newTestTransferInput( + t, clientKey, policy.OperatorKey, + wire.OutPoint{ + Hash: [32]byte{0x01}, + Index: 0, + }, + inputValue, + ), + } + + recipients := []oortx.RecipientOutput{ + { + PkScript: newTestTaprootPkScript(t, clientKey.PubKey()), + Value: inputValue, + }, + } + + deliveryStore := newTestDeliveryStore(t) + + handler := &pausedFinalizeHandler{ + t: t, + clientSigner: clientSigner, + } + + actor1 := NewOORClientActor(ClientActorCfg{ + OutboxHandler: handler, + DeliveryStore: deliveryStore, + ActorID: "oor-resume-snapshot-actor-1", + }) + defer actor1.Stop() + + startResp := actor1.Receive(ctx, &StartTransferRequest{ + Policy: policy, + Inputs: inputs, + Recipients: recipients, + }) + require.True(t, startResp.IsOk()) + + startMsg, ok := startResp.UnwrapOr(nil).(*StartTransferResponse) + require.True(t, ok) + + stateResp := actor1.Receive(ctx, &GetStateRequest{ + SessionID: startMsg.SessionID, + }) + require.True(t, stateResp.IsOk()) + + stateMsg, ok := stateResp.UnwrapOr(nil).(*GetStateResponse) + require.True(t, ok) + + require.IsType(t, &AwaitingFinalizeAccepted{}, stateMsg.State) + + exportResp := actor1.Receive(ctx, &ExportSnapshotRequest{ + SessionID: startMsg.SessionID, + }) + require.True(t, exportResp.IsOk()) + + exportMsg, ok := exportResp.UnwrapOr(nil).(*ExportSnapshotResponse) + require.True(t, ok) + require.NotNil(t, exportMsg.Snapshot) + require.Equal(t, OutgoingPhaseFinalizeSent, exportMsg.Snapshot.Phase) + + // Restore into a new actor and resume. + actor2 := NewOORClientActor(ClientActorCfg{ + OutboxHandler: handler, + DeliveryStore: deliveryStore, + ActorID: "oor-resume-snapshot-actor-2", + }) + defer actor2.Stop() + + restoreResp := actor2.Receive(ctx, &RestoreSessionRequest{ + Snapshot: exportMsg.Snapshot, + }) + require.True(t, restoreResp.IsOk()) + + restoreMsg, ok := restoreResp.UnwrapOr(nil).(*RestoreSessionResponse) + require.True(t, ok) + require.Equal(t, startMsg.SessionID, restoreMsg.SessionID) + + resumeResp := actor2.Receive(ctx, &ResumeSessionRequest{ + SessionID: restoreMsg.SessionID, + }) + require.True(t, resumeResp.IsOk()) + + finalStateResp := actor2.Receive(ctx, &GetStateRequest{ + SessionID: restoreMsg.SessionID, + }) + require.True(t, finalStateResp.IsOk()) + + finalStateMsg, ok := finalStateResp.UnwrapOr(nil).(*GetStateResponse) + require.True(t, ok) + require.IsType(t, &AwaitingLocalVTXOUpdate{}, finalStateMsg.State) +} + +// TestOORClientActorResumeAfterServerCoSigned verifies the client can resume +// safely if the server reached point-of-no-return (co-signed) but the client +// missed the submit response. +func TestOORClientActorResumeAfterServerCoSigned(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) + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + clientSigner := input.NewMockSigner([]*btcec.PrivateKey{clientKey}, nil) + + inputs := []TransferInput{ + newTestTransferInput( + t, clientKey, policy.OperatorKey, + wire.OutPoint{ + Hash: [32]byte{0x01}, + Index: 0, + }, + inputValue, + ), + } + + recipients := []oortx.RecipientOutput{ + { + PkScript: newTestTaprootPkScript(t, clientKey.PubKey()), + Value: inputValue, + }, + } + + deliveryStore := newTestDeliveryStore(t) + handler := &cosignedButDroppedHandler{ + t: t, + clientSigner: clientSigner, + } + + actor1 := NewOORClientActor(ClientActorCfg{ + OutboxHandler: handler, + DeliveryStore: deliveryStore, + ActorID: "oor-resume-cosigned-actor-1", + }) + defer actor1.Stop() + + startResp := actor1.Receive(ctx, &StartTransferRequest{ + Policy: policy, + Inputs: inputs, + Recipients: recipients, + }) + require.True(t, startResp.IsOk()) + + startMsg, ok := startResp.UnwrapOr(nil).(*StartTransferResponse) + require.True(t, ok) + + // At this point, the handler simulated the server already co-signing + // but the client did not receive the response, so we should still be + // waiting for submit acceptance. + stateResp := actor1.Receive(ctx, &GetStateRequest{ + SessionID: startMsg.SessionID, + }) + require.True(t, stateResp.IsOk()) + + stateMsg, ok := stateResp.UnwrapOr(nil).(*GetStateResponse) + require.True(t, ok) + require.IsType(t, &AwaitingSubmitAccepted{}, stateMsg.State) + + exportResp := actor1.Receive(ctx, &ExportSnapshotRequest{ + SessionID: startMsg.SessionID, + }) + require.True(t, exportResp.IsOk()) + + exportMsg, ok := exportResp.UnwrapOr(nil).(*ExportSnapshotResponse) + require.True(t, ok) + require.NotNil(t, exportMsg.Snapshot) + require.Equal(t, OutgoingPhaseSubmitSent, exportMsg.Snapshot.Phase) + + // Restore into a new actor and resume (which should re-send submit and + // receive the already-co-signed artifacts). + actor2 := NewOORClientActor(ClientActorCfg{ + OutboxHandler: handler, + DeliveryStore: deliveryStore, + ActorID: "oor-resume-cosigned-actor-2", + }) + defer actor2.Stop() + + restoreResp := actor2.Receive(ctx, &RestoreSessionRequest{ + Snapshot: exportMsg.Snapshot, + }) + require.True(t, restoreResp.IsOk()) + + restoreMsg, ok := restoreResp.UnwrapOr(nil).(*RestoreSessionResponse) + require.True(t, ok) + + resumeResp := actor2.Receive(ctx, &ResumeSessionRequest{ + SessionID: restoreMsg.SessionID, + }) + require.True(t, resumeResp.IsOk()) + + finalStateResp := actor2.Receive(ctx, &GetStateRequest{ + SessionID: restoreMsg.SessionID, + }) + require.True(t, finalStateResp.IsOk()) + + finalStateMsg, ok := finalStateResp.UnwrapOr(nil).(*GetStateResponse) + require.True(t, ok) + require.IsType(t, &AwaitingLocalVTXOUpdate{}, finalStateMsg.State) +} + +// TestOORClientActorResumeFromSnapshotSubmitSent verifies the client can resume +// after submit was sent but the response was dropped. +func TestOORClientActorResumeFromSnapshotSubmitSent(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) + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + clientSigner := input.NewMockSigner([]*btcec.PrivateKey{clientKey}, nil) + + inputs := []TransferInput{ + newTestTransferInput( + t, clientKey, policy.OperatorKey, + wire.OutPoint{ + Hash: [32]byte{0x01}, + Index: 0, + }, + inputValue, + ), + } + + recipients := []oortx.RecipientOutput{ + { + PkScript: newTestTaprootPkScript(t, clientKey.PubKey()), + Value: inputValue, + }, + } + + deliveryStore := newTestDeliveryStore(t) + handler := &pausedSubmitHandler{ + t: t, + clientSigner: clientSigner, + } + + actor1 := NewOORClientActor(ClientActorCfg{ + OutboxHandler: handler, + DeliveryStore: deliveryStore, + ActorID: "oor-resume-submit-actor-1", + }) + defer actor1.Stop() + + startResp := actor1.Receive(ctx, &StartTransferRequest{ + Policy: policy, + Inputs: inputs, + Recipients: recipients, + }) + require.True(t, startResp.IsOk()) + + startMsg, ok := startResp.UnwrapOr(nil).(*StartTransferResponse) + require.True(t, ok) + + stateResp := actor1.Receive(ctx, &GetStateRequest{ + SessionID: startMsg.SessionID, + }) + require.True(t, stateResp.IsOk()) + + stateMsg, ok := stateResp.UnwrapOr(nil).(*GetStateResponse) + require.True(t, ok) + require.IsType(t, &AwaitingSubmitAccepted{}, stateMsg.State) + + exportResp := actor1.Receive(ctx, &ExportSnapshotRequest{ + SessionID: startMsg.SessionID, + }) + require.True(t, exportResp.IsOk()) + + exportMsg, ok := exportResp.UnwrapOr(nil).(*ExportSnapshotResponse) + require.True(t, ok) + require.NotNil(t, exportMsg.Snapshot) + require.Equal(t, OutgoingPhaseSubmitSent, exportMsg.Snapshot.Phase) + + actor2 := NewOORClientActor(ClientActorCfg{ + OutboxHandler: handler, + DeliveryStore: deliveryStore, + ActorID: "oor-resume-submit-actor-2", + }) + defer actor2.Stop() + + restoreResp := actor2.Receive(ctx, &RestoreSessionRequest{ + Snapshot: exportMsg.Snapshot, + }) + require.True(t, restoreResp.IsOk()) + + restoreMsg, ok := restoreResp.UnwrapOr(nil).(*RestoreSessionResponse) + require.True(t, ok) + + resumeResp := actor2.Receive(ctx, &ResumeSessionRequest{ + SessionID: restoreMsg.SessionID, + }) + require.True(t, resumeResp.IsOk()) + + finalStateResp := actor2.Receive(ctx, &GetStateRequest{ + SessionID: restoreMsg.SessionID, + }) + require.True(t, finalStateResp.IsOk()) + + finalStateMsg, ok := finalStateResp.UnwrapOr(nil).(*GetStateResponse) + require.True(t, ok) + require.IsType(t, &AwaitingLocalVTXOUpdate{}, finalStateMsg.State) +} + +// TestOORClientActorResumeFromSnapshotCoSigned verifies the client can resume +// after the server accepted/co-signed but the client did not complete signing +// checkpoints yet. +func TestOORClientActorResumeFromSnapshotCoSigned(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) + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + clientSigner := input.NewMockSigner([]*btcec.PrivateKey{clientKey}, nil) + + inputs := []TransferInput{ + newTestTransferInput( + t, clientKey, policy.OperatorKey, + wire.OutPoint{ + Hash: [32]byte{0x01}, + Index: 0, + }, + inputValue, + ), + } + + recipients := []oortx.RecipientOutput{ + { + PkScript: newTestTaprootPkScript(t, clientKey.PubKey()), + Value: inputValue, + }, + } + + deliveryStore := newTestDeliveryStore(t) + handler := &pausedCoSignedHandler{ + t: t, + clientSigner: clientSigner, + } + + actor1 := NewOORClientActor(ClientActorCfg{ + OutboxHandler: handler, + DeliveryStore: deliveryStore, + ActorID: "oor-resume-cosigned-phase-actor-1", + }) + defer actor1.Stop() + + startResp := actor1.Receive(ctx, &StartTransferRequest{ + Policy: policy, + Inputs: inputs, + Recipients: recipients, + }) + require.True(t, startResp.IsOk()) + + startMsg, ok := startResp.UnwrapOr(nil).(*StartTransferResponse) + require.True(t, ok) + + stateResp := actor1.Receive(ctx, &GetStateRequest{ + SessionID: startMsg.SessionID, + }) + require.True(t, stateResp.IsOk()) + + stateMsg, ok := stateResp.UnwrapOr(nil).(*GetStateResponse) + require.True(t, ok) + require.IsType(t, &AwaitingCheckpointSignatures{}, stateMsg.State) + + exportResp := actor1.Receive(ctx, &ExportSnapshotRequest{ + SessionID: startMsg.SessionID, + }) + require.True(t, exportResp.IsOk()) + + exportMsg, ok := exportResp.UnwrapOr(nil).(*ExportSnapshotResponse) + require.True(t, ok) + require.NotNil(t, exportMsg.Snapshot) + require.Equal(t, OutgoingPhaseCoSigned, exportMsg.Snapshot.Phase) + + actor2 := NewOORClientActor(ClientActorCfg{ + OutboxHandler: handler, + DeliveryStore: deliveryStore, + ActorID: "oor-resume-cosigned-phase-actor-2", + }) + defer actor2.Stop() + + restoreResp := actor2.Receive(ctx, &RestoreSessionRequest{ + Snapshot: exportMsg.Snapshot, + }) + require.True(t, restoreResp.IsOk()) + + restoreMsg, ok := restoreResp.UnwrapOr(nil).(*RestoreSessionResponse) + require.True(t, ok) + + resumeResp := actor2.Receive(ctx, &ResumeSessionRequest{ + SessionID: restoreMsg.SessionID, + }) + require.True(t, resumeResp.IsOk()) + + finalStateResp := actor2.Receive(ctx, &GetStateRequest{ + SessionID: restoreMsg.SessionID, + }) + require.True(t, finalStateResp.IsOk()) + + finalStateMsg, ok := finalStateResp.UnwrapOr(nil).(*GetStateResponse) + require.True(t, ok) + require.IsType(t, &AwaitingLocalVTXOUpdate{}, finalStateMsg.State) +} + +// TestOORClientActorDurableRestartAutoResume verifies the durable actor can +// restore checkpointed sessions and auto-resume pending outbox work after a +// process restart, without using ExportSnapshot/RestoreSession requests. +func TestOORClientActorDurableRestartAutoResume(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) + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + clientSigner := input.NewMockSigner([]*btcec.PrivateKey{clientKey}, nil) + + inputs := []TransferInput{ + newTestTransferInput( + t, clientKey, policy.OperatorKey, + wire.OutPoint{ + Hash: [32]byte{0x01}, + Index: 0, + }, + inputValue, + ), + } + + recipients := []oortx.RecipientOutput{ + { + PkScript: newTestTaprootPkScript(t, clientKey.PubKey()), + Value: inputValue, + }, + } + + deliveryStore := newTestDeliveryStore(t) + handler := &pausedFinalizeHandler{ + t: t, + clientSigner: clientSigner, + } + + const actorID = "oor-durable-restart-actor" + + actor1 := NewOORClientActor(ClientActorCfg{ + OutboxHandler: handler, + DeliveryStore: deliveryStore, + ActorID: actorID, + }) + + startResp := actor1.Receive(ctx, &StartTransferRequest{ + Policy: policy, + Inputs: inputs, + Recipients: recipients, + }) + require.True(t, startResp.IsOk()) + + startMsg, ok := startResp.UnwrapOr(nil).(*StartTransferResponse) + require.True(t, ok) + + stateResp := actor1.Receive(ctx, &GetStateRequest{ + SessionID: startMsg.SessionID, + }) + require.True(t, stateResp.IsOk()) + + stateMsg, ok := stateResp.UnwrapOr(nil).(*GetStateResponse) + require.True(t, ok) + require.IsType(t, &AwaitingFinalizeAccepted{}, stateMsg.State) + + actor1.Stop() + + actor2 := NewOORClientActor(ClientActorCfg{ + OutboxHandler: handler, + DeliveryStore: deliveryStore, + ActorID: actorID, + }) + defer actor2.Stop() + + require.Eventually(t, func() bool { + resp := actor2.Receive(ctx, &GetStateRequest{ + SessionID: startMsg.SessionID, + }) + if resp.IsErr() { + return false + } + + got, ok := resp.UnwrapOr(nil).(*GetStateResponse) + if !ok { + return false + } + + _, ok = got.State.(*AwaitingLocalVTXOUpdate) + + return ok + }, 5*time.Second, 50*time.Millisecond) +} diff --git a/oor/actor_test.go b/oor/actor_test.go index 34a4d3b58..e73601ec1 100644 --- a/oor/actor_test.go +++ b/oor/actor_test.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/darepo-client/lib/scripts" oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/lightningnetwork/lnd/input" "github.com/stretchr/testify/require" ) @@ -17,6 +18,8 @@ import ( // drive the FSM forward. type testOutboxHandler struct { t *testing.T + + clientSigner input.Signer } // Handle processes the outbox request and returns follow-up events. @@ -30,23 +33,34 @@ func (h *testOutboxHandler) Handle(_ context.Context, sessionID SessionID, txid := msg.ArkPSBT.UnsignedTx.TxHash() require.Equal(h.t, SessionID(txid), sessionID) - return []Event{&SubmitAcceptedEvent{ - SessionID: sessionID, - ArkPSBT: msg.ArkPSBT, - CoSignedCheckpointPSBTs: msg.CheckpointPSBTs, - }}, nil + return []Event{ + &SubmitAcceptedEvent{ + SessionID: sessionID, + ArkPSBT: msg.ArkPSBT, + CoSignedCheckpointPSBTs: msg.CheckpointPSBTs, + }, + }, nil case *RequestCheckpointSignatures: + err := SignCheckpointPSBTs( + h.clientSigner, msg.TransferInputs, + msg.CoSignedCheckpointPSBTs, + ) + require.NoError(h.t, err) + finalCheckpoints := msg.CoSignedCheckpointPSBTs - finalCheckpoints[0].Inputs[0].TaprootKeySpendSig = []byte{0x01} - return []Event{&CheckpointsSignedEvent{ - FinalCheckpointPSBTs: finalCheckpoints, - }}, nil + return []Event{ + &CheckpointsSignedEvent{ + FinalCheckpointPSBTs: finalCheckpoints, + }, + }, nil case *SendFinalizePackageRequest: _ = msg - return []Event{&FinalizeAcceptedEvent{}}, nil + return []Event{ + &FinalizeAcceptedEvent{}, + }, nil default: return nil, nil @@ -72,26 +86,38 @@ func TestOORClientActorHappyPath(t *testing.T) { 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}, - }} + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) - recipients := []oortx.RecipientOutput{{ - PkScript: []byte{0x51}, - Value: inputValue, - }} + clientSigner := input.NewMockSigner([]*btcec.PrivateKey{clientKey}, nil) + + inputs := []TransferInput{ + newTestTransferInput( + t, clientKey, policy.OperatorKey, + wire.OutPoint{ + Hash: [32]byte{0x01}, + Index: 0, + }, + inputValue, + ), + } + + recipients := []oortx.RecipientOutput{ + { + PkScript: newTestTaprootPkScript(t, clientKey.PubKey()), + Value: inputValue, + }, + } actor := NewOORClientActor(ClientActorCfg{ - OutboxHandler: &testOutboxHandler{t: t}, + OutboxHandler: &testOutboxHandler{ + t: t, + clientSigner: clientSigner, + }, + DeliveryStore: newTestDeliveryStore(t), + ActorID: "oor-actor-test-happy", }) + defer actor.Stop() startResp := actor.Receive(ctx, &StartTransferRequest{ Policy: policy, diff --git a/oor/ark_recipients.go b/oor/ark_recipients.go index 5ca6369aa..649974635 100644 --- a/oor/ark_recipients.go +++ b/oor/ark_recipients.go @@ -5,7 +5,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" - oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/lightninglabs/darepo-client/lib/tx/arktx" ) // ArkRecipientOutput is a non-anchor Ark tx output intended for the receiver. @@ -34,7 +34,7 @@ func ExtractArkRecipients(ark *psbt.Packet) ([]ArkRecipientOutput, error) { return nil, fmt.Errorf("ark psbt must be provided") } - err := oortx.ValidateCanonicalArkPSBT(ark) + err := arktx.ValidateCanonicalPSBT(ark) if err != nil { return nil, err } @@ -43,7 +43,7 @@ func ExtractArkRecipients(ark *psbt.Packet) ([]ArkRecipientOutput, error) { recipients := make([]ArkRecipientOutput, 0, len(tx.TxOut)) for idx, out := range tx.TxOut { - if oortx.IsAnchorOutput(out) { + if arktx.IsAnchorOutput(out) { continue } diff --git a/oor/checkpoint_sign.go b/oor/checkpoint_sign.go new file mode 100644 index 000000000..a8df722aa --- /dev/null +++ b/oor/checkpoint_sign.go @@ -0,0 +1,128 @@ +package oor + +import ( + "fmt" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/tx" + "github.com/lightninglabs/darepo-client/lib/tx/psbtutil" + "github.com/lightningnetwork/lnd/input" +) + +// SignCheckpointPSBTs attaches the client-side collaborative VTXO spend +// signatures to each checkpoint PSBT. +// +// Each checkpoint PSBT is expected to spend exactly one VTXO (input index 0). +// The TransferInput slice is expected to match the checkpoint PSBT slice +// 1:1. +func SignCheckpointPSBTs(signer input.Signer, inputs []TransferInput, + checkpoints []*psbt.Packet) error { + + switch { + case signer == nil: + return fmt.Errorf("signer must be provided") + + case len(inputs) == 0: + return fmt.Errorf("transfer inputs must be provided") + + case len(checkpoints) == 0: + return fmt.Errorf("checkpoint psbts must be provided") + + case len(inputs) != len(checkpoints): + return fmt.Errorf("input count %d does not match checkpoint "+ + "count %d", len(inputs), len(checkpoints)) + } + + for i := range inputs { + err := signCheckpointPSBT(signer, &inputs[i], checkpoints[i]) + if err != nil { + return fmt.Errorf("sign checkpoint %d: %w", i, err) + } + } + + return nil +} + +// signCheckpointPSBT signs checkpoint input 0 with the client key for the +// collaborative VTXO leaf path. +func signCheckpointPSBT(signer input.Signer, in *TransferInput, + checkpoint *psbt.Packet) error { + + switch { + case signer == nil: + return fmt.Errorf("signer must be provided") + + case in == nil: + return fmt.Errorf("transfer input must be provided") + + case checkpoint == nil || checkpoint.UnsignedTx == nil: + return fmt.Errorf("checkpoint psbt must include unsigned tx") + + case len(checkpoint.Inputs) == 0: + return fmt.Errorf("checkpoint psbt must have inputs") + } + + err := in.Validate() + if err != nil { + return err + } + + if len(checkpoint.UnsignedTx.TxIn) != 1 || + len(checkpoint.Inputs) != 1 { + + return fmt.Errorf("checkpoint psbt must have exactly one "+ + "input, got tx=%d psbt=%d", + len(checkpoint.UnsignedTx.TxIn), + len(checkpoint.Inputs)) + } + + prevOut := &wire.TxOut{ + Value: int64(in.VTXO.Amount), + PkScript: in.VTXO.PkScript, + } + + prevFetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + + sigHashes := txscript.NewTxSigHashes( + checkpoint.UnsignedTx, prevFetcher, + ) + + signDesc, spendInfo, err := tx.NewVTXOCollabSignDescriptor( + &tx.VTXOSpendContext{ + Outpoint: in.VTXO.Outpoint, + Output: prevOut, + TapScript: in.VTXO.TapScript, + }, + in.VTXO.ClientKey, + 0, + sigHashes, + prevFetcher, + ) + if err != nil { + return err + } + + sig, err := signer.SignOutputRaw(checkpoint.UnsignedTx, signDesc) + if err != nil { + return fmt.Errorf("sign output: %w", err) + } + + sigBytes := sig.Serialize() + if len(sigBytes) == 0 { + return fmt.Errorf("signer returned empty signature") + } + + err = psbtutil.AddTapLeafScript(&checkpoint.Inputs[0], spendInfo) + if err != nil { + return err + } + + return psbtutil.AddTaprootScriptSpendSig( + &checkpoint.Inputs[0], in.VTXO.ClientKey.PubKey, + spendInfo.WitnessScript, sigBytes, signDesc.HashType, + ) +} diff --git a/oor/doc.go b/oor/doc.go new file mode 100644 index 000000000..97f0f672e --- /dev/null +++ b/oor/doc.go @@ -0,0 +1,18 @@ +package oor + +// Package oor implements client-side coordination for out-of-round (OOR) Ark +// transfers. +// +// The main goal is to let a client transfer VTXOs to one or more recipients +// without waiting for a normal round, while still preserving: +// - deterministic transaction construction for safe retries; and +// - crash-safe "resume" semantics for mobile clients. +// +// This package is built around a protofsm-based state machine. All I/O and +// external side effects are modeled as explicit outbox requests that the caller +// executes (via RPC, in-process adaptors, or other mechanisms) and then feeds +// back as events. +// +// For outgoing transfers, the critical point-of-no-return is when the server +// has co-signed the checkpoint transaction(s). After that, the client must be +// able to resume and obtain byte-identical co-signed PSBTs to safely finalize. diff --git a/oor/events.go b/oor/events.go index 7cf1bcda9..94b01fc2f 100644 --- a/oor/events.go +++ b/oor/events.go @@ -23,8 +23,8 @@ type Event interface { // 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 + // VTXOInputs is the set of client VTXOs to spend for this transfer. + VTXOInputs []TransferInput // RecipientOutputs are the Ark tx outputs to produce. RecipientOutputs []oortx.RecipientOutput @@ -36,8 +36,17 @@ type StartTransferEvent struct { // eventSealed marks this as implementing the sealed Event interface. func (e *StartTransferEvent) eventSealed() {} +// ArkSignedEvent is emitted after the client signs the Ark PSBT. +type ArkSignedEvent struct { + // ArkPSBT is the signed Ark PSBT. + ArkPSBT *psbt.Packet +} + +// eventSealed marks this as implementing the sealed Event interface. +func (e *ArkSignedEvent) eventSealed() {} + // SubmitAcceptedEvent is emitted when the server accepts the submit package and -// co-signs checkpoint PSBTs (point-of-no-return for the outgoing flow). +// co-signs checkpoint PSBTs (the 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. @@ -46,7 +55,9 @@ type SubmitAcceptedEvent struct { SessionID SessionID // ArkPSBT is the canonical session artifact for consistency checks and - // stateless finalize retries. + // stateless finalize retries. The operator does not add Ark signature + // material in submit-accepted. + // Operator co-signing applies to checkpoints. ArkPSBT *psbt.Packet // CoSignedCheckpointPSBTs are checkpoint PSBTs co-signed by the @@ -113,6 +124,12 @@ type IncomingTransferEvent struct { // ArkPSBT is the canonical Ark tx PSBT for this transfer. ArkPSBT *psbt.Packet + + // FinalCheckpointPSBTs are the finalized checkpoint packages associated + // with the Ark PSBT. + // These can be used by the materialization boundary to derive parent + // lineage and future unroll proofs. + FinalCheckpointPSBTs []*psbt.Packet } // eventSealed marks this as implementing the sealed Event interface. diff --git a/oor/incoming_vtxo.go b/oor/incoming_vtxo.go new file mode 100644 index 000000000..ca37cfe73 --- /dev/null +++ b/oor/incoming_vtxo.go @@ -0,0 +1,117 @@ +package oor + +import ( + "bytes" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/lightninglabs/darepo-client/lib/tx/arktx" + "github.com/lightninglabs/darepo-client/vtxo" + "github.com/lightningnetwork/lnd/keychain" +) + +// IncomingVTXOConfig describes how to materialize an Ark tx output into a +// spendable local VTXO descriptor. +// +// This is intentionally explicit: mapping Ark outputs to local keys requires +// wallet/application context that the FSM does not have. +type IncomingVTXOConfig struct { + // OutputIndex is the Ark tx output index being materialized. + OutputIndex uint32 + + // ClientKey is the recipient key descriptor that controls this VTXO. + ClientKey keychain.KeyDescriptor + + // OperatorKey is the operator public key used by the collaborative + // spend path. + OperatorKey *btcec.PublicKey + + // ExitDelay is the unilateral CSV delay used by the timeout spend + // path. + ExitDelay uint32 +} + +// BuildIncomingVTXODescriptor constructs a VTXO descriptor for a recipient +// output of a canonical Ark PSBT. +// +// The returned VTXO descriptor uses an outpoint of (arkTxid, outputIndex) and +// derives the tapscript from (client key, operator key, exit delay). This +// allows later signing of collaborative and timeout spends. +func BuildIncomingVTXODescriptor(ark *psbt.Packet, + cfg IncomingVTXOConfig) (*vtxo.Descriptor, error) { + + switch { + case ark == nil || ark.UnsignedTx == nil: + return nil, fmt.Errorf("ark psbt must be provided") + + case cfg.ClientKey.PubKey == nil: + return nil, fmt.Errorf("client key must be provided") + + case cfg.OperatorKey == nil: + return nil, fmt.Errorf("operator key must be provided") + } + + err := arktx.ValidateCanonicalPSBT(ark) + if err != nil { + return nil, err + } + + tx := ark.UnsignedTx + if int(cfg.OutputIndex) >= len(tx.TxOut) { + return nil, fmt.Errorf("output index %d out of bounds", + cfg.OutputIndex) + } + + out := tx.TxOut[cfg.OutputIndex] + if arktx.IsAnchorOutput(out) { + return nil, fmt.Errorf("output index %d is anchor output", + cfg.OutputIndex) + } + + tapscript, err := scripts.VTXOTapScript( + cfg.ClientKey.PubKey, cfg.OperatorKey, cfg.ExitDelay, + ) + if err != nil { + return nil, fmt.Errorf("derive vtxo tapscript: %w", err) + } + + tapKey, err := scripts.VTXOTapKey( + cfg.ClientKey.PubKey, cfg.OperatorKey, cfg.ExitDelay, + ) + if err != nil { + return nil, fmt.Errorf("derive vtxo tapkey: %w", err) + } + + expectedPkScript, err := txscript.PayToTaprootScript(tapKey) + if err != nil { + return nil, fmt.Errorf("derive vtxo pkscript: %w", err) + } + + if !bytes.Equal(expectedPkScript, out.PkScript) { + return nil, fmt.Errorf("ark output pkscript does not match " + + "derived vtxo pkscript") + } + + arkTxid := tx.TxHash() + + return &vtxo.Descriptor{ + Outpoint: wire.OutPoint{ + Hash: arkTxid, + Index: cfg.OutputIndex, + }, + Amount: btcutil.Amount(out.Value), + PkScript: out.PkScript, + ClientKey: cfg.ClientKey, + OperatorKey: cfg.OperatorKey, + TapScript: tapscript, + RoundID: fmt.Sprintf("oor:%s", arkTxid), + CommitmentTxID: arkTxid, + RelativeExpiry: cfg.ExitDelay, + Status: vtxo.VTXOStatusLive, + }, nil +} diff --git a/oor/outbox_messages.go b/oor/outbox_messages.go index 96442609b..6b2bf4151 100644 --- a/oor/outbox_messages.go +++ b/oor/outbox_messages.go @@ -1,10 +1,26 @@ package oor import ( + "bytes" + "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/darepo-client/baselib/actor" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/lightninglabs/darepo-client/lib/tx/psbtutil" + "github.com/lightningnetwork/lnd/tlv" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" +) + +const ( + oorOutboxProtoTypeURLPrefix = "type.lightninglabs.dev/darepo-client/" + + "oor/" +) + +const ( + finalizePayloadArkPSBTRecordType tlv.Type = 1 + finalizePayloadCheckpointsRecordType tlv.Type = 3 ) // OutboxEvent is a sealed interface for side-effect requests emitted by the @@ -36,6 +52,11 @@ type SendSubmitPackageRequest struct { // In v0, client signing happens only after the server returns operator // co-signed checkpoints. CheckpointPSBTs []*psbt.Packet + + // TransferInputs carry the VTXO descriptors and scripts for the inputs + // referenced by the checkpoint PSBTs. This is used by in-process test + // adaptors, and will later be mapped to RPC request fields. + TransferInputs []TransferInput } // outboxType returns a stable identifier for this outbox message. @@ -47,10 +68,16 @@ func (m *SendSubmitPackageRequest) outboxType() string { 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 + payload, err := oortx.MarshalSubmitPackage(&oortx.SubmitPackage{ + ArkPSBT: m.ArkPSBT, + CheckpointPSBTs: m.CheckpointPSBTs, + }) + if err != nil { + return protoErrorEnvelope("SendSubmitPackageRequest", err) + } + + return protoEnvelope("SendSubmitPackageRequest", payload) } // RequestCheckpointSignatures asks the signing layer to add client signature @@ -67,6 +94,10 @@ type RequestCheckpointSignatures struct { // PSBT input witness/signature fields and return finalized // checkpoint PSBTs. CoSignedCheckpointPSBTs []*psbt.Packet + + // TransferInputs carry the client-side VTXO signing context. These are + // required to construct taproot script-spend signing descriptors. + TransferInputs []TransferInput } // outboxType returns a stable identifier for this outbox message. @@ -98,10 +129,15 @@ func (m *SendFinalizePackageRequest) outboxType() string { 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 + payload, err := encodeFinalizePayload( + m.ArkPSBT, m.FinalCheckpointPSBTs, + ) + if err != nil { + return protoErrorEnvelope("SendFinalizePackageRequest", err) + } + + return protoEnvelope("SendFinalizePackageRequest", payload) } // MarkInputsSpentRequest asks the persistence layer to mark the OOR inputs as @@ -127,10 +163,13 @@ func (m *MarkInputsSpentRequest) outboxType() string { 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 + payload, err := encodeOutpoints(m.Outpoints) + if err != nil { + return protoErrorEnvelope("MarkInputsSpentRequest", err) + } + + return protoEnvelope("MarkInputsSpentRequest", payload) } // IncomingTransferNotification is emitted when an incoming transfer has been @@ -210,8 +249,67 @@ func (m *SendIncomingAckRequest) outboxType() string { 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 + payload, err := encodeSessionPayload(m.SessionID) + if err != nil { + return protoErrorEnvelope("SendIncomingAckRequest", err) + } + + return protoEnvelope("SendIncomingAckRequest", payload) +} + +func protoEnvelope(typeName string, payload []byte) proto.Message { + return &anypb.Any{ + TypeUrl: oorOutboxProtoTypeURLPrefix + typeName, + Value: payload, + } +} + +func protoErrorEnvelope(typeName string, err error) proto.Message { + return &anypb.Any{ + TypeUrl: oorOutboxProtoTypeURLPrefix + typeName + ".error", + Value: []byte(err.Error()), + } +} + +func encodeFinalizePayload(ark *psbt.Packet, + checkpoints []*psbt.Packet) ([]byte, error) { + + arkRaw, err := psbtutil.Serialize(ark) + if err != nil { + return nil, err + } + + checkpointRaws, err := serializePSBTSlice(checkpoints) + if err != nil { + return nil, err + } + + checkpointPayload, err := encodeLengthPrefixedBlobList(checkpointRaws) + if err != nil { + return nil, err + } + + checkpointPayloadRecord := tlv.MakePrimitiveRecord( + finalizePayloadCheckpointsRecordType, &checkpointPayload, + ) + + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + finalizePayloadArkPSBTRecordType, &arkRaw, + ), + checkpointPayloadRecord, + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil } diff --git a/oor/outbox_messages_test.go b/oor/outbox_messages_test.go new file mode 100644 index 000000000..0b1953516 --- /dev/null +++ b/oor/outbox_messages_test.go @@ -0,0 +1,159 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/anypb" +) + +// TestOutboxToProtoSubmitRequest verifies that a valid submit package +// outbox request serializes into the expected proto Any envelope. +func TestOutboxToProtoSubmitRequest(t *testing.T) { + t.Parallel() + + ark, checkpoints := testOutboxPSBTPair(t) + + msg := &SendSubmitPackageRequest{ + ArkPSBT: ark, + CheckpointPSBTs: checkpoints, + } + + protoMsg := msg.ToProto() + require.IsType(t, &anypb.Any{}, protoMsg) + + anyMsg, ok := protoMsg.(*anypb.Any) + require.True(t, ok) + require.Equal( + t, + oorOutboxProtoTypeURLPrefix+"SendSubmitPackageRequest", + anyMsg.TypeUrl, + ) + require.NotEmpty(t, anyMsg.Value) +} + +// TestOutboxToProtoSubmitRequestErrorEnvelope verifies that a submit +// request with missing PSBTs produces an error-typed proto envelope. +func TestOutboxToProtoSubmitRequestErrorEnvelope(t *testing.T) { + t.Parallel() + + msg := &SendSubmitPackageRequest{} + + protoMsg := msg.ToProto() + require.IsType(t, &anypb.Any{}, protoMsg) + + anyMsg, ok := protoMsg.(*anypb.Any) + require.True(t, ok) + require.Equal( + t, + oorOutboxProtoTypeURLPrefix+"SendSubmitPackageRequest.error", + anyMsg.TypeUrl, + ) + require.NotEmpty(t, anyMsg.Value) +} + +// TestOutboxToProtoFinalizeRequest verifies that a valid finalize +// package outbox request serializes into the expected proto Any +// envelope. +func TestOutboxToProtoFinalizeRequest(t *testing.T) { + t.Parallel() + + ark, checkpoints := testOutboxPSBTPair(t) + + msg := &SendFinalizePackageRequest{ + ArkPSBT: ark, + FinalCheckpointPSBTs: checkpoints, + } + + protoMsg := msg.ToProto() + require.IsType(t, &anypb.Any{}, protoMsg) + + anyMsg, ok := protoMsg.(*anypb.Any) + require.True(t, ok) + require.Equal( + t, + oorOutboxProtoTypeURLPrefix+"SendFinalizePackageRequest", + anyMsg.TypeUrl, + ) + require.NotEmpty(t, anyMsg.Value) +} + +// TestOutboxToProtoMarkInputsSpentRequest verifies that a mark-inputs- +// spent outbox request serializes into the expected proto Any envelope. +func TestOutboxToProtoMarkInputsSpentRequest(t *testing.T) { + t.Parallel() + + msg := &MarkInputsSpentRequest{ + Outpoints: []wire.OutPoint{{ + Hash: chainhash.Hash{1, 2, 3}, + Index: 4, + }}, + } + + protoMsg := msg.ToProto() + require.IsType(t, &anypb.Any{}, protoMsg) + + anyMsg, ok := protoMsg.(*anypb.Any) + require.True(t, ok) + require.Equal( + t, + oorOutboxProtoTypeURLPrefix+"MarkInputsSpentRequest", + anyMsg.TypeUrl, + ) + require.NotEmpty(t, anyMsg.Value) +} + +// TestOutboxToProtoSendIncomingAckRequest verifies that an incoming-ack +// outbox request serializes into the expected proto Any envelope. +func TestOutboxToProtoSendIncomingAckRequest(t *testing.T) { + t.Parallel() + + msg := &SendIncomingAckRequest{ + SessionID: SessionID(chainhash.Hash{9, 8, 7}), + } + + protoMsg := msg.ToProto() + require.IsType(t, &anypb.Any{}, protoMsg) + + anyMsg, ok := protoMsg.(*anypb.Any) + require.True(t, ok) + require.Equal( + t, + oorOutboxProtoTypeURLPrefix+"SendIncomingAckRequest", + anyMsg.TypeUrl, + ) + require.NotEmpty(t, anyMsg.Value) +} + +// testOutboxPSBTPair builds a minimal Ark + checkpoint PSBT pair for +// outbox envelope tests. +func testOutboxPSBTPair(t *testing.T) (*psbt.Packet, []*psbt.Packet) { + t.Helper() + + checkpointTx := wire.NewMsgTx(3) + checkpointTx.AddTxIn(&wire.TxIn{}) + checkpointTx.AddTxOut(&wire.TxOut{ + Value: 5, + PkScript: []byte{0x51}, + }) + checkpointPSBT, err := psbt.NewFromUnsignedTx(checkpointTx) + require.NoError(t, err) + + arkTx := wire.NewMsgTx(3) + arkTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: checkpointTx.TxHash(), + Index: 0, + }, + }) + arkTx.AddTxOut(&wire.TxOut{Value: 5, PkScript: []byte{0x51}}) + arkTx.AddTxOut(scripts.AnchorOutput()) + arkPSBT, err := psbt.NewFromUnsignedTx(arkTx) + require.NoError(t, err) + + return arkPSBT, []*psbt.Packet{checkpointPSBT} +} diff --git a/oor/outgoing_snapshot.go b/oor/outgoing_snapshot.go new file mode 100644 index 000000000..100719446 --- /dev/null +++ b/oor/outgoing_snapshot.go @@ -0,0 +1,463 @@ +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/tx/psbtutil" +) + +// OutgoingPhase identifies the coarse stage of an outgoing client transfer. +// +// This is intentionally more stable than Go state type names to keep snapshot +// formats evolvable. +type OutgoingPhase string + +const ( + // OutgoingPhaseSubmitSent indicates the client has built the submit + // package and is waiting for the server to accept/co-sign it. + OutgoingPhaseSubmitSent OutgoingPhase = "submit_sent" + + // OutgoingPhaseCoSigned indicates the server accepted/co-signed and the + // client must attach its checkpoint signatures. + OutgoingPhaseCoSigned OutgoingPhase = "cosigned" + + // OutgoingPhaseFinalizeSent indicates the client has sent finalized + // checkpoints and is awaiting server acknowledgement. + OutgoingPhaseFinalizeSent OutgoingPhase = "finalize_sent" + + // OutgoingPhaseLocalVTXOUpdate indicates finalize was accepted, but the + // client still needs to update local VTXO state. + OutgoingPhaseLocalVTXOUpdate OutgoingPhase = "local_vtxo_update" + + // OutgoingPhaseCompleted indicates the transfer is fully complete. + OutgoingPhaseCompleted OutgoingPhase = "completed" + + // OutgoingPhaseFailed indicates the transfer entered a terminal failure + // state. + OutgoingPhaseFailed OutgoingPhase = "failed" +) + +// OutgoingSnapshot is a client-side snapshot of an outgoing OOR transfer. +// +// This structure is intended to support mobile/restart semantics. The fields +// are intentionally "artifact heavy" (PSBT bytes) so an app can resume without +// needing to reconstruct taproot metadata or re-derive ordering rules. +// +// NOTE: TransferInputs contains rich Go types (tapscript/key descriptors) and +// is not currently encoded for cross-process durability. The durable actor work +// in darepo-client will eventually provide a canonical encoding for these. +// +// TransferInputSnapshots is the portable encoding used for persistence. +type OutgoingSnapshot struct { + // Version is the snapshot version. + Version uint8 + + // SessionID is the stable session identifier (Ark txid). + SessionID SessionID + + // Phase is the coarse outgoing transfer phase. + Phase OutgoingPhase + + // ArkPSBT is the canonical Ark PSBT (serialized). + ArkPSBT []byte + + // CheckpointPSBTs are checkpoint PSBTs relevant for the current + // phase (serialized). + CheckpointPSBTs [][]byte + + // TransferInputs are kept for in-process state handling. + // Durable snapshot encoding uses TransferInputSnapshots. + TransferInputs []TransferInput + + // TransferInputSnapshots are a portable encoding of + // TransferInputs. + TransferInputSnapshots []*TransferInputSnapshot + + // InputOutpoints are the VTXO inputs consumed by this transfer + // session. + InputOutpoints []wire.OutPoint + + // FailReason is the terminal failure reason, when Phase is + // Failed. + FailReason string +} + +// NewOutgoingSnapshot exports an outgoing transfer FSM state into a snapshot. +func NewOutgoingSnapshot(sessionID SessionID, state State) (*OutgoingSnapshot, + error) { + + if sessionID == (SessionID{}) { + return nil, fmt.Errorf("session id must be provided") + } + + if state == nil { + return nil, fmt.Errorf("state must be provided") + } + + snap := &OutgoingSnapshot{ + Version: 2, + SessionID: sessionID, + } + + switch s := state.(type) { + case *AwaitingSubmitAccepted: + // Snapshot the entire submit package because it is the + // canonical v0 payload, and the natural unit for idempotence. + // + // Retries should use byte-identical PSBTs. + // + // Storing PSBT bytes rather than Go structs makes this snapshot + // resilient to later refactors in the PSBT builder. + snap.Phase = OutgoingPhaseSubmitSent + + ark, err := psbtutil.Serialize(s.ArkPSBT) + if err != nil { + return nil, err + } + snap.ArkPSBT = ark + + cps, err := serializePSBTSlice(s.CheckpointPSBTs) + if err != nil { + return nil, err + } + snap.CheckpointPSBTs = cps + err = assignTransferInputSnapshots(snap, s.TransferInputs) + if err != nil { + return nil, err + } + + case *AwaitingCheckpointSignatures: + // This is the "point-of-no-return" state from the client's + // perspective: we have received operator signature material. + // + // The client must be able to resume signing and finalizing + // after a crash. + snap.Phase = OutgoingPhaseCoSigned + + ark, err := psbtutil.Serialize(s.ArkPSBT) + if err != nil { + return nil, err + } + snap.ArkPSBT = ark + + cps, err := serializePSBTSlice(s.CoSignedCheckpointPSBTs) + if err != nil { + return nil, err + } + snap.CheckpointPSBTs = cps + err = assignTransferInputSnapshots(snap, s.TransferInputs) + if err != nil { + return nil, err + } + + case *AwaitingFinalizeAccepted: + // Once finalize is sent, the client should only need the + // finalized checkpoints (plus Ark PSBT) to retry finalize. + snap.Phase = OutgoingPhaseFinalizeSent + + ark, err := psbtutil.Serialize(s.ArkPSBT) + if err != nil { + return nil, err + } + snap.ArkPSBT = ark + + cps, err := serializePSBTSlice(s.FinalCheckpointPSBTs) + if err != nil { + return nil, err + } + snap.CheckpointPSBTs = cps + snap.InputOutpoints = s.InputOutpoints + + case *AwaitingLocalVTXOUpdate: + snap.Phase = OutgoingPhaseLocalVTXOUpdate + snap.InputOutpoints = s.InputOutpoints + + case *Completed: + // Completed is a terminal state. There is no outbox implied by + // this state, so resumes are no-ops. + snap.Phase = OutgoingPhaseCompleted + + case *Failed: + // Failed is terminal. Retrying is not attempted automatically. + snap.Phase = OutgoingPhaseFailed + snap.FailReason = s.Reason + + default: + return nil, fmt.Errorf("unsupported outgoing state type: %T", + state) + } + + return snap, nil +} + +// NewSessionFromSnapshot restores an outgoing transfer session from a snapshot. +func NewSessionFromSnapshot(ctx context.Context, + snapshot *OutgoingSnapshot) (*Session, error) { + + if snapshot == nil { + return nil, fmt.Errorf("snapshot must be provided") + } + + state, err := OutgoingStateFromSnapshot(snapshot) + if err != nil { + return nil, err + } + + env := &Environment{SessionID: snapshot.SessionID} + + fsmCfg := StateMachineCfg{ + Logger: log.WithPrefix(snapshot.SessionID.LogPrefix()), + ErrorReporter: newContextErrorReporter(ctx, snapshot.SessionID.LogPrefix()), + InitialState: state, + Env: env, + } + + sm := protofsm.NewStateMachine(fsmCfg) + sm.Start(ctx) + + return &Session{ + ID: snapshot.SessionID, + FSM: &sm, + }, nil +} + +// OutgoingStateFromSnapshot converts a snapshot into the corresponding +// concrete state type. +func OutgoingStateFromSnapshot(snapshot *OutgoingSnapshot) (State, error) { + if snapshot.SessionID == (SessionID{}) { + return nil, fmt.Errorf("session id must be provided") + } + + if snapshot.Version == 0 { + return nil, fmt.Errorf("snapshot version must be provided") + } + + switch snapshot.Phase { + case OutgoingPhaseSubmitSent: + ark, cps, err := parseOutgoingPSBTs( + snapshot.ArkPSBT, snapshot.CheckpointPSBTs, + ) + if err != nil { + return nil, err + } + + err = requireSessionIDMatchesArk(snapshot.SessionID, ark) + if err != nil { + return nil, err + } + + inputs, err := restoreTransferInputs(snapshot) + if err != nil { + return nil, err + } + + return &AwaitingSubmitAccepted{ + ArkPSBT: ark, + CheckpointPSBTs: cps, + TransferInputs: inputs, + }, nil + + case OutgoingPhaseCoSigned: + ark, cps, err := parseOutgoingPSBTs( + snapshot.ArkPSBT, snapshot.CheckpointPSBTs, + ) + if err != nil { + return nil, err + } + + err = requireSessionIDMatchesArk(snapshot.SessionID, ark) + if err != nil { + return nil, err + } + + inputs, err := restoreTransferInputs(snapshot) + if err != nil { + return nil, err + } + + return &AwaitingCheckpointSignatures{ + SessionID: snapshot.SessionID, + ArkPSBT: ark, + CoSignedCheckpointPSBTs: cps, + TransferInputs: inputs, + }, nil + + case OutgoingPhaseFinalizeSent: + ark, cps, err := parseOutgoingPSBTs( + snapshot.ArkPSBT, snapshot.CheckpointPSBTs, + ) + if err != nil { + return nil, err + } + + err = requireSessionIDMatchesArk(snapshot.SessionID, ark) + if err != nil { + return nil, err + } + + return &AwaitingFinalizeAccepted{ + SessionID: snapshot.SessionID, + InputOutpoints: snapshot.InputOutpoints, + ArkPSBT: ark, + FinalCheckpointPSBTs: cps, + }, nil + + case OutgoingPhaseLocalVTXOUpdate: + return &AwaitingLocalVTXOUpdate{ + SessionID: snapshot.SessionID, + InputOutpoints: snapshot.InputOutpoints, + }, nil + + case OutgoingPhaseCompleted: + return &Completed{}, nil + + case OutgoingPhaseFailed: + return &Failed{Reason: snapshot.FailReason}, nil + + default: + return nil, fmt.Errorf("unknown outgoing phase: %s", + snapshot.Phase) + } +} + +// snapshotTransferInputs converts transfer inputs into portable snapshots. +func snapshotTransferInputs(inputs []TransferInput) ([]*TransferInputSnapshot, + error) { + + if len(inputs) == 0 { + return nil, fmt.Errorf("transfer inputs required") + } + + out := make([]*TransferInputSnapshot, 0, len(inputs)) + for i := range inputs { + snap, err := inputs[i].ToSnapshot() + if err != nil { + return nil, err + } + + out = append(out, snap) + } + + return out, nil +} + +// assignTransferInputSnapshots stores transfer inputs and their portable +// snapshot form on the outgoing snapshot. +func assignTransferInputSnapshots(snap *OutgoingSnapshot, + inputs []TransferInput) error { + + snap.TransferInputs = inputs + + inputSnaps, err := snapshotTransferInputs(inputs) + if err != nil { + return err + } + + snap.TransferInputSnapshots = inputSnaps + + return nil +} + +// restoreTransferInputs returns transfer inputs from either the in-memory +// representation or the portable snapshots. +func restoreTransferInputs(snapshot *OutgoingSnapshot) ([]TransferInput, + error) { + + if snapshot == nil { + return nil, fmt.Errorf("snapshot must be provided") + } + + if len(snapshot.TransferInputs) != 0 { + return snapshot.TransferInputs, nil + } + + if len(snapshot.TransferInputSnapshots) == 0 { + return nil, fmt.Errorf("transfer inputs required") + } + + inputs := make([]TransferInput, 0, len(snapshot.TransferInputSnapshots)) + for i := range snapshot.TransferInputSnapshots { + in, err := TransferInputFromSnapshot( + snapshot.TransferInputSnapshots[i], + ) + if err != nil { + return nil, err + } + + inputs = append(inputs, in) + } + + return inputs, nil +} + +// parseOutgoingPSBTs parses an Ark PSBT and a list of checkpoint PSBTs. +func parseOutgoingPSBTs(arkRaw []byte, checkpointRaws [][]byte) (*psbt.Packet, + []*psbt.Packet, error) { + + ark, err := psbtutil.Parse(arkRaw) + if err != nil { + return nil, nil, err + } + + checkpoints, err := parsePSBTSlice(checkpointRaws) + if err != nil { + return nil, nil, err + } + + return ark, checkpoints, nil +} + +// requireSessionIDMatchesArk ensures the snapshot session id matches the Ark +// PSBT txid. +func requireSessionIDMatchesArk(sessionID SessionID, ark *psbt.Packet) error { + if ark == nil || ark.UnsignedTx == nil { + return fmt.Errorf("ark psbt must be provided") + } + + arkTxid := ark.UnsignedTx.TxHash() + if SessionID(arkTxid) != sessionID { + return fmt.Errorf("ark txid mismatch with session id") + } + + return nil +} + +// serializePSBTSlice serializes a slice of PSBT packets. +func serializePSBTSlice(pkts []*psbt.Packet) ([][]byte, error) { + if len(pkts) == 0 { + return nil, fmt.Errorf("psbt slice must be provided") + } + + raws := make([][]byte, 0, len(pkts)) + for i := range pkts { + raw, err := psbtutil.Serialize(pkts[i]) + if err != nil { + return nil, err + } + raws = append(raws, raw) + } + + return raws, nil +} + +// parsePSBTSlice parses a slice of serialized PSBT packets. +func parsePSBTSlice(raws [][]byte) ([]*psbt.Packet, error) { + if len(raws) == 0 { + return nil, fmt.Errorf("psbt bytes must be provided") + } + + pkts := make([]*psbt.Packet, 0, len(raws)) + for i := range raws { + pkt, err := psbtutil.Parse(raws[i]) + if err != nil { + return nil, err + } + pkts = append(pkts, pkt) + } + + return pkts, nil +} diff --git a/oor/outgoing_snapshot_codec.go b/oor/outgoing_snapshot_codec.go new file mode 100644 index 000000000..470482e64 --- /dev/null +++ b/oor/outgoing_snapshot_codec.go @@ -0,0 +1,318 @@ +package oor + +import ( + "bytes" + "fmt" + "math" + + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/tlv" +) + +const ( + checkpointVersionRecordType tlv.Type = 1 + checkpointSnapshotsRecordType tlv.Type = 3 +) + +const ( + snapshotVersionRecordType tlv.Type = 1 + snapshotSessionIDRecordType tlv.Type = 3 + snapshotPhaseRecordType tlv.Type = 5 + snapshotArkPSBTRecordType tlv.Type = 7 + snapshotCheckpointPSBTsRecordType tlv.Type = 9 + snapshotTransferInputsRecordType tlv.Type = 11 + snapshotInputOutpointsRecordType tlv.Type = 13 + snapshotFailReasonRecordType tlv.Type = 19 +) + +func encodeOutgoingSessionsCheckpoint( + checkpoint outgoingSessionsCheckpoint) ([]byte, error) { + + snapshotBlobs := make([][]byte, 0, len(checkpoint.Snapshots)) + for i := range checkpoint.Snapshots { + raw, err := encodeOutgoingSnapshot(checkpoint.Snapshots[i]) + if err != nil { + return nil, err + } + + snapshotBlobs = append(snapshotBlobs, raw) + } + + snapshotsRaw, err := encodeLengthPrefixedBlobList(snapshotBlobs) + if err != nil { + return nil, err + } + + version := uint64(checkpoint.Version) + records := []tlv.Record{ + tlv.MakePrimitiveRecord(checkpointVersionRecordType, &version), + tlv.MakePrimitiveRecord( + checkpointSnapshotsRecordType, &snapshotsRaw, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func decodeOutgoingSessionsCheckpoint( + raw []byte) (outgoingSessionsCheckpoint, error) { + + var ( + version uint64 + snapshotsRaw []byte + ) + + records := []tlv.Record{ + tlv.MakePrimitiveRecord(checkpointVersionRecordType, &version), + tlv.MakePrimitiveRecord( + checkpointSnapshotsRecordType, &snapshotsRaw, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return outgoingSessionsCheckpoint{}, err + } + + reader := bytes.NewReader(raw) + if _, err := stream.DecodeWithParsedTypes(reader); err != nil { + return outgoingSessionsCheckpoint{}, err + } + + snapshotBlobs, err := decodeLengthPrefixedBlobList(snapshotsRaw) + if err != nil { + return outgoingSessionsCheckpoint{}, err + } + + snapshots := make([]*OutgoingSnapshot, 0, len(snapshotBlobs)) + for i := range snapshotBlobs { + snapshot, err := decodeOutgoingSnapshot(snapshotBlobs[i]) + if err != nil { + return outgoingSessionsCheckpoint{}, err + } + + snapshots = append(snapshots, snapshot) + } + + decodedVersion, err := decodeUint64ToInt(version, "checkpoint version") + if err != nil { + return outgoingSessionsCheckpoint{}, err + } + + return outgoingSessionsCheckpoint{ + Version: decodedVersion, + Snapshots: snapshots, + }, nil +} + +func encodeOutgoingSnapshot(snapshot *OutgoingSnapshot) ([]byte, error) { + if snapshot == nil { + return nil, fmt.Errorf("snapshot must be provided") + } + + sessionBytes := sessionIDBytes(snapshot.SessionID) + phaseBytes := []byte(snapshot.Phase) + arkPSBT := snapshot.ArkPSBT + checkpointPSBTs, err := encodeLengthPrefixedBlobList( + snapshot.CheckpointPSBTs, + ) + if err != nil { + return nil, err + } + + inputSnapshots, err := encodeTransferInputSnapshots( + snapshot.TransferInputSnapshots, + ) + if err != nil { + return nil, err + } + + outpointsRaw, err := encodeOutpoints(snapshot.InputOutpoints) + if err != nil { + return nil, err + } + + failReason := []byte(snapshot.FailReason) + + version := uint64(snapshot.Version) + records := []tlv.Record{ + tlv.MakePrimitiveRecord(snapshotVersionRecordType, &version), + tlv.MakePrimitiveRecord( + snapshotSessionIDRecordType, &sessionBytes, + ), + tlv.MakePrimitiveRecord(snapshotPhaseRecordType, &phaseBytes), + tlv.MakePrimitiveRecord(snapshotArkPSBTRecordType, &arkPSBT), + tlv.MakePrimitiveRecord( + snapshotCheckpointPSBTsRecordType, &checkpointPSBTs, + ), + tlv.MakePrimitiveRecord( + snapshotTransferInputsRecordType, &inputSnapshots, + ), + tlv.MakePrimitiveRecord( + snapshotInputOutpointsRecordType, &outpointsRaw, + ), + tlv.MakePrimitiveRecord( + snapshotFailReasonRecordType, &failReason, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func decodeOutgoingSnapshot(raw []byte) (*OutgoingSnapshot, error) { + var ( + version uint64 + sessionBytes []byte + phaseBytes []byte + arkPSBT []byte + checkpointPSBTsRaw []byte + inputSnapshotsRaw []byte + outpointsRaw []byte + failReasonRaw []byte + ) + + records := []tlv.Record{ + tlv.MakePrimitiveRecord(snapshotVersionRecordType, &version), + tlv.MakePrimitiveRecord( + snapshotSessionIDRecordType, &sessionBytes, + ), + tlv.MakePrimitiveRecord(snapshotPhaseRecordType, &phaseBytes), + tlv.MakePrimitiveRecord(snapshotArkPSBTRecordType, &arkPSBT), + tlv.MakePrimitiveRecord( + snapshotCheckpointPSBTsRecordType, &checkpointPSBTsRaw, + ), + tlv.MakePrimitiveRecord( + snapshotTransferInputsRecordType, &inputSnapshotsRaw, + ), + tlv.MakePrimitiveRecord( + snapshotInputOutpointsRecordType, &outpointsRaw, + ), + tlv.MakePrimitiveRecord( + snapshotFailReasonRecordType, &failReasonRaw, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + reader := bytes.NewReader(raw) + if _, err := stream.DecodeWithParsedTypes(reader); err != nil { + return nil, err + } + + sessionID, err := parseSessionID(sessionBytes) + if err != nil { + return nil, err + } + + checkpointPSBTs, err := decodeLengthPrefixedBlobList(checkpointPSBTsRaw) + if err != nil { + return nil, err + } + if len(checkpointPSBTs) == 0 { + checkpointPSBTs = nil + } + + inputSnapshots, err := decodeTransferInputSnapshots(inputSnapshotsRaw) + if err != nil { + return nil, err + } + if len(inputSnapshots) == 0 { + inputSnapshots = nil + } + + outpoints, err := decodeOutpoints(outpointsRaw) + if err != nil { + return nil, err + } + if len(outpoints) == 0 { + outpoints = nil + } + + if len(arkPSBT) == 0 { + arkPSBT = nil + } + + decodedVersion, err := decodeUint64ToUint8(version, "snapshot version") + if err != nil { + return nil, err + } + + return &OutgoingSnapshot{ + Version: decodedVersion, + SessionID: sessionID, + Phase: OutgoingPhase(phaseBytes), + ArkPSBT: arkPSBT, + CheckpointPSBTs: checkpointPSBTs, + TransferInputSnapshots: inputSnapshots, + InputOutpoints: outpoints, + FailReason: string(failReasonRaw), + }, nil +} + +func encodeOutpoints(outpoints []wire.OutPoint) ([]byte, error) { + blobs := make([][]byte, 0, len(outpoints)) + for i := range outpoints { + blobs = append(blobs, outPointBytes(outpoints[i])) + } + + return encodeLengthPrefixedBlobList(blobs) +} + +func decodeOutpoints(raw []byte) ([]wire.OutPoint, error) { + blobs, err := decodeLengthPrefixedBlobList(raw) + if err != nil { + return nil, err + } + + outpoints := make([]wire.OutPoint, 0, len(blobs)) + for i := range blobs { + outpoint, err := parseOutPointBytes(blobs[i]) + if err != nil { + return nil, err + } + + outpoints = append(outpoints, outpoint) + } + + return outpoints, nil +} + +func decodeUint64ToUint8(value uint64, field string) (uint8, error) { + if value > math.MaxUint8 { + return 0, fmt.Errorf("%s overflows uint8: %d", field, value) + } + + return uint8(value), nil +} + +func decodeUint64ToInt(value uint64, field string) (int, error) { + maxInt := uint64(^uint(0) >> 1) + if value > maxInt { + return 0, fmt.Errorf("%s overflows int: %d", field, value) + } + + return int(value), nil +} diff --git a/oor/outgoing_snapshot_codec_test.go b/oor/outgoing_snapshot_codec_test.go new file mode 100644 index 000000000..1e3174209 --- /dev/null +++ b/oor/outgoing_snapshot_codec_test.go @@ -0,0 +1,190 @@ +package oor + +import ( + "bytes" + "math" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +func TestOutgoingSnapshotTLVRoundTrip(t *testing.T) { + t.Parallel() + + snapshot := &OutgoingSnapshot{ + Version: 3, + SessionID: SessionID(chainhash.Hash{1, 2, 3}), + Phase: OutgoingPhaseSubmitSent, + ArkPSBT: []byte{1, 2, 3, 4}, + CheckpointPSBTs: [][]byte{{5, 6}, {7, 8}}, + TransferInputSnapshots: []*TransferInputSnapshot{ + { + Outpoint: wire.OutPoint{ + Hash: chainhash.Hash{9, 10}, + Index: 11, + }, + AmountSat: 123, + ClientKeyFamily: 1, + ClientKeyIndex: 2, + ClientPubKey: []byte{2, 3, 4}, + OperatorPubKey: []byte{2, 5, 6}, + ExitDelay: 42, + OwnerLeafScript: []byte{0x51}, + }, + }, + InputOutpoints: []wire.OutPoint{ + { + Hash: chainhash.Hash{12, 13, 14}, + Index: 15, + }, + }, + FailReason: "retry later", + } + + raw, err := encodeOutgoingSnapshot(snapshot) + require.NoError(t, err) + + decoded, err := decodeOutgoingSnapshot(raw) + require.NoError(t, err) + require.Equal(t, snapshot, decoded) +} + +func TestOutgoingCheckpointTLVRoundTrip(t *testing.T) { + t.Parallel() + + checkpoint := outgoingSessionsCheckpoint{ + Version: 1, + Snapshots: []*OutgoingSnapshot{ + { + Version: 3, + SessionID: SessionID(chainhash.Hash{1}), + Phase: OutgoingPhaseCompleted, + }, + { + Version: 3, + SessionID: SessionID(chainhash.Hash{2}), + Phase: OutgoingPhaseFailed, + FailReason: "boom", + }, + }, + } + + raw, err := encodeOutgoingSessionsCheckpoint(checkpoint) + require.NoError(t, err) + + decoded, err := decodeOutgoingSessionsCheckpoint(raw) + require.NoError(t, err) + require.Equal(t, checkpoint, decoded) +} + +func TestRestoreSnapshotPayloadTLVRoundTrip(t *testing.T) { + t.Parallel() + + snapshot := &OutgoingSnapshot{ + Version: 3, + SessionID: SessionID(chainhash.Hash{21, 22, 23}), + Phase: OutgoingPhaseCompleted, + } + + raw, err := encodeRestoreSnapshotPayload(snapshot) + require.NoError(t, err) + + decoded, err := decodeRestoreSnapshotPayload(raw) + require.NoError(t, err) + require.Equal(t, snapshot, decoded) +} + +func TestDecodeOutgoingSnapshotRejectsVersionOverflow(t *testing.T) { + t.Parallel() + + raw, err := encodeSnapshotRawForDecodeTest( + uint64(math.MaxUint8) + 1, + ) + require.NoError(t, err) + + _, err = decodeOutgoingSnapshot(raw) + require.ErrorContains(t, err, "snapshot version overflows uint8") +} + +func TestDecodeOutgoingCheckpointRejectsVersionOverflow(t *testing.T) { + t.Parallel() + + snapshotsRaw, err := encodeLengthPrefixedBlobList(nil) + require.NoError(t, err) + + version := uint64(^uint(0)>>1) + 1 + records := []tlv.Record{ + tlv.MakePrimitiveRecord(checkpointVersionRecordType, &version), + tlv.MakePrimitiveRecord( + checkpointSnapshotsRecordType, &snapshotsRaw, + ), + } + + stream, err := tlv.NewStream(records...) + require.NoError(t, err) + + var raw bytes.Buffer + require.NoError(t, stream.Encode(&raw)) + + _, err = decodeOutgoingSessionsCheckpoint(raw.Bytes()) + require.ErrorContains(t, err, "checkpoint version overflows int") +} + +func encodeSnapshotRawForDecodeTest(version uint64) ([]byte, error) { + sessionBytes := sessionIDBytes(SessionID(chainhash.Hash{1})) + phaseBytes := []byte(OutgoingPhaseCompleted) + arkPSBT := []byte(nil) + + checkpointPSBTsRaw, err := encodeLengthPrefixedBlobList(nil) + if err != nil { + return nil, err + } + + inputSnapshotsRaw, err := encodeTransferInputSnapshots(nil) + if err != nil { + return nil, err + } + + outpointsRaw, err := encodeOutpoints(nil) + if err != nil { + return nil, err + } + + failReasonRaw := []byte(nil) + + records := []tlv.Record{ + tlv.MakePrimitiveRecord(snapshotVersionRecordType, &version), + tlv.MakePrimitiveRecord( + snapshotSessionIDRecordType, &sessionBytes, + ), + tlv.MakePrimitiveRecord(snapshotPhaseRecordType, &phaseBytes), + tlv.MakePrimitiveRecord(snapshotArkPSBTRecordType, &arkPSBT), + tlv.MakePrimitiveRecord( + snapshotCheckpointPSBTsRecordType, &checkpointPSBTsRaw, + ), + tlv.MakePrimitiveRecord( + snapshotTransferInputsRecordType, &inputSnapshotsRaw, + ), + tlv.MakePrimitiveRecord( + snapshotInputOutpointsRecordType, &outpointsRaw, + ), + tlv.MakePrimitiveRecord( + snapshotFailReasonRecordType, &failReasonRaw, + ), + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + var raw bytes.Buffer + if err := stream.Encode(&raw); err != nil { + return nil, err + } + + return raw.Bytes(), nil +} diff --git a/oor/outgoing_snapshot_test.go b/oor/outgoing_snapshot_test.go new file mode 100644 index 000000000..40e7d5819 --- /dev/null +++ b/oor/outgoing_snapshot_test.go @@ -0,0 +1,70 @@ +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" +) + +// TestNewOutgoingSnapshotFinalizeSentMinimality verifies finalize-sent +// snapshots persist only the artifacts needed for deterministic retry/resume. +func TestNewOutgoingSnapshotFinalizeSentMinimality(t *testing.T) { + t.Parallel() + + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + policy := scripts.CheckpointPolicy{ + OperatorKey: operatorKey.PubKey(), + CSVDelay: 10, + } + + const inputValue = btcutil.Amount(10000) + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + input := newTestTransferInput( + t, clientKey, policy.OperatorKey, wire.OutPoint{ + Hash: [32]byte{0x01}, + Index: 0, + }, inputValue, + ) + + recipients := []oortx.RecipientOutput{ + { + PkScript: newTestTaprootPkScript(t, clientKey.PubKey()), + Value: inputValue, + }, + } + + ark, checkpoints, err := buildSubmitPackage( + policy, []TransferInput{input}, recipients, + ) + require.NoError(t, err) + + state := &AwaitingFinalizeAccepted{ + SessionID: SessionID(ark.UnsignedTx.TxHash()), + InputOutpoints: []wire.OutPoint{input.VTXO.Outpoint}, + ArkPSBT: ark, + FinalCheckpointPSBTs: checkpoints, + } + + snapshot, err := NewOutgoingSnapshot(state.SessionID, state) + require.NoError(t, err) + + require.Equal(t, OutgoingPhaseFinalizeSent, snapshot.Phase) + require.NotEmpty(t, snapshot.ArkPSBT) + require.NotEmpty(t, snapshot.CheckpointPSBTs) + require.Equal(t, state.InputOutpoints, snapshot.InputOutpoints) + + // Finalize retries do not require transfer input material, so the + // snapshot should not carry those fields in this phase. + require.Nil(t, snapshot.TransferInputs) + require.Nil(t, snapshot.TransferInputSnapshots) +} diff --git a/oor/receive_session_test.go b/oor/receive_session_test.go index f92882585..a625a0c6d 100644 --- a/oor/receive_session_test.go +++ b/oor/receive_session_test.go @@ -5,17 +5,31 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/darepo-client/lib/scripts" oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/lightninglabs/darepo-client/vtxo" + "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/require" ) +// TestReceiveSessionNotifiesAndAcks verifies the incoming-transfer FSM emits +// notification, materialization and ack outbox messages for a canonical Ark +// transfer. func TestReceiveSessionNotifiesAndAcks(t *testing.T) { t.Parallel() ctx := t.Context() + // This test exercises the client-side incoming-transfer FSM. + // + // We construct an Ark PSBT that looks like a canonical v0 transfer + // (checkpoint input -> recipients + anchor), then verify the receive + // session: + // - emits an application-facing notification + // - requests recipient materialization into local VTXO state + // - requests an ack back to the server (transport boundary) operatorKey, err := btcec.NewPrivateKey() require.NoError(t, err) @@ -24,34 +38,64 @@ func TestReceiveSessionNotifiesAndAcks(t *testing.T) { CSVDelay: 10, } + recipientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + exitDelay := uint32(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}, + inputs := []oortx.CheckpointInput{ + { + SpentVTXO: oortx.SpentVTXORef{ + Outpoint: wire.OutPoint{ + Hash: [32]byte{0x01}, + Index: 0, + }, + Output: &wire.TxOut{ + Value: int64(inputValue), + PkScript: newTestTaprootPkScript( + t, operatorKey.PubKey(), + ), + }, + }, + OwnerLeafScript: []byte{0x51}, }, - OwnerLeafScript: []byte{0x51}, - }} + } + + vtxoTapKey, err := scripts.VTXOTapKey( + recipientKey.PubKey(), policy.OperatorKey, exitDelay, + ) + require.NoError(t, err) - outputs := []oortx.RecipientOutput{{ - PkScript: []byte{0x51}, - Value: inputValue, - }} + recipientPkScript, err := txscript.PayToTaprootScript(vtxoTapKey) + require.NoError(t, err) + + outputs := []oortx.RecipientOutput{ + { + PkScript: recipientPkScript, + Value: inputValue, + }, + } // Build a canonical Ark PSBT for the receive notification. + // + // The checkpoint PSBT is only used to derive a realistic Ark input: + // we are not testing checkpoint validity here, only the receive FSM's + // structural checks and outbox emission. 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) + 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()) @@ -63,9 +107,25 @@ func TestReceiveSessionNotifiesAndAcks(t *testing.T) { _, ok := outbox[0].(*IncomingTransferNotification) require.True(t, ok) - _, ok = outbox[1].(*MaterializeIncomingVTXOsRequest) + materializeMsg, ok := outbox[1].(*MaterializeIncomingVTXOsRequest) require.True(t, ok) + require.NotEmpty(t, materializeMsg.Recipients) _, ok = outbox[2].(*SendIncomingAckRequest) require.True(t, ok) + + desc, err := BuildIncomingVTXODescriptor(materializeMsg.ArkPSBT, + IncomingVTXOConfig{ + OutputIndex: materializeMsg.Recipients[0].OutputIndex, + ClientKey: keychain.KeyDescriptor{ + PubKey: recipientKey.PubKey(), + }, + OperatorKey: policy.OperatorKey, + ExitDelay: exitDelay, + }, + ) + require.NoError(t, err) + require.Equal(t, recipientPkScript, desc.PkScript) + require.Equal(t, inputValue, desc.Amount) + require.Equal(t, vtxo.VTXOStatusLive, desc.Status) } diff --git a/oor/receive_transitions.go b/oor/receive_transitions.go index 46a351185..ca389b0d0 100644 --- a/oor/receive_transitions.go +++ b/oor/receive_transitions.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/lightninglabs/darepo-client/lib/tx/arktx" fn "github.com/lightningnetwork/lnd/fn/v2" ) @@ -71,7 +71,7 @@ func (s *ReceiveIdle) ProcessEvent(ctx context.Context, event Event, // // The goal is that all parties derive identical semantics from // identical bytes. - err := oor.ValidateCanonicalArkPSBT(evt.ArkPSBT) + err := arktx.ValidateCanonicalPSBT(evt.ArkPSBT) if err != nil { return nil, err } diff --git a/oor/resume.go b/oor/resume.go new file mode 100644 index 000000000..e4b7fc876 --- /dev/null +++ b/oor/resume.go @@ -0,0 +1,61 @@ +package oor + +import ( + "fmt" +) + +// OutboxForState returns the outbox request implied by the current outgoing +// session state. +// +// This is used to support explicit retry/resume logic: after a restart, the app +// can either rely on durable-actor restart handling or explicitly call +// ResumeSessionRequest to re-send the submit/finalize request (or re-request +// checkpoint signing). +func OutboxForState(state State) ([]OutboxEvent, error) { + if state == nil { + return nil, fmt.Errorf("state must be provided") + } + + switch s := state.(type) { + case *AwaitingSubmitAccepted: + return []OutboxEvent{ + &SendSubmitPackageRequest{ + ArkPSBT: s.ArkPSBT, + CheckpointPSBTs: s.CheckpointPSBTs, + TransferInputs: s.TransferInputs, + }, + }, nil + + case *AwaitingCheckpointSignatures: + return []OutboxEvent{ + &RequestCheckpointSignatures{ + ArkPSBT: s.ArkPSBT, + CoSignedCheckpointPSBTs: s. + CoSignedCheckpointPSBTs, + TransferInputs: s.TransferInputs, + }, + }, nil + + case *AwaitingFinalizeAccepted: + return []OutboxEvent{ + &SendFinalizePackageRequest{ + ArkPSBT: s.ArkPSBT, + FinalCheckpointPSBTs: s.FinalCheckpointPSBTs, + }, + }, nil + + case *AwaitingLocalVTXOUpdate: + return []OutboxEvent{ + &MarkInputsSpentRequest{ + Outpoints: s.InputOutpoints, + }, + }, nil + + case *Completed, *Failed: + return nil, nil + + default: + return nil, fmt.Errorf("unsupported outgoing state type: %T", + state) + } +} diff --git a/oor/session.go b/oor/session.go index e31d5e28b..4b718fc57 100644 --- a/oor/session.go +++ b/oor/session.go @@ -5,7 +5,6 @@ import ( "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" @@ -20,11 +19,12 @@ type Session struct { 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. +// NewSession creates a new outgoing OOR transfer session and returns the first +// outbox request produced by the FSM. // -// 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. +// This helper lets the FSM build the submit package itself, then derives the +// stable session ID from the resulting Ark txid before returning control to the +// caller. // // 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 @@ -32,51 +32,56 @@ type Session struct { // 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, + inputs []TransferInput, outputs []oortx.RecipientOutput) (*Session, []OutboxEvent, error) { - inputOutpoints := make([]wire.OutPoint, 0, len(inputs)) - for i := range inputs { - inputOutpoints = append(inputOutpoints, inputs[i].Outpoint) + env := &Environment{} + startupID := SessionID{} + + fsmCfg := StateMachineCfg{ + Logger: log.WithPrefix(startupID.LogPrefix()), + ErrorReporter: newContextErrorReporter(ctx, startupID.LogPrefix()), + InitialState: &Idle{}, + Env: env, } - ark, checkpoints, err := buildSubmitPackage(policy, inputs, outputs) - if err != nil { - return nil, nil, err + sm := protofsm.NewStateMachine(fsmCfg) + sm.Start(ctx) + + fut := sm.AskEvent(ctx, &StartTransferEvent{ + VTXOInputs: inputs, + RecipientOutputs: outputs, + Policy: policy, + }) + result := fut.Await(ctx) + if result.IsErr() { + return nil, nil, result.Err() } - sessionID, err := sessionIDFromArk(ark) + currentState, err := sm.CurrentState() 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, + awaitingSubmit, ok := currentState.(*AwaitingSubmitAccepted) + if !ok { + return nil, nil, fmt.Errorf("unexpected start state: %T", + currentState) } - sm := protofsm.NewStateMachine(fsmCfg) - sm.Start(ctx) - - outbox := []OutboxEvent{ - &SendSubmitPackageRequest{ - ArkPSBT: ark, - CheckpointPSBTs: checkpoints, - }, + sessionID, err := sessionIDFromArk(awaitingSubmit.ArkPSBT) + if err != nil { + return nil, nil, err } + // Bind the FSM environment to the stable session identifier only after + // StartTransfer has deterministically built the package. + env.SessionID = sessionID + return &Session{ ID: sessionID, FSM: &sm, - }, outbox, nil + }, result.UnwrapOr(nil), nil } // sessionIDFromArk derives the v0 session identifier from an Ark PSBT. diff --git a/oor/session_test.go b/oor/session_test.go index 0edf0adb6..0797fd88a 100644 --- a/oor/session_test.go +++ b/oor/session_test.go @@ -8,9 +8,12 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/darepo-client/lib/scripts" oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/lightningnetwork/lnd/input" "github.com/stretchr/testify/require" ) +// TestSessionHappyPath exercises the outgoing transfer FSM without the actor +// wrapper. func TestSessionHappyPath(t *testing.T) { t.Parallel() @@ -26,22 +29,28 @@ func TestSessionHappyPath(t *testing.T) { 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}, - }} + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + clientSigner := input.NewMockSigner([]*btcec.PrivateKey{clientKey}, nil) + + inputs := []TransferInput{ + newTestTransferInput( + t, clientKey, policy.OperatorKey, + wire.OutPoint{ + Hash: [32]byte{0x01}, + Index: 0, + }, + inputValue, + ), + } - outputs := []oortx.RecipientOutput{{ - PkScript: []byte{0x51}, - Value: inputValue, - }} + outputs := []oortx.RecipientOutput{ + { + PkScript: newTestTaprootPkScript(t, clientKey.PubKey()), + Value: inputValue, + }, + } session, outbox, err := NewSession(ctx, policy, inputs, outputs) require.NoError(t, err) @@ -70,15 +79,19 @@ func TestSessionHappyPath(t *testing.T) { submitOutbox := result.UnwrapOr(nil) require.Len(t, submitOutbox, 1) - _, ok = submitOutbox[0].(*RequestCheckpointSignatures) + signReq, ok := submitOutbox[0].(*RequestCheckpointSignatures) require.True(t, ok) + require.NotEmpty(t, signReq.TransferInputs) // Step 2: Wallet attaches client signatures to checkpoints. - finalCheckpoints := submit.CheckpointPSBTs - finalCheckpoints[0].Inputs[0].TaprootKeySpendSig = []byte{0x01} + err = SignCheckpointPSBTs( + clientSigner, signReq.TransferInputs, + signReq.CoSignedCheckpointPSBTs, + ) + require.NoError(t, err) fut = session.FSM.AskEvent(ctx, &CheckpointsSignedEvent{ - FinalCheckpointPSBTs: finalCheckpoints, + FinalCheckpointPSBTs: signReq.CoSignedCheckpointPSBTs, }) result = fut.Await(ctx) require.False(t, result.IsErr()) diff --git a/oor/states.go b/oor/states.go index d1b283bc0..efc32f6ee 100644 --- a/oor/states.go +++ b/oor/states.go @@ -52,6 +52,15 @@ type AwaitingSubmitAccepted struct { // CheckpointPSBTs are the checkpoint tx PSBTs for this session. CheckpointPSBTs []*psbt.Packet + + // TransferInputs carry the VTXO descriptors and scripts needed to + // sign checkpoint PSBTs at the co-sign step. + // + // These are not used by the FSM's transition logic. They are threaded + // through the state so the FSM can emit complete outbox events (which + // need the signing context) and so checkpoint snapshots capture them + // for crash-resume. + TransferInputs []TransferInput } // String returns a human-readable representation of AwaitingSubmitAccepted. @@ -87,6 +96,13 @@ type AwaitingCheckpointSignatures struct { // CoSignedCheckpointPSBTs are the operator co-signed checkpoint PSBTs. CoSignedCheckpointPSBTs []*psbt.Packet + + // TransferInputs carry the client-side VTXO signing context needed + // for the checkpoint signing outbox event. + // + // See AwaitingSubmitAccepted.TransferInputs for rationale on why + // this is carried on the FSM state. + TransferInputs []TransferInput } // String returns a human-readable representation of @@ -185,6 +201,8 @@ func (s *Completed) stateSealed() {} // Failed is the terminal failure state for the OOR client transfer session. type Failed struct { + // Reason is a human-readable failure reason intended for logs and + // tests. Reason string } diff --git a/oor/test_helpers_test.go b/oor/test_helpers_test.go new file mode 100644 index 000000000..e68a26bc6 --- /dev/null +++ b/oor/test_helpers_test.go @@ -0,0 +1,91 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/db/actordelivery" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/lightninglabs/darepo-client/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// newTestTransferInput creates a minimally valid transfer input for unit +// tests. +func newTestTransferInput(t *testing.T, ownerKey *btcec.PrivateKey, + operatorKey *btcec.PublicKey, outpoint wire.OutPoint, + amount btcutil.Amount) TransferInput { + + t.Helper() + + exitDelay := uint32(10) + + tapscript, err := scripts.VTXOTapScript( + ownerKey.PubKey(), operatorKey, exitDelay, + ) + require.NoError(t, err) + + tapKey, err := scripts.VTXOTapKey( + ownerKey.PubKey(), operatorKey, exitDelay, + ) + require.NoError(t, err) + + pkScript, err := txscript.PayToTaprootScript(tapKey) + require.NoError(t, err) + + return TransferInput{ + VTXO: &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: amount, + PkScript: pkScript, + ClientKey: keychain.KeyDescriptor{ + PubKey: ownerKey.PubKey(), + }, + OperatorKey: operatorKey, + TapScript: tapscript, + RelativeExpiry: exitDelay, + }, + OwnerLeafScript: []byte{0x51}, + } +} + +// newTestTaprootPkScript returns a valid P2TR pkScript for tests. +func newTestTaprootPkScript(t *testing.T, + key *btcec.PublicKey) []byte { + + t.Helper() + + pkScript, err := txscript.PayToTaprootScript(key) + require.NoError(t, err) + + return pkScript +} + +// newTestDeliveryStore creates a tx-aware delivery store for durable actor +// tests. +func newTestDeliveryStore(t *testing.T) actor.DeliveryStore { + t.Helper() + + sqlDB := db.NewTestDB(t) + store, err := actordelivery.NewTxAwareDeliveryStoreFromDB( + sqlDB.DB, + sqlDB.Backend(), + clock.NewDefaultClock(), + btclog.Disabled, + ) + require.NoError(t, err) + + txAwareStore, ok := store.(*actordelivery.TxAwareActorDeliveryStore) + require.True(t, ok) + + // Tests don't need the durable actor's outer transaction wrapper. + return txAwareStore.Store +} diff --git a/oor/transfer_input_snapshot.go b/oor/transfer_input_snapshot.go new file mode 100644 index 000000000..fd18f17c1 --- /dev/null +++ b/oor/transfer_input_snapshot.go @@ -0,0 +1,160 @@ +package oor + +import ( + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/lightninglabs/darepo-client/vtxo" + "github.com/lightningnetwork/lnd/keychain" +) + +// TransferInputSnapshot is a portable encoding of the client-side signing +// context required to finalize checkpoint PSBTs. +// +// The goal is to persist enough information to reconstruct the VTXO tapscript +// deterministically, without serializing Go wallet types directly. +type TransferInputSnapshot struct { + // Outpoint identifies the input VTXO being transferred. + Outpoint wire.OutPoint + + // AmountSat is the input VTXO amount in satoshis. + AmountSat int64 + + // ClientKeyFamily is the key family for the client signing key. + ClientKeyFamily int32 + + // ClientKeyIndex is the key index for the client signing key. + ClientKeyIndex uint32 + + // ClientPubKey is the compressed client public key. + ClientPubKey []byte + + // OperatorPubKey is the compressed operator public key. + OperatorPubKey []byte + + // ExitDelay is the VTXO CSV delay used to derive the VTXO script. + ExitDelay uint32 + + // OwnerLeafScript is the leaf script committed to the checkpoint tap + // tree. + // + // This is currently a draft implementation, and may change as the + // checkpoint policy is refined. + OwnerLeafScript []byte +} + +// ToSnapshot converts the transfer input into a portable snapshot. +func (i *TransferInput) ToSnapshot() (*TransferInputSnapshot, error) { + err := i.Validate() + if err != nil { + return nil, err + } + + operatorKey := i.VTXO.OperatorKey + if operatorKey == nil { + return nil, fmt.Errorf("vtxo operator key must be provided") + } + + exitDelay := i.VTXO.RelativeExpiry + if exitDelay == 0 { + return nil, fmt.Errorf("vtxo exit delay must be provided") + } + + return &TransferInputSnapshot{ + Outpoint: i.VTXO.Outpoint, + AmountSat: int64(i.VTXO.Amount), + ClientKeyFamily: int32(i.VTXO.ClientKey.KeyLocator.Family), + ClientKeyIndex: i.VTXO.ClientKey.KeyLocator.Index, + ClientPubKey: i.VTXO.ClientKey.PubKey.SerializeCompressed(), + OperatorPubKey: operatorKey.SerializeCompressed(), + ExitDelay: exitDelay, + OwnerLeafScript: i.OwnerLeafScript, + }, nil +} + +// TransferInputFromSnapshot reconstructs a transfer input from a snapshot. +func TransferInputFromSnapshot(snap *TransferInputSnapshot) (TransferInput, + error) { + + if snap == nil { + return TransferInput{}, fmt.Errorf("snapshot must be provided") + } + + if snap.AmountSat <= 0 { + return TransferInput{}, fmt.Errorf("amount must be positive") + } + + if len(snap.ClientPubKey) == 0 || len(snap.OperatorPubKey) == 0 { + return TransferInput{}, fmt.Errorf("pubkeys must be provided") + } + + if snap.ExitDelay == 0 { + return TransferInput{}, fmt.Errorf("exit delay must be " + + "provided") + } + + if len(snap.OwnerLeafScript) == 0 { + return TransferInput{}, fmt.Errorf("owner leaf script must " + + "be provided") + } + + clientPub, err := btcec.ParsePubKey(snap.ClientPubKey) + if err != nil { + return TransferInput{}, fmt.Errorf("parse client pubkey: %w", + err) + } + + operatorPub, err := btcec.ParsePubKey(snap.OperatorPubKey) + if err != nil { + return TransferInput{}, fmt.Errorf("parse operator pubkey: %w", + err) + } + + tapScript, err := scripts.VTXOTapScript(clientPub, operatorPub, + snap.ExitDelay) + if err != nil { + return TransferInput{}, fmt.Errorf("rebuild vtxo tapscript: %w", + err) + } + + tapKey, err := scripts.VTXOTapKey(clientPub, operatorPub, + snap.ExitDelay) + if err != nil { + return TransferInput{}, fmt.Errorf("rebuild vtxo tapkey: %w", + err) + } + + pkScript, err := txscript.PayToTaprootScript(tapKey) + if err != nil { + return TransferInput{}, fmt.Errorf("build vtxo pkscript: %w", + err) + } + + desc := &vtxo.Descriptor{ + Outpoint: snap.Outpoint, + Amount: btcutil.Amount(snap.AmountSat), + PkScript: pkScript, + ClientKey: keychain.KeyDescriptor{ + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily( + snap.ClientKeyFamily, + ), + Index: snap.ClientKeyIndex, + }, + PubKey: clientPub, + }, + OperatorKey: operatorPub, + TapScript: tapScript, + RelativeExpiry: snap.ExitDelay, + Status: vtxo.VTXOStatusLive, + } + + return TransferInput{ + VTXO: desc, + OwnerLeafScript: snap.OwnerLeafScript, + }, nil +} diff --git a/oor/transfer_input_snapshot_test.go b/oor/transfer_input_snapshot_test.go new file mode 100644 index 000000000..a24b390ae --- /dev/null +++ b/oor/transfer_input_snapshot_test.go @@ -0,0 +1,141 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/lightninglabs/darepo-client/vtxo" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// TestTransferInputSnapshotRoundTrip asserts that transfer input snapshots +// contain enough information to rebuild the VTXO signing descriptor. +func TestTransferInputSnapshotRoundTrip(t *testing.T) { + t.Parallel() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + exitDelay := uint32(10) + + tapScript, err := scripts.VTXOTapScript( + clientKey.PubKey(), operatorKey.PubKey(), exitDelay, + ) + require.NoError(t, err) + + tapKey, err := scripts.VTXOTapKey( + clientKey.PubKey(), operatorKey.PubKey(), exitDelay, + ) + require.NoError(t, err) + + pkScript, err := txscript.PayToTaprootScript(tapKey) + require.NoError(t, err) + + in := &TransferInput{ + VTXO: &vtxo.Descriptor{ + Outpoint: wire.OutPoint{ + Hash: [32]byte{1}, + Index: 2, + }, + Amount: btcutil.Amount(5000), + PkScript: pkScript, + ClientKey: keychain.KeyDescriptor{ + KeyLocator: keychain.KeyLocator{ + Family: 1, + Index: 2, + }, + PubKey: clientKey.PubKey(), + }, + OperatorKey: operatorKey.PubKey(), + TapScript: tapScript, + RelativeExpiry: exitDelay, + Status: vtxo.VTXOStatusLive, + }, + OwnerLeafScript: []byte{txscript.OP_1}, + } + + snap, err := in.ToSnapshot() + require.NoError(t, err) + require.NotNil(t, snap) + require.Equal(t, in.VTXO.Outpoint, snap.Outpoint) + require.Equal(t, int64(in.VTXO.Amount), snap.AmountSat) + require.Equal(t, int32(in.VTXO.ClientKey.KeyLocator.Family), + snap.ClientKeyFamily) + require.Equal(t, in.VTXO.ClientKey.KeyLocator.Index, + snap.ClientKeyIndex) + require.Equal(t, in.VTXO.ClientKey.PubKey.SerializeCompressed(), + snap.ClientPubKey) + require.Equal(t, in.VTXO.OperatorKey.SerializeCompressed(), + snap.OperatorPubKey) + require.Equal(t, in.VTXO.RelativeExpiry, snap.ExitDelay) + require.Equal(t, in.OwnerLeafScript, snap.OwnerLeafScript) + + rebuilt, err := TransferInputFromSnapshot(snap) + require.NoError(t, err) + require.NotNil(t, rebuilt.VTXO) + require.Equal(t, in.VTXO.Outpoint, rebuilt.VTXO.Outpoint) + require.Equal(t, in.VTXO.Amount, rebuilt.VTXO.Amount) + require.Equal(t, in.VTXO.PkScript, rebuilt.VTXO.PkScript) + require.Equal(t, in.VTXO.ClientKey.KeyLocator, + rebuilt.VTXO.ClientKey.KeyLocator) + require.Equal(t, in.VTXO.ClientKey.PubKey.SerializeCompressed(), + rebuilt.VTXO.ClientKey.PubKey.SerializeCompressed()) + require.Equal(t, in.VTXO.OperatorKey.SerializeCompressed(), + rebuilt.VTXO.OperatorKey.SerializeCompressed()) + require.Equal(t, in.VTXO.RelativeExpiry, rebuilt.VTXO.RelativeExpiry) + require.NotNil(t, rebuilt.VTXO.TapScript) + require.Equal(t, in.OwnerLeafScript, rebuilt.OwnerLeafScript) +} + +// TestTransferInputValidateRejectsNil asserts nil receivers are rejected. +func TestTransferInputValidateRejectsNil(t *testing.T) { + t.Parallel() + + var in *TransferInput + err := in.Validate() + require.Error(t, err) +} + +// TestTransferInputFromSnapshotRejectsMissingFields asserts malformed snapshots +// are rejected early. +func TestTransferInputFromSnapshotRejectsMissingFields(t *testing.T) { + t.Parallel() + + _, err := TransferInputFromSnapshot(nil) + require.Error(t, err) + + _, err = TransferInputFromSnapshot(&TransferInputSnapshot{}) + require.Error(t, err) + + _, err = TransferInputFromSnapshot(&TransferInputSnapshot{ + AmountSat: 1, + }) + require.Error(t, err) + + _, err = TransferInputFromSnapshot(&TransferInputSnapshot{ + AmountSat: 1, + ClientPubKey: []byte{0x02}, + OperatorPubKey: []byte{0x02}, + ExitDelay: 1, + OwnerLeafScript: []byte{0x51}, + }) + require.Error(t, err) +} + +// TestTransferInputToSnapshotRejectsMissingVTXO asserts we require a full VTXO +// descriptor before snapshotting. +func TestTransferInputToSnapshotRejectsMissingVTXO(t *testing.T) { + t.Parallel() + + in := &TransferInput{} + _, err := in.ToSnapshot() + require.Error(t, err) +} diff --git a/oor/transfer_inputs.go b/oor/transfer_inputs.go new file mode 100644 index 000000000..9d00fb8f9 --- /dev/null +++ b/oor/transfer_inputs.go @@ -0,0 +1,75 @@ +package oor + +import ( + "fmt" + + "github.com/btcsuite/btcd/wire" + oortx "github.com/lightninglabs/darepo-client/lib/tx/oor" + "github.com/lightninglabs/darepo-client/vtxo" +) + +// TransferInput describes a spendable VTXO being used as an input to an +// outgoing OOR transfer. +// +// The VTXO descriptor provides everything needed for client-side signing (key +// descriptor + tapscript). The OwnerLeafScript is the draft checkpoint output +// leaf script committed to in the checkpoint output tap tree. +type TransferInput struct { + // VTXO is the descriptor for the input VTXO being transferred. + VTXO *vtxo.Descriptor + + // OwnerLeafScript is the leaf script committed to the checkpoint tap + // tree. + // + // This is currently a draft implementation, and may change as the + // checkpoint policy is refined. + OwnerLeafScript []byte +} + +// Validate performs basic structural validation. +func (i *TransferInput) Validate() error { + switch { + case i == nil: + return fmt.Errorf("transfer input must be provided") + + case i.VTXO == nil: + return fmt.Errorf("vtxo must be provided") + + case i.VTXO.Amount <= 0: + return fmt.Errorf("vtxo amount must be positive") + + case len(i.VTXO.PkScript) == 0: + return fmt.Errorf("vtxo pkScript must be provided") + + case i.VTXO.TapScript == nil: + return fmt.Errorf("vtxo tapscript must be provided") + + case i.VTXO.ClientKey.PubKey == nil: + return fmt.Errorf("vtxo client key must be provided") + + case len(i.OwnerLeafScript) == 0: + return fmt.Errorf("owner leaf script must be provided") + } + + return nil +} + +// CheckpointInput converts the OOR transfer input into the common tx builder +// checkpoint input type. +func (i *TransferInput) CheckpointInput() (oortx.CheckpointInput, error) { + err := i.Validate() + if err != nil { + return oortx.CheckpointInput{}, err + } + + return oortx.CheckpointInput{ + SpentVTXO: oortx.SpentVTXORef{ + Outpoint: i.VTXO.Outpoint, + Output: &wire.TxOut{ + Value: int64(i.VTXO.Amount), + PkScript: i.VTXO.PkScript, + }, + }, + OwnerLeafScript: i.OwnerLeafScript, + }, nil +} diff --git a/oor/transitions.go b/oor/transitions.go index 6df68be6e..b78355c3d 100644 --- a/oor/transitions.go +++ b/oor/transitions.go @@ -30,22 +30,14 @@ 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. + // The Ark txid is the stable session identifier. ark, checkpoints, err := buildSubmitPackage( evt.Policy, evt.VTXOInputs, @@ -55,18 +47,59 @@ func (s *Idle) ProcessEvent(ctx context.Context, event Event, return nil, err } + if ark == nil || ark.UnsignedTx == nil { + return nil, fmt.Errorf("ark psbt must be provided") + } + + if len(checkpoints) == 0 { + return nil, fmt.Errorf("checkpoint psbts must be " + + "provided") + } + + inputOutpoints := make([]wire.OutPoint, 0, len(evt.VTXOInputs)) + for i := range evt.VTXOInputs { + if evt.VTXOInputs[i].VTXO == nil { + return nil, fmt.Errorf( + "checkpoint input vtxo required", + ) + } + inputOutpoints = append( + inputOutpoints, evt.VTXOInputs[i].VTXO.Outpoint, + ) + } + + // If the environment is already bound to a stable session id, + // verify the derived Ark txid matches. A mismatch indicates + // non-determinism in the builder or inconsistent state + // reconstruction. + if env != nil && env.SessionID != (SessionID{}) { + sessionID, err := sessionIDFromArk(ark) + if err != nil { + return nil, err + } + + if sessionID != env.SessionID { + return nil, fmt.Errorf("ark txid mismatch " + + "with session id") + } + } + + submitReq := &SendSubmitPackageRequest{ + ArkPSBT: ark, + CheckpointPSBTs: checkpoints, + TransferInputs: evt.VTXOInputs, + } + return &StateTransition{ NextState: &AwaitingSubmitAccepted{ InputOutpoints: inputOutpoints, ArkPSBT: ark, CheckpointPSBTs: checkpoints, + TransferInputs: evt.VTXOInputs, }, NewEvents: fn.Some(EmittedEvent{ Outbox: []OutboxEvent{ - &SendSubmitPackageRequest{ - ArkPSBT: ark, - CheckpointPSBTs: checkpoints, - }, + submitReq, }, }), }, nil @@ -103,6 +136,12 @@ func (s *AwaitingSubmitAccepted) ProcessEvent(ctx context.Context, event Event, } stateTxid := s.ArkPSBT.UnsignedTx.TxHash() + if evt.SessionID != SessionID(stateTxid) { + return nil, fmt.Errorf( + "submit accepted session id mismatch", + ) + } + evTxid := evt.ArkPSBT.UnsignedTx.TxHash() if stateTxid != evTxid { return nil, fmt.Errorf("ark txid mismatch") @@ -115,21 +154,24 @@ func (s *AwaitingSubmitAccepted) ProcessEvent(ctx context.Context, event Event, checkpoints := evt.CoSignedCheckpointPSBTs // Signature material is produced outside the FSM. - // The actor boundary uses a wallet to finalize checkpoints. + // Ask the outbox boundary to finalize checkpoints. + signReq := &RequestCheckpointSignatures{ + ArkPSBT: evt.ArkPSBT, + CoSignedCheckpointPSBTs: evt.CoSignedCheckpointPSBTs, + TransferInputs: s.TransferInputs, + } + return &StateTransition{ NextState: &AwaitingCheckpointSignatures{ SessionID: evt.SessionID, InputOutpoints: s.InputOutpoints, ArkPSBT: evt.ArkPSBT, CoSignedCheckpointPSBTs: checkpoints, + TransferInputs: s.TransferInputs, }, NewEvents: fn.Some(EmittedEvent{ Outbox: []OutboxEvent{ - &RequestCheckpointSignatures{ - ArkPSBT: evt.ArkPSBT, - CoSignedCheckpointPSBTs: evt. - CoSignedCheckpointPSBTs, - }, + signReq, }, }), }, nil @@ -293,7 +335,7 @@ func (s *Failed) ProcessEvent(ctx context.Context, event Event, // buildSubmitPackage constructs a v0 OOR submit package using the shared // darepo-client lib/tx/oor primitives. func buildSubmitPackage(policy scripts.CheckpointPolicy, - inputs []oortx.CheckpointInput, + inputs []TransferInput, outputs []oortx.RecipientOutput) (*psbt.Packet, []*psbt.Packet, error) { if len(inputs) == 0 { @@ -304,7 +346,14 @@ func buildSubmitPackage(policy scripts.CheckpointPolicy, checkpointOuts := make([]oortx.CheckpointOutput, 0, len(inputs)) for i := range inputs { - result, err := oortx.BuildCheckpointPSBT(policy, inputs[i]) + checkpointInput, err := inputs[i].CheckpointInput() + if err != nil { + return nil, nil, err + } + + result, err := oortx.BuildCheckpointPSBT( + policy, checkpointInput, + ) if err != nil { return nil, nil, err }