From 5a984e4ab34028c133748c42aefa11a163c60f9e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 3 Nov 2026 11:13:04 +0100 Subject: [PATCH 01/25] swapkit: Share Bitcoin script leaf builders Route existing HTLC, static address and reservation scripts through the same leaf serializers. Pin the quote templates so moving the serialization cannot change signed numbers or zero-hash fee estimates. --- go.mod | 2 +- instantout/reservation/script/script.go | 13 +++----- staticaddr/script/script.go | 12 +++---- swap/htlc.go | 27 +++++----------- swapkit/script.go | 42 +++++++++++++++++++++++++ swapkit/script_test.go | 30 ++++++++++++++++++ swapkit/swapkit.go | 4 +++ 7 files changed, 92 insertions(+), 38 deletions(-) create mode 100644 swapkit/script.go create mode 100644 swapkit/script_test.go create mode 100644 swapkit/swapkit.go diff --git a/go.mod b/go.mod index e55dcf2f6..72d366e22 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/urfave/cli-docs/v3 v3.1.1-0.20251020101624-bec07369b4f6 github.com/urfave/cli/v3 v3.4.1 go.etcd.io/bbolt v1.4.3 + golang.org/x/crypto v0.55.0 golang.org/x/sync v0.22.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d google.golang.org/grpc v1.83.2 @@ -179,7 +180,6 @@ require ( go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.24.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 // indirect golang.org/x/mod v0.38.0 // indirect golang.org/x/net v0.58.0 // indirect diff --git a/instantout/reservation/script/script.go b/instantout/reservation/script/script.go index a86504f52..ee1892f83 100644 --- a/instantout/reservation/script/script.go +++ b/instantout/reservation/script/script.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/loop/swapkit" "github.com/lightningnetwork/lnd/input" ) @@ -94,15 +95,9 @@ func PayToWitnessTaprootScript(taprootKey *btcec.PublicKey) ([]byte, error) { func TaprootExpiryScript(expiry uint32, serverKey *btcec.PublicKey) (*txscript.TapLeaf, error) { - builder := txscript.NewScriptBuilder() - - builder.AddData(schnorr.SerializePubKey(serverKey)) - builder.AddOp(txscript.OP_CHECKSIGVERIFY) - - builder.AddInt64(int64(expiry)) - builder.AddOp(txscript.OP_CHECKLOCKTIMEVERIFY) - - script, err := builder.Script() + script, err := swapkit.CLTVTimeoutScript( + [32]byte(schnorr.SerializePubKey(serverKey)), int64(expiry), + ) if err != nil { return nil, err } diff --git a/staticaddr/script/script.go b/staticaddr/script/script.go index 37e78a072..d0204ebb6 100644 --- a/staticaddr/script/script.go +++ b/staticaddr/script/script.go @@ -6,6 +6,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" "github.com/lightningnetwork/lnd/input" ) @@ -116,14 +117,9 @@ func (s *StaticAddress) StaticAddressScript() ([]byte, error) { func GenTimeoutPathScript(clientKey *btcec.PublicKey, csvExpiry int64) ([]byte, error) { - builder := txscript.NewScriptBuilder() - - builder.AddData(schnorr.SerializePubKey(clientKey)) - builder.AddOp(txscript.OP_CHECKSIGVERIFY) - builder.AddInt64(csvExpiry) - builder.AddOp(txscript.OP_CHECKSEQUENCEVERIFY) - - return builder.Script() + return swapkit.CSVTimeoutScript( + [32]byte(schnorr.SerializePubKey(clientKey)), csvExpiry, + ) } // GenSuccessWitness returns the success witness to spend the static address diff --git a/swap/htlc.go b/swap/htlc.go index 2e87d8aee..3683cd8e4 100644 --- a/swap/htlc.go +++ b/swap/htlc.go @@ -14,6 +14,7 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" secp "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/lightninglabs/loop/swapkit" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" ) @@ -622,12 +623,9 @@ func newHTLCScriptV3(muSig2Version input.MuSig2Version, cltvExpiry int32, func GenTimeoutPathScript(senderHtlcKey *btcec.PublicKey, cltvExpiry int64) ( []byte, error) { - builder := txscript.NewScriptBuilder() - builder.AddData(schnorr.SerializePubKey(senderHtlcKey)) - builder.AddOp(txscript.OP_CHECKSIGVERIFY) - builder.AddInt64(cltvExpiry) - builder.AddOp(txscript.OP_CHECKLOCKTIMEVERIFY) - return builder.Script() + return swapkit.CLTVTimeoutScript( + [32]byte(schnorr.SerializePubKey(senderHtlcKey)), cltvExpiry, + ) } // GenSuccessPathScript constructs an HtlcScript for the success payment path. @@ -640,20 +638,9 @@ func GenTimeoutPathScript(senderHtlcKey *btcec.PublicKey, cltvExpiry int64) ( func GenSuccessPathScript(receiverHtlcKey *btcec.PublicKey, swapHash lntypes.Hash) ([]byte, error) { - builder := txscript.NewScriptBuilder() - - builder.AddData(schnorr.SerializePubKey(receiverHtlcKey)) - builder.AddOp(txscript.OP_CHECKSIGVERIFY) - builder.AddOp(txscript.OP_SIZE) - builder.AddInt64(32) - builder.AddOp(txscript.OP_EQUALVERIFY) - builder.AddOp(txscript.OP_HASH160) - builder.AddData(input.Ripemd160H(swapHash[:])) - builder.AddOp(txscript.OP_EQUALVERIFY) - builder.AddInt64(1) - builder.AddOp(txscript.OP_CHECKSEQUENCEVERIFY) - - return builder.Script() + return swapkit.HTLCSuccessScript( + [32]byte(schnorr.SerializePubKey(receiverHtlcKey)), [32]byte(swapHash), + ) } // genControlBlock constructs the control block with the depth 1 leaf of the diff --git a/swapkit/script.go b/swapkit/script.go new file mode 100644 index 000000000..7cd53bcff --- /dev/null +++ b/swapkit/script.go @@ -0,0 +1,42 @@ +package swapkit + +import ( + "github.com/btcsuite/btcd/txscript" + "golang.org/x/crypto/ripemd160" //nolint:gosec,staticcheck // OP_HASH160. +) + +// HTLCSuccessScript serializes Loop's Bitcoin HTLC success leaf. The key is +// x-only. This primitive accepts any payment hash, including the zero hash used +// for fee estimation; funded contract validation belongs to the caller. +func HTLCSuccessScript(receiver [32]byte, paymentHash Hash) ([]byte, error) { + // #nosec G406 -- Bitcoin OP_HASH160 requires RIPEMD160. + hash160 := ripemd160.New() + _, _ = hash160.Write(paymentHash[:]) + return txscript.NewScriptBuilder(). + AddData(receiver[:]). + AddOp(txscript.OP_CHECKSIGVERIFY). + AddOp(txscript.OP_SIZE).AddInt64(32). + AddOp(txscript.OP_EQUALVERIFY). + AddOp(txscript.OP_HASH160).AddData(hash160.Sum(nil)). + AddOp(txscript.OP_EQUALVERIFY).AddInt64(1). + AddOp(txscript.OP_CHECKSEQUENCEVERIFY).Script() +} + +// CSVTimeoutScript serializes Loop's Bitcoin deposit refund leaf. The key is +// x-only. It preserves the caller's signed script number without applying +// contract policy; callers must validate expiry before funding an output. +func CSVTimeoutScript(sender [32]byte, expiry int64) ([]byte, error) { + return timeoutScript(sender, expiry, txscript.OP_CHECKSEQUENCEVERIFY) +} + +// CLTVTimeoutScript serializes Loop's V3 Bitcoin HTLC refund leaf. Like +// CSVTimeoutScript, it leaves expiry validation to the contract caller. +func CLTVTimeoutScript(sender [32]byte, expiry int64) ([]byte, error) { + return timeoutScript(sender, expiry, txscript.OP_CHECKLOCKTIMEVERIFY) +} + +func timeoutScript(sender [32]byte, expiry int64, opcode byte) ([]byte, error) { + return txscript.NewScriptBuilder().AddData(sender[:]). + AddOp(txscript.OP_CHECKSIGVERIFY).AddInt64(expiry). + AddOp(opcode).Script() +} diff --git a/swapkit/script_test.go b/swapkit/script_test.go new file mode 100644 index 000000000..ee7aec914 --- /dev/null +++ b/swapkit/script_test.go @@ -0,0 +1,30 @@ +package swapkit + +import ( + "encoding/hex" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestQuoteLeafEncodings pins the unfunded templates used by existing +// callers, including a zero payment hash and signed script numbers. +func TestQuoteLeafEncodings(t *testing.T) { + var key [32]byte + prefix := "20" + strings.Repeat("00", 32) + "ad" + + success, err := HTLCSuccessScript(key, Hash{}) + require.NoError(t, err) + const zeroPaymentHash160 = "d1a70126ff7a149ca6f9b638db084480440ff842" + require.Equal(t, prefix+"82012088a914"+zeroPaymentHash160+"8851b2", + hex.EncodeToString(success)) + + csv, err := CSVTimeoutScript(key, -1) + require.NoError(t, err) + require.Equal(t, prefix+"4fb2", hex.EncodeToString(csv)) + + cltv, err := CLTVTimeoutScript(key, 0) + require.NoError(t, err) + require.Equal(t, prefix+"00b1", hex.EncodeToString(cltv)) +} diff --git a/swapkit/swapkit.go b/swapkit/swapkit.go new file mode 100644 index 000000000..a80157e2f --- /dev/null +++ b/swapkit/swapkit.go @@ -0,0 +1,4 @@ +package swapkit + +// Hash is a binary 32-byte commitment or payment hash. +type Hash = [32]byte From 749c8aa6e9c79db9a1ca13eba3ae9ffef273734e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 3 Nov 2026 11:13:07 +0100 Subject: [PATCH 02/25] swapkit: Define asset deposit, reservation and HTLC contracts Define the key roles, cooperative and unilateral paths, witnesses and weight estimates for both directions. Exercise the legacy vectors and execute the spend conditions against the Bitcoin script engine. --- swapkit/README.md | 68 ++++ swapkit/doc.go | 33 ++ swapkit/example_test.go | 71 +++++ swapkit/roles_test.go | 259 ++++++++++++++++ swapkit/swapkit.go | 669 ++++++++++++++++++++++++++++++++++++++++ swapkit/swapkit_test.go | 472 ++++++++++++++++++++++++++++ swapkit/weight.go | 46 +++ swapkit/weight_test.go | 155 ++++++++++ 8 files changed, 1773 insertions(+) create mode 100644 swapkit/README.md create mode 100644 swapkit/doc.go create mode 100644 swapkit/example_test.go create mode 100644 swapkit/roles_test.go create mode 100644 swapkit/swapkit_test.go create mode 100644 swapkit/weight.go create mode 100644 swapkit/weight_test.go diff --git a/swapkit/README.md b/swapkit/README.md new file mode 100644 index 000000000..935bf0126 --- /dev/null +++ b/swapkit/README.md @@ -0,0 +1,68 @@ +# Shared asset swap contracts + +This package uses the Bitcoin contracts and flow records from Kon's static +asset Loop In implementation as the common contract layer for both asset swap +directions. The old asset deposit, HTLC and sweep engines are replaced by these +contracts and the native SDK adapter in [`tapsdk`](tapsdk/README.md). + +The root package is pure Bitcoin policy: fixed-size public keys, leaves, +control blocks, spend constraints and witness stacks. It has no RPC clients, +wallet implementation or Tap SDK dependency. Weight helpers use lnd's +transaction estimator types. Ordinary Bitcoin HTLC, +static-address and reservation helpers call the same leaf serializers here. +The primitive serializers preserve signed expiry encodings and zero-hash quote +templates; funded constructors validate their keys, hashes and timelocks. + +| Constructor | Cooperative path | Unilateral refund | +| --- | --- | --- | +| `NewDepositKit` | Sorted MuSig2 | Sender CSV delay | +| `NewReservationKit` | Sorted MuSig2 | Server/sender CLTV height | +| `NewSwapKit(SwapHTLCLegacyCSV)` | Sorted MuSig2 | Sender CSV delay | +| `NewSwapKit(SwapHTLCStaticLoopInV3)` | Sorted MuSig2 | Sender CLTV height | + +Both HTLC policies share the receiver signature and correct 32-byte preimage +success path, including its existing one-block CSV. `SwapHTLCCLTV` is a +same-value, direction-neutral alias for `SwapHTLCStaticLoopInV3`. Policy values +and legacy contract vectors remain stable. + +Sender and receiver follow the direction of the on-chain assets. For Loop In, +the client sends and refunds, and the server claims with the preimage. For Loop +Out, the server sends and refunds, and the client claims. Internal keys control +the cooperative MuSig2 path; script keys control unilateral paths. A party may +reuse its own key across those roles, while cross-party x-only aliases are +rejected. + +`SwapKitParams` retains Kon's `SenderKey`, `ReceiverKey`, internal-key fields, +`PaymentHash` and policy-selected `Expiry`. `SenderScriptKey` and +`ReceiverScriptKey` are explicit script-role aliases; conflicting aliases are +rejected. `DepositKitParams` retains the original `ClientKey`/`ServerKey` form, +or accepts all four explicit roles. `ReservationKitParams` similarly accepts +`ServerKey`/`ClientKey`, or four roles, with a distinct `RefundHeight`. Mixing +the two-key convenience form and explicit roles is rejected. + +Supply a verified complete asset commitment root to construct spend +information, then compare its Bitcoin script with the authenticated prevout. +Apply the returned sequence and locktime before signing any inputs, including +fee inputs. CSV uses a relative block delay; CLTV uses a non-final sequence with +BIP68 disabled and an absolute height. New CLTV refunds use `0xfffffffd` +to signal RBF; already signed transactions keep their original sequence. Chain maturity, confirmation depth, +Lightning deadlines, invoice state and authority release remain owned by the +calling FSM. + +The [`tapsdk`](tapsdk/README.md) adapter owns conversion to native SDK requests, +proof validation and package handling. The SDK owns generic transfer +construction, allocation, virtual transitions, proof processing and sealed +recovery packages. LND and tapd retain their normal signing and publication +roles. The asset-level OP_TRUE descriptor is independent of the Bitcoin +contract; the fixed NUMS construction remains the policy for new contracts in +this integration, and funded outputs retain their original descriptor. + +`SwapAnchorSpendInfo.MaxSuccessWitnessSize` and `MaxTimeoutWitnessSize` +include the complete asset control block and a maximum 65-byte signature. +`AddSuccessToEstimator` and `AddTimeoutToEstimator` apply these sizes to lnd's +transaction weight estimator. They describe witnesses emitted by this kit, +without an annex. + +Run `go test ./swapkit` from the Loop module. Tests freeze legacy contract +vectors and execute valid and adversarial Bitcoin witnesses, including +independent key roles and server-funded CLTV reservations. diff --git a/swapkit/doc.go b/swapkit/doc.go new file mode 100644 index 000000000..b32ced21e --- /dev/null +++ b/swapkit/doc.go @@ -0,0 +1,33 @@ +// Package swapkit owns the Bitcoin policies and flow records shared by asset +// Loop In and Loop Out. It uses Kon's static asset Loop In contracts directly; +// swapkit/tapsdk adapts those contracts to the native Tap SDK transfer, signing +// request, proof and recovery APIs. This package itself has no SDK, wallet, +// tapd or lnd dependency. +// +// NewDepositKit constructs the cooperative MuSig2 deposit and sender CSV refund +// used by static Loop In. Its original ClientKey/ServerKey form remains valid; +// callers needing separate cooperative and script keys supply all four roles. +// NewReservationKit constructs a server-funded reservation with an absolute +// RefundHeight and sender CLTV refund. It never reinterprets a CSV delay. +// +// NewSwapKit requires an explicit HTLC policy. SwapHTLCLegacyCSV retains the +// original relative refund; SwapHTLCStaticLoopInV3 and its direction-neutral +// SwapHTLCCLTV alias select the absolute refund. Both retain the receiver's +// signature, 32-byte preimage and one-block CSV on success. Sender and receiver +// follow the on-chain asset direction: client/server for Loop In and +// server/client for Loop Out. Their cooperative internal and script signing +// roles remain separate. +// +// AnchorPlan supplies the cooperative key and Bitcoin policy tree. Spend +// information requires a verified complete asset commitment root, which is a +// sibling of that tree. Before signing any input, compare the returned script +// with the authenticated prevout and apply its sequence and locktime. CLTV +// spends use a non-final sequence with BIP68 disabled. These helpers do not +// establish chain maturity, Lightning deadlines, provenance or authorization +// to expose a preimage or a signature. The calling FSM owns those decisions. +// +// Asset script keys are selected by the SDK adapter, independently of these +// Bitcoin policies. The adapter retains the fixed NUMS OP_TRUE construction +// for existing contracts and must preserve the exact descriptor of any funded +// output throughout signing, publication, proof delivery and recovery. +package swapkit diff --git a/swapkit/example_test.go b/swapkit/example_test.go new file mode 100644 index 000000000..939d1f696 --- /dev/null +++ b/swapkit/example_test.go @@ -0,0 +1,71 @@ +package swapkit_test + +import ( + "crypto/sha256" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/loop/swapkit" +) + +func ExampleNewSwapKit() { + _, client := btcec.PrivKeyFromBytes([]byte{1}) + _, server := btcec.PrivKeyFromBytes([]byte{2}) + clientKey := swapkit.PubKey(client.SerializeCompressed()) + serverKey := swapkit.PubKey(server.SerializeCompressed()) + preimage := [32]byte{42} + // In Loop Out the server sends assets and owns the refund path. + kit, err := swapkit.NewSwapKit(swapkit.SwapKitParams{ + Policy: swapkit.SwapHTLCCLTV, + SenderInternalKey: serverKey, ReceiverInternalKey: clientKey, + SenderScriptKey: serverKey, ReceiverScriptKey: clientKey, + PaymentHash: sha256.Sum256(preimage[:]), Expiry: 1_000_000, + }) + if err != nil { + panic(err) + } + spend, err := kit.TimeoutSpend(swapkit.Hash{1}) + if err != nil { + panic(err) + } + fmt.Println(spend.Sequence, spend.LockTime) + // Output: 4294967293 1000000 +} + +func ExampleNewDepositKit() { + _, client := btcec.PrivKeyFromBytes([]byte{1}) + _, server := btcec.PrivKeyFromBytes([]byte{2}) + kit, err := swapkit.NewDepositKit(swapkit.DepositKitParams{ + ClientKey: swapkit.PubKey(client.SerializeCompressed()), + ServerKey: swapkit.PubKey(server.SerializeCompressed()), + CSVExpiry: 144, + }) + if err != nil { + panic(err) + } + spend, err := kit.TimeoutSpend(swapkit.Hash{1}) + if err != nil { + panic(err) + } + fmt.Println(spend.Sequence, spend.LockTime) + // Output: 144 0 +} + +func ExampleNewReservationKit() { + _, client := btcec.PrivKeyFromBytes([]byte{1}) + _, server := btcec.PrivKeyFromBytes([]byte{2}) + kit, err := swapkit.NewReservationKit(swapkit.ReservationKitParams{ + ClientKey: swapkit.PubKey(client.SerializeCompressed()), + ServerKey: swapkit.PubKey(server.SerializeCompressed()), + RefundHeight: 1_000_000, + }) + if err != nil { + panic(err) + } + spend, err := kit.TimeoutSpend(swapkit.Hash{1}) + if err != nil { + panic(err) + } + fmt.Println(spend.Sequence, spend.LockTime) + // Output: 4294967293 1000000 +} diff --git a/swapkit/roles_test.go b/swapkit/roles_test.go new file mode 100644 index 000000000..5864cb976 --- /dev/null +++ b/swapkit/roles_test.go @@ -0,0 +1,259 @@ +package swapkit + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// TestSwapKitIndependentRoles executes both HTLC versions in both on-chain +// directions with separate cooperative and unilateral signing keys. +func TestSwapKitIndependentRoles(t *testing.T) { + for _, policy := range []SwapHTLCPolicy{ + SwapHTLCLegacyCSV, SwapHTLCCLTV, + } { + for _, reverse := range []bool{false, true} { + name := swapKitTestName(policy, false, "loop in") + if reverse { + name = swapKitTestName(policy, false, "loop out") + } + t.Run(name, func(t *testing.T) { + params := swapKitTestParams(t) + params.Policy = policy + params.SenderKey, params.ReceiverKey = testKey(t, 3), testKey(t, 4) + sender, receiver := byte(3), byte(4) + senderInternal, receiverInternal := byte(1), byte(2) + if reverse { + params.SenderKey, params.ReceiverKey = params.ReceiverKey, params.SenderKey + params.SenderInternalKey, params.ReceiverInternalKey = + params.ReceiverInternalKey, params.SenderInternalKey + sender, receiver = receiver, sender + senderInternal, receiverInternal = receiverInternal, senderInternal + } + kit, err := NewSwapKit(params) + require.NoError(t, err) + root := swapKitTestAssetRoot() + for _, success := range []bool{false, true} { + spend, err := kit.TimeoutSpend(root) + key, wrong := sender, senderInternal + if success { + spend, err = kit.SuccessSpend(root) + key, wrong = receiver, receiverInternal + } + require.NoError(t, err) + for _, scalar := range []byte{key, wrong} { + tx, previous, signature := swapKitSignTestTx(t, spend, scalar, nil) + if success { + tx.TxIn[0].Witness, err = kit.SuccessWitness(root, + signature, [32]byte{42}) + } else { + tx.TxIn[0].Witness, err = kit.TimeoutWitness(root, signature) + } + require.NoError(t, err) + err = swapKitExecuteTestTx(tx, previous) + if scalar == key { + require.NoError(t, err) + } else { + require.Error(t, err) + } + } + } + }) + } + } +} + +func TestSwapKitKeyRoleValidation(t *testing.T) { + for _, opposite := range []bool{false, true} { + for _, internal := range []bool{false, true} { + params := swapKitTestParams(t) + params.SenderKey, params.ReceiverKey = testKey(t, 3), testKey(t, 4) + alias := params.SenderInternalKey + if internal { + alias = params.SenderKey + } + if opposite { + alias[0] ^= 1 + } + params.ReceiverKey = alias + _, err := NewSwapKit(params) + require.Error(t, err) + params.ReceiverKey = testKey(t, 4) + params.ReceiverInternalKey = alias + _, err = NewSwapKit(params) + require.Error(t, err) + } + } + params := swapKitTestParams(t) + params.SenderKey, params.ReceiverKey = PubKey{}, PubKey{} + params.SenderScriptKey, params.ReceiverScriptKey = testKey(t, 3), testKey(t, 4) + kit, err := NewSwapKit(params) + require.NoError(t, err) + require.Equal(t, params.SenderScriptKey, kit.Params().SenderKey) + require.Equal(t, params.ReceiverScriptKey, kit.Params().ReceiverKey) + params.SenderKey = testKey(t, 1) + _, err = NewSwapKit(params) + require.ErrorContains(t, err, "conflicting sender") + params.SenderKey = PubKey{} + params.ReceiverKey = testKey(t, 2) + _, err = NewSwapKit(params) + require.ErrorContains(t, err, "conflicting receiver") +} + +// TestDepositKitIndependentRoles retains the existing CSV deposit while +// separating its funder's cooperative key from its refund signer. +func TestDepositKitIndependentRoles(t *testing.T) { + params := DepositKitParams{ + SenderInternalKey: testKey(t, 1), ReceiverInternalKey: testKey(t, 2), + SenderScriptKey: testKey(t, 3), ReceiverScriptKey: testKey(t, 4), + CSVExpiry: 144, + } + kit, err := NewDepositKit(params) + require.NoError(t, err) + spend, err := kit.TimeoutSpend(swapKitTestAssetRoot()) + require.NoError(t, err) + require.Equal(t, [32]byte(params.SenderScriptKey[1:]), spend.RequiredSigner) + for _, scalar := range []byte{3, 1, 2, 4} { + tx, previous, signature := swapKitSignTestTx(t, spend, scalar, nil) + tx.TxIn[0].Witness, err = kit.TimeoutWitness(swapKitTestAssetRoot(), signature) + require.NoError(t, err) + err = swapKitExecuteTestTx(tx, previous) + if scalar == 3 { + require.NoError(t, err) + } else { + require.Error(t, err) + } + } + invalid := params + invalid.ClientKey = testKey(t, 1) + _, err = NewDepositKit(invalid) + require.ErrorContains(t, err, "cannot mix") + invalid = params + invalid.ReceiverScriptKey = PubKey{} + _, err = NewDepositKit(invalid) + require.Error(t, err) + invalid = params + invalid.ReceiverScriptKey = params.SenderInternalKey + _, err = NewDepositKit(invalid) + require.Error(t, err) +} + +// TestReservationKit executes the server-funded absolute refund independently +// of static deposits' CSV clock and rejects early or wrong-key refunds. +func TestReservationKit(t *testing.T) { + params := ReservationKitParams{ + ServerKey: testKey(t, 2), ClientKey: testKey(t, 1), RefundHeight: 1_000_000, + } + legacy, err := NewReservationKit(params) + require.NoError(t, err) + require.Equal(t, "20c6047f9441ed7d6d3045406e95c07cd85c778e"+ + "4b8cef3ca7abac09b95c709ee5ad0340420fb1", + hex.EncodeToString(legacy.TimeoutScript())) + spend, err := legacy.TimeoutSpend(swapKitTestAssetRoot()) + require.NoError(t, err) + require.Len(t, spend.ControlBlock, 65) + require.Equal(t, wire.MaxTxInSequenceNum-2, spend.Sequence) + require.Equal(t, params.RefundHeight, spend.LockTime) + require.Equal(t, [32]byte(params.ServerKey[1:]), spend.RequiredSigner) + for _, test := range []struct { + name string + mutate func(*wire.MsgTx) + key byte + valid bool + }{ + {name: "server refund", key: 2, valid: true}, + {name: "client cannot refund", key: 1}, + {name: "early", key: 2, mutate: func(tx *wire.MsgTx) { tx.LockTime-- }}, + {name: "later", key: 2, valid: true, mutate: func(tx *wire.MsgTx) { tx.LockTime++ }}, + {name: "final sequence", key: 2, mutate: func(tx *wire.MsgTx) { + tx.TxIn[0].Sequence = wire.MaxTxInSequenceNum + }}, + {name: "timestamp", key: 2, mutate: func(tx *wire.MsgTx) { + tx.LockTime = txscript.LockTimeThreshold + }}, + } { + t.Run(test.name, func(t *testing.T) { + tx, previous, signature := swapKitSignTestTx(t, spend, test.key, test.mutate) + tx.TxIn[0].Witness, err = legacy.TimeoutWitness(swapKitTestAssetRoot(), signature) + require.NoError(t, err) + err = swapKitExecuteTestTx(tx, previous) + if test.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } + explicit, err := NewReservationKit(ReservationKitParams{ + SenderInternalKey: params.ServerKey, ReceiverInternalKey: params.ClientKey, + SenderScriptKey: testKey(t, 4), ReceiverScriptKey: testKey(t, 3), + RefundHeight: params.RefundHeight, + }) + require.NoError(t, err) + require.Equal(t, legacy.AnchorPlan().InternalKey, explicit.AnchorPlan().InternalKey) + spend, err = explicit.TimeoutSpend(swapKitTestAssetRoot()) + require.NoError(t, err) + for _, scalar := range []byte{4, 2} { + tx, previous, signature := swapKitSignTestTx(t, spend, scalar, nil) + tx.TxIn[0].Witness, err = explicit.TimeoutWitness(swapKitTestAssetRoot(), signature) + require.NoError(t, err) + err = swapKitExecuteTestTx(tx, previous) + if scalar == 4 { + require.NoError(t, err) + } else { + require.Error(t, err) + } + } +} + +func TestReservationKitValidation(t *testing.T) { + params := ReservationKitParams{ServerKey: testKey(t, 2), ClientKey: testKey(t, 1)} + for _, height := range []uint32{0, txscript.LockTimeThreshold, ^uint32(0)} { + params.RefundHeight = height + _, err := NewReservationKit(params) + require.Error(t, err) + } + for _, height := range []uint32{1, txscript.LockTimeThreshold - 1} { + params.RefundHeight = height + _, err := NewReservationKit(params) + require.NoError(t, err) + } + params.RefundHeight = 100 + params.SenderScriptKey = params.ServerKey + _, err := NewReservationKit(params) + require.ErrorContains(t, err, "cannot mix") + params.SenderScriptKey = PubKey{} + params.ClientKey = params.ServerKey + _, err = NewReservationKit(params) + require.Error(t, err) +} + +func TestSwapKitSnapshots(t *testing.T) { + params := swapKitTestParams(t) + kit, err := NewSwapKit(params) + require.NoError(t, err) + plan := kit.AnchorPlan() + plan.Leaves[0][0] ^= 1 + participants := kit.CooperativeParticipants() + participants[0][0] ^= 1 + copyParams := kit.Params() + copyParams.PaymentHash = sha256.Sum256([]byte{1}) + require.Equal(t, params, kit.Params()) + require.NotEqual(t, participants, kit.CooperativeParticipants()) + require.NotEqual(t, plan.Leaves, kit.AnchorPlan().Leaves) + spend, err := kit.SuccessSpend(swapKitTestAssetRoot()) + require.NoError(t, err) + spend.LeafScript[0] ^= 1 + spend.PkScript[0] ^= 1 + spend.ControlBlock[0] ^= 1 + fresh, err := kit.SuccessSpend(swapKitTestAssetRoot()) + require.NoError(t, err) + require.False(t, bytes.Equal(spend.LeafScript, fresh.LeafScript)) + require.NotEqual(t, spend.PkScript, fresh.PkScript) + require.NotEqual(t, spend.ControlBlock, fresh.ControlBlock) +} diff --git a/swapkit/swapkit.go b/swapkit/swapkit.go index a80157e2f..08f96f96f 100644 --- a/swapkit/swapkit.go +++ b/swapkit/swapkit.go @@ -1,4 +1,673 @@ package swapkit +import ( + "bytes" + "crypto/sha256" + "fmt" + "slices" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" +) + +// PubKey is a compressed secp256k1 key. It has no wallet dependency. +type PubKey = [33]byte + // Hash is a binary 32-byte commitment or payment hash. type Hash = [32]byte + +// AnchorPlan contains the aggregate Bitcoin internal key and policy leaves. +type AnchorPlan struct { + InternalKey PubKey + Leaves [][]byte +} + +// SwapHTLCPolicy identifies the immutable Bitcoin contract of an asset HTLC. +type SwapHTLCPolicy uint8 + +const ( + // SwapHTLCUnspecified forces callers to select a contract explicitly. + SwapHTLCUnspecified SwapHTLCPolicy = iota + + // SwapHTLCLegacyCSV preserves the original asset deposit HTLC scripts. + // Expiry is a relative refund delay in blocks. + SwapHTLCLegacyCSV + + // SwapHTLCStaticLoopInV3 uses Loop's V3 Bitcoin HTLC scripts. Expiry is + // an absolute refund block height in a separate on-chain contract. + SwapHTLCStaticLoopInV3 +) + +const ( + // SwapHTLCCLTV is the direction-neutral name of the same CLTV policy. + // Sender refunds and receiver claims follow the on-chain asset direction. + SwapHTLCCLTV = SwapHTLCStaticLoopInV3 +) + +// SwapKitParams specifies the Bitcoin policy for an asset HTLC. Asset identity, +// amounts, and the asset-level OP_TRUE descriptor are selected separately. +type SwapKitParams struct { + Policy SwapHTLCPolicy + + // SenderKey authorizes refunds; ReceiverKey authorizes preimage claims. + // The sender is the client for Loop In and the server for Loop Out. + SenderKey PubKey + ReceiverKey PubKey + + // SenderScriptKey and ReceiverScriptKey are explicit script-role aliases. + // They may replace SenderKey/ReceiverKey, or equal them when both are set. + SenderScriptKey PubKey + ReceiverScriptKey PubKey + + // Internal keys authorize the cooperative MuSig2 key path. They may + // equal the corresponding script keys, but must be supplied explicitly. + SenderInternalKey PubKey + ReceiverInternalKey PubKey + + // PaymentHash is SHA256 of the 32-byte success preimage. + PaymentHash Hash + + // Expiry is a block delay or height, as selected by Policy. + Expiry uint32 +} + +// SwapKit constructs an OP_TRUE asset HTLC whose authorization resides on its +// Bitcoin anchor. Its policy is immutable after construction. It does not own +// Lightning invoices, pricing, signing sessions, or preimage release policy. +type SwapKit struct { + params SwapKitParams + anchor swapAnchorPolicy + success []byte + timeout []byte +} + +// NewSwapKit validates and constructs an asset HTLC policy. +func NewSwapKit(params SwapKitParams) (*SwapKit, error) { + if params.SenderScriptKey != (PubKey{}) { + if params.SenderKey != (PubKey{}) && + params.SenderKey != params.SenderScriptKey { + + return nil, fmt.Errorf("conflicting sender script keys") + } + params.SenderKey = params.SenderScriptKey + } + if params.ReceiverScriptKey != (PubKey{}) { + if params.ReceiverKey != (PubKey{}) && + params.ReceiverKey != params.ReceiverScriptKey { + + return nil, fmt.Errorf("conflicting receiver script keys") + } + params.ReceiverKey = params.ReceiverScriptKey + } + if err := validateContractKeyRoles(params.SenderInternalKey, + params.ReceiverInternalKey, params.SenderKey, + params.ReceiverKey); err != nil { + return nil, fmt.Errorf("HTLC keys: %w", err) + } + if params.PaymentHash == (Hash{}) { + return nil, fmt.Errorf("payment hash is required") + } + + var timeoutOp byte + switch params.Policy { + case SwapHTLCLegacyCSV: + if err := validateSwapCSV(params.Expiry); err != nil { + return nil, err + } + if params.Expiry <= 1 { + return nil, fmt.Errorf("HTLC CSV expiry must exceed the " + + "one-block success delay") + } + timeoutOp = txscript.OP_CHECKSEQUENCEVERIFY + + case SwapHTLCStaticLoopInV3: + if params.Expiry == 0 || + params.Expiry >= txscript.LockTimeThreshold { + + return nil, fmt.Errorf("expiry must be a block height") + } + timeoutOp = txscript.OP_CHECKLOCKTIMEVERIFY + + default: + return nil, fmt.Errorf( + "unsupported HTLC policy %d", params.Policy, + ) + } + + success, err := GenSuccessScript(params.ReceiverKey, params.PaymentHash) + if err != nil { + return nil, err + } + timeout, err := swapTimeoutScript( + params.SenderKey, params.Expiry, timeoutOp, + ) + if err != nil { + return nil, err + } + anchor, err := newSwapAnchorPolicy( + params.SenderInternalKey, params.ReceiverInternalKey, + [][]byte{success, timeout}, + ) + if err != nil { + return nil, err + } + + return &SwapKit{ + params: params, + anchor: anchor, + success: success, + timeout: timeout, + }, nil +} + +// Params returns a value copy of the immutable contract parameters. +func (s *SwapKit) Params() SwapKitParams { + return s.params +} + +// AnchorPlan returns the cooperative internal key and Bitcoin HTLC leaves. +func (s *SwapKit) AnchorPlan() AnchorPlan { + return s.anchor.plan() +} + +// CooperativeParticipants returns the exact sorted MuSig2 participant order. +func (s *SwapKit) CooperativeParticipants() []PubKey { + return slices.Clone(s.anchor.participants) +} + +// SuccessScript returns an independent copy of the Bitcoin success leaf. +func (s *SwapKit) SuccessScript() []byte { + return bytes.Clone(s.success) +} + +// TimeoutScript returns an independent copy of the Bitcoin refund leaf. +func (s *SwapKit) TimeoutScript() []byte { + return bytes.Clone(s.timeout) +} + +// SuccessSpend constructs the receiver's Bitcoin script path. assetRoot must +// come from the verified custom-anchor output, not an ownership challenge. The +// spending transaction must have version >= 2 and sequence >= 1 block. +func (s *SwapKit) SuccessSpend(assetRoot Hash) (*SwapAnchorSpendInfo, error) { + return s.anchor.spend( + assetRoot, s.success, s.params.ReceiverKey, 1, 0, + ) +} + +// TimeoutSpend constructs the sender's Bitcoin refund path. Legacy policy +// requires CSV; StaticLoopInV3 requires nLockTime >= Expiry and a non-final +// input sequence. New CLTV refunds opt into replacement without enabling +// BIP68 relative locks. Maturity must also be checked against the chain. +func (s *SwapKit) TimeoutSpend(assetRoot Hash) (*SwapAnchorSpendInfo, error) { + sequence, lockTime := s.params.Expiry, uint32(0) + if s.params.Policy == SwapHTLCStaticLoopInV3 { + sequence, lockTime = wire.MaxTxInSequenceNum-2, s.params.Expiry + } + + return s.anchor.spend( + assetRoot, s.timeout, s.params.SenderKey, sequence, lockTime, + ) +} + +// SuccessWitness constructs the Bitcoin success witness after checking the +// preimage. The SDK signing flow verifies the signature against the actual +// transaction; this helper does not sign or release the preimage to a peer. +// ApplyScriptPathWitness accepts only the unlocking prefix: omit the final +// script and control block, which the SDK obtains from its sealed package. +func (s *SwapKit) SuccessWitness(assetRoot Hash, signature []byte, + preimage [32]byte) ([][]byte, error) { + + if sha256.Sum256(preimage[:]) != s.params.PaymentHash { + return nil, fmt.Errorf("preimage does not match payment hash") + } + spend, err := s.SuccessSpend(assetRoot) + if err != nil { + return nil, err + } + return spend.witness(signature, preimage[:]) +} + +// TimeoutWitness constructs the sender's Bitcoin refund witness. +// ApplyScriptPathWitness accepts only the signature, not the final script and +// control block, which the SDK obtains from its sealed package. +func (s *SwapKit) TimeoutWitness(assetRoot Hash, + signature []byte) ([][]byte, error) { + + spend, err := s.TimeoutSpend(assetRoot) + if err != nil { + return nil, err + } + return spend.witness(signature, nil) +} + +// DepositKitParams specifies a cooperative asset deposit with a unilateral, +// block-based CSV refund to the sender, matching Loop's static address. +type DepositKitParams struct { + // ClientKey and ServerKey preserve the original two-key constructor. + // Use these together, or supply all four explicit roles below. + ClientKey PubKey + ServerKey PubKey + + SenderInternalKey PubKey + ReceiverInternalKey PubKey + SenderScriptKey PubKey + ReceiverScriptKey PubKey + CSVExpiry uint32 +} + +// DepositKit constructs an asset deposit whose Bitcoin anchor requires both +// participants to spend cooperatively, or the sender after CSV expiry. The +// legacy two-key constructor makes the client the sender. +type DepositKit struct { + params DepositKitParams + anchor swapAnchorPolicy + senderScript PubKey + timeout []byte +} + +// NewDepositKit validates and constructs a static asset deposit policy. +func NewDepositKit(params DepositKitParams) (*DepositKit, error) { + if err := validateSwapCSV(params.CSVExpiry); err != nil { + return nil, err + } + keys, err := resolveFundingKeys(params.ClientKey, params.ServerKey, + params.SenderInternalKey, params.ReceiverInternalKey, + params.SenderScriptKey, params.ReceiverScriptKey) + if err != nil { + return nil, fmt.Errorf("deposit keys: %w", err) + } + timeout, err := swapTimeoutScript( + keys.senderScript, params.CSVExpiry, + txscript.OP_CHECKSEQUENCEVERIFY, + ) + if err != nil { + return nil, err + } + anchor, err := newSwapAnchorPolicy( + keys.senderInternal, keys.receiverInternal, [][]byte{timeout}, + ) + if err != nil { + return nil, err + } + + return &DepositKit{ + params: params, anchor: anchor, + senderScript: keys.senderScript, timeout: timeout, + }, nil +} + +// Params returns a value copy of the deposit parameters. +func (d *DepositKit) Params() DepositKitParams { + return d.params +} + +// AnchorPlan returns the cooperative internal key and Bitcoin refund leaf. +func (d *DepositKit) AnchorPlan() AnchorPlan { + return d.anchor.plan() +} + +// CooperativeParticipants returns the exact sorted MuSig2 participant order. +func (d *DepositKit) CooperativeParticipants() []PubKey { + return slices.Clone(d.anchor.participants) +} + +// TimeoutScript returns an independent copy of the Bitcoin refund leaf. +func (d *DepositKit) TimeoutScript() []byte { + return bytes.Clone(d.timeout) +} + +// TimeoutSpend constructs the sender's Bitcoin refund path. The transaction +// needs version >= 2 and sequence >= CSVExpiry blocks. Check chain maturity. +func (d *DepositKit) TimeoutSpend(assetRoot Hash) ( + *SwapAnchorSpendInfo, error) { + + return d.anchor.spend( + assetRoot, d.timeout, d.senderScript, d.params.CSVExpiry, 0, + ) +} + +// TimeoutWitness constructs the deposit's unilateral Bitcoin refund witness. +// ApplyScriptPathWitness accepts only the signature, not the final script and +// control block, which the SDK obtains from its sealed package. +func (d *DepositKit) TimeoutWitness(assetRoot Hash, + signature []byte) ([][]byte, error) { + + spend, err := d.TimeoutSpend(assetRoot) + if err != nil { + return nil, err + } + return spend.witness(signature, nil) +} + +// ReservationKitParams specifies a server-funded cooperative reservation with +// an absolute block-height refund. It never interprets a CSV delay as a height. +// ServerKey/ClientKey are the two-key convenience form; new callers may instead +// supply all four explicit roles, with the server as sender and refund signer. +type ReservationKitParams struct { + ServerKey PubKey + ClientKey PubKey + + SenderInternalKey PubKey + ReceiverInternalKey PubKey + SenderScriptKey PubKey + ReceiverScriptKey PubKey + RefundHeight uint32 +} + +// ReservationKit constructs the server-funded reservation policy. The server +// can refund after CLTV expiry; before that, spending requires cooperation. +type ReservationKit struct { + params ReservationKitParams + anchor swapAnchorPolicy + senderScript PubKey + timeout []byte +} + +// NewReservationKit validates and constructs a CLTV asset reservation. +func NewReservationKit(params ReservationKitParams) (*ReservationKit, error) { + if params.RefundHeight == 0 || + params.RefundHeight >= txscript.LockTimeThreshold { + + return nil, fmt.Errorf("reservation refund must be a block height") + } + keys, err := resolveFundingKeys(params.ServerKey, params.ClientKey, + params.SenderInternalKey, params.ReceiverInternalKey, + params.SenderScriptKey, params.ReceiverScriptKey) + if err != nil { + return nil, fmt.Errorf("reservation keys: %w", err) + } + timeout, err := swapTimeoutScript(keys.senderScript, + params.RefundHeight, txscript.OP_CHECKLOCKTIMEVERIFY) + if err != nil { + return nil, err + } + anchor, err := newSwapAnchorPolicy(keys.senderInternal, + keys.receiverInternal, [][]byte{timeout}) + if err != nil { + return nil, err + } + return &ReservationKit{params: params, anchor: anchor, + senderScript: keys.senderScript, timeout: timeout}, nil +} + +// Params returns a value copy of the immutable reservation parameters. +func (r *ReservationKit) Params() ReservationKitParams { return r.params } + +// AnchorPlan returns the cooperative internal key and reservation refund leaf. +func (r *ReservationKit) AnchorPlan() AnchorPlan { return r.anchor.plan() } + +// CooperativeParticipants returns the exact sorted MuSig2 participant order. +func (r *ReservationKit) CooperativeParticipants() []PubKey { + return slices.Clone(r.anchor.participants) +} + +// TimeoutScript returns an independent copy of the server's CLTV refund leaf. +func (r *ReservationKit) TimeoutScript() []byte { return bytes.Clone(r.timeout) } + +// TimeoutSpend constructs the server's refund path. Apply the height locktime +// and non-final input sequence before signing; the template opts into +// replacement without enabling BIP68. Check maturity against the chain. +func (r *ReservationKit) TimeoutSpend(assetRoot Hash) (*SwapAnchorSpendInfo, error) { + return r.anchor.spend(assetRoot, r.timeout, r.senderScript, + wire.MaxTxInSequenceNum-2, r.params.RefundHeight) +} + +// TimeoutWitness constructs the complete reservation refund witness. The SDK +// signing API instead takes only the signature prefix and adds script/control. +func (r *ReservationKit) TimeoutWitness(assetRoot Hash, + signature []byte) ([][]byte, error) { + + spend, err := r.TimeoutSpend(assetRoot) + if err != nil { + return nil, err + } + return spend.witness(signature, nil) +} + +// SwapAnchorSpendInfo contains Loop-owned Bitcoin script-path material. Compare +// PkScript with the authenticated prevout before signing. It does not prove +// issuance, provenance, amount, confirmation, or timelock maturity. +type SwapAnchorSpendInfo struct { + InternalKey PubKey + TaprootMerkleRoot Hash + PkScript []byte + LeafScript []byte + ControlBlock []byte + RequiredSigner [32]byte + Sequence uint32 + LockTime uint32 +} + +func (s *SwapAnchorSpendInfo) witness(signature, + preimage []byte) ([][]byte, error) { + + if err := validateSwapSignature(signature); err != nil { + return nil, err + } + witness := make([][]byte, 0, 4) + if preimage != nil { + witness = append(witness, bytes.Clone(preimage)) + } + return append( + witness, bytes.Clone(signature), bytes.Clone(s.LeafScript), + bytes.Clone(s.ControlBlock), + ), nil +} + +type swapAnchorPolicy struct { + internalKey PubKey + participants []PubKey + leaves [][]byte +} + +func newSwapAnchorPolicy(first, second PubKey, + leaves [][]byte) (swapAnchorPolicy, error) { + + if err := validateSwapKeyPair(first, second); err != nil { + return swapAnchorPolicy{}, fmt.Errorf("MuSig2 keys: %w", err) + } + participants := []PubKey{first, second} + slices.SortFunc(participants, func(first, second PubKey) int { + return bytes.Compare(first[:], second[:]) + }) + keys := make([]*btcec.PublicKey, len(participants)) + for i := range participants { + keys[i], _ = btcec.ParsePubKey(participants[i][:]) + } + aggregate, _, _, err := musig2.AggregateKeys(keys, false) + if err != nil { + return swapAnchorPolicy{}, fmt.Errorf("aggregate keys: %w", err) + } + key, err := parsePubKey(aggregate.PreTweakedKey.SerializeCompressed()) + if err != nil { + return swapAnchorPolicy{}, err + } + return swapAnchorPolicy{ + internalKey: key, participants: participants, leaves: leaves, + }, nil +} + +func (p swapAnchorPolicy) plan() AnchorPlan { + leaves := make([][]byte, len(p.leaves)) + for i := range p.leaves { + leaves[i] = bytes.Clone(p.leaves[i]) + } + return AnchorPlan{InternalKey: p.internalKey, Leaves: leaves} +} + +func (p swapAnchorPolicy) spend(assetRoot Hash, script []byte, signer PubKey, + sequence, lockTime uint32) (*SwapAnchorSpendInfo, error) { + + if assetRoot == (Hash{}) { + return nil, fmt.Errorf("asset commitment root is required") + } + leaves := make([]txscript.TapLeaf, len(p.leaves)) + for i := range p.leaves { + leaves[i] = txscript.NewBaseTapLeaf(p.leaves[i]) + } + tree := txscript.AssembleTaprootScriptTree(leaves...) + leafHash := txscript.NewBaseTapLeaf(script).TapHash() + proof := tree.LeafMerkleProofs[tree.LeafProofIndex[leafHash]] + internalKey, err := btcec.ParsePubKey(p.internalKey[:]) + if err != nil { + return nil, err + } + control := proof.ToControlBlock(internalKey) + control.InclusionProof = append( + bytes.Clone(control.InclusionProof), assetRoot[:]..., + ) + root := control.RootHash(script) + outputKey := txscript.ComputeTaprootOutputKey(internalKey, root) + control.OutputKeyYIsOdd = outputKey.SerializeCompressed()[0] == 0x03 + controlBytes, err := control.ToBytes() + if err != nil { + return nil, err + } + pkScript, err := txscript.PayToTaprootScript(outputKey) + if err != nil { + return nil, err + } + return &SwapAnchorSpendInfo{ + InternalKey: p.internalKey, + TaprootMerkleRoot: Hash(root), + PkScript: pkScript, + LeafScript: bytes.Clone(script), + ControlBlock: controlBytes, + RequiredSigner: [32]byte(signer[1:]), + Sequence: sequence, + LockTime: lockTime, + }, nil +} + +func swapTimeoutScript(key PubKey, expiry uint32, opcode byte) ([]byte, error) { + return timeoutScript([32]byte(key[1:]), int64(expiry), opcode) +} + +func validateSwapCSV(expiry uint32) error { + if expiry == 0 || expiry > wire.SequenceLockTimeMask { + return fmt.Errorf("CSV expiry must be 1 to 65535 blocks") + } + return nil +} + +func validateSwapKeyPair(first, second PubKey) error { + if err := validatePubKey("first key", first); err != nil { + return err + } + if err := validatePubKey("second key", second); err != nil { + return err + } + if bytes.Equal(first[1:], second[1:]) { + return fmt.Errorf("participant keys must differ") + } + return nil +} + +func validateSwapSignature(signature []byte) error { + if len(signature) != schnorr.SignatureSize && + len(signature) != schnorr.SignatureSize+1 { + + return fmt.Errorf("signature must be 64 or 65 bytes") + } + if len(signature) == schnorr.SignatureSize+1 { + switch txscript.SigHashType(signature[schnorr.SignatureSize]) { + case txscript.SigHashAll, txscript.SigHashNone, + txscript.SigHashSingle, + txscript.SigHashAll | txscript.SigHashAnyOneCanPay, + txscript.SigHashNone | txscript.SigHashAnyOneCanPay, + txscript.SigHashSingle | txscript.SigHashAnyOneCanPay: + + default: + return fmt.Errorf("invalid signature hash type") + } + } + _, err := schnorr.ParseSignature(signature[:schnorr.SignatureSize]) + if err != nil { + return fmt.Errorf("invalid Schnorr signature: %w", err) + } + return nil +} + +func parsePubKey(encoded []byte) (PubKey, error) { + if len(encoded) != 33 { + return PubKey{}, fmt.Errorf("public key must be 33 bytes") + } + if _, err := btcec.ParsePubKey(encoded); err != nil { + return PubKey{}, err + } + return PubKey(encoded), nil +} + +func validatePubKey(name string, key PubKey) error { + if _, err := parsePubKey(key[:]); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + return nil +} + +// GenSuccessScript constructs the shared Bitcoin HTLC success leaf. +func GenSuccessScript(receiver PubKey, paymentHash Hash) ([]byte, error) { + if err := validatePubKey("receiver key", receiver); err != nil { + return nil, err + } + if paymentHash == (Hash{}) { + return nil, fmt.Errorf("payment hash is required") + } + return HTLCSuccessScript([32]byte(receiver[1:]), paymentHash) +} + +// GenCSVTimeoutScript constructs the shared block-based Bitcoin refund leaf. +func GenCSVTimeoutScript(sender PubKey, expiry uint32) ([]byte, error) { + if err := validatePubKey("sender key", sender); err != nil { + return nil, err + } + if err := validateSwapCSV(expiry); err != nil { + return nil, err + } + return swapTimeoutScript( + sender, expiry, txscript.OP_CHECKSEQUENCEVERIFY, + ) +} + +type fundingKeys struct { + senderInternal, receiverInternal PubKey + senderScript, receiverScript PubKey +} + +func resolveFundingKeys(legacySender, legacyReceiver, senderInternal, + receiverInternal, senderScript, receiverScript PubKey) (fundingKeys, error) { + + explicit := senderInternal != (PubKey{}) || receiverInternal != (PubKey{}) || + senderScript != (PubKey{}) || receiverScript != (PubKey{}) + if explicit { + if legacySender != (PubKey{}) || legacyReceiver != (PubKey{}) { + return fundingKeys{}, fmt.Errorf("cannot mix legacy and explicit key roles") + } + } else { + senderInternal, senderScript = legacySender, legacySender + receiverInternal, receiverScript = legacyReceiver, legacyReceiver + } + if err := validateContractKeyRoles(senderInternal, receiverInternal, + senderScript, receiverScript); err != nil { + return fundingKeys{}, err + } + return fundingKeys{senderInternal: senderInternal, + receiverInternal: receiverInternal, senderScript: senderScript, + receiverScript: receiverScript}, nil +} + +func validateContractKeyRoles(senderInternal, receiverInternal, + senderScript, receiverScript PubKey) error { + + for _, sender := range []PubKey{senderInternal, senderScript} { + for _, receiver := range []PubKey{receiverInternal, receiverScript} { + if err := validateSwapKeyPair(sender, receiver); err != nil { + return err + } + } + } + return nil +} diff --git a/swapkit/swapkit_test.go b/swapkit/swapkit_test.go new file mode 100644 index 000000000..0fb713184 --- /dev/null +++ b/swapkit/swapkit_test.go @@ -0,0 +1,472 @@ +package swapkit + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// TestSwapKitContractVectors freezes the Bitcoin contract vectors from Loop's +// shared-asset-kit HTLC tests, including the extra asset commitment sibling. +// V3 differs only in its CLTV refund opcode for these otherwise equal inputs. +func TestSwapKitContractVectors(t *testing.T) { + params := swapKitTestParams(t) + for i := range params.PaymentHash { + params.PaymentHash[i] = byte(i) + } + root := swapKitTestAssetRoot() + const successHex = "20c6047f9441ed7d6d3045406e95c07cd85c778e" + + "4b8cef3ca7abac09b95c709ee5ad82012088a914e6babb9619d7a812" + + "72711fc546a16b211dd939578851b2" + const timeoutPrefix = "2079be667ef9dcbbac55a06295ce870b07029bfcdb2d" + + "ce28d959f2815b16f81798ad029000" + const aggregateHex = "023b46d262d2f610e9038b44beabdfe97ab5a0feb" + + "89870acc2264edfb7f63ec2ec" + const successControlHex = "c13b46d262d2f610e9038b44beabdfe97ab5a0feb" + + "89" + + "870acc2264edfb7f63ec2eccbf40123d0ca1191c0dba575c7a92ea255" + + "f9e9fc81484e3929afeaa1a6f459d6a0a1a2a3a4a5a6a7a8a9aaab" + + "acadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf" + const timeoutControlHex = "c13b46d262d2f610e9038b44beabdfe97ab5a0feb" + + "89" + + "870acc2264edfb7f63ec2ec00114fbfd01b5de82dfa6f54f0f27af41" + + "d5a66ed39ac1a87d614fa12808f9adfa0a1a2a3a4a5a6a7a8a9aaab" + + "acadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf" + const anchorHex = "512021b15bddf2d991b8791ec7c2105fa69be61008d78" + + "b67d261451290d7a44dce5f" + + legacy, err := NewSwapKit(params) + require.NoError(t, err) + success, err := legacy.SuccessSpend(root) + require.NoError(t, err) + timeout, err := legacy.TimeoutSpend(root) + require.NoError(t, err) + require.Equal(t, successHex, hex.EncodeToString(success.LeafScript)) + require.Equal(t, timeoutPrefix+"b2", + hex.EncodeToString(timeout.LeafScript)) + anchorPlan := legacy.AnchorPlan() + require.Equal(t, aggregateHex, + hex.EncodeToString(anchorPlan.InternalKey[:])) + require.Equal(t, successControlHex, + hex.EncodeToString(success.ControlBlock)) + require.Equal(t, timeoutControlHex, + hex.EncodeToString(timeout.ControlBlock)) + require.Equal(t, anchorHex, hex.EncodeToString(success.PkScript)) + require.Equal(t, success.PkScript, timeout.PkScript) + + params.Policy = SwapHTLCStaticLoopInV3 + v3, err := NewSwapKit(params) + require.NoError(t, err) + v3Success, err := v3.SuccessSpend(root) + require.NoError(t, err) + v3Timeout, err := v3.TimeoutSpend(root) + require.NoError(t, err) + require.Equal(t, successHex, hex.EncodeToString(v3Success.LeafScript)) + require.Equal(t, timeoutPrefix+"b1", + hex.EncodeToString(v3Timeout.LeafScript)) + require.NotEqual(t, success.PkScript, v3Success.PkScript) + require.Equal(t, uint32(144), v3Timeout.LockTime) + require.Equal(t, wire.MaxTxInSequenceNum-2, v3Timeout.Sequence) + + deposit, err := NewDepositKit(DepositKitParams{ + ClientKey: params.SenderKey, ServerKey: params.ReceiverKey, + CSVExpiry: 144, + }) + require.NoError(t, err) + depositTimeout, err := deposit.TimeoutSpend(root) + require.NoError(t, err) + require.Equal(t, timeout.LeafScript, depositTimeout.LeafScript) + require.Equal(t, legacy.AnchorPlan().InternalKey, + deposit.AnchorPlan().InternalKey) + require.Len(t, depositTimeout.ControlBlock, 65) + require.Len(t, success.ControlBlock, 97) +} + +// TestSwapKitScriptExecution verifies authorization and timelock boundaries in +// the Bitcoin VM, signing each altered transaction afresh so a failed timelock +// cannot be masked by an unrelated invalid signature. +func TestSwapKitScriptExecution(t *testing.T) { + for _, policy := range []SwapHTLCPolicy{ + SwapHTLCLegacyCSV, SwapHTLCStaticLoopInV3, + } { + for _, success := range []bool{true, false} { + name := swapKitTestName(policy, success, "") + t.Run(name, func(t *testing.T) { + testSwapKitScriptExecution(t, policy, success) + }) + } + } +} + +func testSwapKitScriptExecution(t *testing.T, policy SwapHTLCPolicy, + success bool) { + + t.Helper() + params := swapKitTestParams(t) + params.Policy = policy + kit, err := NewSwapKit(params) + require.NoError(t, err) + type executionCase struct { + name string + mutateTx func(*wire.MsgTx) + mutate func([][]byte) + wrongKey bool + valid bool + } + tests := []executionCase{ + {name: "valid", valid: true}, + {name: "wrong signer", wrongKey: true}, + { + name: "wrong asset commitment", + mutate: func(w [][]byte) { + w[len(w)-1][len(w[len(w)-1])-1] ^= 1 + }, + }, + } + if success || policy == SwapHTLCLegacyCSV { + tests = append(tests, []executionCase{ + { + name: "early CSV", + mutateTx: func(tx *wire.MsgTx) { + tx.TxIn[0].Sequence-- + }, + }, + { + name: "version one", + mutateTx: func(tx *wire.MsgTx) { + tx.Version = 1 + }, + }, + { + name: "CSV disabled", + mutateTx: func(tx *wire.MsgTx) { + tx.TxIn[0].Sequence = 0x80000090 + }, + }, + { + name: "time-based CSV", + mutateTx: func(tx *wire.MsgTx) { + tx.TxIn[0].Sequence = 0x00400090 + }, + }, + }...) + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := swapKitTestAssetRoot() + spend, scalar := swapKitTestSpend(t, kit, root, success) + if test.wrongKey { + scalar = 3 + } + tx, prevOut, sig := swapKitSignTestTx( + t, spend, scalar, test.mutateTx, + ) + var witness [][]byte + var err error + if success { + witness, err = kit.SuccessWitness( + root, sig, [32]byte{42}, + ) + } else { + witness, err = kit.TimeoutWitness(root, sig) + } + require.NoError(t, err) + if test.mutate != nil { + test.mutate(witness) + } + tx.TxIn[0].Witness = witness + err = swapKitExecuteTestTx(tx, prevOut) + if test.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +// TestSwapKitCLTVAndPreimage checks both on-chain enforcement and the kit's +// preimage boundary, including transaction locktime type and final sequences. +func TestSwapKitCLTVAndPreimage(t *testing.T) { + params := swapKitTestParams(t) + params.Policy = SwapHTLCStaticLoopInV3 + kit, err := NewSwapKit(params) + require.NoError(t, err) + root := swapKitTestAssetRoot() + spend, err := kit.TimeoutSpend(root) + require.NoError(t, err) + for _, test := range []struct { + name string + mutate func(*wire.MsgTx) + }{ + {"early height", func(tx *wire.MsgTx) { tx.LockTime-- }}, + {"time lock", func(tx *wire.MsgTx) { + tx.LockTime = 500_000_000 + }}, + {"final sequence", func(tx *wire.MsgTx) { + tx.TxIn[0].Sequence = wire.MaxTxInSequenceNum + }}, + } { + t.Run(test.name, func(t *testing.T) { + tx, prevOut, sig := swapKitSignTestTx( + t, spend, 1, test.mutate, + ) + tx.TxIn[0].Witness, err = kit.TimeoutWitness(root, sig) + require.NoError(t, err) + require.Error(t, swapKitExecuteTestTx(tx, prevOut)) + }) + } + + spend, err = kit.SuccessSpend(root) + require.NoError(t, err) + for _, preimage := range [][]byte{bytes.Repeat([]byte{7}, 32), {42}} { + tx, prevOut, sig := swapKitSignTestTx(t, spend, 2, nil) + witness, err := kit.SuccessWitness(root, sig, [32]byte{42}) + require.NoError(t, err) + witness[0] = preimage + tx.TxIn[0].Witness = witness + require.Error(t, swapKitExecuteTestTx(tx, prevOut)) + _, err = kit.SuccessWitness(root, sig, [32]byte{7}) + require.ErrorContains(t, err, "preimage does not match") + } +} + +// TestDepositKitScriptExecution verifies that the client alone can refund only +// with the correct version and block-based CSV sequence. +func TestDepositKitScriptExecution(t *testing.T) { + params := swapKitTestParams(t) + kit, err := NewDepositKit(DepositKitParams{ + ClientKey: params.SenderKey, ServerKey: params.ReceiverKey, + CSVExpiry: 144, + }) + require.NoError(t, err) + root := swapKitTestAssetRoot() + spend, err := kit.TimeoutSpend(root) + require.NoError(t, err) + for _, test := range []struct { + name string + mutate func(*wire.MsgTx) + key byte + valid bool + }{ + {name: "mature", key: 1, valid: true}, + {name: "server cannot refund", key: 2}, + {name: "early", key: 1, mutate: func(tx *wire.MsgTx) { + tx.TxIn[0].Sequence-- + }}, + {name: "version one", key: 1, mutate: func(tx *wire.MsgTx) { + tx.Version = 1 + }}, + {name: "CSV disabled", key: 1, mutate: func(tx *wire.MsgTx) { + tx.TxIn[0].Sequence |= wire.SequenceLockTimeDisabled + }}, + {name: "time-based CSV", key: 1, mutate: func(tx *wire.MsgTx) { + tx.TxIn[0].Sequence |= wire.SequenceLockTimeIsSeconds + }}, + } { + t.Run(test.name, func(t *testing.T) { + tx, prevOut, sig := swapKitSignTestTx( + t, spend, test.key, test.mutate, + ) + tx.TxIn[0].Witness, err = kit.TimeoutWitness(root, sig) + require.NoError(t, err) + err = swapKitExecuteTestTx(tx, prevOut) + if test.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +// TestSwapKitValidation rejects malformed Bitcoin contract parameters. +func TestSwapKitValidation(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*SwapKitParams) + }{ + {"no policy", func(p *SwapKitParams) { + p.Policy = SwapHTLCUnspecified + }}, + {"unknown policy", func(p *SwapKitParams) { p.Policy = 99 }}, + {"zero hash", func(p *SwapKitParams) { + p.PaymentHash = Hash{} + }}, + {"bad key", func(p *SwapKitParams) { + p.SenderKey = PubKey{} + }}, + {"same key", func(p *SwapKitParams) { + p.ReceiverKey = p.SenderKey + }}, + {"opposite key", func(p *SwapKitParams) { + p.ReceiverKey = p.SenderKey + p.ReceiverKey[0] ^= 1 + }}, + {"same internal key", func(p *SwapKitParams) { + p.ReceiverInternalKey = p.SenderInternalKey + }}, + {"zero expiry", func(p *SwapKitParams) { p.Expiry = 0 }}, + {"no claim margin", func(p *SwapKitParams) { p.Expiry = 1 }}, + {"large CSV", func(p *SwapKitParams) { p.Expiry = 65536 }}, + {"timestamp CLTV", func(p *SwapKitParams) { + p.Policy, p.Expiry = SwapHTLCStaticLoopInV3, 500_000_000 + }}, + } { + t.Run(test.name, func(t *testing.T) { + params := swapKitTestParams(t) + test.mutate(¶ms) + _, err := NewSwapKit(params) + require.Error(t, err) + }) + } + + kit, err := NewSwapKit(swapKitTestParams(t)) + require.NoError(t, err) + _, err = kit.SuccessSpend(Hash{}) + require.ErrorContains(t, err, "asset commitment root") + _, err = kit.TimeoutWitness(swapKitTestAssetRoot(), nil) + require.ErrorContains(t, err, "signature must be") +} + +// TestSwapKitSignatureEncoding rejects malformed Schnorr encodings and the +// forbidden explicit SIGHASH_DEFAULT byte; a valid explicit hash mode spends. +func TestSwapKitSignatureEncoding(t *testing.T) { + kit, err := NewSwapKit(swapKitTestParams(t)) + require.NoError(t, err) + root := swapKitTestAssetRoot() + spend, err := kit.TimeoutSpend(root) + require.NoError(t, err) + tx, prevOut, signature := swapKitSignTestTx(t, spend, 1, nil) + for _, invalid := range [][]byte{ + nil, signature[:63], append(bytes.Clone(signature), 0), + append(bytes.Clone(signature), 4), + append(bytes.Clone(signature), 0x80), + bytes.Repeat([]byte{0xff}, 64), + } { + _, err := kit.TimeoutWitness(root, invalid) + require.Error(t, err) + } + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + keyBytes := make([]byte, 32) + keyBytes[31] = 1 + key, _ := btcec.PrivKeyFromBytes(keyBytes) + signature, err = txscript.RawTxInTapscriptSignature( + tx, txscript.NewTxSigHashes(tx, fetcher), 0, prevOut.Value, + prevOut.PkScript, txscript.NewBaseTapLeaf(spend.LeafScript), + txscript.SigHashAll, key, + ) + require.NoError(t, err) + require.Len(t, signature, 65) + tx.TxIn[0].Witness, err = kit.TimeoutWitness(root, signature) + require.NoError(t, err) + require.NoError(t, swapKitExecuteTestTx(tx, prevOut)) + signature[0] ^= 1 + require.NotEqual(t, signature, tx.TxIn[0].Witness[0]) +} + +func swapKitTestParams(t *testing.T) SwapKitParams { + t.Helper() + sender := testKey(t, 1) + receiver := testKey(t, 2) + preimage := [32]byte{42} + return SwapKitParams{ + Policy: SwapHTLCLegacyCSV, + SenderKey: sender, ReceiverKey: receiver, + SenderInternalKey: sender, ReceiverInternalKey: receiver, + PaymentHash: sha256.Sum256(preimage[:]), + Expiry: 144, + } +} + +func swapKitTestAssetRoot() Hash { + var root Hash + for i := range root { + root[i] = byte(0xa0 + i) + } + return root +} + +func swapKitTestName(policy SwapHTLCPolicy, success bool, name string) string { + prefix := "legacy/" + if policy == SwapHTLCStaticLoopInV3 { + prefix = "v3/" + } + if success { + return prefix + "success/" + name + } + return prefix + "timeout/" + name +} + +func swapKitTestSpend(t *testing.T, kit *SwapKit, root Hash, + success bool) (*SwapAnchorSpendInfo, byte) { + + t.Helper() + if success { + spend, err := kit.SuccessSpend(root) + require.NoError(t, err) + return spend, 2 + } + spend, err := kit.TimeoutSpend(root) + require.NoError(t, err) + return spend, 1 +} + +func swapKitSignTestTx(t *testing.T, spend *SwapAnchorSpendInfo, + scalar byte, mutate func(*wire.MsgTx)) ( + *wire.MsgTx, *wire.TxOut, []byte) { + + t.Helper() + tx := wire.NewMsgTx(2) + tx.LockTime = spend.LockTime + tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Index: 1}, nil, nil)) + tx.TxIn[0].Sequence = spend.Sequence + tx.AddTxOut(&wire.TxOut{Value: 900, PkScript: spend.PkScript}) + prevOut := &wire.TxOut{Value: 1_000, PkScript: spend.PkScript} + if mutate != nil { + mutate(tx) + } + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + keyBytes := make([]byte, 32) + keyBytes[31] = scalar + key, _ := btcec.PrivKeyFromBytes(keyBytes) + sig, err := txscript.RawTxInTapscriptSignature( + tx, txscript.NewTxSigHashes(tx, fetcher), 0, prevOut.Value, + prevOut.PkScript, txscript.NewBaseTapLeaf(spend.LeafScript), + txscript.SigHashDefault, key, + ) + require.NoError(t, err) + return tx, prevOut, sig +} + +func swapKitExecuteTestTx(tx *wire.MsgTx, prevOut *wire.TxOut) error { + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + vm, err := txscript.NewEngine( + prevOut.PkScript, tx, 0, txscript.StandardVerifyFlags, nil, + txscript.NewTxSigHashes(tx, fetcher), prevOut.Value, fetcher, + ) + if err != nil { + return err + } + return vm.Execute() +} + +func testKey(t *testing.T, scalar byte) PubKey { + t.Helper() + encoded := make([]byte, 32) + encoded[31] = scalar + _, key := btcec.PrivKeyFromBytes(encoded) + return PubKey(key.SerializeCompressed()) +} diff --git a/swapkit/weight.go b/swapkit/weight.go new file mode 100644 index 000000000..aa8caf57b --- /dev/null +++ b/swapkit/weight.go @@ -0,0 +1,46 @@ +package swapkit + +import ( + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" +) + +// MaxSuccessWitnessSize returns the serialized size of a success witness with +// a 32-byte preimage and the largest accepted Schnorr signature, including its +// optional sighash byte. It uses the actual leaf and control block, including +// the asset commitment sibling. Witnesses produced by the kit have no annex. +func (s *SwapAnchorSpendInfo) MaxSuccessWitnessSize() lntypes.WeightUnit { + var signature [schnorr.SignatureSize + 1]byte + var preimage [32]byte + return lntypes.WeightUnit(wire.TxWitness{ + preimage[:], signature[:], s.LeafScript, s.ControlBlock, + }.SerializeSize()) +} + +// MaxTimeoutWitnessSize returns the serialized size of a refund witness with +// the largest accepted Schnorr signature, including its optional sighash byte. +// It uses the actual leaf and asset-aware control block and includes no annex. +func (s *SwapAnchorSpendInfo) MaxTimeoutWitnessSize() lntypes.WeightUnit { + var signature [schnorr.SignatureSize + 1]byte + return lntypes.WeightUnit(wire.TxWitness{ + signature[:], s.LeafScript, s.ControlBlock, + }.SerializeSize()) +} + +// AddSuccessToEstimator adds the complete success input to a weight estimate. +// Its leaf and asset commitment proof are already included in the witness size. +func (s *SwapAnchorSpendInfo) AddSuccessToEstimator( + estimator *input.TxWeightEstimator) { + + estimator.AddWitnessInput(s.MaxSuccessWitnessSize()) +} + +// AddTimeoutToEstimator adds the complete refund input to a weight estimate. +// Its leaf and asset commitment proof are already included in the witness size. +func (s *SwapAnchorSpendInfo) AddTimeoutToEstimator( + estimator *input.TxWeightEstimator) { + + estimator.AddWitnessInput(s.MaxTimeoutWitnessSize()) +} diff --git a/swapkit/weight_test.go b/swapkit/weight_test.go new file mode 100644 index 000000000..bae81a585 --- /dev/null +++ b/swapkit/weight_test.go @@ -0,0 +1,155 @@ +package swapkit + +import ( + "bytes" + "testing" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/stretchr/testify/require" +) + +// TestAssetWitnessWeights compares both estimators with complete witnesses and +// transaction serializations for every supported asset anchor contract. +func TestAssetWitnessWeights(t *testing.T) { + root := swapKitTestAssetRoot() + params := swapKitTestParams(t) + type spendCase struct { + name string + spend *SwapAnchorSpendInfo + success bool + witness func([]byte) ([][]byte, error) + } + var cases []spendCase + for _, policy := range []SwapHTLCPolicy{ + SwapHTLCLegacyCSV, SwapHTLCCLTV, + } { + params.Policy = policy + kit, err := NewSwapKit(params) + require.NoError(t, err) + for _, success := range []bool{false, true} { + spend, _ := swapKitTestSpend(t, kit, root, success) + require.Len(t, spend.ControlBlock, 97) + witness := func(signature []byte) ([][]byte, error) { + return kit.TimeoutWitness(root, signature) + } + if success { + witness = func(signature []byte) ([][]byte, error) { + return kit.SuccessWitness(root, signature, [32]byte{42}) + } + } + cases = append(cases, spendCase{ + name: swapKitTestName(policy, success, ""), + spend: spend, success: success, witness: witness, + }) + } + } + deposit, err := NewDepositKit(DepositKitParams{ + ClientKey: params.SenderKey, ServerKey: params.ReceiverKey, + CSVExpiry: 144, + }) + require.NoError(t, err) + depositSpend, err := deposit.TimeoutSpend(root) + require.NoError(t, err) + require.Len(t, depositSpend.ControlBlock, 65) + cases = append(cases, spendCase{ + name: "deposit CSV refund", spend: depositSpend, + witness: func(signature []byte) ([][]byte, error) { + return deposit.TimeoutWitness(root, signature) + }, + }) + reservation, err := NewReservationKit(ReservationKitParams{ + ServerKey: params.SenderKey, ClientKey: params.ReceiverKey, + RefundHeight: 1_000_000, + }) + require.NoError(t, err) + reservationSpend, err := reservation.TimeoutSpend(root) + require.NoError(t, err) + require.Len(t, reservationSpend.ControlBlock, 65) + cases = append(cases, spendCase{ + name: "reservation CLTV refund", spend: reservationSpend, + witness: func(signature []byte) ([][]byte, error) { + return reservation.TimeoutWitness(root, signature) + }, + }) + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + key := byte(1) + if test.success { + key = 2 + } + tx, _, signature := swapKitSignTestTx(t, test.spend, key, nil) + for _, explicitSighash := range []bool{false, true} { + signature := bytes.Clone(signature) + if explicitSighash { + signature = append(signature, byte(txscript.SigHashAll)) + } + tx.TxIn[0].Witness, err = test.witness(signature) + require.NoError(t, err) + var full, stripped bytes.Buffer + require.NoError(t, tx.Serialize(&full)) + require.NoError(t, tx.SerializeNoWitness(&stripped)) + witnessSize := tx.TxIn[0].Witness.SerializeSize() + require.Equal(t, witnessSize, full.Len()-stripped.Len()-2) + + var estimator input.TxWeightEstimator + maxSize := test.spend.MaxTimeoutWitnessSize() + if test.success { + maxSize = test.spend.MaxSuccessWitnessSize() + test.spend.AddSuccessToEstimator(&estimator) + } else { + test.spend.AddTimeoutToEstimator(&estimator) + } + estimator.AddTxOutput(tx.TxOut[0]) + padding := schnorr.SignatureSize + 1 - len(signature) + require.EqualValues(t, witnessSize+padding, maxSize) + weight := 3*stripped.Len() + full.Len() + padding + require.Equal(t, lntypes.WeightUnit(weight), estimator.Weight()) + } + }) + } +} + +// TestCLTVRefundRBFDefaults checks new templates signal replacement while the +// old non-RBF sequence still satisfies the same immutable funded contract. +func TestCLTVRefundRBFDefaults(t *testing.T) { + root := swapKitTestAssetRoot() + params := swapKitTestParams(t) + params.Policy = SwapHTLCCLTV + htlc, err := NewSwapKit(params) + require.NoError(t, err) + htlcSpend, err := htlc.TimeoutSpend(root) + require.NoError(t, err) + reservation, err := NewReservationKit(ReservationKitParams{ + ServerKey: params.SenderKey, ClientKey: params.ReceiverKey, + RefundHeight: params.Expiry, + }) + require.NoError(t, err) + reservationSpend, err := reservation.TimeoutSpend(root) + require.NoError(t, err) + for name, spend := range map[string]*SwapAnchorSpendInfo{ + "HTLC": htlcSpend, "reservation": reservationSpend, + } { + t.Run(name, func(t *testing.T) { + require.EqualValues(t, 0xfffffffd, spend.Sequence) + require.Less(t, spend.Sequence, wire.MaxTxInSequenceNum-1) + require.NotZero(t, spend.Sequence&wire.SequenceLockTimeDisabled) + for _, sequence := range []uint32{ + spend.Sequence, wire.MaxTxInSequenceNum - 1, + } { + tx, previous, signature := swapKitSignTestTx( + t, spend, 1, func(tx *wire.MsgTx) { + tx.TxIn[0].Sequence = sequence + }, + ) + tx.TxIn[0].Witness, err = spend.witness(signature, nil) + require.NoError(t, err) + require.NoError(t, swapKitExecuteTestTx(tx, previous)) + } + }) + } +} From 0d63469193bd8fd11282b4fb4f620461dfd0abd8 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 3 Nov 2026 11:13:14 +0100 Subject: [PATCH 03/25] tapsdk: Use tap-sdk v0.1.1 and require Taproot Assets v0.8.8 Build the adapter against released pre-btcd-v2 modules and retain the bounded legacy TLV decoder. Map contracts into SDK plans and reuse the authenticated wallet connection. Define the shared runtime version floor and operation-scoped checks. Warn about older backends during daemon initialization while leaving ordinary payments and offline witness construction available. --- .github/actions/lit-setup/action.yml | 8 + assets/tapd_version.go | 42 + assets/tapd_version_test.go | 131 ++ go.mod | 45 +- go.sum | 1264 ++++++++++++++++-- internal/assetpolicy/tapd_version.go | 142 ++ internal/assetpolicy/tapd_version_test.go | 71 + loopd/daemon.go | 9 + swapkit/asset_types.go | 10 + swapkit/tapsdk/asset.go | 106 ++ swapkit/tapsdk/asset_test.go | 119 ++ swapkit/tapsdk/binding_test.go | 7 + swapkit/tapsdk/builder_fixture_test.go | 128 ++ swapkit/tapsdk/client.go | 108 ++ swapkit/tapsdk/client_test.go | 207 +++ swapkit/tapsdk/proof_tlv_test.go | 53 + swapkit/tapsdk/swapkit.go | 74 + swapkit/tapsdk/swapkit_test.go | 147 ++ swapkit/tapsdk/testdata/builder_request.json | 385 ++++++ 19 files changed, 2923 insertions(+), 133 deletions(-) create mode 100644 assets/tapd_version.go create mode 100644 assets/tapd_version_test.go create mode 100644 internal/assetpolicy/tapd_version.go create mode 100644 internal/assetpolicy/tapd_version_test.go create mode 100644 swapkit/asset_types.go create mode 100644 swapkit/tapsdk/asset.go create mode 100644 swapkit/tapsdk/asset_test.go create mode 100644 swapkit/tapsdk/binding_test.go create mode 100644 swapkit/tapsdk/builder_fixture_test.go create mode 100644 swapkit/tapsdk/client.go create mode 100644 swapkit/tapsdk/client_test.go create mode 100644 swapkit/tapsdk/proof_tlv_test.go create mode 100644 swapkit/tapsdk/swapkit.go create mode 100644 swapkit/tapsdk/swapkit_test.go create mode 100644 swapkit/tapsdk/testdata/builder_request.json diff --git a/.github/actions/lit-setup/action.yml b/.github/actions/lit-setup/action.yml index a3f6a1704..bf8476f93 100644 --- a/.github/actions/lit-setup/action.yml +++ b/.github/actions/lit-setup/action.yml @@ -21,6 +21,14 @@ runs: go mod edit -replace=github.com/lightninglabs/loop=../ go mod edit -replace=github.com/lightninglabs/loop/looprpc=../looprpc go mod edit -replace=github.com/lightninglabs/loop/swapserverrpc=../swapserverrpc + # Module replacements are not inherited by the combined daemon. + # Keep its proof decoder and other source overrides aligned with Loop. + while IFS= read -r replacement; do + go mod edit "$replacement" + done < <(go mod edit -json ../go.mod | jq -r ' + .Replace[] | select(.New.Version != null) | + "-replace=\(.Old.Path)\(if .Old.Version then "@" + .Old.Version else "" end)=\(.New.Path)@\(.New.Version)" + ') go mod tidy shell: bash diff --git a/assets/tapd_version.go b/assets/tapd_version.go new file mode 100644 index 000000000..3a33da666 --- /dev/null +++ b/assets/tapd_version.go @@ -0,0 +1,42 @@ +package assets + +import ( + "context" + + policy "github.com/lightninglabs/loop/internal/assetpolicy" +) + +// MinOnchainSwapTapdVersion is the oldest tapd release that may create new +// shared-kit swap addresses or verify an untrusted HTLC/deposit history. The +// kit checks the running tapd at those RPC boundaries. It does not gate +// ordinary asset payments or local claim/refund witness construction. Recovery +// still needs valid proofs and compliant new transfers; no recovery manager is +// implemented by this version check. +// +// Taproot Assets v0.8.8 fixes asset invoice settlement. Keep the minimum +// in the shared policy used by the client, server, and SDK adapter. +const MinOnchainSwapTapdVersion = policy.MinOnchainSwapTapdVersion + +// ErrTapdVersionUnsupported is returned when tapd is older than +// MinOnchainSwapTapdVersion or reports a version that cannot be parsed. +var ErrTapdVersionUnsupported = policy.ErrTapdVersionUnsupported + +// CheckOnchainSwapTapdVersion checks a tapd GetInfo version against the shared +// kit's floor. Applications must also check it before offering or funding new +// swaps through APIs outside the kit. This function does not establish that an +// older backend can safely recover an existing swap. +func CheckOnchainSwapTapdVersion(version string) error { + return policy.CheckOnchainSwapTapdVersion(version) +} + +// TapdInfoClient is the narrow runtime version boundary shared with on-chain +// swap integrations that perform address creation, offering or funding outside +// the kit helpers. +type TapdInfoClient = policy.TapdInfoClient + +// RequireOnchainSwapTapd checks the connected tapd immediately before starting +// an on-chain asset swap operation outside the kit. It leaves ordinary asset +// payments and local recovery signing independent of the version floor. +func RequireOnchainSwapTapd(ctx context.Context, client TapdInfoClient) error { + return policy.RequireOnchainSwapTapd(ctx, client) +} diff --git a/assets/tapd_version_test.go b/assets/tapd_version_test.go new file mode 100644 index 000000000..185e664d5 --- /dev/null +++ b/assets/tapd_version_test.go @@ -0,0 +1,131 @@ +package assets + +import ( + "context" + "testing" + + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +// TestCheckOnchainSwapTapdVersion checks the on-chain swap floor against the +// version strings that tapd reports in GetInfo. +func TestCheckOnchainSwapTapdVersion(t *testing.T) { + tests := []struct { + name string + version string + supported bool + }{ + { + name: "minimum release", + version: "0.8.8-alpha commit=v0.8.8", + supported: true, + }, + { + name: "prefixed minimum release", + version: "v0.8.8", + supported: true, + }, + { + name: "minimum release candidate", + version: "0.8.8-alpha.rc1 commit=v0.8.8-rc1", + }, + { + name: "newer patch", + version: "0.8.9-alpha", + supported: true, + }, + { + name: "newer minor", + version: "0.9.0-alpha", + supported: true, + }, + { + name: "newer major", + version: "1.0.0", + supported: true, + }, + { + name: "v0.8.6 settlement regression", + version: "0.8.6-alpha", + }, + { + name: "older patch", + version: "0.8.7-alpha commit=v0.8.7", + }, + { + name: "older minor", + version: "0.7.9", + }, + { + name: "empty", + version: "", + }, + { + name: "missing patch", + version: "0.8", + }, + { + name: "extra component", + version: "0.8.8.1", + }, + { + name: "negative component", + version: "0.-8.6", + }, + { + name: "not a version", + version: "garbage", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := CheckOnchainSwapTapdVersion(test.version) + if test.supported { + require.NoError(t, err) + return + } + require.ErrorIs(t, err, ErrTapdVersionUnsupported) + }) + } +} + +// TestMinOnchainSwapTapdVersion checks that the floor itself is a parseable +// release, as an invalid floor would reject every tapd version. +func TestMinOnchainSwapTapdVersion(t *testing.T) { + require.Equal(t, "0.8.8", MinOnchainSwapTapdVersion) + require.NoError(t, CheckOnchainSwapTapdVersion( + MinOnchainSwapTapdVersion, + )) +} + +type tapdInfoClient struct { + version string +} + +func (c tapdInfoClient) GetInfo(context.Context, *taprpc.GetInfoRequest, + ...grpc.CallOption) (*taprpc.GetInfoResponse, error) { + + return &taprpc.GetInfoResponse{Version: c.version}, nil +} + +// TestRequireOnchainSwapTapd exercises the public runtime gate used by other +// applications before offering or funding a new swap through their own APIs. +func TestRequireOnchainSwapTapd(t *testing.T) { + for _, version := range []string{"0.8.7-alpha", "0.8.8-alpha"} { + t.Run(version, func(t *testing.T) { + err := RequireOnchainSwapTapd(t.Context(), tapdInfoClient{ + version: version, + }) + if version == "0.8.8-alpha" { + require.NoError(t, err) + return + } + require.ErrorIs(t, err, ErrTapdVersionUnsupported) + }) + } + require.ErrorIs(t, RequireOnchainSwapTapd(t.Context(), nil), + ErrTapdVersionUnsupported) +} diff --git a/go.mod b/go.mod index 72d366e22..1857034d2 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/btcsuite/btcd/btcutil/psbt v1.1.10 github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b - github.com/btcsuite/btcwallet v0.16.18 + github.com/btcsuite/btcwallet v0.16.19 github.com/btcsuite/btcwallet/wtxmgr v1.5.6 github.com/davecgh/go-spew v1.1.1 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 @@ -16,23 +16,24 @@ require ( github.com/google/go-cmp v0.7.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 - github.com/jackc/pgx/v5 v5.9.2 + github.com/jackc/pgx/v5 v5.11.0 github.com/jessevdk/go-flags v1.6.1 github.com/lib/pq v1.10.9 github.com/lightninglabs/aperture v0.4.0 github.com/lightninglabs/lndclient v0.21.0-2 github.com/lightninglabs/loop/looprpc v1.0.17-0.20261021185954-333ed1efccdd github.com/lightninglabs/loop/swapserverrpc v1.0.14 - github.com/lightninglabs/taproot-assets v0.8.1 - github.com/lightninglabs/taproot-assets/taprpc v1.2.0 + github.com/lightninglabs/tap-sdk v0.1.1 + github.com/lightninglabs/taproot-assets v0.8.8 + github.com/lightninglabs/taproot-assets/taprpc v1.3.3 github.com/lightningnetwork/lightning-onion v1.3.0 - github.com/lightningnetwork/lnd v0.21.0-beta + github.com/lightningnetwork/lnd v0.21.3-beta github.com/lightningnetwork/lnd/cert v1.2.2 github.com/lightningnetwork/lnd/clock v1.1.1 github.com/lightningnetwork/lnd/queue v1.2.0 github.com/lightningnetwork/lnd/ticker v1.1.1 github.com/lightningnetwork/lnd/tlv v1.3.2 - github.com/lightningnetwork/lnd/tor v1.1.6 + github.com/lightningnetwork/lnd/tor v1.1.7 github.com/ory/dockertest/v3 v3.10.0 github.com/stretchr/testify v1.11.1 github.com/urfave/cli-docs/v3 v3.1.1-0.20251020101624-bec07369b4f6 @@ -45,7 +46,7 @@ require ( google.golang.org/protobuf v1.36.11 gopkg.in/macaroon-bakery.v2 v2.3.0 gopkg.in/macaroon.v2 v2.1.0 - modernc.org/sqlite v1.34.5 + modernc.org/sqlite v1.59.0 ) require ( @@ -86,7 +87,7 @@ require ( github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fergusstrange/embedded-postgres v1.25.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -95,7 +96,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/golang/snappy v0.0.4 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.0.1 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect @@ -116,6 +117,7 @@ require ( github.com/kkdai/bstream v1.0.0 // indirect github.com/klauspost/compress v1.18.7 // indirect github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect + github.com/lightninglabs/lightning-node-connect/hashmailrpc v1.0.4-0.20250610182311-2f1d46ef18b7 // indirect github.com/lightninglabs/neutrino v0.17.1 // indirect github.com/lightninglabs/neutrino/cache v1.1.3 // indirect github.com/lightningnetwork/lnd/actor v0.0.6 // indirect @@ -124,8 +126,7 @@ require ( github.com/lightningnetwork/lnd/kvdb v1.5.1 // indirect github.com/lightningnetwork/lnd/sqldb v1.0.13 // indirect github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/miekg/dns v1.1.50 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/moby/api v1.54.2 // indirect @@ -134,15 +135,16 @@ require ( github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runc v1.3.6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.14.0 // indirect - github.com/prometheus/client_model v0.3.0 // indirect - github.com/prometheus/common v0.37.0 // indirect - github.com/prometheus/procfs v0.8.0 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/fastuuid v1.2.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect @@ -179,6 +181,7 @@ require ( go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.24.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 // indirect golang.org/x/mod v0.38.0 // indirect @@ -194,9 +197,9 @@ require ( gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.55.3 // indirect - modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.8.0 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect pgregory.net/rapid v1.2.0 // indirect sigs.k8s.io/yaml v1.2.0 // indirect ) @@ -239,3 +242,7 @@ godebug multipathtcp=0 // vulnerable legacy pgx v4/pgproto3 dependency chain. Remove this replacement // once the required lnd version includes an sqldb release with that migration. replace github.com/lightningnetwork/lnd/sqldb v1.0.13 => github.com/lightningnetwork/lnd/sqldb v1.0.13-0.20260514041430-e9b422f78581 + +// Match the bounded legacy TLV decoder shipped with tapd v0.8.8. Replacements +// are not inherited: consuming binaries must include this pin as well. +replace github.com/lightningnetwork/lnd/tlv => github.com/lightninglabs/taproot-assets/third_party/lnd/tlv v0.0.0-20261022183431-6e0fef585a97 diff --git a/go.sum b/go.sum index f6325acd2..fb2df0c79 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,7 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= @@ -13,30 +14,594 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= +cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= +cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= +cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= +cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= +cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= +cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= +cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= +cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= +cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= +cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U= +cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= +cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= +cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU= +cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRYtA= +cloud.google.com/go v0.105.0/go.mod h1:PrLgOJNe5nfE9UMxKxgXj4mD3voiP+YQ6gdt6KMFOKM= +cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I= +cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY= +cloud.google.com/go/accessapproval v1.4.0/go.mod h1:zybIuC3KpDOvotz59lFe5qxRZx6C75OtwbisN56xYB4= +cloud.google.com/go/accessapproval v1.5.0/go.mod h1:HFy3tuiGvMdcd/u+Cu5b9NkO1pEICJ46IR82PoUdplw= +cloud.google.com/go/accessapproval v1.6.0/go.mod h1:R0EiYnwV5fsRFiKZkPHr6mwyk2wxUJ30nL4j2pcFY2E= +cloud.google.com/go/accesscontextmanager v1.3.0/go.mod h1:TgCBehyr5gNMz7ZaH9xubp+CE8dkrszb4oK9CWyvD4o= +cloud.google.com/go/accesscontextmanager v1.4.0/go.mod h1:/Kjh7BBu/Gh83sv+K60vN9QE5NJcd80sU33vIe2IFPE= +cloud.google.com/go/accesscontextmanager v1.6.0/go.mod h1:8XCvZWfYw3K/ji0iVnp+6pu7huxoQTLmxAbVjbloTtM= +cloud.google.com/go/accesscontextmanager v1.7.0/go.mod h1:CEGLewx8dwa33aDAZQujl7Dx+uYhS0eay198wB/VumQ= +cloud.google.com/go/aiplatform v1.22.0/go.mod h1:ig5Nct50bZlzV6NvKaTwmplLLddFx0YReh9WfTO5jKw= +cloud.google.com/go/aiplatform v1.24.0/go.mod h1:67UUvRBKG6GTayHKV8DBv2RtR1t93YRu5B1P3x99mYY= +cloud.google.com/go/aiplatform v1.27.0/go.mod h1:Bvxqtl40l0WImSb04d0hXFU7gDOiq9jQmorivIiWcKg= +cloud.google.com/go/aiplatform v1.35.0/go.mod h1:7MFT/vCaOyZT/4IIFfxH4ErVg/4ku6lKv3w0+tFTgXQ= +cloud.google.com/go/aiplatform v1.36.1/go.mod h1:WTm12vJRPARNvJ+v6P52RDHCNe4AhvjcIZ/9/RRHy/k= +cloud.google.com/go/aiplatform v1.37.0/go.mod h1:IU2Cv29Lv9oCn/9LkFiiuKfwrRTq+QQMbW+hPCxJGZw= +cloud.google.com/go/analytics v0.11.0/go.mod h1:DjEWCu41bVbYcKyvlws9Er60YE4a//bK6mnhWvQeFNI= +cloud.google.com/go/analytics v0.12.0/go.mod h1:gkfj9h6XRf9+TS4bmuhPEShsh3hH8PAZzm/41OOhQd4= +cloud.google.com/go/analytics v0.17.0/go.mod h1:WXFa3WSym4IZ+JiKmavYdJwGG/CvpqiqczmL59bTD9M= +cloud.google.com/go/analytics v0.18.0/go.mod h1:ZkeHGQlcIPkw0R/GW+boWHhCOR43xz9RN/jn7WcqfIE= +cloud.google.com/go/analytics v0.19.0/go.mod h1:k8liqf5/HCnOUkbawNtrWWc+UAzyDlW89doe8TtoDsE= +cloud.google.com/go/apigateway v1.3.0/go.mod h1:89Z8Bhpmxu6AmUxuVRg/ECRGReEdiP3vQtk4Z1J9rJk= +cloud.google.com/go/apigateway v1.4.0/go.mod h1:pHVY9MKGaH9PQ3pJ4YLzoj6U5FUDeDFBllIz7WmzJoc= +cloud.google.com/go/apigateway v1.5.0/go.mod h1:GpnZR3Q4rR7LVu5951qfXPJCHquZt02jf7xQx7kpqN8= +cloud.google.com/go/apigeeconnect v1.3.0/go.mod h1:G/AwXFAKo0gIXkPTVfZDd2qA1TxBXJ3MgMRBQkIi9jc= +cloud.google.com/go/apigeeconnect v1.4.0/go.mod h1:kV4NwOKqjvt2JYR0AoIWo2QGfoRtn/pkS3QlHp0Ni04= +cloud.google.com/go/apigeeconnect v1.5.0/go.mod h1:KFaCqvBRU6idyhSNyn3vlHXc8VMDJdRmwDF6JyFRqZ8= +cloud.google.com/go/apigeeregistry v0.4.0/go.mod h1:EUG4PGcsZvxOXAdyEghIdXwAEi/4MEaoqLMLDMIwKXY= +cloud.google.com/go/apigeeregistry v0.5.0/go.mod h1:YR5+s0BVNZfVOUkMa5pAR2xGd0A473vA5M7j247o1wM= +cloud.google.com/go/apigeeregistry v0.6.0/go.mod h1:BFNzW7yQVLZ3yj0TKcwzb8n25CFBri51GVGOEUcgQsc= +cloud.google.com/go/apikeys v0.4.0/go.mod h1:XATS/yqZbaBK0HOssf+ALHp8jAlNHUgyfprvNcBIszU= +cloud.google.com/go/apikeys v0.5.0/go.mod h1:5aQfwY4D+ewMMWScd3hm2en3hCj+BROlyrt3ytS7KLI= +cloud.google.com/go/apikeys v0.6.0/go.mod h1:kbpXu5upyiAlGkKrJgQl8A0rKNNJ7dQ377pdroRSSi8= +cloud.google.com/go/appengine v1.4.0/go.mod h1:CS2NhuBuDXM9f+qscZ6V86m1MIIqPj3WC/UoEuR1Sno= +cloud.google.com/go/appengine v1.5.0/go.mod h1:TfasSozdkFI0zeoxW3PTBLiNqRmzraodCWatWI9Dmak= +cloud.google.com/go/appengine v1.6.0/go.mod h1:hg6i0J/BD2cKmDJbaFSYHFyZkgBEfQrDg/X0V5fJn84= +cloud.google.com/go/appengine v1.7.0/go.mod h1:eZqpbHFCqRGa2aCdope7eC0SWLV1j0neb/QnMJVWx6A= +cloud.google.com/go/appengine v1.7.1/go.mod h1:IHLToyb/3fKutRysUlFO0BPt5j7RiQ45nrzEJmKTo6E= +cloud.google.com/go/area120 v0.5.0/go.mod h1:DE/n4mp+iqVyvxHN41Vf1CR602GiHQjFPusMFW6bGR4= +cloud.google.com/go/area120 v0.6.0/go.mod h1:39yFJqWVgm0UZqWTOdqkLhjoC7uFfgXRC8g/ZegeAh0= +cloud.google.com/go/area120 v0.7.0/go.mod h1:a3+8EUD1SX5RUcCs3MY5YasiO1z6yLiNLRiFrykbynY= +cloud.google.com/go/area120 v0.7.1/go.mod h1:j84i4E1RboTWjKtZVWXPqvK5VHQFJRF2c1Nm69pWm9k= +cloud.google.com/go/artifactregistry v1.6.0/go.mod h1:IYt0oBPSAGYj/kprzsBjZ/4LnG/zOcHyFHjWPCi6SAQ= +cloud.google.com/go/artifactregistry v1.7.0/go.mod h1:mqTOFOnGZx8EtSqK/ZWcsm/4U8B77rbcLP6ruDU2Ixk= +cloud.google.com/go/artifactregistry v1.8.0/go.mod h1:w3GQXkJX8hiKN0v+at4b0qotwijQbYUqF2GWkZzAhC0= +cloud.google.com/go/artifactregistry v1.9.0/go.mod h1:2K2RqvA2CYvAeARHRkLDhMDJ3OXy26h3XW+3/Jh2uYc= +cloud.google.com/go/artifactregistry v1.11.1/go.mod h1:lLYghw+Itq9SONbCa1YWBoWs1nOucMH0pwXN1rOBZFI= +cloud.google.com/go/artifactregistry v1.11.2/go.mod h1:nLZns771ZGAwVLzTX/7Al6R9ehma4WUEhZGWV6CeQNQ= +cloud.google.com/go/artifactregistry v1.12.0/go.mod h1:o6P3MIvtzTOnmvGagO9v/rOjjA0HmhJ+/6KAXrmYDCI= +cloud.google.com/go/artifactregistry v1.13.0/go.mod h1:uy/LNfoOIivepGhooAUpL1i30Hgee3Cu0l4VTWHUC08= +cloud.google.com/go/asset v1.5.0/go.mod h1:5mfs8UvcM5wHhqtSv8J1CtxxaQq3AdBxxQi2jGW/K4o= +cloud.google.com/go/asset v1.7.0/go.mod h1:YbENsRK4+xTiL+Ofoj5Ckf+O17kJtgp3Y3nn4uzZz5s= +cloud.google.com/go/asset v1.8.0/go.mod h1:mUNGKhiqIdbr8X7KNayoYvyc4HbbFO9URsjbytpUaW0= +cloud.google.com/go/asset v1.9.0/go.mod h1:83MOE6jEJBMqFKadM9NLRcs80Gdw76qGuHn8m3h8oHQ= +cloud.google.com/go/asset v1.10.0/go.mod h1:pLz7uokL80qKhzKr4xXGvBQXnzHn5evJAEAtZiIb0wY= +cloud.google.com/go/asset v1.11.1/go.mod h1:fSwLhbRvC9p9CXQHJ3BgFeQNM4c9x10lqlrdEUYXlJo= +cloud.google.com/go/asset v1.12.0/go.mod h1:h9/sFOa4eDIyKmH6QMpm4eUK3pDojWnUhTgJlk762Hg= +cloud.google.com/go/asset v1.13.0/go.mod h1:WQAMyYek/b7NBpYq/K4KJWcRqzoalEsxz/t/dTk4THw= +cloud.google.com/go/assuredworkloads v1.5.0/go.mod h1:n8HOZ6pff6re5KYfBXcFvSViQjDwxFkAkmUFffJRbbY= +cloud.google.com/go/assuredworkloads v1.6.0/go.mod h1:yo2YOk37Yc89Rsd5QMVECvjaMKymF9OP+QXWlKXUkXw= +cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVoYoxeLBoj4XkKYscNI= +cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo= +cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0= +cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E= +cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0= +cloud.google.com/go/automl v1.6.0/go.mod h1:ugf8a6Fx+zP0D59WLhqgTDsQI9w07o64uf/Is3Nh5p8= +cloud.google.com/go/automl v1.7.0/go.mod h1:RL9MYCCsJEOmt0Wf3z9uzG0a7adTT1fe+aObgSpkCt8= +cloud.google.com/go/automl v1.8.0/go.mod h1:xWx7G/aPEe/NP+qzYXktoBSDfjO+vnKMGgsApGJJquM= +cloud.google.com/go/automl v1.12.0/go.mod h1:tWDcHDp86aMIuHmyvjuKeeHEGq76lD7ZqfGLN6B0NuU= +cloud.google.com/go/baremetalsolution v0.3.0/go.mod h1:XOrocE+pvK1xFfleEnShBlNAXf+j5blPPxrhjKgnIFc= +cloud.google.com/go/baremetalsolution v0.4.0/go.mod h1:BymplhAadOO/eBa7KewQ0Ppg4A4Wplbn+PsFKRLo0uI= +cloud.google.com/go/baremetalsolution v0.5.0/go.mod h1:dXGxEkmR9BMwxhzBhV0AioD0ULBmuLZI8CdwalUxuss= +cloud.google.com/go/batch v0.3.0/go.mod h1:TR18ZoAekj1GuirsUsR1ZTKN3FC/4UDnScjT8NXImFE= +cloud.google.com/go/batch v0.4.0/go.mod h1:WZkHnP43R/QCGQsZ+0JyG4i79ranE2u8xvjq/9+STPE= +cloud.google.com/go/batch v0.7.0/go.mod h1:vLZN95s6teRUqRQ4s3RLDsH8PvboqBK+rn1oevL159g= +cloud.google.com/go/beyondcorp v0.2.0/go.mod h1:TB7Bd+EEtcw9PCPQhCJtJGjk/7TC6ckmnSFS+xwTfm4= +cloud.google.com/go/beyondcorp v0.3.0/go.mod h1:E5U5lcrcXMsCuoDNyGrpyTm/hn7ne941Jz2vmksAxW8= +cloud.google.com/go/beyondcorp v0.4.0/go.mod h1:3ApA0mbhHx6YImmuubf5pyW8srKnCEPON32/5hj+RmM= +cloud.google.com/go/beyondcorp v0.5.0/go.mod h1:uFqj9X+dSfrheVp7ssLTaRHd2EHqSL4QZmH4e8WXGGU= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/bigquery v1.42.0/go.mod h1:8dRTJxhtG+vwBKzE5OseQn/hiydoQN3EedCaOdYmxRA= +cloud.google.com/go/bigquery v1.43.0/go.mod h1:ZMQcXHsl+xmU1z36G2jNGZmKp9zNY5BUua5wDgmNCfw= +cloud.google.com/go/bigquery v1.44.0/go.mod h1:0Y33VqXTEsbamHJvJHdFmtqHvMIY28aK1+dFsvaChGc= +cloud.google.com/go/bigquery v1.47.0/go.mod h1:sA9XOgy0A8vQK9+MWhEQTY6Tix87M/ZurWFIxmF9I/E= +cloud.google.com/go/bigquery v1.48.0/go.mod h1:QAwSz+ipNgfL5jxiaK7weyOhzdoAy1zFm0Nf1fysJac= +cloud.google.com/go/bigquery v1.49.0/go.mod h1:Sv8hMmTFFYBlt/ftw2uN6dFdQPzBlREY9yBh7Oy7/4Q= +cloud.google.com/go/bigquery v1.50.0/go.mod h1:YrleYEh2pSEbgTBZYMJ5SuSr0ML3ypjRB1zgf7pvQLU= +cloud.google.com/go/billing v1.4.0/go.mod h1:g9IdKBEFlItS8bTtlrZdVLWSSdSyFUZKXNS02zKMOZY= +cloud.google.com/go/billing v1.5.0/go.mod h1:mztb1tBc3QekhjSgmpf/CV4LzWXLzCArwpLmP2Gm88s= +cloud.google.com/go/billing v1.6.0/go.mod h1:WoXzguj+BeHXPbKfNWkqVtDdzORazmCjraY+vrxcyvI= +cloud.google.com/go/billing v1.7.0/go.mod h1:q457N3Hbj9lYwwRbnlD7vUpyjq6u5U1RAOArInEiD5Y= +cloud.google.com/go/billing v1.12.0/go.mod h1:yKrZio/eu+okO/2McZEbch17O5CB5NpZhhXG6Z766ss= +cloud.google.com/go/billing v1.13.0/go.mod h1:7kB2W9Xf98hP9Sr12KfECgfGclsH3CQR0R08tnRlRbc= +cloud.google.com/go/binaryauthorization v1.1.0/go.mod h1:xwnoWu3Y84jbuHa0zd526MJYmtnVXn0syOjaJgy4+dM= +cloud.google.com/go/binaryauthorization v1.2.0/go.mod h1:86WKkJHtRcv5ViNABtYMhhNWRrD1Vpi//uKEy7aYEfI= +cloud.google.com/go/binaryauthorization v1.3.0/go.mod h1:lRZbKgjDIIQvzYQS1p99A7/U1JqvqeZg0wiI5tp6tg0= +cloud.google.com/go/binaryauthorization v1.4.0/go.mod h1:tsSPQrBd77VLplV70GUhBf/Zm3FsKmgSqgm4UmiDItk= +cloud.google.com/go/binaryauthorization v1.5.0/go.mod h1:OSe4OU1nN/VswXKRBmciKpo9LulY41gch5c68htf3/Q= +cloud.google.com/go/certificatemanager v1.3.0/go.mod h1:n6twGDvcUBFu9uBgt4eYvvf3sQ6My8jADcOVwHmzadg= +cloud.google.com/go/certificatemanager v1.4.0/go.mod h1:vowpercVFyqs8ABSmrdV+GiFf2H/ch3KyudYQEMM590= +cloud.google.com/go/certificatemanager v1.6.0/go.mod h1:3Hh64rCKjRAX8dXgRAyOcY5vQ/fE1sh8o+Mdd6KPgY8= +cloud.google.com/go/channel v1.8.0/go.mod h1:W5SwCXDJsq/rg3tn3oG0LOxpAo6IMxNa09ngphpSlnk= +cloud.google.com/go/channel v1.9.0/go.mod h1:jcu05W0my9Vx4mt3/rEHpfxc9eKi9XwsdDL8yBMbKUk= +cloud.google.com/go/channel v1.11.0/go.mod h1:IdtI0uWGqhEeatSB62VOoJ8FSUhJ9/+iGkJVqp74CGE= +cloud.google.com/go/channel v1.12.0/go.mod h1:VkxCGKASi4Cq7TbXxlaBezonAYpp1GCnKMY6tnMQnLU= +cloud.google.com/go/cloudbuild v1.3.0/go.mod h1:WequR4ULxlqvMsjDEEEFnOG5ZSRSgWOywXYDb1vPE6U= +cloud.google.com/go/cloudbuild v1.4.0/go.mod h1:5Qwa40LHiOXmz3386FrjrYM93rM/hdRr7b53sySrTqA= +cloud.google.com/go/cloudbuild v1.6.0/go.mod h1:UIbc/w9QCbH12xX+ezUsgblrWv+Cv4Tw83GiSMHOn9M= +cloud.google.com/go/cloudbuild v1.7.0/go.mod h1:zb5tWh2XI6lR9zQmsm1VRA+7OCuve5d8S+zJUul8KTg= +cloud.google.com/go/cloudbuild v1.9.0/go.mod h1:qK1d7s4QlO0VwfYn5YuClDGg2hfmLZEb4wQGAbIgL1s= +cloud.google.com/go/clouddms v1.3.0/go.mod h1:oK6XsCDdW4Ib3jCCBugx+gVjevp2TMXFtgxvPSee3OM= +cloud.google.com/go/clouddms v1.4.0/go.mod h1:Eh7sUGCC+aKry14O1NRljhjyrr0NFC0G2cjwX0cByRk= +cloud.google.com/go/clouddms v1.5.0/go.mod h1:QSxQnhikCLUw13iAbffF2CZxAER3xDGNHjsTAkQJcQA= +cloud.google.com/go/cloudtasks v1.5.0/go.mod h1:fD92REy1x5woxkKEkLdvavGnPJGEn8Uic9nWuLzqCpY= +cloud.google.com/go/cloudtasks v1.6.0/go.mod h1:C6Io+sxuke9/KNRkbQpihnW93SWDU3uXt92nu85HkYI= +cloud.google.com/go/cloudtasks v1.7.0/go.mod h1:ImsfdYWwlWNJbdgPIIGJWC+gemEGTBK/SunNQQNCAb4= +cloud.google.com/go/cloudtasks v1.8.0/go.mod h1:gQXUIwCSOI4yPVK7DgTVFiiP0ZW/eQkydWzwVMdHxrI= +cloud.google.com/go/cloudtasks v1.9.0/go.mod h1:w+EyLsVkLWHcOaqNEyvcKAsWp9p29dL6uL9Nst1cI7Y= +cloud.google.com/go/cloudtasks v1.10.0/go.mod h1:NDSoTLkZ3+vExFEWu2UJV1arUyzVDAiZtdWcsUyNwBs= +cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= +cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= +cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= +cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= +cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= +cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U= +cloud.google.com/go/compute v1.10.0/go.mod h1:ER5CLbMxl90o2jtNbGSbtfOpQKR0t15FOtRsugnLrlU= +cloud.google.com/go/compute v1.12.0/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU= +cloud.google.com/go/compute v1.12.1/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU= +cloud.google.com/go/compute v1.13.0/go.mod h1:5aPTS0cUNMIc1CE546K+Th6weJUNQErARyZtRXDJ8GE= +cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo= +cloud.google.com/go/compute v1.15.1/go.mod h1:bjjoF/NtFUrkD/urWfdHaKuOPDR5nWIs63rR+SXhcpA= +cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs= +cloud.google.com/go/compute v1.19.0/go.mod h1:rikpw2y+UMidAe9tISo04EHNOIf42RLYF/q8Bs93scU= +cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE= +cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZEXYonfTBHHFPO/4UU= +cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/contactcenterinsights v1.3.0/go.mod h1:Eu2oemoePuEFc/xKFPjbTuPSj0fYJcPls9TFlPNnHHY= +cloud.google.com/go/contactcenterinsights v1.4.0/go.mod h1:L2YzkGbPsv+vMQMCADxJoT9YiTTnSEd6fEvCeHTYVck= +cloud.google.com/go/contactcenterinsights v1.6.0/go.mod h1:IIDlT6CLcDoyv79kDv8iWxMSTZhLxSCofVV5W6YFM/w= +cloud.google.com/go/container v1.6.0/go.mod h1:Xazp7GjJSeUYo688S+6J5V+n/t+G5sKBTFkKNudGRxg= +cloud.google.com/go/container v1.7.0/go.mod h1:Dp5AHtmothHGX3DwwIHPgq45Y8KmNsgN3amoYfxVkLo= +cloud.google.com/go/container v1.13.1/go.mod h1:6wgbMPeQRw9rSnKBCAJXnds3Pzj03C4JHamr8asWKy4= +cloud.google.com/go/container v1.14.0/go.mod h1:3AoJMPhHfLDxLvrlVWaK57IXzaPnLaZq63WX59aQBfM= +cloud.google.com/go/container v1.15.0/go.mod h1:ft+9S0WGjAyjDggg5S06DXj+fHJICWg8L7isCQe9pQA= +cloud.google.com/go/containeranalysis v0.5.1/go.mod h1:1D92jd8gRR/c0fGMlymRgxWD3Qw9C1ff6/T7mLgVL8I= +cloud.google.com/go/containeranalysis v0.6.0/go.mod h1:HEJoiEIu+lEXM+k7+qLCci0h33lX3ZqoYFdmPcoO7s4= +cloud.google.com/go/containeranalysis v0.7.0/go.mod h1:9aUL+/vZ55P2CXfuZjS4UjQ9AgXoSw8Ts6lemfmxBxI= +cloud.google.com/go/containeranalysis v0.9.0/go.mod h1:orbOANbwk5Ejoom+s+DUCTTJ7IBdBQJDcSylAx/on9s= +cloud.google.com/go/datacatalog v1.3.0/go.mod h1:g9svFY6tuR+j+hrTw3J2dNcmI0dzmSiyOzm8kpLq0a0= +cloud.google.com/go/datacatalog v1.5.0/go.mod h1:M7GPLNQeLfWqeIm3iuiruhPzkt65+Bx8dAKvScX8jvs= +cloud.google.com/go/datacatalog v1.6.0/go.mod h1:+aEyF8JKg+uXcIdAmmaMUmZ3q1b/lKLtXCmXdnc0lbc= +cloud.google.com/go/datacatalog v1.7.0/go.mod h1:9mEl4AuDYWw81UGc41HonIHH7/sn52H0/tc8f8ZbZIE= +cloud.google.com/go/datacatalog v1.8.0/go.mod h1:KYuoVOv9BM8EYz/4eMFxrr4DUKhGIOXxZoKYF5wdISM= +cloud.google.com/go/datacatalog v1.8.1/go.mod h1:RJ58z4rMp3gvETA465Vg+ag8BGgBdnRPEMMSTr5Uv+M= +cloud.google.com/go/datacatalog v1.12.0/go.mod h1:CWae8rFkfp6LzLumKOnmVh4+Zle4A3NXLzVJ1d1mRm0= +cloud.google.com/go/datacatalog v1.13.0/go.mod h1:E4Rj9a5ZtAxcQJlEBTLgMTphfP11/lNaAshpoBgemX8= +cloud.google.com/go/dataflow v0.6.0/go.mod h1:9QwV89cGoxjjSR9/r7eFDqqjtvbKxAK2BaYU6PVk9UM= +cloud.google.com/go/dataflow v0.7.0/go.mod h1:PX526vb4ijFMesO1o202EaUmouZKBpjHsTlCtB4parQ= +cloud.google.com/go/dataflow v0.8.0/go.mod h1:Rcf5YgTKPtQyYz8bLYhFoIV/vP39eL7fWNcSOyFfLJE= +cloud.google.com/go/dataform v0.3.0/go.mod h1:cj8uNliRlHpa6L3yVhDOBrUXH+BPAO1+KFMQQNSThKo= +cloud.google.com/go/dataform v0.4.0/go.mod h1:fwV6Y4Ty2yIFL89huYlEkwUPtS7YZinZbzzj5S9FzCE= +cloud.google.com/go/dataform v0.5.0/go.mod h1:GFUYRe8IBa2hcomWplodVmUx/iTL0FrsauObOM3Ipr0= +cloud.google.com/go/dataform v0.6.0/go.mod h1:QPflImQy33e29VuapFdf19oPbE4aYTJxr31OAPV+ulA= +cloud.google.com/go/dataform v0.7.0/go.mod h1:7NulqnVozfHvWUBpMDfKMUESr+85aJsC/2O0o3jWPDE= +cloud.google.com/go/datafusion v1.4.0/go.mod h1:1Zb6VN+W6ALo85cXnM1IKiPw+yQMKMhB9TsTSRDo/38= +cloud.google.com/go/datafusion v1.5.0/go.mod h1:Kz+l1FGHB0J+4XF2fud96WMmRiq/wj8N9u007vyXZ2w= +cloud.google.com/go/datafusion v1.6.0/go.mod h1:WBsMF8F1RhSXvVM8rCV3AeyWVxcC2xY6vith3iw3S+8= +cloud.google.com/go/datalabeling v0.5.0/go.mod h1:TGcJ0G2NzcsXSE/97yWjIZO0bXj0KbVlINXMG9ud42I= +cloud.google.com/go/datalabeling v0.6.0/go.mod h1:WqdISuk/+WIGeMkpw/1q7bK/tFEZxsrFJOJdY2bXvTQ= +cloud.google.com/go/datalabeling v0.7.0/go.mod h1:WPQb1y08RJbmpM3ww0CSUAGweL0SxByuW2E+FU+wXcM= +cloud.google.com/go/dataplex v1.3.0/go.mod h1:hQuRtDg+fCiFgC8j0zV222HvzFQdRd+SVX8gdmFcZzA= +cloud.google.com/go/dataplex v1.4.0/go.mod h1:X51GfLXEMVJ6UN47ESVqvlsRplbLhcsAt0kZCCKsU0A= +cloud.google.com/go/dataplex v1.5.2/go.mod h1:cVMgQHsmfRoI5KFYq4JtIBEUbYwc3c7tXmIDhRmNNVQ= +cloud.google.com/go/dataplex v1.6.0/go.mod h1:bMsomC/aEJOSpHXdFKFGQ1b0TDPIeL28nJObeO1ppRs= +cloud.google.com/go/dataproc v1.7.0/go.mod h1:CKAlMjII9H90RXaMpSxQ8EU6dQx6iAYNPcYPOkSbi8s= +cloud.google.com/go/dataproc v1.8.0/go.mod h1:5OW+zNAH0pMpw14JVrPONsxMQYMBqJuzORhIBfBn9uI= +cloud.google.com/go/dataproc v1.12.0/go.mod h1:zrF3aX0uV3ikkMz6z4uBbIKyhRITnxvr4i3IjKsKrw4= +cloud.google.com/go/dataqna v0.5.0/go.mod h1:90Hyk596ft3zUQ8NkFfvICSIfHFh1Bc7C4cK3vbhkeo= +cloud.google.com/go/dataqna v0.6.0/go.mod h1:1lqNpM7rqNLVgWBJyk5NF6Uen2PHym0jtVJonplVsDA= +cloud.google.com/go/dataqna v0.7.0/go.mod h1:Lx9OcIIeqCrw1a6KdO3/5KMP1wAmTc0slZWwP12Qq3c= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/datastore v1.10.0/go.mod h1:PC5UzAmDEkAmkfaknstTYbNpgE49HAgW2J1gcgUfmdM= +cloud.google.com/go/datastore v1.11.0/go.mod h1:TvGxBIHCS50u8jzG+AW/ppf87v1of8nwzFNgEZU1D3c= +cloud.google.com/go/datastream v1.2.0/go.mod h1:i/uTP8/fZwgATHS/XFu0TcNUhuA0twZxxQ3EyCUQMwo= +cloud.google.com/go/datastream v1.3.0/go.mod h1:cqlOX8xlyYF/uxhiKn6Hbv6WjwPPuI9W2M9SAXwaLLQ= +cloud.google.com/go/datastream v1.4.0/go.mod h1:h9dpzScPhDTs5noEMQVWP8Wx8AFBRyS0s8KWPx/9r0g= +cloud.google.com/go/datastream v1.5.0/go.mod h1:6TZMMNPwjUqZHBKPQ1wwXpb0d5VDVPl2/XoS5yi88q4= +cloud.google.com/go/datastream v1.6.0/go.mod h1:6LQSuswqLa7S4rPAOZFVjHIG3wJIjZcZrw8JDEDJuIs= +cloud.google.com/go/datastream v1.7.0/go.mod h1:uxVRMm2elUSPuh65IbZpzJNMbuzkcvu5CjMqVIUHrww= +cloud.google.com/go/deploy v1.4.0/go.mod h1:5Xghikd4VrmMLNaF6FiRFDlHb59VM59YoDQnOUdsH/c= +cloud.google.com/go/deploy v1.5.0/go.mod h1:ffgdD0B89tToyW/U/D2eL0jN2+IEV/3EMuXHA0l4r+s= +cloud.google.com/go/deploy v1.6.0/go.mod h1:f9PTHehG/DjCom3QH0cntOVRm93uGBDt2vKzAPwpXQI= +cloud.google.com/go/deploy v1.8.0/go.mod h1:z3myEJnA/2wnB4sgjqdMfgxCA0EqC3RBTNcVPs93mtQ= +cloud.google.com/go/dialogflow v1.15.0/go.mod h1:HbHDWs33WOGJgn6rfzBW1Kv807BE3O1+xGbn59zZWI4= +cloud.google.com/go/dialogflow v1.16.1/go.mod h1:po6LlzGfK+smoSmTBnbkIZY2w8ffjz/RcGSS+sh1el0= +cloud.google.com/go/dialogflow v1.17.0/go.mod h1:YNP09C/kXA1aZdBgC/VtXX74G/TKn7XVCcVumTflA+8= +cloud.google.com/go/dialogflow v1.18.0/go.mod h1:trO7Zu5YdyEuR+BhSNOqJezyFQ3aUzz0njv7sMx/iek= +cloud.google.com/go/dialogflow v1.19.0/go.mod h1:JVmlG1TwykZDtxtTXujec4tQ+D8SBFMoosgy+6Gn0s0= +cloud.google.com/go/dialogflow v1.29.0/go.mod h1:b+2bzMe+k1s9V+F2jbJwpHPzrnIyHihAdRFMtn2WXuM= +cloud.google.com/go/dialogflow v1.31.0/go.mod h1:cuoUccuL1Z+HADhyIA7dci3N5zUssgpBJmCzI6fNRB4= +cloud.google.com/go/dialogflow v1.32.0/go.mod h1:jG9TRJl8CKrDhMEcvfcfFkkpp8ZhgPz3sBGmAUYJ2qE= +cloud.google.com/go/dlp v1.6.0/go.mod h1:9eyB2xIhpU0sVwUixfBubDoRwP+GjeUoxxeueZmqvmM= +cloud.google.com/go/dlp v1.7.0/go.mod h1:68ak9vCiMBjbasxeVD17hVPxDEck+ExiHavX8kiHG+Q= +cloud.google.com/go/dlp v1.9.0/go.mod h1:qdgmqgTyReTz5/YNSSuueR8pl7hO0o9bQ39ZhtgkWp4= +cloud.google.com/go/documentai v1.7.0/go.mod h1:lJvftZB5NRiFSX4moiye1SMxHx0Bc3x1+p9e/RfXYiU= +cloud.google.com/go/documentai v1.8.0/go.mod h1:xGHNEB7CtsnySCNrCFdCyyMz44RhFEEX2Q7UD0c5IhU= +cloud.google.com/go/documentai v1.9.0/go.mod h1:FS5485S8R00U10GhgBC0aNGrJxBP8ZVpEeJ7PQDZd6k= +cloud.google.com/go/documentai v1.10.0/go.mod h1:vod47hKQIPeCfN2QS/jULIvQTugbmdc0ZvxxfQY1bg4= +cloud.google.com/go/documentai v1.16.0/go.mod h1:o0o0DLTEZ+YnJZ+J4wNfTxmDVyrkzFvttBXXtYRMHkM= +cloud.google.com/go/documentai v1.18.0/go.mod h1:F6CK6iUH8J81FehpskRmhLq/3VlwQvb7TvwOceQ2tbs= +cloud.google.com/go/domains v0.6.0/go.mod h1:T9Rz3GasrpYk6mEGHh4rymIhjlnIuB4ofT1wTxDeT4Y= +cloud.google.com/go/domains v0.7.0/go.mod h1:PtZeqS1xjnXuRPKE/88Iru/LdfoRyEHYA9nFQf4UKpg= +cloud.google.com/go/domains v0.8.0/go.mod h1:M9i3MMDzGFXsydri9/vW+EWz9sWb4I6WyHqdlAk0idE= +cloud.google.com/go/edgecontainer v0.1.0/go.mod h1:WgkZ9tp10bFxqO8BLPqv2LlfmQF1X8lZqwW4r1BTajk= +cloud.google.com/go/edgecontainer v0.2.0/go.mod h1:RTmLijy+lGpQ7BXuTDa4C4ssxyXT34NIuHIgKuP4s5w= +cloud.google.com/go/edgecontainer v0.3.0/go.mod h1:FLDpP4nykgwwIfcLt6zInhprzw0lEi2P1fjO6Ie0qbc= +cloud.google.com/go/edgecontainer v1.0.0/go.mod h1:cttArqZpBB2q58W/upSG++ooo6EsblxDIolxa3jSjbY= +cloud.google.com/go/errorreporting v0.3.0/go.mod h1:xsP2yaAp+OAW4OIm60An2bbLpqIhKXdWR/tawvl7QzU= +cloud.google.com/go/essentialcontacts v1.3.0/go.mod h1:r+OnHa5jfj90qIfZDO/VztSFqbQan7HV75p8sA+mdGI= +cloud.google.com/go/essentialcontacts v1.4.0/go.mod h1:8tRldvHYsmnBCHdFpvU+GL75oWiBKl80BiqlFh9tp+8= +cloud.google.com/go/essentialcontacts v1.5.0/go.mod h1:ay29Z4zODTuwliK7SnX8E86aUF2CTzdNtvv42niCX0M= +cloud.google.com/go/eventarc v1.7.0/go.mod h1:6ctpF3zTnaQCxUjHUdcfgcA1A2T309+omHZth7gDfmc= +cloud.google.com/go/eventarc v1.8.0/go.mod h1:imbzxkyAU4ubfsaKYdQg04WS1NvncblHEup4kvF+4gw= +cloud.google.com/go/eventarc v1.10.0/go.mod h1:u3R35tmZ9HvswGRBnF48IlYgYeBcPUCjkr4BTdem2Kw= +cloud.google.com/go/eventarc v1.11.0/go.mod h1:PyUjsUKPWoRBCHeOxZd/lbOOjahV41icXyUY5kSTvVY= +cloud.google.com/go/filestore v1.3.0/go.mod h1:+qbvHGvXU1HaKX2nD0WEPo92TP/8AQuCVEBXNY9z0+w= +cloud.google.com/go/filestore v1.4.0/go.mod h1:PaG5oDfo9r224f8OYXURtAsY+Fbyq/bLYoINEK8XQAI= +cloud.google.com/go/filestore v1.5.0/go.mod h1:FqBXDWBp4YLHqRnVGveOkHDf8svj9r5+mUDLupOWEDs= +cloud.google.com/go/filestore v1.6.0/go.mod h1:di5unNuss/qfZTw2U9nhFqo8/ZDSc466dre85Kydllg= +cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE= +cloud.google.com/go/functions v1.6.0/go.mod h1:3H1UA3qiIPRWD7PeZKLvHZ9SaQhR26XIJcC0A5GbvAk= +cloud.google.com/go/functions v1.7.0/go.mod h1:+d+QBcWM+RsrgZfV9xo6KfA1GlzJfxcfZcRPEhDDfzg= +cloud.google.com/go/functions v1.8.0/go.mod h1:RTZ4/HsQjIqIYP9a9YPbU+QFoQsAlYgrwOXJWHn1POY= +cloud.google.com/go/functions v1.9.0/go.mod h1:Y+Dz8yGguzO3PpIjhLTbnqV1CWmgQ5UwtlpzoyquQ08= +cloud.google.com/go/functions v1.10.0/go.mod h1:0D3hEOe3DbEvCXtYOZHQZmD+SzYsi1YbI7dGvHfldXw= +cloud.google.com/go/functions v1.12.0/go.mod h1:AXWGrF3e2C/5ehvwYo/GH6O5s09tOPksiKhz+hH8WkA= +cloud.google.com/go/functions v1.13.0/go.mod h1:EU4O007sQm6Ef/PwRsI8N2umygGqPBS/IZQKBQBcJ3c= +cloud.google.com/go/gaming v1.5.0/go.mod h1:ol7rGcxP/qHTRQE/RO4bxkXq+Fix0j6D4LFPzYTIrDM= +cloud.google.com/go/gaming v1.6.0/go.mod h1:YMU1GEvA39Qt3zWGyAVA9bpYz/yAhTvaQ1t2sK4KPUA= +cloud.google.com/go/gaming v1.7.0/go.mod h1:LrB8U7MHdGgFG851iHAfqUdLcKBdQ55hzXy9xBJz0+w= +cloud.google.com/go/gaming v1.8.0/go.mod h1:xAqjS8b7jAVW0KFYeRUxngo9My3f33kFmua++Pi+ggM= +cloud.google.com/go/gaming v1.9.0/go.mod h1:Fc7kEmCObylSWLO334NcO+O9QMDyz+TKC4v1D7X+Bc0= +cloud.google.com/go/gkebackup v0.2.0/go.mod h1:XKvv/4LfG829/B8B7xRkk8zRrOEbKtEam6yNfuQNH60= +cloud.google.com/go/gkebackup v0.3.0/go.mod h1:n/E671i1aOQvUxT541aTkCwExO/bTer2HDlj4TsBRAo= +cloud.google.com/go/gkebackup v0.4.0/go.mod h1:byAyBGUwYGEEww7xsbnUTBHIYcOPy/PgUWUtOeRm9Vg= +cloud.google.com/go/gkeconnect v0.5.0/go.mod h1:c5lsNAg5EwAy7fkqX/+goqFsU1Da/jQFqArp+wGNr/o= +cloud.google.com/go/gkeconnect v0.6.0/go.mod h1:Mln67KyU/sHJEBY8kFZ0xTeyPtzbq9StAVvEULYK16A= +cloud.google.com/go/gkeconnect v0.7.0/go.mod h1:SNfmVqPkaEi3bF/B3CNZOAYPYdg7sU+obZ+QTky2Myw= +cloud.google.com/go/gkehub v0.9.0/go.mod h1:WYHN6WG8w9bXU0hqNxt8rm5uxnk8IH+lPY9J2TV7BK0= +cloud.google.com/go/gkehub v0.10.0/go.mod h1:UIPwxI0DsrpsVoWpLB0stwKCP+WFVG9+y977wO+hBH0= +cloud.google.com/go/gkehub v0.11.0/go.mod h1:JOWHlmN+GHyIbuWQPl47/C2RFhnFKH38jH9Ascu3n0E= +cloud.google.com/go/gkehub v0.12.0/go.mod h1:djiIwwzTTBrF5NaXCGv3mf7klpEMcST17VBTVVDcuaw= +cloud.google.com/go/gkemulticloud v0.3.0/go.mod h1:7orzy7O0S+5kq95e4Hpn7RysVA7dPs8W/GgfUtsPbrA= +cloud.google.com/go/gkemulticloud v0.4.0/go.mod h1:E9gxVBnseLWCk24ch+P9+B2CoDFJZTyIgLKSalC7tuI= +cloud.google.com/go/gkemulticloud v0.5.0/go.mod h1:W0JDkiyi3Tqh0TJr//y19wyb1yf8llHVto2Htf2Ja3Y= +cloud.google.com/go/grafeas v0.2.0/go.mod h1:KhxgtF2hb0P191HlY5besjYm6MqTSTj3LSI+M+ByZHc= +cloud.google.com/go/gsuiteaddons v1.3.0/go.mod h1:EUNK/J1lZEZO8yPtykKxLXI6JSVN2rg9bN8SXOa0bgM= +cloud.google.com/go/gsuiteaddons v1.4.0/go.mod h1:rZK5I8hht7u7HxFQcFei0+AtfS9uSushomRlg+3ua1o= +cloud.google.com/go/gsuiteaddons v1.5.0/go.mod h1:TFCClYLd64Eaa12sFVmUyG62tk4mdIsI7pAnSXRkcFo= +cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c= +cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= +cloud.google.com/go/iam v0.5.0/go.mod h1:wPU9Vt0P4UmCux7mqtRu6jcpPAb74cP1fh50J3QpkUc= +cloud.google.com/go/iam v0.6.0/go.mod h1:+1AH33ueBne5MzYccyMHtEKqLE4/kJOibtffMHDMFMc= +cloud.google.com/go/iam v0.7.0/go.mod h1:H5Br8wRaDGNc8XP3keLc4unfUUZeyH3Sfl9XpQEYOeg= +cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGESjkE= +cloud.google.com/go/iam v0.11.0/go.mod h1:9PiLDanza5D+oWFZiH1uG+RnRCfEGKoyl6yo4cgWZGY= +cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY= +cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0= +cloud.google.com/go/iap v1.4.0/go.mod h1:RGFwRJdihTINIe4wZ2iCP0zF/qu18ZwyKxrhMhygBEc= +cloud.google.com/go/iap v1.5.0/go.mod h1:UH/CGgKd4KyohZL5Pt0jSKE4m3FR51qg6FKQ/z/Ix9A= +cloud.google.com/go/iap v1.6.0/go.mod h1:NSuvI9C/j7UdjGjIde7t7HBz+QTwBcapPE07+sSRcLk= +cloud.google.com/go/iap v1.7.0/go.mod h1:beqQx56T9O1G1yNPph+spKpNibDlYIiIixiqsQXxLIo= +cloud.google.com/go/iap v1.7.1/go.mod h1:WapEwPc7ZxGt2jFGB/C/bm+hP0Y6NXzOYGjpPnmMS74= +cloud.google.com/go/ids v1.1.0/go.mod h1:WIuwCaYVOzHIj2OhN9HAwvW+DBdmUAdcWlFxRl+KubM= +cloud.google.com/go/ids v1.2.0/go.mod h1:5WXvp4n25S0rA/mQWAg1YEEBBq6/s+7ml1RDCW1IrcY= +cloud.google.com/go/ids v1.3.0/go.mod h1:JBdTYwANikFKaDP6LtW5JAi4gubs57SVNQjemdt6xV4= +cloud.google.com/go/iot v1.3.0/go.mod h1:r7RGh2B61+B8oz0AGE+J72AhA0G7tdXItODWsaA2oLs= +cloud.google.com/go/iot v1.4.0/go.mod h1:dIDxPOn0UvNDUMD8Ger7FIaTuvMkj+aGk94RPP0iV+g= +cloud.google.com/go/iot v1.5.0/go.mod h1:mpz5259PDl3XJthEmh9+ap0affn/MqNSP4My77Qql9o= +cloud.google.com/go/iot v1.6.0/go.mod h1:IqdAsmE2cTYYNO1Fvjfzo9po179rAtJeVGUvkLN3rLE= +cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA= +cloud.google.com/go/kms v1.5.0/go.mod h1:QJS2YY0eJGBg3mnDfuaCyLauWwBJiHRboYxJ++1xJNg= +cloud.google.com/go/kms v1.6.0/go.mod h1:Jjy850yySiasBUDi6KFUwUv2n1+o7QZFyuUJg6OgjA0= +cloud.google.com/go/kms v1.8.0/go.mod h1:4xFEhYFqvW+4VMELtZyxomGSYtSQKzM178ylFW4jMAg= +cloud.google.com/go/kms v1.9.0/go.mod h1:qb1tPTgfF9RQP8e1wq4cLFErVuTJv7UsSC915J8dh3w= +cloud.google.com/go/kms v1.10.0/go.mod h1:ng3KTUtQQU9bPX3+QGLsflZIHlkbn8amFAMY63m8d24= +cloud.google.com/go/kms v1.10.1/go.mod h1:rIWk/TryCkR59GMC3YtHtXeLzd634lBbKenvyySAyYI= +cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic= +cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI= +cloud.google.com/go/language v1.7.0/go.mod h1:DJ6dYN/W+SQOjF8e1hLQXMF21AkH2w9wiPzPCJa2MIE= +cloud.google.com/go/language v1.8.0/go.mod h1:qYPVHf7SPoNNiCL2Dr0FfEFNil1qi3pQEyygwpgVKB8= +cloud.google.com/go/language v1.9.0/go.mod h1:Ns15WooPM5Ad/5no/0n81yUetis74g3zrbeJBE+ptUY= +cloud.google.com/go/lifesciences v0.5.0/go.mod h1:3oIKy8ycWGPUyZDR/8RNnTOYevhaMLqh5vLUXs9zvT8= +cloud.google.com/go/lifesciences v0.6.0/go.mod h1:ddj6tSX/7BOnhxCSd3ZcETvtNr8NZ6t/iPhY2Tyfu08= +cloud.google.com/go/lifesciences v0.8.0/go.mod h1:lFxiEOMqII6XggGbOnKiyZ7IBwoIqA84ClvoezaA/bo= +cloud.google.com/go/logging v1.6.1/go.mod h1:5ZO0mHHbvm8gEmeEUHrmDlTDSu5imF6MUP9OfilNXBw= +cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M= +cloud.google.com/go/longrunning v0.1.1/go.mod h1:UUFxuDWkv22EuY93jjmDMFT5GPQKeFVJBIF6QlTqdsE= +cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc= +cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo= +cloud.google.com/go/managedidentities v1.3.0/go.mod h1:UzlW3cBOiPrzucO5qWkNkh0w33KFtBJU281hacNvsdE= +cloud.google.com/go/managedidentities v1.4.0/go.mod h1:NWSBYbEMgqmbZsLIyKvxrYbtqOsxY1ZrGM+9RgDqInM= +cloud.google.com/go/managedidentities v1.5.0/go.mod h1:+dWcZ0JlUmpuxpIDfyP5pP5y0bLdRwOS4Lp7gMni/LA= +cloud.google.com/go/maps v0.1.0/go.mod h1:BQM97WGyfw9FWEmQMpZ5T6cpovXXSd1cGmFma94eubI= +cloud.google.com/go/maps v0.6.0/go.mod h1:o6DAMMfb+aINHz/p/jbcY+mYeXBoZoxTfdSQ8VAJaCw= +cloud.google.com/go/maps v0.7.0/go.mod h1:3GnvVl3cqeSvgMcpRlQidXsPYuDGQ8naBis7MVzpXsY= +cloud.google.com/go/mediatranslation v0.5.0/go.mod h1:jGPUhGTybqsPQn91pNXw0xVHfuJ3leR1wj37oU3y1f4= +cloud.google.com/go/mediatranslation v0.6.0/go.mod h1:hHdBCTYNigsBxshbznuIMFNe5QXEowAuNmmC7h8pu5w= +cloud.google.com/go/mediatranslation v0.7.0/go.mod h1:LCnB/gZr90ONOIQLgSXagp8XUW1ODs2UmUMvcgMfI2I= +cloud.google.com/go/memcache v1.4.0/go.mod h1:rTOfiGZtJX1AaFUrOgsMHX5kAzaTQ8azHiuDoTPzNsE= +cloud.google.com/go/memcache v1.5.0/go.mod h1:dk3fCK7dVo0cUU2c36jKb4VqKPS22BTkf81Xq617aWM= +cloud.google.com/go/memcache v1.6.0/go.mod h1:XS5xB0eQZdHtTuTF9Hf8eJkKtR3pVRCcvJwtm68T3rA= +cloud.google.com/go/memcache v1.7.0/go.mod h1:ywMKfjWhNtkQTxrWxCkCFkoPjLHPW6A7WOTVI8xy3LY= +cloud.google.com/go/memcache v1.9.0/go.mod h1:8oEyzXCu+zo9RzlEaEjHl4KkgjlNDaXbCQeQWlzNFJM= +cloud.google.com/go/metastore v1.5.0/go.mod h1:2ZNrDcQwghfdtCwJ33nM0+GrBGlVuh8rakL3vdPY3XY= +cloud.google.com/go/metastore v1.6.0/go.mod h1:6cyQTls8CWXzk45G55x57DVQ9gWg7RiH65+YgPsNh9s= +cloud.google.com/go/metastore v1.7.0/go.mod h1:s45D0B4IlsINu87/AsWiEVYbLaIMeUSoxlKKDqBGFS8= +cloud.google.com/go/metastore v1.8.0/go.mod h1:zHiMc4ZUpBiM7twCIFQmJ9JMEkDSyZS9U12uf7wHqSI= +cloud.google.com/go/metastore v1.10.0/go.mod h1:fPEnH3g4JJAk+gMRnrAnoqyv2lpUCqJPWOodSaf45Eo= +cloud.google.com/go/monitoring v1.7.0/go.mod h1:HpYse6kkGo//7p6sT0wsIC6IBDET0RhIsnmlA53dvEk= +cloud.google.com/go/monitoring v1.8.0/go.mod h1:E7PtoMJ1kQXWxPjB6mv2fhC5/15jInuulFdYYtlcvT4= +cloud.google.com/go/monitoring v1.12.0/go.mod h1:yx8Jj2fZNEkL/GYZyTLS4ZtZEZN8WtDEiEqG4kLK50w= +cloud.google.com/go/monitoring v1.13.0/go.mod h1:k2yMBAB1H9JT/QETjNkgdCGD9bPF712XiLTVr+cBrpw= +cloud.google.com/go/networkconnectivity v1.4.0/go.mod h1:nOl7YL8odKyAOtzNX73/M5/mGZgqqMeryi6UPZTk/rA= +cloud.google.com/go/networkconnectivity v1.5.0/go.mod h1:3GzqJx7uhtlM3kln0+x5wyFvuVH1pIBJjhCpjzSt75o= +cloud.google.com/go/networkconnectivity v1.6.0/go.mod h1:OJOoEXW+0LAxHh89nXd64uGG+FbQoeH8DtxCHVOMlaM= +cloud.google.com/go/networkconnectivity v1.7.0/go.mod h1:RMuSbkdbPwNMQjB5HBWD5MpTBnNm39iAVpC3TmsExt8= +cloud.google.com/go/networkconnectivity v1.10.0/go.mod h1:UP4O4sWXJG13AqrTdQCD9TnLGEbtNRqjuaaA7bNjF5E= +cloud.google.com/go/networkconnectivity v1.11.0/go.mod h1:iWmDD4QF16VCDLXUqvyspJjIEtBR/4zq5hwnY2X3scM= +cloud.google.com/go/networkmanagement v1.4.0/go.mod h1:Q9mdLLRn60AsOrPc8rs8iNV6OHXaGcDdsIQe1ohekq8= +cloud.google.com/go/networkmanagement v1.5.0/go.mod h1:ZnOeZ/evzUdUsnvRt792H0uYEnHQEMaz+REhhzJRcf4= +cloud.google.com/go/networkmanagement v1.6.0/go.mod h1:5pKPqyXjB/sgtvB5xqOemumoQNB7y95Q7S+4rjSOPYY= +cloud.google.com/go/networksecurity v0.5.0/go.mod h1:xS6fOCoqpVC5zx15Z/MqkfDwH4+m/61A3ODiDV1xmiQ= +cloud.google.com/go/networksecurity v0.6.0/go.mod h1:Q5fjhTr9WMI5mbpRYEbiexTzROf7ZbDzvzCrNl14nyU= +cloud.google.com/go/networksecurity v0.7.0/go.mod h1:mAnzoxx/8TBSyXEeESMy9OOYwo1v+gZ5eMRnsT5bC8k= +cloud.google.com/go/networksecurity v0.8.0/go.mod h1:B78DkqsxFG5zRSVuwYFRZ9Xz8IcQ5iECsNrPn74hKHU= +cloud.google.com/go/notebooks v1.2.0/go.mod h1:9+wtppMfVPUeJ8fIWPOq1UnATHISkGXGqTkxeieQ6UY= +cloud.google.com/go/notebooks v1.3.0/go.mod h1:bFR5lj07DtCPC7YAAJ//vHskFBxA5JzYlH68kXVdk34= +cloud.google.com/go/notebooks v1.4.0/go.mod h1:4QPMngcwmgb6uw7Po99B2xv5ufVoIQ7nOGDyL4P8AgA= +cloud.google.com/go/notebooks v1.5.0/go.mod h1:q8mwhnP9aR8Hpfnrc5iN5IBhrXUy8S2vuYs+kBJ/gu0= +cloud.google.com/go/notebooks v1.7.0/go.mod h1:PVlaDGfJgj1fl1S3dUwhFMXFgfYGhYQt2164xOMONmE= +cloud.google.com/go/notebooks v1.8.0/go.mod h1:Lq6dYKOYOWUCTvw5t2q1gp1lAp0zxAxRycayS0iJcqQ= +cloud.google.com/go/optimization v1.1.0/go.mod h1:5po+wfvX5AQlPznyVEZjGJTMr4+CAkJf2XSTQOOl9l4= +cloud.google.com/go/optimization v1.2.0/go.mod h1:Lr7SOHdRDENsh+WXVmQhQTrzdu9ybg0NecjHidBq6xs= +cloud.google.com/go/optimization v1.3.1/go.mod h1:IvUSefKiwd1a5p0RgHDbWCIbDFgKuEdB+fPPuP0IDLI= +cloud.google.com/go/orchestration v1.3.0/go.mod h1:Sj5tq/JpWiB//X/q3Ngwdl5K7B7Y0KZ7bfv0wL6fqVA= +cloud.google.com/go/orchestration v1.4.0/go.mod h1:6W5NLFWs2TlniBphAViZEVhrXRSMgUGDfW7vrWKvsBk= +cloud.google.com/go/orchestration v1.6.0/go.mod h1:M62Bevp7pkxStDfFfTuCOaXgaaqRAga1yKyoMtEoWPQ= +cloud.google.com/go/orgpolicy v1.4.0/go.mod h1:xrSLIV4RePWmP9P3tBl8S93lTmlAxjm06NSm2UTmKvE= +cloud.google.com/go/orgpolicy v1.5.0/go.mod h1:hZEc5q3wzwXJaKrsx5+Ewg0u1LxJ51nNFlext7Tanwc= +cloud.google.com/go/orgpolicy v1.10.0/go.mod h1:w1fo8b7rRqlXlIJbVhOMPrwVljyuW5mqssvBtU18ONc= +cloud.google.com/go/osconfig v1.7.0/go.mod h1:oVHeCeZELfJP7XLxcBGTMBvRO+1nQ5tFG9VQTmYS2Fs= +cloud.google.com/go/osconfig v1.8.0/go.mod h1:EQqZLu5w5XA7eKizepumcvWx+m8mJUhEwiPqWiZeEdg= +cloud.google.com/go/osconfig v1.9.0/go.mod h1:Yx+IeIZJ3bdWmzbQU4fxNl8xsZ4amB+dygAwFPlvnNo= +cloud.google.com/go/osconfig v1.10.0/go.mod h1:uMhCzqC5I8zfD9zDEAfvgVhDS8oIjySWh+l4WK6GnWw= +cloud.google.com/go/osconfig v1.11.0/go.mod h1:aDICxrur2ogRd9zY5ytBLV89KEgT2MKB2L/n6x1ooPw= +cloud.google.com/go/oslogin v1.4.0/go.mod h1:YdgMXWRaElXz/lDk1Na6Fh5orF7gvmJ0FGLIs9LId4E= +cloud.google.com/go/oslogin v1.5.0/go.mod h1:D260Qj11W2qx/HVF29zBg+0fd6YCSjSqLUkY/qEenQU= +cloud.google.com/go/oslogin v1.6.0/go.mod h1:zOJ1O3+dTU8WPlGEkFSh7qeHPPSoxrcMbbK1Nm2iX70= +cloud.google.com/go/oslogin v1.7.0/go.mod h1:e04SN0xO1UNJ1M5GP0vzVBFicIe4O53FOfcixIqTyXo= +cloud.google.com/go/oslogin v1.9.0/go.mod h1:HNavntnH8nzrn8JCTT5fj18FuJLFJc4NaZJtBnQtKFs= +cloud.google.com/go/phishingprotection v0.5.0/go.mod h1:Y3HZknsK9bc9dMi+oE8Bim0lczMU6hrX0UpADuMefr0= +cloud.google.com/go/phishingprotection v0.6.0/go.mod h1:9Y3LBLgy0kDTcYET8ZH3bq/7qni15yVUoAxiFxnlSUA= +cloud.google.com/go/phishingprotection v0.7.0/go.mod h1:8qJI4QKHoda/sb/7/YmMQ2omRLSLYSu9bU0EKCNI+Lk= +cloud.google.com/go/policytroubleshooter v1.3.0/go.mod h1:qy0+VwANja+kKrjlQuOzmlvscn4RNsAc0e15GGqfMxg= +cloud.google.com/go/policytroubleshooter v1.4.0/go.mod h1:DZT4BcRw3QoO8ota9xw/LKtPa8lKeCByYeKTIf/vxdE= +cloud.google.com/go/policytroubleshooter v1.5.0/go.mod h1:Rz1WfV+1oIpPdN2VvvuboLVRsB1Hclg3CKQ53j9l8vw= +cloud.google.com/go/policytroubleshooter v1.6.0/go.mod h1:zYqaPTsmfvpjm5ULxAyD/lINQxJ0DDsnWOP/GZ7xzBc= +cloud.google.com/go/privatecatalog v0.5.0/go.mod h1:XgosMUvvPyxDjAVNDYxJ7wBW8//hLDDYmnsNcMGq1K0= +cloud.google.com/go/privatecatalog v0.6.0/go.mod h1:i/fbkZR0hLN29eEWiiwue8Pb+GforiEIBnV9yrRUOKI= +cloud.google.com/go/privatecatalog v0.7.0/go.mod h1:2s5ssIFO69F5csTXcwBP7NPFTZvps26xGzvQ2PQaBYg= +cloud.google.com/go/privatecatalog v0.8.0/go.mod h1:nQ6pfaegeDAq/Q5lrfCQzQLhubPiZhSaNhIgfJlnIXs= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/pubsub v1.26.0/go.mod h1:QgBH3U/jdJy/ftjPhTkyXNj543Tin1pRYcdcPRnFIRI= +cloud.google.com/go/pubsub v1.27.1/go.mod h1:hQN39ymbV9geqBnfQq6Xf63yNhUAhv9CZhzp5O6qsW0= +cloud.google.com/go/pubsub v1.28.0/go.mod h1:vuXFpwaVoIPQMGXqRyUQigu/AX1S3IWugR9xznmcXX8= +cloud.google.com/go/pubsub v1.30.0/go.mod h1:qWi1OPS0B+b5L+Sg6Gmc9zD1Y+HaM0MdUr7LsupY1P4= +cloud.google.com/go/pubsublite v1.5.0/go.mod h1:xapqNQ1CuLfGi23Yda/9l4bBCKz/wC3KIJ5gKcxveZg= +cloud.google.com/go/pubsublite v1.6.0/go.mod h1:1eFCS0U11xlOuMFV/0iBqw3zP12kddMeCbj/F3FSj9k= +cloud.google.com/go/pubsublite v1.7.0/go.mod h1:8hVMwRXfDfvGm3fahVbtDbiLePT3gpoiJYJY+vxWxVM= +cloud.google.com/go/recaptchaenterprise v1.3.1/go.mod h1:OdD+q+y4XGeAlxRaMn1Y7/GveP6zmq76byL6tjPE7d4= +cloud.google.com/go/recaptchaenterprise/v2 v2.1.0/go.mod h1:w9yVqajwroDNTfGuhmOjPDN//rZGySaf6PtFVcSCa7o= +cloud.google.com/go/recaptchaenterprise/v2 v2.2.0/go.mod h1:/Zu5jisWGeERrd5HnlS3EUGb/D335f9k51B/FVil0jk= +cloud.google.com/go/recaptchaenterprise/v2 v2.3.0/go.mod h1:O9LwGCjrhGHBQET5CA7dd5NwwNQUErSgEDit1DLNTdo= +cloud.google.com/go/recaptchaenterprise/v2 v2.4.0/go.mod h1:Am3LHfOuBstrLrNCBrlI5sbwx9LBg3te2N6hGvHn2mE= +cloud.google.com/go/recaptchaenterprise/v2 v2.5.0/go.mod h1:O8LzcHXN3rz0j+LBC91jrwI3R+1ZSZEWrfL7XHgNo9U= +cloud.google.com/go/recaptchaenterprise/v2 v2.6.0/go.mod h1:RPauz9jeLtB3JVzg6nCbe12qNoaa8pXc4d/YukAmcnA= +cloud.google.com/go/recaptchaenterprise/v2 v2.7.0/go.mod h1:19wVj/fs5RtYtynAPJdDTb69oW0vNHYDBTbB4NvMD9c= +cloud.google.com/go/recommendationengine v0.5.0/go.mod h1:E5756pJcVFeVgaQv3WNpImkFP8a+RptV6dDLGPILjvg= +cloud.google.com/go/recommendationengine v0.6.0/go.mod h1:08mq2umu9oIqc7tDy8sx+MNJdLG0fUi3vaSVbztHgJ4= +cloud.google.com/go/recommendationengine v0.7.0/go.mod h1:1reUcE3GIu6MeBz/h5xZJqNLuuVjNg1lmWMPyjatzac= +cloud.google.com/go/recommender v1.5.0/go.mod h1:jdoeiBIVrJe9gQjwd759ecLJbxCDED4A6p+mqoqDvTg= +cloud.google.com/go/recommender v1.6.0/go.mod h1:+yETpm25mcoiECKh9DEScGzIRyDKpZ0cEhWGo+8bo+c= +cloud.google.com/go/recommender v1.7.0/go.mod h1:XLHs/W+T8olwlGOgfQenXBTbIseGclClff6lhFVe9Bs= +cloud.google.com/go/recommender v1.8.0/go.mod h1:PkjXrTT05BFKwxaUxQmtIlrtj0kph108r02ZZQ5FE70= +cloud.google.com/go/recommender v1.9.0/go.mod h1:PnSsnZY7q+VL1uax2JWkt/UegHssxjUVVCrX52CuEmQ= +cloud.google.com/go/redis v1.7.0/go.mod h1:V3x5Jq1jzUcg+UNsRvdmsfuFnit1cfe3Z/PGyq/lm4Y= +cloud.google.com/go/redis v1.8.0/go.mod h1:Fm2szCDavWzBk2cDKxrkmWBqoCiL1+Ctwq7EyqBCA/A= +cloud.google.com/go/redis v1.9.0/go.mod h1:HMYQuajvb2D0LvMgZmLDZW8V5aOC/WxstZHiy4g8OiA= +cloud.google.com/go/redis v1.10.0/go.mod h1:ThJf3mMBQtW18JzGgh41/Wld6vnDDc/F/F35UolRZPM= +cloud.google.com/go/redis v1.11.0/go.mod h1:/X6eicana+BWcUda5PpwZC48o37SiFVTFSs0fWAJ7uQ= +cloud.google.com/go/resourcemanager v1.3.0/go.mod h1:bAtrTjZQFJkiWTPDb1WBjzvc6/kifjj4QBYuKCCoqKA= +cloud.google.com/go/resourcemanager v1.4.0/go.mod h1:MwxuzkumyTX7/a3n37gmsT3py7LIXwrShilPh3P1tR0= +cloud.google.com/go/resourcemanager v1.5.0/go.mod h1:eQoXNAiAvCf5PXxWxXjhKQoTMaUSNrEfg+6qdf/wots= +cloud.google.com/go/resourcemanager v1.6.0/go.mod h1:YcpXGRs8fDzcUl1Xw8uOVmI8JEadvhRIkoXXUNVYcVo= +cloud.google.com/go/resourcemanager v1.7.0/go.mod h1:HlD3m6+bwhzj9XCouqmeiGuni95NTrExfhoSrkC/3EI= +cloud.google.com/go/resourcesettings v1.3.0/go.mod h1:lzew8VfESA5DQ8gdlHwMrqZs1S9V87v3oCnKCWoOuQU= +cloud.google.com/go/resourcesettings v1.4.0/go.mod h1:ldiH9IJpcrlC3VSuCGvjR5of/ezRrOxFtpJoJo5SmXg= +cloud.google.com/go/resourcesettings v1.5.0/go.mod h1:+xJF7QSG6undsQDfsCJyqWXyBwUoJLhetkRMDRnIoXA= +cloud.google.com/go/retail v1.8.0/go.mod h1:QblKS8waDmNUhghY2TI9O3JLlFk8jybHeV4BF19FrE4= +cloud.google.com/go/retail v1.9.0/go.mod h1:g6jb6mKuCS1QKnH/dpu7isX253absFl6iE92nHwlBUY= +cloud.google.com/go/retail v1.10.0/go.mod h1:2gDk9HsL4HMS4oZwz6daui2/jmKvqShXKQuB2RZ+cCc= +cloud.google.com/go/retail v1.11.0/go.mod h1:MBLk1NaWPmh6iVFSz9MeKG/Psyd7TAgm6y/9L2B4x9Y= +cloud.google.com/go/retail v1.12.0/go.mod h1:UMkelN/0Z8XvKymXFbD4EhFJlYKRx1FGhQkVPU5kF14= +cloud.google.com/go/run v0.2.0/go.mod h1:CNtKsTA1sDcnqqIFR3Pb5Tq0usWxJJvsWOCPldRU3Do= +cloud.google.com/go/run v0.3.0/go.mod h1:TuyY1+taHxTjrD0ZFk2iAR+xyOXEA0ztb7U3UNA0zBo= +cloud.google.com/go/run v0.8.0/go.mod h1:VniEnuBwqjigv0A7ONfQUaEItaiCRVujlMqerPPiktM= +cloud.google.com/go/run v0.9.0/go.mod h1:Wwu+/vvg8Y+JUApMwEDfVfhetv30hCG4ZwDR/IXl2Qg= +cloud.google.com/go/scheduler v1.4.0/go.mod h1:drcJBmxF3aqZJRhmkHQ9b3uSSpQoltBPGPxGAWROx6s= +cloud.google.com/go/scheduler v1.5.0/go.mod h1:ri073ym49NW3AfT6DZi21vLZrG07GXr5p3H1KxN5QlI= +cloud.google.com/go/scheduler v1.6.0/go.mod h1:SgeKVM7MIwPn3BqtcBntpLyrIJftQISRrYB5ZtT+KOk= +cloud.google.com/go/scheduler v1.7.0/go.mod h1:jyCiBqWW956uBjjPMMuX09n3x37mtyPJegEWKxRsn44= +cloud.google.com/go/scheduler v1.8.0/go.mod h1:TCET+Y5Gp1YgHT8py4nlg2Sew8nUHMqcpousDgXJVQc= +cloud.google.com/go/scheduler v1.9.0/go.mod h1:yexg5t+KSmqu+njTIh3b7oYPheFtBWGcbVUYF1GGMIc= +cloud.google.com/go/secretmanager v1.6.0/go.mod h1:awVa/OXF6IiyaU1wQ34inzQNc4ISIDIrId8qE5QGgKA= +cloud.google.com/go/secretmanager v1.8.0/go.mod h1:hnVgi/bN5MYHd3Gt0SPuTPPp5ENina1/LxM+2W9U9J4= +cloud.google.com/go/secretmanager v1.9.0/go.mod h1:b71qH2l1yHmWQHt9LC80akm86mX8AL6X1MA01dW8ht4= +cloud.google.com/go/secretmanager v1.10.0/go.mod h1:MfnrdvKMPNra9aZtQFvBcvRU54hbPD8/HayQdlUgJpU= +cloud.google.com/go/security v1.5.0/go.mod h1:lgxGdyOKKjHL4YG3/YwIL2zLqMFCKs0UbQwgyZmfJl4= +cloud.google.com/go/security v1.7.0/go.mod h1:mZklORHl6Bg7CNnnjLH//0UlAlaXqiG7Lb9PsPXLfD0= +cloud.google.com/go/security v1.8.0/go.mod h1:hAQOwgmaHhztFhiQ41CjDODdWP0+AE1B3sX4OFlq+GU= +cloud.google.com/go/security v1.9.0/go.mod h1:6Ta1bO8LXI89nZnmnsZGp9lVoVWXqsVbIq/t9dzI+2Q= +cloud.google.com/go/security v1.10.0/go.mod h1:QtOMZByJVlibUT2h9afNDWRZ1G96gVywH8T5GUSb9IA= +cloud.google.com/go/security v1.12.0/go.mod h1:rV6EhrpbNHrrxqlvW0BWAIawFWq3X90SduMJdFwtLB8= +cloud.google.com/go/security v1.13.0/go.mod h1:Q1Nvxl1PAgmeW0y3HTt54JYIvUdtcpYKVfIB8AOMZ+0= +cloud.google.com/go/securitycenter v1.13.0/go.mod h1:cv5qNAqjY84FCN6Y9z28WlkKXyWsgLO832YiWwkCWcU= +cloud.google.com/go/securitycenter v1.14.0/go.mod h1:gZLAhtyKv85n52XYWt6RmeBdydyxfPeTrpToDPw4Auc= +cloud.google.com/go/securitycenter v1.15.0/go.mod h1:PeKJ0t8MoFmmXLXWm41JidyzI3PJjd8sXWaVqg43WWk= +cloud.google.com/go/securitycenter v1.16.0/go.mod h1:Q9GMaLQFUD+5ZTabrbujNWLtSLZIZF7SAR0wWECrjdk= +cloud.google.com/go/securitycenter v1.18.1/go.mod h1:0/25gAzCM/9OL9vVx4ChPeM/+DlfGQJDwBy/UC8AKK0= +cloud.google.com/go/securitycenter v1.19.0/go.mod h1:LVLmSg8ZkkyaNy4u7HCIshAngSQ8EcIRREP3xBnyfag= +cloud.google.com/go/servicecontrol v1.4.0/go.mod h1:o0hUSJ1TXJAmi/7fLJAedOovnujSEvjKCAFNXPQ1RaU= +cloud.google.com/go/servicecontrol v1.5.0/go.mod h1:qM0CnXHhyqKVuiZnGKrIurvVImCs8gmqWsDoqe9sU1s= +cloud.google.com/go/servicecontrol v1.10.0/go.mod h1:pQvyvSRh7YzUF2efw7H87V92mxU8FnFDawMClGCNuAA= +cloud.google.com/go/servicecontrol v1.11.0/go.mod h1:kFmTzYzTUIuZs0ycVqRHNaNhgR+UMUpw9n02l/pY+mc= +cloud.google.com/go/servicecontrol v1.11.1/go.mod h1:aSnNNlwEFBY+PWGQ2DoM0JJ/QUXqV5/ZD9DOLB7SnUk= +cloud.google.com/go/servicedirectory v1.4.0/go.mod h1:gH1MUaZCgtP7qQiI+F+A+OpeKF/HQWgtAddhTbhL2bs= +cloud.google.com/go/servicedirectory v1.5.0/go.mod h1:QMKFL0NUySbpZJ1UZs3oFAmdvVxhhxB6eJ/Vlp73dfg= +cloud.google.com/go/servicedirectory v1.6.0/go.mod h1:pUlbnWsLH9c13yGkxCmfumWEPjsRs1RlmJ4pqiNjVL4= +cloud.google.com/go/servicedirectory v1.7.0/go.mod h1:5p/U5oyvgYGYejufvxhgwjL8UVXjkuw7q5XcG10wx1U= +cloud.google.com/go/servicedirectory v1.8.0/go.mod h1:srXodfhY1GFIPvltunswqXpVxFPpZjf8nkKQT7XcXaY= +cloud.google.com/go/servicedirectory v1.9.0/go.mod h1:29je5JjiygNYlmsGz8k6o+OZ8vd4f//bQLtvzkPPT/s= +cloud.google.com/go/servicemanagement v1.4.0/go.mod h1:d8t8MDbezI7Z2R1O/wu8oTggo3BI2GKYbdG4y/SJTco= +cloud.google.com/go/servicemanagement v1.5.0/go.mod h1:XGaCRe57kfqu4+lRxaFEAuqmjzF0r+gWHjWqKqBvKFo= +cloud.google.com/go/servicemanagement v1.6.0/go.mod h1:aWns7EeeCOtGEX4OvZUWCCJONRZeFKiptqKf1D0l/Jc= +cloud.google.com/go/servicemanagement v1.8.0/go.mod h1:MSS2TDlIEQD/fzsSGfCdJItQveu9NXnUniTrq/L8LK4= +cloud.google.com/go/serviceusage v1.3.0/go.mod h1:Hya1cozXM4SeSKTAgGXgj97GlqUvF5JaoXacR1JTP/E= +cloud.google.com/go/serviceusage v1.4.0/go.mod h1:SB4yxXSaYVuUBYUml6qklyONXNLt83U0Rb+CXyhjEeU= +cloud.google.com/go/serviceusage v1.5.0/go.mod h1:w8U1JvqUqwJNPEOTQjrMHkw3IaIFLoLsPLvsE3xueec= +cloud.google.com/go/serviceusage v1.6.0/go.mod h1:R5wwQcbOWsyuOfbP9tGdAnCAc6B9DRwPG1xtWMDeuPA= +cloud.google.com/go/shell v1.3.0/go.mod h1:VZ9HmRjZBsjLGXusm7K5Q5lzzByZmJHf1d0IWHEN5X4= +cloud.google.com/go/shell v1.4.0/go.mod h1:HDxPzZf3GkDdhExzD/gs8Grqk+dmYcEjGShZgYa9URw= +cloud.google.com/go/shell v1.6.0/go.mod h1:oHO8QACS90luWgxP3N9iZVuEiSF84zNyLytb+qE2f9A= +cloud.google.com/go/spanner v1.41.0/go.mod h1:MLYDBJR/dY4Wt7ZaMIQ7rXOTLjYrmxLE/5ve9vFfWos= +cloud.google.com/go/spanner v1.44.0/go.mod h1:G8XIgYdOK+Fbcpbs7p2fiprDw4CaZX63whnSMLVBxjk= +cloud.google.com/go/spanner v1.45.0/go.mod h1:FIws5LowYz8YAE1J8fOS7DJup8ff7xJeetWEo5REA2M= +cloud.google.com/go/speech v1.6.0/go.mod h1:79tcr4FHCimOp56lwC01xnt/WPJZc4v3gzyT7FoBkCM= +cloud.google.com/go/speech v1.7.0/go.mod h1:KptqL+BAQIhMsj1kOP2la5DSEEerPDuOP/2mmkhHhZQ= +cloud.google.com/go/speech v1.8.0/go.mod h1:9bYIl1/tjsAnMgKGHKmBZzXKEkGgtU+MpdDPTE9f7y0= +cloud.google.com/go/speech v1.9.0/go.mod h1:xQ0jTcmnRFFM2RfX/U+rk6FQNUF6DQlydUSyoooSpco= +cloud.google.com/go/speech v1.14.1/go.mod h1:gEosVRPJ9waG7zqqnsHpYTOoAS4KouMRLDFMekpJ0J0= +cloud.google.com/go/speech v1.15.0/go.mod h1:y6oH7GhqCaZANH7+Oe0BhgIogsNInLlz542tg3VqeYI= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y= +cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc= +cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s= +cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y= +cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4= +cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w= +cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I= +cloud.google.com/go/storagetransfer v1.7.0/go.mod h1:8Giuj1QNb1kfLAiWM1bN6dHzfdlDAVC9rv9abHot2W4= +cloud.google.com/go/storagetransfer v1.8.0/go.mod h1:JpegsHHU1eXg7lMHkvf+KE5XDJ7EQu0GwNJbbVGanEw= +cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw= +cloud.google.com/go/talent v1.2.0/go.mod h1:MoNF9bhFQbiJ6eFD3uSsg0uBALw4n4gaCaEjBw9zo8g= +cloud.google.com/go/talent v1.3.0/go.mod h1:CmcxwJ/PKfRgd1pBjQgU6W3YBwiewmUzQYH5HHmSCmM= +cloud.google.com/go/talent v1.4.0/go.mod h1:ezFtAgVuRf8jRsvyE6EwmbTK5LKciD4KVnHuDEFmOOA= +cloud.google.com/go/talent v1.5.0/go.mod h1:G+ODMj9bsasAEJkQSzO2uHQWXHHXUomArjWQQYkqK6c= +cloud.google.com/go/texttospeech v1.4.0/go.mod h1:FX8HQHA6sEpJ7rCMSfXuzBcysDAuWusNNNvN9FELDd8= +cloud.google.com/go/texttospeech v1.5.0/go.mod h1:oKPLhR4n4ZdQqWKURdwxMy0uiTS1xU161C8W57Wkea4= +cloud.google.com/go/texttospeech v1.6.0/go.mod h1:YmwmFT8pj1aBblQOI3TfKmwibnsfvhIBzPXcW4EBovc= +cloud.google.com/go/tpu v1.3.0/go.mod h1:aJIManG0o20tfDQlRIej44FcwGGl/cD0oiRyMKG19IQ= +cloud.google.com/go/tpu v1.4.0/go.mod h1:mjZaX8p0VBgllCzF6wcU2ovUXN9TONFLd7iz227X2Xg= +cloud.google.com/go/tpu v1.5.0/go.mod h1:8zVo1rYDFuW2l4yZVY0R0fb/v44xLh3llq7RuV61fPM= +cloud.google.com/go/trace v1.3.0/go.mod h1:FFUE83d9Ca57C+K8rDl/Ih8LwOzWIV1krKgxg6N0G28= +cloud.google.com/go/trace v1.4.0/go.mod h1:UG0v8UBqzusp+z63o7FK74SdFE+AXpCLdFb1rshXG+Y= +cloud.google.com/go/trace v1.8.0/go.mod h1:zH7vcsbAhklH8hWFig58HvxcxyQbaIqMarMg9hn5ECA= +cloud.google.com/go/trace v1.9.0/go.mod h1:lOQqpE5IaWY0Ixg7/r2SjixMuc6lfTFeO4QGM4dQWOk= +cloud.google.com/go/translate v1.3.0/go.mod h1:gzMUwRjvOqj5i69y/LYLd8RrNQk+hOmIXTi9+nb3Djs= +cloud.google.com/go/translate v1.4.0/go.mod h1:06Dn/ppvLD6WvA5Rhdp029IX2Mi3Mn7fpMRLPvXT5Wg= +cloud.google.com/go/translate v1.5.0/go.mod h1:29YDSYveqqpA1CQFD7NQuP49xymq17RXNaUDdc0mNu0= +cloud.google.com/go/translate v1.6.0/go.mod h1:lMGRudH1pu7I3n3PETiOB2507gf3HnfLV8qlkHZEyos= +cloud.google.com/go/translate v1.7.0/go.mod h1:lMGRudH1pu7I3n3PETiOB2507gf3HnfLV8qlkHZEyos= +cloud.google.com/go/video v1.8.0/go.mod h1:sTzKFc0bUSByE8Yoh8X0mn8bMymItVGPfTuUBUyRgxk= +cloud.google.com/go/video v1.9.0/go.mod h1:0RhNKFRF5v92f8dQt0yhaHrEuH95m068JYOvLZYnJSw= +cloud.google.com/go/video v1.12.0/go.mod h1:MLQew95eTuaNDEGriQdcYn0dTwf9oWiA4uYebxM5kdg= +cloud.google.com/go/video v1.13.0/go.mod h1:ulzkYlYgCp15N2AokzKjy7MQ9ejuynOJdf1tR5lGthk= +cloud.google.com/go/video v1.14.0/go.mod h1:SkgaXwT+lIIAKqWAJfktHT/RbgjSuY6DobxEp0C5yTQ= +cloud.google.com/go/video v1.15.0/go.mod h1:SkgaXwT+lIIAKqWAJfktHT/RbgjSuY6DobxEp0C5yTQ= +cloud.google.com/go/videointelligence v1.6.0/go.mod h1:w0DIDlVRKtwPCn/C4iwZIJdvC69yInhW0cfi+p546uU= +cloud.google.com/go/videointelligence v1.7.0/go.mod h1:k8pI/1wAhjznARtVT9U1llUaFNPh7muw8QyOUpavru4= +cloud.google.com/go/videointelligence v1.8.0/go.mod h1:dIcCn4gVDdS7yte/w+koiXn5dWVplOZkE+xwG9FgK+M= +cloud.google.com/go/videointelligence v1.9.0/go.mod h1:29lVRMPDYHikk3v8EdPSaL8Ku+eMzDljjuvRs105XoU= +cloud.google.com/go/videointelligence v1.10.0/go.mod h1:LHZngX1liVtUhZvi2uNS0VQuOzNi2TkY1OakiuoUOjU= +cloud.google.com/go/vision v1.2.0/go.mod h1:SmNwgObm5DpFBme2xpyOyasvBc1aPdjvMk2bBk0tKD0= +cloud.google.com/go/vision/v2 v2.2.0/go.mod h1:uCdV4PpN1S0jyCyq8sIM42v2Y6zOLkZs+4R9LrGYwFo= +cloud.google.com/go/vision/v2 v2.3.0/go.mod h1:UO61abBx9QRMFkNBbf1D8B1LXdS2cGiiCRx0vSpZoUo= +cloud.google.com/go/vision/v2 v2.4.0/go.mod h1:VtI579ll9RpVTrdKdkMzckdnwMyX2JILb+MhPqRbPsY= +cloud.google.com/go/vision/v2 v2.5.0/go.mod h1:MmaezXOOE+IWa+cS7OhRRLK2cNv1ZL98zhqFFZaaH2E= +cloud.google.com/go/vision/v2 v2.6.0/go.mod h1:158Hes0MvOS9Z/bDMSFpjwsUrZ5fPrdwuyyvKSGAGMY= +cloud.google.com/go/vision/v2 v2.7.0/go.mod h1:H89VysHy21avemp6xcf9b9JvZHVehWbET0uT/bcuY/0= +cloud.google.com/go/vmmigration v1.2.0/go.mod h1:IRf0o7myyWFSmVR1ItrBSFLFD/rJkfDCUTO4vLlJvsE= +cloud.google.com/go/vmmigration v1.3.0/go.mod h1:oGJ6ZgGPQOFdjHuocGcLqX4lc98YQ7Ygq8YQwHh9A7g= +cloud.google.com/go/vmmigration v1.5.0/go.mod h1:E4YQ8q7/4W9gobHjQg4JJSgXXSgY21nA5r8swQV+Xxc= +cloud.google.com/go/vmmigration v1.6.0/go.mod h1:bopQ/g4z+8qXzichC7GW1w2MjbErL54rk3/C843CjfY= +cloud.google.com/go/vmwareengine v0.1.0/go.mod h1:RsdNEf/8UDvKllXhMz5J40XxDrNJNN4sagiox+OI208= +cloud.google.com/go/vmwareengine v0.2.2/go.mod h1:sKdctNJxb3KLZkE/6Oui94iw/xs9PRNC2wnNLXsHvH8= +cloud.google.com/go/vmwareengine v0.3.0/go.mod h1:wvoyMvNWdIzxMYSpH/R7y2h5h3WFkx6d+1TIsP39WGY= +cloud.google.com/go/vpcaccess v1.4.0/go.mod h1:aQHVbTWDYUR1EbTApSVvMq1EnT57ppDmQzZ3imqIk4w= +cloud.google.com/go/vpcaccess v1.5.0/go.mod h1:drmg4HLk9NkZpGfCmZ3Tz0Bwnm2+DKqViEpeEpOq0m8= +cloud.google.com/go/vpcaccess v1.6.0/go.mod h1:wX2ILaNhe7TlVa4vC5xce1bCnqE3AeH27RV31lnmZes= +cloud.google.com/go/webrisk v1.4.0/go.mod h1:Hn8X6Zr+ziE2aNd8SliSDWpEnSS1u4R9+xXZmFiHmGE= +cloud.google.com/go/webrisk v1.5.0/go.mod h1:iPG6fr52Tv7sGk0H6qUFzmL3HHZev1htXuWDEEsqMTg= +cloud.google.com/go/webrisk v1.6.0/go.mod h1:65sW9V9rOosnc9ZY7A7jsy1zoHS5W9IAXv6dGqhMQMc= +cloud.google.com/go/webrisk v1.7.0/go.mod h1:mVMHgEYH0r337nmt1JyLthzMr6YxwN1aAIEc2fTcq7A= +cloud.google.com/go/webrisk v1.8.0/go.mod h1:oJPDuamzHXgUc+b8SiHRcVInZQuybnvEW72PqTc7sSg= +cloud.google.com/go/websecurityscanner v1.3.0/go.mod h1:uImdKm2wyeXQevQJXeh8Uun/Ym1VqworNDlBXQevGMo= +cloud.google.com/go/websecurityscanner v1.4.0/go.mod h1:ebit/Fp0a+FWu5j4JOmJEV8S8CzdTkAS77oDsiSqYWQ= +cloud.google.com/go/websecurityscanner v1.5.0/go.mod h1:Y6xdCPy81yi0SQnDY1xdNTNpfY1oAgXUlcfN3B3eSng= +cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1Vwf+KmJENM0= +cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M= +cloud.google.com/go/workflows v1.8.0/go.mod h1:ysGhmEajwZxGn1OhGOGKsTXc5PyxOc0vfKf5Af+to4M= +cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT3ujaO/WwSA= +cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= +git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e h1:n+DcnTNkQnHlwpsrHoQtkrJIO7CBx029fw6oR4vIob4= @@ -45,24 +610,27 @@ github.com/NebulousLabs/go-upnp v0.0.0-20180202185039-29b680b06c82 h1:MG93+PZYs9 github.com/NebulousLabs/go-upnp v0.0.0-20180202185039-29b680b06c82/go.mod h1:GbuBk21JqF+driLX3XtJYNZjGa45YDoa9IqCTzNSfEc= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 h1:cDVUiFo+npB0ZASqnw4q90ylaVAbnYyx0JYqK4YcGok= github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344/go.mod h1:9pIqrY6SXNL8vjRQE5Hd/OL5GyK/9MrGUWs87z/eFfk= github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA= github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= +github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= +github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= +github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI= +github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= @@ -92,8 +660,8 @@ github.com/btcsuite/btclog v1.0.0/go.mod h1:w7xnGOhwT3lmrS4H3b/D1XAXxvh+tbhUm8xe github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b h1:MQ+Q6sDy37V1wP1Yu79A5KqJutolqUGwA99UZWQDWZM= github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b/go.mod h1:XItGUfVOxotJL8kkuk2Hj3EVow5KCugXl3wWfQ6K0AE= github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= -github.com/btcsuite/btcwallet v0.16.18 h1:6h0kMxij4igPu35jOPAWZbn22ceOC4me4L3jj8Za6Zk= -github.com/btcsuite/btcwallet v0.16.18/go.mod h1:4TTru0cgIPbCZpY4aRfAVwX87zrQw4GXM8MH6+A5xZw= +github.com/btcsuite/btcwallet v0.16.19 h1:F52y5luPecfjSDajteVm8qYR6H8vsZ40Ot+Mim8e3JA= +github.com/btcsuite/btcwallet v0.16.19/go.mod h1:u4908XXsJNEl8t8uYz01dEMZqGg6cMN4WCumcuHmbUE= github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5 h1:Rr0njWI3r341nhSPesKQ2JF+ugDSzdPoeckS75SeDZk= github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5/go.mod h1:+tXJ3Ym0nlQc/iHSwW1qzjmPs3ev+UVWMbGgfV1OZqU= github.com/btcsuite/btcwallet/wallet/txrules v1.2.2 h1:YEO+Lx1ZJJAtdRrjuhXjWrYsmAk26wLTlNzxt2q0lhk= @@ -120,8 +688,11 @@ github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -129,6 +700,18 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA= github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= @@ -175,16 +758,30 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= +github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJJM//w9BV6Fxbg2LuVd34= +github.com/envoyproxy/go-control-plane v0.11.1-0.20230524094728-9239064ad72f/go.mod h1:sfYdkwUW4BA3PbKjySwjJy+O4Pu0h62rlqCMHNk+K+Q= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo= +github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w= +github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fergusstrange/embedded-postgres v1.25.0 h1:sa+k2Ycrtz40eCRPOzI7Ry7TtkWXXJ+YRsxpKMDhxK0= github.com/fergusstrange/embedded-postgres v1.25.0/go.mod h1:t/MLs0h9ukYM6FSt99R7InCHs1nW0ordoVCcnzmpTYw= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/frankban/quicktest v1.0.0/go.mod h1:R98jIehRai+d1/3Hv2//jOVCTJhW1VBavT6B6CuGq2k= @@ -198,17 +795,16 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4 github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= +github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= +github.com/go-fonts/liberation v0.2.0/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= +github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -216,18 +812,23 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-macaroon-bakery/macaroonpb v1.0.0 h1:It9exBaRMZ9iix1iJ6gwzfwsDE6ExNuwtAJ9e09v6XE= github.com/go-macaroon-bakery/macaroonpb v1.0.0/go.mod h1:UzrGOcbiwTXISFP2XDLDPjfhMINZa+fX/7A2lMd31zc= +github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= +github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= +github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -238,6 +839,8 @@ github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -249,31 +852,47 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gonum/gonum v0.11.0 h1:Lffpf1Aq8WeVUJTbfiKHlOVlFm+c021iePrlwvE5woI= github.com/gonum/gonum v0.11.0/go.mod h1:fSG4YDCxxUZQJ7rKsQrj0gMOg00Il0Z96/qMA4bVQhA= +github.com/gonum/plot v0.10.1/go.mod h1:VZW5OlhkL1mysU9vaqNHnsy86inf6Ot+jB3r+BczCEo= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= @@ -281,25 +900,56 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= +github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= +github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= +github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= +github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= +github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= +github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= +github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= +github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= +github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo= +github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY= +github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8= +github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= +github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.0-rc.0 h1:mdLirNAJBxnGgyB6pjZLcs6ue/6eZGBui6gXspfq4ks= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.0-rc.0/go.mod h1:kdXbOySqcQeTxiqglW7aahTmWZy3Pgi6SYL36yvKeyA= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0-rc.3 h1:o95KDiV/b1xdkumY5YbLR0/n2+wBxUpgf3HgfKgTyLI= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0-rc.3/go.mod h1:hTxjzRcX49ogbTGVJ1sM5mz5s+SSgiGIyL3jjPxl32E= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -308,9 +958,14 @@ github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= @@ -325,8 +980,8 @@ github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUO github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= -github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackpal/gateway v1.0.5 h1:qzXWUJfuMdlLMtt0a3Dgt+xkWQiA5itDEITVJtuSwMc= @@ -339,13 +994,9 @@ github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bB github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/jrick/logrotate v1.1.2 h1:6ePk462NCX7TfKtNp5JJ7MbA2YIslkpfgP03TlTYMN0= github.com/jrick/logrotate v1.1.2/go.mod h1:f9tdWggSVK3iqavGpyvegq5IhNois7KXmasU6/N96OQ= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= @@ -356,25 +1007,31 @@ github.com/juju/qthttptest v0.0.1/go.mod h1://LCf/Ls22/rPw2u1yWukUJvYtfPY4nYpWUl github.com/juju/schema v1.0.0/go.mod h1:Y+ThzXpUJ0E7NYYocAbuvJ7vTivXfrof/IfRPq/0abI= github.com/juju/webbrowser v0.0.0-20160309143629-54b8c57083b4/go.mod h1:G6PCelgkM6cuvyD10iYJsjLBsSadVXtJ+nBxFAxE2BU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/kkdai/bstream v1.0.0 h1:Se5gHwgp2VT2uHfDrkbbgbgEvV9cimLELwrPJctSjg8= github.com/kkdai/bstream v1.0.0/go.mod h1:FDnDOHt5Yx4p3FaHcioFT0QjDOtgUpvjeZqAs+NVZZA= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -395,14 +1052,18 @@ github.com/lightninglabs/neutrino/cache v1.1.3 h1:rgnabC41W+XaPuBTQrdeFjFCCAVKh1 github.com/lightninglabs/neutrino/cache v1.1.3/go.mod h1:qxkJb+pUxR5p84jl5uIGFCR4dGdFkhNUwMSxw3EUWls= github.com/lightninglabs/protobuf-go-hex-display v1.34.2-hex-display h1:w7FM5LH9Z6CpKxl13mS48idsu6F+cEZf0lkyiV+Dq9g= github.com/lightninglabs/protobuf-go-hex-display v1.34.2-hex-display/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= -github.com/lightninglabs/taproot-assets v0.8.1 h1:E0xbHcQ/B+KmMZz6o3nqiYSMcDs2Vu0aWd+FuomDFYg= -github.com/lightninglabs/taproot-assets v0.8.1/go.mod h1:iq4ZybixussUx6NUaMYyo20uXMptibhbVLM3y/0ihIc= -github.com/lightninglabs/taproot-assets/taprpc v1.2.0 h1:CDiGyo19DTUyJcZTdhp5d6AQBz94N+mpGQNO0kEcnPk= -github.com/lightninglabs/taproot-assets/taprpc v1.2.0/go.mod h1:RkBWxHKN8JpPtoa/yBK5qZW5svz5pR9GAOT2rUP7oOI= +github.com/lightninglabs/tap-sdk v0.1.1 h1:y4qqkIw46/C/h9z+gQoez3L9X36VyiS59CwXvf8j+oM= +github.com/lightninglabs/tap-sdk v0.1.1/go.mod h1:YGGIq/EGyCzBMnJCJGMvW4cJ2CptEqvdztYWT3OOON8= +github.com/lightninglabs/taproot-assets v0.8.8 h1:4c95GFwOuhJSvjZRVaX0GmCEH1scHKF/3pCmYDn3AqU= +github.com/lightninglabs/taproot-assets v0.8.8/go.mod h1:6cuyFnJr8CCLn2bzjOzxebbqRf6MCNZOt+RSllwhqe0= +github.com/lightninglabs/taproot-assets/taprpc v1.3.3 h1:KwscIMQ7Nyy8SleDiUUTWPCgixpgrN9nunM+QwM/KJQ= +github.com/lightninglabs/taproot-assets/taprpc v1.3.3/go.mod h1:0dBS6jLkddm8EvpJSpJjPUMWr294/YRoM/IAzdfo3pw= +github.com/lightninglabs/taproot-assets/third_party/lnd/tlv v0.0.0-20261022183431-6e0fef585a97 h1:0X9Tw7cjmRPsT/NWWwae59tnYCIdbl7pSMC2g6W+yNg= +github.com/lightninglabs/taproot-assets/third_party/lnd/tlv v0.0.0-20261022183431-6e0fef585a97/go.mod h1:pJuiBj1ecr1WWLOtcZ+2+hu9Ey25aJWFIsjmAoPPnmc= github.com/lightningnetwork/lightning-onion v1.3.0 h1:FqILgHjD6euc/Muo1VOzZ4+XDPuFnw6EYROBq0rR/5c= github.com/lightningnetwork/lightning-onion v1.3.0/go.mod h1:nP85zMHG7c0si/eHBbSQpuDCtnIXfSvFrK3tW6YWzmU= -github.com/lightningnetwork/lnd v0.21.0-beta h1:bDP5UH15E7DVGTztsmBPQLqgyilq5EXDrglvQFmRc3U= -github.com/lightningnetwork/lnd v0.21.0-beta/go.mod h1:HcKq9DyxbVEZXuR28TIyGbIIgAjANCxI+N6dqOnRBAA= +github.com/lightningnetwork/lnd v0.21.3-beta h1:Tp1YdwnP0daq/wenwObkaCqe0hckIRnxibN2QKoUv9Y= +github.com/lightningnetwork/lnd v0.21.3-beta/go.mod h1:cUmbodV1/27JGrM/aKib8mXfz8bFxT9iGm0WJi647ds= github.com/lightningnetwork/lnd/actor v0.0.6 h1:Ge8N2wivARG+27qJBwTlB0vwsypStZYZy8vk4Zl38sU= github.com/lightningnetwork/lnd/actor v0.0.6/go.mod h1:YAsoniSbY/cAM9HTVNfZLvt7RI6swDxy6wzPspTcMZg= github.com/lightningnetwork/lnd/cert v1.2.2 h1:71YK6hogeJtxSxw2teq3eGeuy4rHGKcFf0d0Uy4qBjI= @@ -421,20 +1082,24 @@ github.com/lightningnetwork/lnd/sqldb v1.0.13-0.20260514041430-e9b422f78581 h1:s github.com/lightningnetwork/lnd/sqldb v1.0.13-0.20260514041430-e9b422f78581/go.mod h1:PQvrB+2SlYuyAoe0ac/SUIMZ9rP73jYlanIN8O7/eA0= github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6ijJlbdiZFbSM= github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA= -github.com/lightningnetwork/lnd/tlv v1.3.2 h1:MO4FCk7F4k5xPMqVZF6Nb/kOpxlwPrUQpYjmyKny5s0= -github.com/lightningnetwork/lnd/tlv v1.3.2/go.mod h1:pJuiBj1ecr1WWLOtcZ+2+hu9Ey25aJWFIsjmAoPPnmc= -github.com/lightningnetwork/lnd/tor v1.1.6 h1:WHUumk7WgU6BUFsqHuqszI9P6nfhMeIG+rjJBlVE6OE= -github.com/lightningnetwork/lnd/tor v1.1.6/go.mod h1:qSRB8llhAK+a6kaTPWOLLXSZc6Hg8ZC0mq1sUQ/8JfI= +github.com/lightningnetwork/lnd/tor v1.1.7 h1:BfY1KQGz3LVaeaAGI4XYGYCwDQR1ADFD1P/uXsS2dQc= +github.com/lightningnetwork/lnd/tor v1.1.7/go.mod h1:DANQ6QCQBiR+CqrM+mKudgkMy6CR/RS4ALDbjb2W4FU= github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 h1:sjOGyegMIhvgfq5oaue6Td+hxZuf3tDC8lAPrFldqFw= github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796/go.mod h1:3p7ZTf9V1sNPI5H8P3NkTFF4LuwMdPl2DodF60qAKqY= github.com/ltcsuite/ltcutil v0.0.0-20181217130922-17f3b04680b6/go.mod h1:8Vg/LTOO0KYa/vlHWJ6XZAevPQThGH5sufO0Hrou/lA= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= -github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/lukechampine/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= +github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= +github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= @@ -448,16 +1113,14 @@ github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFL github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -482,55 +1145,53 @@ github.com/opencontainers/runc v1.3.6/go.mod h1:o1wyv76EDlTkcf0KTFgN8bMWLPvgF/Hf github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4aNE4= github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= +github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= -github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE= -github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo= -github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= +github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= +github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= +github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -542,9 +1203,13 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -573,6 +1238,10 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec h1:FpfFs4EhNehiVfzQttTuxanPIT43FtkkCFypIod8LHo= gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec/go.mod h1:BZ1RAoRPbCxum9Grlv5aeksu2H8BiKehBYooU2LFiOQ= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= @@ -596,6 +1265,9 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= @@ -616,6 +1288,9 @@ go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJj go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= +go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -629,33 +1304,49 @@ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9i go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180723164146-c126467f60eb/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 h1:3yiSh9fhy5/RhCSntf4Sy0Tnx50DmMpQ4MQdKKk4yg4= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20210607152325-775e3b0c77b9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.0.0-20220302094943-723b81ca9867/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -666,6 +1357,8 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= @@ -674,7 +1367,15 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20150829230318-ea47fc708ee3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -682,7 +1383,6 @@ golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -690,7 +1390,6 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -711,12 +1410,38 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -724,8 +1449,30 @@ golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= +golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= +golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.0.0-20221006150949-b44042a4b9c1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= +golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= +golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -737,12 +1484,15 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -760,8 +1510,8 @@ golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -774,29 +1524,71 @@ golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210304124612-50617c2ba197/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -804,13 +1596,25 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -828,6 +1632,7 @@ golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -856,14 +1661,36 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -880,12 +1707,54 @@ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= +google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= +google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= +google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= +google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= +google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= +google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= +google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= +google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= +google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= +google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= +google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= +google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= +google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= +google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= +google.golang.org/api v0.77.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= +google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= +google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= +google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o= +google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g= +google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= +google.golang.org/api v0.93.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= +google.golang.org/api v0.95.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI= +google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= +google.golang.org/api v0.97.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= +google.golang.org/api v0.98.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= +google.golang.org/api v0.99.0/go.mod h1:1YOf74vkVndF7pG6hIHuINsM7eWwpVTAfNMNiL91A08= +google.golang.org/api v0.100.0/go.mod h1:ZE3Z2+ZOr87Rx7dqFsdRQkRBk36kDtp/h+QpHbB7a70= +google.golang.org/api v0.102.0/go.mod h1:3VFl6/fzoA+qNuS1N1/VfXY4LjoXN/wzeIp7TweWwGo= +google.golang.org/api v0.103.0/go.mod h1:hGtW6nK1AC+d9si/UBhw8Xli+QMOf6xyNAyJw4qU9w0= +google.golang.org/api v0.106.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= +google.golang.org/api v0.107.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= +google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= +google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI= +google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0= +google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -912,10 +1781,113 @@ google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfG google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= +google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= +google.golang.org/genproto v0.0.0-20220329172620-7be39ac1afc7/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE= +google.golang.org/genproto v0.0.0-20220801145646-83ce21fca29f/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc= +google.golang.org/genproto v0.0.0-20220815135757-37a418bb8959/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220817144833-d7fd3f11b9b1/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220829144015-23454907ede3/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220913154956-18f8339a66a5/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220914142337-ca0e39ece12f/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220915135415-7fd63a7952de/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220916172020-2692e8806bfa/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006/go.mod h1:ht8XFiar2npT/g4vkk7O0WYS1sHOHbdujxbEp7CJWbw= +google.golang.org/genproto v0.0.0-20220926165614-551eb538f295/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= +google.golang.org/genproto v0.0.0-20220926220553-6981cbe3cfce/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= +google.golang.org/genproto v0.0.0-20221010155953-15ba04fc1c0e/go.mod h1:3526vdqwhZAwq4wsRUaVG555sVgsNmIjRtO7t/JH29U= +google.golang.org/genproto v0.0.0-20221014173430-6e2ab493f96b/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= +google.golang.org/genproto v0.0.0-20221014213838-99cd37c6964a/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= +google.golang.org/genproto v0.0.0-20221024153911-1573dae28c9c/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s= +google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s= +google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c/go.mod h1:CGI5F/G+E5bKwmfYo09AXuVN4dD894kIKUFmVbP2/Fo= +google.golang.org/genproto v0.0.0-20221109142239-94d6d90a7d66/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= +google.golang.org/genproto v0.0.0-20221114212237-e4508ebdbee1/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= +google.golang.org/genproto v0.0.0-20221117204609-8f9c96812029/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= +google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= +google.golang.org/genproto v0.0.0-20221201164419-0e50fba7f41c/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= +google.golang.org/genproto v0.0.0-20221201204527-e3fa12d562f3/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= +google.golang.org/genproto v0.0.0-20221202195650-67e5cbc046fd/go.mod h1:cTsE614GARnxrLsqKREzmNYJACSWWpAWdNMwnD7c2BE= +google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230112194545-e10362b5ecf9/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230113154510-dbe35b8444a5/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230123190316-2c411cf9d197/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230124163310-31e0e69b6fc2/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230125152338-dcaf20b6aeaa/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230127162408-596548ed4efa/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44/go.mod h1:8B0gmkoRebU8ukX6HP+4wrVQUY1+6PkQ44BSyIlflHA= +google.golang.org/genproto v0.0.0-20230222225845-10f96fb3dbec/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw= +google.golang.org/genproto v0.0.0-20230223222841-637eb2293923/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw= +google.golang.org/genproto v0.0.0-20230303212802-e74f57abe488/go.mod h1:TvhZT5f700eVlTNwND1xoEZQeWTB2RY/65kplwl/bFA= +google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s= +google.golang.org/genproto v0.0.0-20230320184635-7606e756e683/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s= +google.golang.org/genproto v0.0.0-20230323212658-478b75c54725/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= +google.golang.org/genproto v0.0.0-20230330154414-c0448cd141ea/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= +google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= +google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:mqHbVIp48Muh7Ywss/AD6I5kNVKZMmAa/QEW58Gxp2s= google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= @@ -934,13 +1906,40 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= +google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww= +google.golang.org/grpc v1.52.3/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY= +google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= +google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= +google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v1 v1.0.0/go.mod h1:CxwszS/Xz1C49Ucd2i6Zil5UToP1EmyrFhKaMVbg1mk= @@ -963,7 +1962,6 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= @@ -981,33 +1979,71 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= -modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= -modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= -modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= -modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= -modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= -modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= -modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= +modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc= +modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw= +modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= +modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= +modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws= +modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= +modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= +modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A= +modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU= +modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU= +modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= +modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0= +modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= +modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= +modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= -modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= -modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= -modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= -modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= +modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw= +modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= diff --git a/internal/assetpolicy/tapd_version.go b/internal/assetpolicy/tapd_version.go new file mode 100644 index 000000000..f996a976c --- /dev/null +++ b/internal/assetpolicy/tapd_version.go @@ -0,0 +1,142 @@ +package policy + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/lightninglabs/taproot-assets/taprpc" + "google.golang.org/grpc" +) + +// MinOnchainSwapTapdVersion is the oldest tapd release that may create new +// on-chain asset swap addresses or verify an untrusted HTLC/deposit history. +// Local transaction and witness builders remain available for recovery of +// existing contracts; this does not implement an application recovery manager. +// +// Taproot Assets v0.8.8 fixes a regression introduced in v0.8.6 that +// could prevent asset invoices from settling. New on-chain swaps rely on +// that fix as well as tapd's activation and Taproot proof validation. +const MinOnchainSwapTapdVersion = "0.8.8" + +// ErrTapdVersionUnsupported is returned when tapd is older than +// MinOnchainSwapTapdVersion or reports a version that cannot be parsed. +var ErrTapdVersionUnsupported = errors.New("unsupported tapd version") + +// tapdVersion is the release number of a tapd version. +type tapdVersion struct { + major uint64 + minor uint64 + patch uint64 + qualifier string +} + +// String returns the release number in its vMAJOR.MINOR.PATCH form. +func (v tapdVersion) String() string { + release := fmt.Sprintf("v%d.%d.%d", v.major, v.minor, v.patch) + if v.qualifier != "" { + return release + "-" + v.qualifier + } + return release +} + +// atLeast returns true if the release number is not older than other. +func (v tapdVersion) atLeast(other tapdVersion) bool { + if v.major != other.major { + return v.major > other.major + } + if v.minor != other.minor { + return v.minor > other.minor + } + + if v.patch != other.patch { + return v.patch > other.patch + } + // The v0.8.8 release identifies itself as 0.8.8-alpha. + return v.qualifier == "" || v.qualifier == "alpha" +} + +// parseTapdVersion parses the release number of a version string as tapd +// reports it in GetInfo, such as "0.8.8-alpha commit=v0.8.8". +func parseTapdVersion(version string) (tapdVersion, bool) { + fields := strings.Fields(version) + if len(fields) == 0 { + return tapdVersion{}, false + } + + release, qualifier, _ := strings.Cut(strings.TrimPrefix(fields[0], "v"), "-") + parts := strings.Split(release, ".") + if len(parts) != 3 { + return tapdVersion{}, false + } + + var numbers [3]uint64 + for idx, part := range parts { + number, err := strconv.ParseUint(part, 10, 32) + if err != nil { + return tapdVersion{}, false + } + numbers[idx] = number + } + + return tapdVersion{ + major: numbers[0], + minor: numbers[1], + patch: numbers[2], + qualifier: qualifier, + }, true +} + +// CheckOnchainSwapTapdVersion returns an error wrapping +// ErrTapdVersionUnsupported unless the version that tapd reports in GetInfo is +// at least MinOnchainSwapTapdVersion. +func CheckOnchainSwapTapdVersion(version string) error { + floor, ok := parseTapdVersion(MinOnchainSwapTapdVersion) + if !ok { + return fmt.Errorf("invalid minimum tapd version %q", + MinOnchainSwapTapdVersion) + } + + actual, ok := parseTapdVersion(version) + if !ok { + return fmt.Errorf("%w: cannot parse %q", ErrTapdVersionUnsupported, + version) + } + if !actual.atLeast(floor) { + return fmt.Errorf("%w: %v is older than %v", + ErrTapdVersionUnsupported, actual, floor) + } + + return nil +} + +// TapdInfoClient is the tapd boundary needed to check the running verifier and +// address backend, independently of any module version used to build a caller. +type TapdInfoClient interface { + GetInfo(context.Context, *taprpc.GetInfoRequest, + ...grpc.CallOption) (*taprpc.GetInfoResponse, error) +} + +// RequireOnchainSwapTapd checks the running backend immediately before an +// address-creation or untrusted-history acceptance operation. It is not a +// daemon-wide startup gate: ordinary asset payments and local recovery +// signing do not use this boundary. +func RequireOnchainSwapTapd(ctx context.Context, client TapdInfoClient) error { + if client == nil { + return fmt.Errorf("%w: tapd client is required", + ErrTapdVersionUnsupported) + } + + info, err := client.GetInfo(ctx, &taprpc.GetInfoRequest{}) + if err != nil { + return fmt.Errorf("unable to get tapd version: %w", err) + } + if info == nil { + return fmt.Errorf("%w: tapd returned no version", + ErrTapdVersionUnsupported) + } + + return CheckOnchainSwapTapdVersion(info.Version) +} diff --git a/internal/assetpolicy/tapd_version_test.go b/internal/assetpolicy/tapd_version_test.go new file mode 100644 index 000000000..6ddf9da8a --- /dev/null +++ b/internal/assetpolicy/tapd_version_test.go @@ -0,0 +1,71 @@ +package policy + +import ( + "context" + "errors" + "testing" + + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type infoClient struct { + response *taprpc.GetInfoResponse + err error +} + +func (c infoClient) GetInfo(context.Context, *taprpc.GetInfoRequest, + ...grpc.CallOption) (*taprpc.GetInfoResponse, error) { + + return c.response, c.err +} + +// TestRequireOnchainSwapTapd fails closed when the running backend's version +// is unavailable, malformed, or older than the required verifier. +func TestRequireOnchainSwapTapd(t *testing.T) { + rpcErr := errors.New("tapd unavailable") + for _, test := range []struct { + name string + client infoClient + err error + }{ + { + name: "minimum", + client: infoClient{response: &taprpc.GetInfoResponse{ + Version: "0.8.8-alpha", + }}, + }, + { + name: "older verifier", + client: infoClient{response: &taprpc.GetInfoResponse{ + Version: "0.8.7-alpha", + }}, + err: ErrTapdVersionUnsupported, + }, + { + name: "unparseable", + client: infoClient{response: &taprpc.GetInfoResponse{ + Version: "unknown", + }}, + err: ErrTapdVersionUnsupported, + }, + { + name: "missing response", + err: ErrTapdVersionUnsupported, + }, + { + name: "RPC failure", client: infoClient{err: rpcErr}, + err: rpcErr, + }, + } { + t.Run(test.name, func(t *testing.T) { + err := RequireOnchainSwapTapd(t.Context(), test.client) + if test.err == nil { + require.NoError(t, err) + return + } + require.ErrorIs(t, err, test.err) + }) + } +} diff --git a/loopd/daemon.go b/loopd/daemon.go index 530514d30..36164ce5d 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -487,6 +487,15 @@ func (d *Daemon) initialize(withMacaroonService bool) error { } infof("Using asset client with version %v", getInfo.Version) + + // The shared kit checks its running backend before address + // creation and full-history proof acceptance. This warning is + // informational; no on-chain asset swap manager is wired here. + err = assets.CheckOnchainSwapTapdVersion(getInfo.Version) + if err != nil { + warnf("Shared asset kit address creation and proof "+ + "acceptance require an upgraded tapd: %v", err) + } } // Create an instance of the loop client library. diff --git a/swapkit/asset_types.go b/swapkit/asset_types.go new file mode 100644 index 000000000..aad7dee9c --- /dev/null +++ b/swapkit/asset_types.go @@ -0,0 +1,10 @@ +package swapkit + +import ( + "errors" +) + +var ( + ErrInvalid = errors.New("invalid asset transaction") + ErrPending = errors.New("asset transaction pending") +) diff --git a/swapkit/tapsdk/asset.go b/swapkit/tapsdk/asset.go new file mode 100644 index 000000000..7cedffd98 --- /dev/null +++ b/swapkit/tapsdk/asset.go @@ -0,0 +1,106 @@ +package tapsdk + +import ( + "bytes" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/asset" +) + +// LegacyOPTrueDescriptor returns the unchanged NUMS/single-OP_TRUE asset tree. +// Keys are x-only at the asset boundary. The control block retains the actual +// tweaked output parity, and every returned byte slice is independently owned. +func LegacyOPTrueDescriptor() (sdk.ScriptKey, + *sdk.CustomAssetOPTrueSpendInfo, error) { + + internal := *asset.NUMSPubKey + leaf := txscript.NewBaseTapLeaf([]byte{txscript.OP_TRUE}) + tree := txscript.AssembleTaprootScriptTree(leaf) + root := tree.RootNode.TapHash() + output := txscript.ComputeTaprootOutputKey(&internal, root[:]) + even, err := schnorr.ParsePubKey(schnorr.SerializePubKey(output)) + if err != nil { + return sdk.ScriptKey{}, nil, err + } + key, err := sdk.ParsePubKey(even.SerializeCompressed()) + if err != nil { + return sdk.ScriptKey{}, nil, err + } + raw, err := sdk.ParsePubKey(internal.SerializeCompressed()) + if err != nil { + return sdk.ScriptKey{}, nil, err + } + control := tree.LeafMerkleProofs[0].ToControlBlock(&internal) + encoded, err := control.ToBytes() + if err != nil { + return sdk.ScriptKey{}, nil, err + } + spend := &sdk.CustomAssetOPTrueSpendInfo{ + LeafScript: bytes.Clone(leaf.Script), + LeafVersion: uint8(leaf.LeafVersion), + ControlBlock: encoded, InternalKey: raw, + } + if err := spend.Validate(key); err != nil { + return sdk.ScriptKey{}, nil, err + } + return sdk.ScriptKey{PubKey: key, + KeyDesc: sdk.KeyDescriptor{RawKeyBytes: raw}, + TapTweak: bytes.Clone(root[:])}, spend, nil +} + +// LegacyOPTrueOutput binds an allocation to the existing asset script policy. +// Native SDK External mode preserves the exact key and derivation. No wallet +// key or asset-ID leaf is added; selecting SDKOPTrueOutput is a separate choice. +func LegacyOPTrueOutput(output sdk.CustomAssetOutput, + anchor swapkit.AnchorPlan) (sdk.CustomAssetOutput, error) { + + key, _, err := LegacyOPTrueDescriptor() + if err != nil { + return sdk.CustomAssetOutput{}, err + } + output.Script = sdk.CustomAssetScriptPlan{ + Mode: sdk.CustomAssetScriptExternal, + External: &sdk.CustomAssetExternalScriptPlan{ScriptKey: key}, + } + output.Anchor = AnchorPlan(anchor) + return output, nil +} + +// SDKOPTrueOutput explicitly selects the SDK's supplied-key/asset-ID tree. +// Existing funded outputs always retain their saved key and witness instead. +func SDKOPTrueOutput(output sdk.CustomAssetOutput, key sdk.KeyDescriptor, + anchor swapkit.AnchorPlan) sdk.CustomAssetOutput { + + return OPTrueOutput(output, key, anchor) +} + +// OPTrueSpendInfo validates a saved SDK descriptor or reconstructs only the +// exact legacy External tree. An ordinary wallet External key is never treated +// as OP_TRUE, and native sealed package summaries are never modified. +func OPTrueSpendInfo(output sdk.CustomAnchorAssetOutputSummary) ( + *sdk.CustomAssetOPTrueSpendInfo, error) { + + if output.OPTrueSpend != nil { + if err := output.OPTrueSpend.Validate(output.ScriptKey); err != nil { + return nil, err + } + return output.OPTrueSpend.Clone(), nil + } + if output.ScriptMode != sdk.CustomAssetScriptExternal { + return nil, swapkit.ErrInvalid + } + key, spend, err := LegacyOPTrueDescriptor() + if err != nil { + return nil, err + } + if !bytes.Equal(key.PubKey[1:], output.ScriptKey[1:]) { + return nil, swapkit.ErrInvalid + } + if err := spend.Validate(output.ScriptKey); err != nil { + return nil, err + } + return spend, nil +} diff --git a/swapkit/tapsdk/asset_test.go b/swapkit/tapsdk/asset_test.go new file mode 100644 index 000000000..aa9eb6b7b --- /dev/null +++ b/swapkit/tapsdk/asset_test.go @@ -0,0 +1,119 @@ +package tapsdk + +import ( + "encoding/hex" + "testing" + + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +// These vectors freeze the pre-SDK NUMS/single-OP_TRUE contract. A dependency +// upgrade or a fresh wallet locator must not change existing asset ownership. +func TestLegacyOPTruePolicy(t *testing.T) { + key, spend, err := LegacyOPTrueDescriptor() + require.NoError(t, err) + require.Equal( + t, + "027bbbb85ff774141ee36c2cc9f38b40be4a02c2a156c8a1def8781cf39177deca", + hex.EncodeToString(key.PubKey[:]), + ) + require.Equal( + t, + "a85b2107f791b26a84e7586c28cec7cb61202ed3d01944d832500f363782d675", + hex.EncodeToString(key.TapTweak), + ) + require.Equal( + t, + "c17c79b9b26e463895eef5679d8558942c86c4ad2233adef01bc3e6d540b3653fe", + hex.EncodeToString(spend.ControlBlock), + ) + require.Zero(t, key.KeyDesc.KeyLocator) + require.NoError(t, spend.Validate(key.PubKey)) + external := sdk.CustomAnchorAssetOutputSummary{ + ScriptMode: sdk.CustomAssetScriptExternal, + ScriptKey: key.PubKey, + } + restored, err := OPTrueSpendInfo(external) + require.NoError(t, err) + require.Equal(t, spend, restored) + // The policy changes require explicit selection; nil ordinary wallet + // descriptors cannot be promoted into public asset witnesses. + external.ScriptKey = testKey(t, 12) + _, err = OPTrueSpendInfo(external) + require.Error(t, err) + external.ScriptKey = key.PubKey + external.ScriptMode = sdk.CustomAssetScriptWallet + _, err = OPTrueSpendInfo(external) + require.Error(t, err) + external.ScriptMode = sdk.CustomAssetScriptExternal + external.OPTrueSpend = spend.Clone() + external.OPTrueSpend.ControlBlock[0] ^= 1 + _, err = OPTrueSpendInfo(external) + require.Error(t, err) + // Returned descriptors never share mutable tweak or witness storage. + key.TapTweak[0] ^= 1 + spend.ControlBlock[0] ^= 1 + fresh, freshSpend, err := LegacyOPTrueDescriptor() + require.NoError(t, err) + require.NotEqual(t, key.TapTweak, fresh.TapTweak) + require.NotEqual(t, spend.ControlBlock, freshSpend.ControlBlock) +} + +func TestNativeLegacyAndExplicitSDKPolicies(t *testing.T) { + for _, explicit := range []bool{false, true} { + name := "legacy default" + if explicit { + name = "explicit SDK tree" + } + t.Run(name, func(t *testing.T) { + fixture := newCustomAnchorBuilderFixture(t) + kit, err := swapkit.NewSwapKit(swapKitTestParams(t)) + require.NoError(t, err) + if explicit { + fixture.request.Outputs[0] = SDKOPTrueOutput( + fixture.request.Outputs[0], + sdk.KeyDescriptor{ + RawKeyBytes: testKey(t, 13), + }, + kit.AnchorPlan(), + ) + } else { + fixture.request.Outputs[0], err = LegacyOPTrueOutput(fixture.request.Outputs[0], kit.AnchorPlan()) + require.NoError(t, err) + } + wallet := sdk.NewWallet( + fixture.client, + sdk.NetworkRegtest, + ) + plan, err := wallet.NewCustomAnchorTxBuilder(). + Build(t.Context(), fixture.request) + require.NoError(t, err) + out := plan.Outputs()[0] + summary := sdk.CustomAnchorAssetOutputSummary{ + ScriptKey: out.ScriptKey, + ScriptMode: out.ScriptMode, + OPTrueSpend: out.OPTrueSpend, + } + spend, err := OPTrueSpendInfo(summary) + require.NoError(t, err) + require.NoError(t, spend.Validate(out.ScriptKey)) + key, _, err := LegacyOPTrueDescriptor() + require.NoError(t, err) + if explicit { + require.Equal( + t, + sdk.CustomAssetScriptOPTrue, + out.ScriptMode, + ) + require.NotEqual(t, key.PubKey, out.ScriptKey) + require.NotNil(t, out.OPTrueSpend) + } else { + require.Equal(t, sdk.CustomAssetScriptExternal, out.ScriptMode) + require.Equal(t, key.PubKey, out.ScriptKey) + require.Nil(t, out.OPTrueSpend) + } + }) + } +} diff --git a/swapkit/tapsdk/binding_test.go b/swapkit/tapsdk/binding_test.go new file mode 100644 index 000000000..2bf489a73 --- /dev/null +++ b/swapkit/tapsdk/binding_test.go @@ -0,0 +1,7 @@ +package tapsdk + +import ( + sdk "github.com/lightninglabs/tap-sdk" +) + +func fixtureNodeInfo() *sdk.Info { return &sdk.Info{Version: "0.8.8", Network: "regtest"} } diff --git a/swapkit/tapsdk/builder_fixture_test.go b/swapkit/tapsdk/builder_fixture_test.go new file mode 100644 index 000000000..90ffd98ea --- /dev/null +++ b/swapkit/tapsdk/builder_fixture_test.go @@ -0,0 +1,128 @@ +package tapsdk + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/txscript" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +// builderFixtureJSON is the SDK's deterministic minted-proof and caller-funded +// anchor fixture, exported through public request and verification types. This +// freezes input bytes while the real builder derives each new kit commitment. +// +//go:embed testdata/builder_request.json +var builderFixtureJSON []byte + +type builderFixtureClient struct { + sdk.Client + + raw []byte + verified *sdk.VerifyProofResponse + verifyCalls int + afterVerify func() +} + +func (c *builderFixtureClient) VerifyProof(_ context.Context, raw []byte) ( + *sdk.VerifyProofResponse, error) { + + if !bytes.Equal(c.raw, raw) { + return nil, fmt.Errorf("unexpected proof fixture") + } + c.verifyCalls++ + if c.afterVerify != nil { + c.afterVerify() + } + return c.verified, nil +} + +func (c *builderFixtureClient) QueryScriptKey(_ context.Context, + key []byte) (*sdk.ScriptKey, error) { + + // The frozen SDK proof fixture is minted to the scalar-one BIP86 key. + private, _ := btcec.PrivKeyFromBytes([]byte{1}) + output, err := sdk.ParsePubKey(txscript.ComputeTaprootKeyNoScript( + private.PubKey(), + ).SerializeCompressed()) + if err != nil { + return nil, err + } + if !bytes.Equal(key, output[1:]) { + return nil, fmt.Errorf("unexpected script key fixture") + } + raw, err := sdk.ParsePubKey(private.PubKey().SerializeCompressed()) + if err != nil { + return nil, err + } + return &sdk.ScriptKey{ + PubKey: output, KeyDesc: sdk.KeyDescriptor{RawKeyBytes: raw}, + }, nil +} + +type builderFixture struct { + request *sdk.CustomAnchorRequest + client *builderFixtureClient +} + +func newCustomAnchorBuilderFixture(t *testing.T) *builderFixture { + t.Helper() + //nolint:tagliatelle // Retain the SDK fixture's serialized field names. + var fixture struct { + Request *sdk.CustomAnchorRequest `json:"Request"` + Verified *sdk.VerifyProofResponse `json:"Verified"` + } + // Preserve the existing SDK/contract JSON field names. + //nolint:musttag + require.NoError(t, json.Unmarshal(builderFixtureJSON, &fixture)) + require.NotNil(t, fixture.Request) + require.Len(t, fixture.Request.Inputs, 1) + return &builderFixture{ + request: fixture.Request, + client: &builderFixtureClient{ + raw: fixture.Request.Inputs[0].ProofFile, + verified: fixture.Verified, + }, + } +} + +func testKey(t *testing.T, scalar byte) sdk.PubKey { + t.Helper() + key, _ := btcec.PrivKeyFromBytes([]byte{scalar}) + pub, err := sdk.ParsePubKey(key.PubKey().SerializeCompressed()) + require.NoError(t, err) + return pub +} + +func validateMuSig2Aggregate(expected sdk.XOnlyPubKey, + participants []sdk.PubKey) error { + + keys := make([]*btcec.PublicKey, len(participants)) + for i, key := range participants { + parsed, err := btcec.ParsePubKey(key[:]) + if err != nil { + return err + } + keys[i] = parsed + } + aggregate, _, _, err := musig2.AggregateKeys(keys, false) + if err != nil { + return err + } + actual := aggregate.FinalKey.SerializeCompressed()[1:] + if !bytes.Equal(expected[:], actual) { + return fmt.Errorf("aggregate key mismatch") + } + return nil +} + +func (c *builderFixtureClient) GetInfo(context.Context) (*sdk.Info, error) { + return fixtureNodeInfo(), nil +} diff --git a/swapkit/tapsdk/client.go b/swapkit/tapsdk/client.go new file mode 100644 index 000000000..f30a9b1b4 --- /dev/null +++ b/swapkit/tapsdk/client.go @@ -0,0 +1,108 @@ +package tapsdk + +import ( + "context" + "time" + + sdk "github.com/lightninglabs/tap-sdk" + sdkgrpc "github.com/lightninglabs/tap-sdk/grpc" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +const defaultClientTimeout = 30 * time.Second + +// anchorWalletClient preserves the SDK's optional custom-anchor operations. +type anchorWalletClient interface { + sdk.WalletKitClient + sdk.CustomAnchorWalletKitClient + sdk.CustomAnchorCapabilityProvider +} + +// issuanceProofClient preserves registration of a specific grouped issuance. +type issuanceProofClient interface { + sdk.ProofClient + + RegisterTransferWithIssuance(context.Context, sdk.AssetRef, sdk.AssetID, + sdk.PubKey, sdk.Outpoint) (*sdk.RegisteredAsset, error) +} + +// connectionClient composes the released SDK clients without owning their +// shared connection. All RPC encoding and asset operations remain in the SDK. +type connectionClient struct { + sdk.WalletClient + anchorWalletClient + issuanceProofClient + sdk.UniverseClient + sdk.MintClient + sdk.EventClient +} + +// NewClientFromConn adapts an existing authenticated tapd connection to the SDK. +// The caller owns transport security, credentials, message limits and connection +// shutdown. Closing the returned client leaves that connection open. Non-positive +// timeouts use 30 seconds; streaming calls retain the caller's context lifetime. +func NewClientFromConn(conn grpc.ClientConnInterface, + timeout time.Duration) sdk.Client { + + if timeout <= 0 { + timeout = defaultClientTimeout + } + conn = credentialConn{ClientConnInterface: conn} + + return &connectionClient{ + WalletClient: sdkgrpc.NewWalletClient(conn, timeout, ""), + anchorWalletClient: sdkgrpc.NewWalletKitClient( + conn, timeout, "", + ), + issuanceProofClient: sdkgrpc.NewProofClient(conn, timeout, ""), + UniverseClient: sdkgrpc.NewUniverseClient(conn, timeout, ""), + MintClient: sdkgrpc.NewMintClient(conn, timeout, ""), + EventClient: sdkgrpc.NewEventClient( + conn, timeout, "", "", + ), + } +} + +// Close leaves the caller-owned connection open. +func (*connectionClient) Close() error { + return nil +} + +// credentialConn removes the empty macaroon appended by SDK v0.1.1. The +// existing context metadata and connection credentials remain authoritative. +type credentialConn struct { + grpc.ClientConnInterface +} + +func (c credentialConn) Invoke(ctx context.Context, method string, + args, reply any, opts ...grpc.CallOption) error { + + return c.ClientConnInterface.Invoke( + withoutSDKMacaroon(ctx), method, args, reply, opts..., + ) +} + +func (c credentialConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, + method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { + + return c.ClientConnInterface.NewStream( + withoutSDKMacaroon(ctx), desc, method, opts..., + ) +} + +// withoutSDKMacaroon removes only the SDK's trailing empty credential. The +// metadata returned by FromOutgoingContext is a copy, so the caller is unchanged. +func withoutSDKMacaroon(ctx context.Context) context.Context { + md, _ := metadata.FromOutgoingContext(ctx) + values := md.Get("macaroon") + if len(values) == 0 || values[len(values)-1] != "" { + return ctx + } + if len(values) == 1 { + delete(md, "macaroon") + } else { + md.Set("macaroon", values[:len(values)-1]...) + } + return metadata.NewOutgoingContext(ctx, md) +} diff --git a/swapkit/tapsdk/client_test.go b/swapkit/tapsdk/client_test.go new file mode 100644 index 000000000..14eb3830d --- /dev/null +++ b/swapkit/tapsdk/client_test.go @@ -0,0 +1,207 @@ +package tapsdk + +import ( + "context" + "errors" + "testing" + "time" + + tapsdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/stretchr/testify/require" + grpcpkg "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" +) + +type connectionProbe struct { + invoke func(context.Context, string, any, any) error + stream func(context.Context, string) error +} + +func (c *connectionProbe) Invoke(ctx context.Context, method string, + args, reply any, _ ...grpcpkg.CallOption) error { + + return c.invoke(ctx, method, args, reply) +} + +func (c *connectionProbe) NewStream(ctx context.Context, + _ *grpcpkg.StreamDesc, method string, _ ...grpcpkg.CallOption) ( + grpcpkg.ClientStream, error) { + + return nil, c.stream(ctx, method) +} + +func TestClientFromConnCapabilities(t *testing.T) { + t.Parallel() + + wallet := tapsdk.NewWallet( + NewClientFromConn(nil, time.Second), tapsdk.NetworkRegtest, + ) + _, ok := wallet.Client().(tapsdk.CustomAnchorWalletKitClient) + require.True(t, ok) + + caps, err := wallet.CustomAnchorCapabilities(t.Context()) + require.NoError(t, err) + require.Equal(t, tapsdk.DefaultTapdCustomAnchorCapabilities(), *caps) + + _, ok = wallet.Client().(interface { + RegisterTransferWithIssuance(context.Context, tapsdk.AssetRef, + tapsdk.AssetID, tapsdk.PubKey, tapsdk.Outpoint) ( + *tapsdk.RegisteredAsset, error) + }) + require.True(t, ok, "grouped imports require issuance registration") + require.NoError(t, wallet.Close()) +} + +func TestClientFromConnGroupedRegistration(t *testing.T) { + t.Parallel() + + group := testKey(t, 1) + + ref := tapsdk.AssetRefFromGroupKey(group) + issuance := tapsdk.AssetID{1} + point := tapsdk.Outpoint{Txid: tapsdk.Hash{2}, Index: 3} + called := false + conn := &connectionProbe{ + invoke: func(_ context.Context, method string, + args, reply any) error { + + called = true + require.Equal(t, "/taprpc.TaprootAssets/RegisterTransfer", + method) + req, ok := args.(*taprpc.RegisterTransferRequest) + require.True(t, ok) + require.Equal(t, issuance[:], req.AssetId) + require.Equal(t, group[:], req.GroupKey) + require.Equal(t, group[:], req.ScriptKey) + require.Equal(t, point.Txid[:], req.Outpoint.Txid) + require.Equal(t, point.Index, req.Outpoint.OutputIndex) + + resp, ok := reply.(*taprpc.RegisterTransferResponse) + require.True(t, ok) + resp.RegisteredAsset = &taprpc.Asset{ + Amount: 1_000, + AssetGenesis: &taprpc.GenesisInfo{ + AssetId: issuance[:], + }, + ScriptKey: group[:], + ChainAnchor: &taprpc.AnchorInfo{ + AnchorOutpoint: point.String(), + }, + } + return nil + }, + } + client := NewClientFromConn(conn, time.Second) + proofs := client.(issuanceProofClient) + registered, err := proofs.RegisterTransferWithIssuance( + t.Context(), ref, issuance, group, point, + ) + require.NoError(t, err) + require.True(t, called) + require.Equal(t, issuance, registered.IssuanceID) + require.Equal(t, ref, registered.AssetRef) + require.Equal(t, point, registered.Outpoint) + require.Equal(t, uint64(1_000), registered.Amount) +} + +func TestClientFromConnAuthentication(t *testing.T) { + t.Parallel() + + for _, supplied := range []bool{false, true} { + name := "connection credentials" + if supplied { + name = "context credentials" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + + original := metadata.Pairs("trace", "transfer") + if supplied { + original.Set("macaroon", "credential") + } + ctx := metadata.NewOutgoingContext(t.Context(), original) + check := func(ctx context.Context) { + md, _ := metadata.FromOutgoingContext(ctx) + require.Equal(t, original, md) + } + streamErr := errors.New("stream intercepted") + conn := &connectionProbe{ + invoke: func(ctx context.Context, _ string, + _, reply any) error { + + check(ctx) + require.IsType(t, &taprpc.GetInfoResponse{}, reply) + return nil + }, + stream: func(ctx context.Context, _ string) error { + check(ctx) + return streamErr + }, + } + client := NewClientFromConn(conn, time.Second) + _, err := client.GetInfo(ctx) + require.NoError(t, err) + + _, _, err = client.SubscribeReceiveEvents( + ctx, &tapsdk.SubscribeReceiveEventsRequest{}, + ) + require.ErrorIs(t, err, streamErr) + md, _ := metadata.FromOutgoingContext(ctx) + require.Equal(t, original, md) + }) + } +} + +func TestClientFromConnTimeout(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + timeout time.Duration + want time.Duration + }{ + {name: "default", want: defaultClientTimeout}, + {name: "negative", timeout: -1, want: defaultClientTimeout}, + {name: "explicit", timeout: time.Second, want: time.Second}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + conn := &connectionProbe{ + invoke: func(ctx context.Context, _ string, + _, _ any) error { + + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.InDelta(t, test.want.Seconds(), + time.Until(deadline).Seconds(), 1) + return nil + }, + } + client := NewClientFromConn(conn, test.timeout) + _, err := client.GetInfo(t.Context()) + require.NoError(t, err) + }) + } +} + +func TestClientConnectionOwnership(t *testing.T) { + t.Parallel() + + conn, err := grpcpkg.NewClient( + "passthrough:///unused", + grpcpkg.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) + + wallet := tapsdk.NewWallet( + NewClientFromConn(conn, time.Second), tapsdk.NetworkRegtest, + ) + require.NoError(t, wallet.Close()) + require.NotEqual(t, connectivity.Shutdown, conn.GetState()) +} diff --git a/swapkit/tapsdk/proof_tlv_test.go b/swapkit/tapsdk/proof_tlv_test.go new file mode 100644 index 000000000..3b52f780d --- /dev/null +++ b/swapkit/tapsdk/proof_tlv_test.go @@ -0,0 +1,53 @@ +package tapsdk + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "math" + "strconv" + "testing" + + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestAssetProofPathMalformedRecordLength rejects truncated TLV records without +// allocating their declared length. The proof file itself fits comfortably +// within the proof-path budget, including its valid checksum. +func TestAssetProofPathMalformedRecordLength(t *testing.T) { + for _, length := range []uint64{math.MaxInt64, math.MaxUint64} { + t.Run(strconv.FormatUint(length, 10), func(t *testing.T) { + var raw bytes.Buffer + raw.Write(proof.PrefixMagicBytes[:]) + var scratch [8]byte + require.NoError(t, tlv.WriteVarInt(&raw, 65535, &scratch)) + require.NoError(t, tlv.WriteVarInt(&raw, length, &scratch)) + + var file bytes.Buffer + file.Write(proof.FilePrefixMagicBytes[:]) + require.NoError(t, binary.Write( + &file, binary.BigEndian, uint32(proof.V0), + )) + require.NoError(t, tlv.WriteVarInt(&file, 1, &scratch)) + require.NoError(t, tlv.WriteVarInt( + &file, uint64(raw.Len()), &scratch, + )) + file.Write(raw.Bytes()) + var previous [sha256.Size]byte + hash := sha256.New() + hash.Write(previous[:]) + hash.Write(raw.Bytes()) + file.Write(hash.Sum(nil)) + + path := &sdk.AssetProofPath{ConfirmedBaseProof: file.Bytes()} + var err error + require.NotPanics(t, func() { + err = path.Validate() + }) + require.ErrorIs(t, err, sdk.ErrAssetProofPathInvalid) + }) + } +} diff --git a/swapkit/tapsdk/swapkit.go b/swapkit/tapsdk/swapkit.go new file mode 100644 index 000000000..a1b38dec4 --- /dev/null +++ b/swapkit/tapsdk/swapkit.go @@ -0,0 +1,74 @@ +// Package tapsdk connects Loop asset contracts and node services to the +// Taproot Assets SDK. Swap policy remains owned by Loop's swapkit package. +package tapsdk + +import ( + "bytes" + + "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" +) + +// AnchorPlan converts a Loop Bitcoin contract into an SDK output plan. +func AnchorPlan(plan swapkit.AnchorPlan) sdk.CustomAnchorOutputPlan { + leaves := make([]sdk.TapLeaf, len(plan.Leaves)) + for i := range plan.Leaves { + leaves[i] = sdk.TapLeaf{Script: bytes.Clone(plan.Leaves[i])} + } + return sdk.CustomAnchorOutputPlan{ + InternalKey: sdk.InternalKey{ + PubKey: sdk.PubKey(plan.InternalKey), + }, + Tapscript: sdk.CustomAnchorTapscriptPlan{TapLeaves: leaves}, + } +} + +// OPTrueOutput explicitly selects the SDK supplied-key/asset-ID asset tree. +// Use LegacyOPTrueOutput for the unchanged fixed NUMS policy. +// The SDK builder validates the complete output and creates its asset witness. +func OPTrueOutput(output sdk.CustomAssetOutput, key sdk.KeyDescriptor, + anchor swapkit.AnchorPlan) sdk.CustomAssetOutput { + + output.Script = sdk.CustomAssetScriptPlan{ + Mode: sdk.CustomAssetScriptOPTrue, + OPTrue: &sdk.CustomAssetOPTrueScriptPlan{InternalKey: key}, + } + output.Anchor = AnchorPlan(anchor) + return output +} + +// CooperativeSigningPlan selects a contract's sorted aggregate key path. +// Nonce exchange remains external; sessionContext must not contain secrets. +func CooperativeSigningPlan(participants [][33]byte, inputIndex uint32, + sessionContext []byte) sdk.CustomAnchorInputSigningPlan { + + keys := make([]sdk.PubKey, len(participants)) + for i := range participants { + keys[i] = sdk.PubKey(participants[i]) + } + return sdk.CustomAnchorInputSigningPlan{ + InputIndex: inputIndex, + MuSig2: &sdk.CustomAnchorMuSig2SigningPlan{ + Participants: keys, + SessionContext: bytes.Clone(sessionContext), + }, + } +} + +// ScriptSigningPlan selects a contract's exact Bitcoin leaf for SDK signing. +// The anchor PSBT must also include its leaf script and control block. +func ScriptSigningPlan(spend *swapkit.SwapAnchorSpendInfo, + inputIndex uint32) sdk.CustomAnchorInputSigningPlan { + + leafHash := txscript.NewBaseTapLeaf(spend.LeafScript).TapHash() + return sdk.CustomAnchorInputSigningPlan{ + InputIndex: inputIndex, + ScriptPath: &sdk.CustomAnchorScriptPathSigningPlan{ + LeafHash: sdk.Hash(leafHash), + RequiredSigners: []sdk.XOnlyPubKey{ + sdk.XOnlyPubKey(spend.RequiredSigner), + }, + }, + } +} diff --git a/swapkit/tapsdk/swapkit_test.go b/swapkit/tapsdk/swapkit_test.go new file mode 100644 index 000000000..9817134b4 --- /dev/null +++ b/swapkit/tapsdk/swapkit_test.go @@ -0,0 +1,147 @@ +package tapsdk + +import ( + "bytes" + "crypto/sha256" + "testing" + + "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/tappsbt" + "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/stretchr/testify/require" +) + +// TestSwapKitSigningPlan checks SDK signing metadata and defensive copies. +// Bitcoin policy vectors and VM execution are covered by the swapkit module. +func TestSwapKitSigningPlan(t *testing.T) { + kit, err := swapkit.NewSwapKit(swapKitTestParams(t)) + require.NoError(t, err) + source := kit.AnchorPlan() + plan := AnchorPlan(source) + expected := bytes.Clone(plan.Tapscript.TapLeaves[0].Script) + plan.Tapscript.TapLeaves[0].Script[0] ^= 1 + require.Equal(t, expected, + source.Leaves[0]) + + context := []byte("unique-signing-round") + signing := CooperativeSigningPlan( + kit.CooperativeParticipants(), 2, context, + ) + require.Equal(t, uint32(2), signing.InputIndex) + require.NoError(t, validateMuSig2Aggregate( + sdk.PubKey(source.InternalKey).XOnly(), + signing.MuSig2.Participants, + )) + context[0] ^= 1 + require.NotEqual(t, context, signing.MuSig2.SessionContext) + signing.MuSig2.Participants[0] = sdk.PubKey{} + require.NotEqual(t, signing.MuSig2.Participants, + CooperativeSigningPlan( + kit.CooperativeParticipants(), 2, nil, + ).MuSig2.Participants) +} + +// TestSwapKitBuilderCommitments binds the kit's Bitcoin control blocks to the +// custom-anchor builder's independently assembled asset and policy roots. +func TestSwapKitBuilderCommitments(t *testing.T) { + params := swapKitTestParams(t) + legacy, err := swapkit.NewSwapKit(params) + require.NoError(t, err) + params.Policy = swapkit.SwapHTLCStaticLoopInV3 + v3, err := swapkit.NewSwapKit(params) + require.NoError(t, err) + deposit, err := swapkit.NewDepositKit(swapkit.DepositKitParams{ + ClientKey: params.SenderKey, ServerKey: params.ReceiverKey, + CSVExpiry: 144, + }) + require.NoError(t, err) + for name, kit := range map[string]swapKitOutput{ + "legacy": legacy, "v3": v3, "deposit": deposit, + } { + t.Run(name, func(t *testing.T) { + first := testSwapKitBuilderCommitment(t, kit, 10) + second := testSwapKitBuilderCommitment(t, kit, 11) + require.NotEqual(t, first, second) + }) + } +} + +type swapKitOutput interface { + AnchorPlan() swapkit.AnchorPlan + TimeoutSpend(swapkit.Hash) (*swapkit.SwapAnchorSpendInfo, error) +} + +func testSwapKitBuilderCommitment(t *testing.T, kit swapKitOutput, + scalar byte) sdk.PubKey { + + t.Helper() + fixture := newCustomAnchorBuilderFixture(t) + original := fixture.request.Outputs[0] + output := OPTrueOutput(original, sdk.KeyDescriptor{ + RawKeyBytes: testKey(t, scalar), + }, kit.AnchorPlan()) + fixture.request.Outputs[0] = output + wallet := sdk.NewWallet(fixture.client, sdk.NetworkRegtest) + plan, err := wallet.NewCustomAnchorTxBuilder(). + Build(t.Context(), fixture.request) + require.NoError(t, err) + planned := plan.Outputs()[0] + require.NotNil(t, planned.OPTrueSpend) + require.NoError(t, planned.OPTrueSpend.Validate(planned.ScriptKey)) + require.Equal(t, []byte{txscript.OP_TRUE}, + planned.OPTrueSpend.LeafScript) + + // Derive the builder's root independently through tapd's public + // commitment implementation. No signing or backend commit is needed. + packets := plan.ActiveVirtualPSBTs() + require.Len(t, packets, 1) + packet, err := tappsbt.NewFromRawBytes( + bytes.NewReader(packets[0]), + false, + ) + require.NoError(t, err) + commitments, err := tapsend.CreateOutputCommitments( + []*tappsbt.VPacket{packet}, tapsend.WithSpenderLeaves(), + ) + require.NoError(t, err) + virtual := packet.Outputs[planned.VirtualOutputIndex] + _, merkle, root, err := tapsend.AnchorOutputScript( + virtual.AnchorOutputInternalKey, + virtual.AnchorOutputTapscriptSibling, + commitments[virtual.AnchorOutputIndex], + ) + require.NoError(t, err) + spend, err := kit.TimeoutSpend(sdk.Hash(root)) + require.NoError(t, err) + require.Equal(t, [32]byte(merkle), spend.TaprootMerkleRoot) + signing := ScriptSigningPlan(spend, 3) + require.Equal(t, uint32(3), signing.InputIndex) + require.Equal(t, sdk.Hash(txscript.NewBaseTapLeaf( + spend.LeafScript, + ).TapHash()), signing.ScriptPath.LeafHash) + require.Equal(t, []sdk.XOnlyPubKey{ + sdk.XOnlyPubKey(spend.RequiredSigner), + }, signing.ScriptPath.RequiredSigners) + control, err := txscript.ParseControlBlock(spend.ControlBlock) + require.NoError(t, err) + require.NoError(t, txscript.VerifyTaprootLeafCommitment( + control, spend.PkScript[2:], spend.LeafScript, + )) + return planned.ScriptKey +} + +func swapKitTestParams(t *testing.T) swapkit.SwapKitParams { + t.Helper() + sender := testKey(t, 1) + receiver := testKey(t, 2) + preimage := [32]byte{42} + return swapkit.SwapKitParams{ + Policy: swapkit.SwapHTLCLegacyCSV, + SenderKey: sender, ReceiverKey: receiver, + SenderInternalKey: sender, ReceiverInternalKey: receiver, + PaymentHash: sdk.Hash(sha256.Sum256(preimage[:])), + Expiry: 144, + } +} diff --git a/swapkit/tapsdk/testdata/builder_request.json b/swapkit/tapsdk/testdata/builder_request.json new file mode 100644 index 000000000..176b1375d --- /dev/null +++ b/swapkit/tapsdk/testdata/builder_request.json @@ -0,0 +1,385 @@ +{ + "Request": { + "Inputs": [ + { + "ID": "asset-input", + "AssetRef": "tapr1qqqvf4wta3rfpxvuauv3xx0glv5lgy78x34y2dyq4r7ksuvxm9zsm5qjdcsj7", + "Amount": 100, + "ProofFile": "VEFQRgAAAAAB/QLhVEFQUAAEAAAAAQIkAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAatWwK2mb6b9cBCjkRUli09D6oBMXGNjptuiXD3kg+ZwAJbogAAAAAAAAAAAZeAwAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAFKAQAAAAAAACJRIBWAwqm9Ntk9OxJQKldaBpaxLfQZFu/6NkIsFhNVXbN/AAAAAAgBAAr3AAEBAloBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEQYXNzZXQtcHJvb2YtcGF0aAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAQAGAWQLaQFnAWUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4CAAAQIQLaRxCWT3hSaV3i2gJSkOJK9tjCgd5aC5ArcTX9n9dNIQyfAAQAAAAAAiECxgR/lEHtfW0wRUBulcB82Fx3jkuM7zynq6wJuVxwnuUDdAFJAAEBAiDE1cvsRpCZnO8ZExno+yn0E8c0akU0gKj9aHGG2UUN0AQiAAD//////////////////////////////////////////wInAAECAiIAAP//////////////////////////////////////////FgQAAABkF1oBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEQYXNzZXQtcHJvb2YtcGF0aAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABan+WnD6nnUqtFpTlr4jyhk5IS73ZCp551AN4sK+C/XA==", + "ProofPath": null, + "Witness": { + "Mode": 1, + "Stack": null + } + } + ], + "Outputs": [ + { + "ID": "asset-output", + "AssetRef": "tapr1qqqvf4wta3rfpxvuauv3xx0glv5lgy78x34y2dyq4r7ksuvxm9zsm5qjdcsj7", + "Amount": 100, + "AnchorOutputIndex": 1, + "AnchorValueSat": 777, + "Script": { + "Mode": 2, + "Wallet": null, + "External": { + "ScriptKey": { + "PubKey": [ + 3, + 236, + 235, + 1, + 9, + 8, + 90, + 10, + 62, + 52, + 52, + 250, + 234, + 178, + 14, + 157, + 8, + 229, + 180, + 253, + 200, + 83, + 140, + 67, + 139, + 163, + 230, + 116, + 131, + 73, + 213, + 242, + 95 + ], + "KeyDesc": { + "RawKeyBytes": [ + 3, + 172, + 212, + 132, + 226, + 240, + 199, + 246, + 83, + 9, + 173, + 23, + 138, + 159, + 85, + 154, + 189, + 224, + 151, + 150, + 151, + 76, + 87, + 231, + 20, + 195, + 95, + 17, + 13, + 252, + 39, + 204, + 190 + ], + "KeyLocator": { + "Family": 0, + "Index": 0 + } + }, + "TapTweak": null + } + }, + "OPTrue": null, + "Burn": null + }, + "Anchor": { + "InternalKey": { + "PubKey": [ + 2, + 47, + 1, + 229, + 225, + 92, + 202, + 53, + 29, + 175, + 243, + 132, + 63, + 183, + 15, + 60, + 47, + 10, + 27, + 221, + 5, + 229, + 175, + 136, + 138, + 103, + 120, + 78, + 243, + 225, + 10, + 42, + 1 + ], + "KeyLocator": { + "Family": 0, + "Index": 0 + } + }, + "Tapscript": { + "TapLeaves": null, + "SerializedSibling": null + } + }, + "ProofDelivery": { + "recipient_id": "", + "courier_address": "", + "opaque_metadata": null + }, + "Timelocks": { + "Absolute": 0, + "Relative": 0 + } + } + ], + "AnchorPSBT": "cHNidP8BAH0DAAAAAqoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEQAAAAARERERGrVsCtpm+m/XAQo5EVJYtPQ+qATFxjY6bbolw95IPmcAAAAAACIiIiIDOTAAAAAAAAABUQkDAAAAAAAAAQAAAAAAAAAAAARRAk5zKgAAAAABASv4MQAAAAAAACJRIBnLpbY73L9GYp7fF82hO85We5BEK7Qh6uijLjWEOWfDARcgXL3wZG5dtOqjmPNl8up6Dj1Bm34DMOOc6Svd7crE+bwAAAAAAA==", + "Funding": { + "Mode": 3, + "WalletFunded": null, + "CallerFundedExact": null, + "ExternalP2AFeeBump": { + "P2AOutputIndex": 2 + } + }, + "PassiveAssets": { + "Policy": 0, + "Packets": null + }, + "LossPolicy": { + "Mode": 0, + "Allowances": null, + "ConfirmIrreversibleLoss": false + }, + "SigningPlans": [ + { + "input_index": 0, + "key_path": { + "signer": [ + 92, + 189, + 240, + 100, + 110, + 93, + 180, + 234, + 163, + 152, + 243, + 101, + 242, + 234, + 122, + 14, + 61, + 65, + 155, + 126, + 3, + 48, + 227, + 156, + 233, + 43, + 221, + 237, + 202, + 196, + 249, + 188 + ] + } + }, + { + "input_index": 1, + "key_path": { + "signer": [ + 198, + 4, + 127, + 148, + 65, + 237, + 125, + 109, + 48, + 69, + 64, + 110, + 149, + 192, + 124, + 216, + 92, + 119, + 142, + 75, + 140, + 239, + 60, + 167, + 171, + 172, + 9, + 185, + 92, + 112, + 158, + 229 + ] + } + } + ] + }, + "Verified": { + "Valid": true, + "DecodedProof": { + "ProofAtDepth": 0, + "NumberOfProofs": 0, + "AssetRef": "tapr1qqqvf4wta3rfpxvuauv3xx0glv5lgy78x34y2dyq4r7ksuvxm9zsm5qjdcsj7", + "IssuanceID": [ + 196, + 213, + 203, + 236, + 70, + 144, + 153, + 156, + 239, + 25, + 19, + 25, + 232, + 251, + 41, + 244, + 19, + 199, + 52, + 106, + 69, + 52, + 128, + 168, + 253, + 104, + 113, + 134, + 217, + 69, + 13, + 208 + ], + "ScriptKey": [ + 2, + 218, + 71, + 16, + 150, + 79, + 120, + 82, + 105, + 93, + 226, + 218, + 2, + 82, + 144, + 226, + 74, + 246, + 216, + 194, + 129, + 222, + 90, + 11, + 144, + 43, + 113, + 53, + 253, + 159, + 215, + 77, + 33 + ], + "Amount": 100, + "Outpoint": { + "Txid": [ + 26, + 181, + 108, + 10, + 218, + 102, + 250, + 111, + 215, + 1, + 10, + 57, + 17, + 82, + 88, + 180, + 244, + 62, + 168, + 4, + 197, + 198, + 54, + 58, + 109, + 186, + 37, + 195, + 222, + 72, + 62, + 103 + ], + "Index": 0 + }, + "AltLeaves": null, + "PrevIDs": null, + "IsIssuance": false + } + } +} \ No newline at end of file From 5916d94e6b44da32a552bbe512705e2312efc181 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 11 Nov 2026 14:18:38 +0100 Subject: [PATCH 04/25] tapsdk: Validate asset proofs and current-chain inputs Authenticate proof history, asset commitments, group issuances and Bitcoin inputs before granting on-chain authority. Apply the shared tapd floor at proof boundaries while keeping offline construction available. Distinguish invalid funding candidates from exact outputs waiting on confirmations or a chain backend. Preserve retryable results when no candidate is ready. --- internal/assetpolicy/networks.go | 65 +++ internal/assetpolicy/networks_test.go | 79 ++++ internal/assetpolicy/provenance.go | 257 +++++++++++ internal/assetpolicy/provenance_test.go | 482 ++++++++++++++++++++ internal/assetpolicy/test_constants_test.go | 3 + internal/assetpolicy/tlv_test.go | 56 +++ internal/assetpolicy/versions.go | 75 +++ internal/assetproof/anchor.go | 190 ++++++++ internal/assetproof/anchor_test.go | 95 ++++ swapkit/asset_types.go | 15 + swapkit/tapsdk/asset.go | 32 ++ swapkit/tapsdk/binding_test.go | 199 ++++++++ swapkit/tapsdk/bindings.go | 314 +++++++++++++ swapkit/tapsdk/chain.go | 173 +++++++ swapkit/tapsdk/chain_guard_test.go | 97 ++++ swapkit/tapsdk/chain_test.go | 147 ++++++ swapkit/tapsdk/confirmation_chain_test.go | 79 ++++ swapkit/tapsdk/deposit.go | 83 ++++ swapkit/tapsdk/funding.go | 64 +++ swapkit/tapsdk/funding_test.go | 210 +++++++++ swapkit/tapsdk/group.go | 21 + swapkit/tapsdk/group_proofs.go | 93 ++++ swapkit/tapsdk/group_proofs_test.go | 90 ++++ swapkit/tapsdk/group_test.go | 72 +++ swapkit/tapsdk/inputs.go | 100 ++++ swapkit/tapsdk/issuance.go | 98 ++++ swapkit/tapsdk/issuance_test.go | 116 +++++ swapkit/tapsdk/policy.go | 135 ++++++ swapkit/tapsdk/proofs.go | 184 ++++++++ swapkit/tapsdk/test_constants_test.go | 11 + 30 files changed, 3635 insertions(+) create mode 100644 internal/assetpolicy/networks.go create mode 100644 internal/assetpolicy/networks_test.go create mode 100644 internal/assetpolicy/provenance.go create mode 100644 internal/assetpolicy/provenance_test.go create mode 100644 internal/assetpolicy/test_constants_test.go create mode 100644 internal/assetpolicy/tlv_test.go create mode 100644 internal/assetpolicy/versions.go create mode 100644 internal/assetproof/anchor.go create mode 100644 internal/assetproof/anchor_test.go create mode 100644 swapkit/tapsdk/bindings.go create mode 100644 swapkit/tapsdk/chain.go create mode 100644 swapkit/tapsdk/chain_guard_test.go create mode 100644 swapkit/tapsdk/chain_test.go create mode 100644 swapkit/tapsdk/confirmation_chain_test.go create mode 100644 swapkit/tapsdk/deposit.go create mode 100644 swapkit/tapsdk/funding.go create mode 100644 swapkit/tapsdk/funding_test.go create mode 100644 swapkit/tapsdk/group.go create mode 100644 swapkit/tapsdk/group_proofs.go create mode 100644 swapkit/tapsdk/group_proofs_test.go create mode 100644 swapkit/tapsdk/group_test.go create mode 100644 swapkit/tapsdk/inputs.go create mode 100644 swapkit/tapsdk/issuance.go create mode 100644 swapkit/tapsdk/issuance_test.go create mode 100644 swapkit/tapsdk/policy.go create mode 100644 swapkit/tapsdk/proofs.go create mode 100644 swapkit/tapsdk/test_constants_test.go diff --git a/internal/assetpolicy/networks.go b/internal/assetpolicy/networks.go new file mode 100644 index 000000000..ef99cf028 --- /dev/null +++ b/internal/assetpolicy/networks.go @@ -0,0 +1,65 @@ +package policy + +import ( + "fmt" + + "github.com/lightninglabs/taproot-assets/address" +) + +// RawSimNetHDCoinType is the BIP-0044 coin type used by btcd's native simnet +// parameters. LND deliberately uses the testnet coin type for simnet instead, +// so both forms need explicit, stable definitions. +const RawSimNetHDCoinType uint32 = 115 + +// CloneAddressParams returns an independent copy of address parameters, +// including the nested Bitcoin network parameters when present. +func CloneAddressParams(params address.ChainParams) *address.ChainParams { + paramsCopy := params + if params.Params != nil { + bitcoinParams := *params.Params + paramsCopy.Params = &bitcoinParams + } + + return ¶msCopy +} + +// CanonicalAddressParams returns an independent copy of the supported network +// parameters that match the caller's Taproot Asset address tuple of HRP, +// network and HD coin type. Every other field, including the network name that +// selects the proof activation height, comes from the canonical definition. +func CanonicalAddressParams(params *address.ChainParams) ( + *address.ChainParams, error) { + + if params == nil { + return nil, fmt.Errorf("address parameters are required") + } + if params.Params == nil || params.TapHRP == "" { + return nil, fmt.Errorf("address parameters are incomplete") + } + + rawSimNet := CloneAddressParams(address.SimNetTap) + rawSimNet.HDCoinType = RawSimNetHDCoinType + lndSimNet := CloneAddressParams(address.SimNetTap) + lndSimNet.HDCoinType = address.TestNet3Tap.HDCoinType + + knownNetworks := []*address.ChainParams{ + &address.MainNetTap, + &address.TestNet3Tap, + &address.TestNet4Tap, + &address.RegressionNetTap, + &address.SigNetTap, + rawSimNet, + lndSimNet, + } + for _, known := range knownNetworks { + if params.TapHRP != known.TapHRP || params.Net != known.Net || + params.HDCoinType != known.HDCoinType { + + continue + } + + return CloneAddressParams(*known), nil + } + + return nil, fmt.Errorf("unsupported or mismatched address parameters") +} diff --git a/internal/assetpolicy/networks_test.go b/internal/assetpolicy/networks_test.go new file mode 100644 index 000000000..dfa2a49dc --- /dev/null +++ b/internal/assetpolicy/networks_test.go @@ -0,0 +1,79 @@ +package policy + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/taproot-assets/address" + "github.com/stretchr/testify/require" +) + +// TestCanonicalAddressParams verifies that supported network tuples resolve to +// independent copies of their canonical parameters, and that incomplete or +// mismatched tuples are rejected. +func TestCanonicalAddressParams(t *testing.T) { + simNetCoinType := address.SimNetTap.HDCoinType + + rawSimNet := CloneAddressParams(address.SimNetTap) + rawSimNet.HDCoinType = RawSimNetHDCoinType + lndSimNet := CloneAddressParams(address.SimNetTap) + lndSimNet.HDCoinType = address.TestNet3Tap.HDCoinType + + for _, network := range []*address.ChainParams{ + &address.MainNetTap, + &address.TestNet3Tap, + &address.TestNet4Tap, + &address.RegressionNetTap, + &address.SigNetTap, + rawSimNet, + lndSimNet, + } { + canonical, err := CanonicalAddressParams(network) + require.NoError(t, err) + require.Equal(t, network.TapHRP, canonical.TapHRP) + require.Equal(t, network.Net, canonical.Net) + require.Equal(t, network.HDCoinType, canonical.HDCoinType) + require.Equal(t, network.Name, canonical.Name) + require.NotSame(t, network.Params, canonical.Params) + } + require.Equal(t, simNetCoinType, address.SimNetTap.HDCoinType) + + // The tuple selects the network, so a caller cannot rename it and + // thereby select another proof activation height. + renamed := CloneAddressParams(address.MainNetTap) + renamed.Name = chaincfg.RegressionNetParams.Name + canonical, err := CanonicalAddressParams(renamed) + require.NoError(t, err) + require.Equal(t, chaincfg.MainNetParams.Name, canonical.Name) + + canonical.Name = "mutated" + require.Equal(t, chaincfg.MainNetParams.Name, address.MainNetTap.Name) + + mismatched := CloneAddressParams(address.RegressionNetTap) + mismatched.TapHRP = address.MainNetTap.TapHRP + _, err = CanonicalAddressParams(mismatched) + require.ErrorContains(t, err, "unsupported or mismatched") + + _, err = CanonicalAddressParams(nil) + require.ErrorContains(t, err, "required") + + _, err = CanonicalAddressParams(&address.ChainParams{ + TapHRP: address.MainNetTap.TapHRP, + }) + require.ErrorContains(t, err, "incomplete") + + missingHRP := CloneAddressParams(address.MainNetTap) + missingHRP.TapHRP = "" + _, err = CanonicalAddressParams(missingHRP) + require.ErrorContains(t, err, "incomplete") +} + +// TestCheckProofFileSize verifies the proof file limit at its boundary. +func TestCheckProofFileSize(t *testing.T) { + require.NoError(t, checkProofFileSize(make([]byte, 10), 10)) + require.ErrorContains( + t, checkProofFileSize(make([]byte, 11), 10), + "exceeds the 10 byte limit", + ) + require.NoError(t, CheckProofFileSize(nil)) +} diff --git a/internal/assetpolicy/provenance.go b/internal/assetpolicy/provenance.go new file mode 100644 index 000000000..6107ab782 --- /dev/null +++ b/internal/assetpolicy/provenance.go @@ -0,0 +1,257 @@ +package policy + +import ( + "bytes" + "context" + "fmt" + + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/proof" +) + +const ( + // MaxProvenanceDepth bounds how deeply additional input files may nest + // in an accepted proof history. + MaxProvenanceDepth = 16 + + // MaxProvenanceBytes bounds the total size of the proofs decoded while + // walking an accepted proof history. Decoding a proof copies every + // input file nested beneath it, so without a budget the work grows with + // the nesting depth times the file size rather than with the file size. + MaxProvenanceBytes = 64 << 20 +) + +// CheckProofFileSize rejects a proof file larger than MaxProvenanceBytes before +// it is copied or decoded. Proof files share the limit of the decoding budget, +// which is below the maximum file size of Taproot Assets. +func CheckProofFileSize(rawFile []byte) error { + return checkProofFileSize(rawFile, MaxProvenanceBytes) +} + +// checkProofFileSize implements CheckProofFileSize with an explicit limit. +func checkProofFileSize(rawFile []byte, maxBytes int) error { + if len(rawFile) > maxBytes { + return fmt.Errorf("proof file of %d bytes exceeds the %d byte "+ + "limit", len(rawFile), maxBytes) + } + + return proof.CheckMaxFileSize(rawFile) +} + +// ActivationHeight returns the block height from which transition proofs on +// the given network must satisfy the Taproot Assets v0.8.5 activation rules. +// A network without a scheduled height, such as regtest, uses tapd's +// process-wide default, which is unset unless configured. The boolean is false +// when no activation rules apply. +func ActivationHeight(params *address.ChainParams) (uint32, bool) { + height := proof.DefaultActivationHeight() + if params != nil && params.Params != nil { + scheduled := proof.NetworkActivationHeight(params.Params.Name) + if scheduled.IsSome() { + height = scheduled + } + } + + return height.UnwrapOr(0), height.IsSome() +} + +// ValidateActivation applies the Taproot Assets v0.8.5 activation rules to a +// proof as tapd applies them to a confirmed proof. Issuance proofs are exempt, +// as is a proof that claims a non-zero block height below the activation +// height. Any other transition proof must be of version one and carry the root +// locator proof of a split, the STXO proofs of a split asset's root, and the +// spender proofs of its transfer root. Only full proof verification +// authenticates the claimed height. +func ValidateActivation(p *proof.Proof, activationHeight uint32) error { + if p == nil { + return fmt.Errorf("proof is required") + } + if p.Asset.IsGenesisAsset() { + return nil + } + if p.BlockHeight != 0 && p.BlockHeight < activationHeight { + return nil + } + + // A split asset proves its transfer root through its split root proof. + splitAsset := p.Asset.HasSplitCommitmentWitness() + rootProof := &p.InclusionProof + if splitAsset { + rootProof = p.SplitRootProof + } + + var missing error + switch { + case p.Version == proof.TransitionV0: + missing = proof.ErrTransitionV1Required + + case (splitAsset || p.Asset.SplitCommitmentRoot != nil) && + p.RootLocatorProof == nil: + + missing = proof.ErrMissingRootLocatorProof + + case splitAsset && (rootProof == nil || + rootProof.CommitmentProof == nil || + len(rootProof.CommitmentProof.STXOProofs) == 0): + + missing = proof.ErrMissingSplitSTXOProofs + + case rootProof == nil || rootProof.CommitmentProof == nil || + len(rootProof.CommitmentProof.SpenderProofs) == 0: + + missing = proof.ErrMissingSpenderProofs + } + if missing != nil { + return fmt.Errorf("%w: proof at height %d", missing, + p.BlockHeight) + } + + return nil +} + +// provenanceFile is a proof file awaiting the provenance walk, together with +// its nesting depth below the supplied file. +type provenanceFile struct { + file *proof.File + depth int +} + +// ValidateProvenance rejects proof histories that establish no provenance, +// before tapd verifies issuance and transitions. Every file, including each +// nested additional input, must start at genesis and contain no ownership +// challenge, and every proof must satisfy the Taproot Assets v0.8.5 +// activation rules of the given network for its claimed height. Nested files +// are walked iteratively so peer input cannot determine the call-stack depth, +// and the nesting depth and total decoded size are bounded so peer input +// cannot amplify the work. These structural checks alone do not establish +// that an asset is valid. +func ValidateProvenance(ctx context.Context, file *proof.File, + params *address.ChainParams) error { + + return validateProvenance( + ctx, file, params, MaxProvenanceDepth, MaxProvenanceBytes, + ) +} + +// validateProvenance implements ValidateProvenance with explicit bounds. +func validateProvenance(ctx context.Context, file *proof.File, + params *address.ChainParams, maxDepth, maxBytes int) error { + + if file == nil { + return fmt.Errorf("proof file is required") + } + walk := &provenanceWalk{maxDepth: maxDepth, maxBytes: maxBytes} + walk.activationHeight, walk.activated = ActivationHeight(params) + + pending := []provenanceFile{{file: file}} + for len(pending) > 0 { + last := len(pending) - 1 + entry := pending[last] + pending[last] = provenanceFile{} + pending = pending[:last] + + inputs, err := walk.visit(ctx, entry) + if err != nil { + return err + } + pending = append(pending, inputs...) + } + + return nil +} + +// provenanceWalk holds the bounds and the decoding budget shared by every +// file of one provenance walk. +type provenanceWalk struct { + maxDepth int + maxBytes int + decodedBytes int + activationHeight uint32 + activated bool +} + +// visit checks every proof of a file and returns its additional inputs. +func (w *provenanceWalk) visit(ctx context.Context, + entry provenanceFile) ([]provenanceFile, error) { + + file := entry.file + if file.Version != ProofFileVersion { + return nil, fmt.Errorf("unsupported proof file version %d", + file.Version) + } + if file.NumProofs() == 0 { + return nil, fmt.Errorf("proof history is empty") + } + + var inputs []provenanceFile + for idx := range file.NumProofs() { + if err := ctx.Err(); err != nil { + return nil, err + } + p, err := w.decode(file, idx) + if err != nil { + return nil, err + } + if err := w.check(p, idx == 0); err != nil { + return nil, err + } + + if len(p.AdditionalInputs) == 0 { + continue + } + if entry.depth >= w.maxDepth { + return nil, fmt.Errorf("proof inputs nest more than %d "+ + "levels deep", w.maxDepth) + } + for inputIdx := range p.AdditionalInputs { + inputs = append(inputs, provenanceFile{ + file: &p.AdditionalInputs[inputIdx], + depth: entry.depth + 1, + }) + } + } + + return inputs, nil +} + +// decode charges a proof's size to the budget before decoding the proof, as +// decoding copies every input file nested in it. +func (w *provenanceWalk) decode(file *proof.File, idx int) (*proof.Proof, + error) { + + rawProof, err := file.RawProofAt(uint32(idx)) + if err != nil { + return nil, fmt.Errorf("unable to read proof: %w", err) + } + w.decodedBytes += len(rawProof) + if w.decodedBytes > w.maxBytes { + return nil, fmt.Errorf("proof history exceeds the %d byte "+ + "decoding budget", w.maxBytes) + } + + p := &proof.Proof{} + if err := p.Decode(bytes.NewReader(rawProof)); err != nil { + return nil, fmt.Errorf("unable to decode proof: %w", err) + } + + return p, nil +} + +// check applies the provenance and activation rules to one proof, the first +// of its file if first is set. +func (w *provenanceWalk) check(p *proof.Proof, first bool) error { + if err := ValidateTransitionVersion(p.Version); err != nil { + return err + } + if p.ChallengeWitness != nil { + return fmt.Errorf("proof history contains an ownership challenge") + } + if first && !p.Asset.IsGenesisAsset() { + return fmt.Errorf("proof history must start at genesis") + } + if !w.activated { + return nil + } + + return ValidateActivation(p, w.activationHeight) +} diff --git a/internal/assetpolicy/provenance_test.go b/internal/assetpolicy/provenance_test.go new file mode 100644 index 000000000..7729888cc --- /dev/null +++ b/internal/assetpolicy/provenance_test.go @@ -0,0 +1,482 @@ +package policy + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/commitment" + "github.com/lightninglabs/taproot-assets/mssmt" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/stretchr/testify/require" +) + +// testKey derives a deterministic test key from a one-byte scalar. +func testKey(scalar byte) *btcec.PublicKey { + keyBytes := make([]byte, 32) + keyBytes[31] = scalar + _, publicKey := btcec.PrivKeyFromBytes(keyBytes) + + return publicKey +} + +// issuanceProof returns an encodable issuance proof. The provenance walk only +// decodes proofs, so it need not pass verification. +func issuanceProof(t *testing.T) proof.Proof { + t.Helper() + + genesis := asset.Genesis{ + FirstPrevOut: wire.OutPoint{Hash: chainhash.Hash{0x61}, Index: 1}, + Tag: "provenance policy test", + Type: asset.Normal, + } + issued, err := asset.New( + genesis, 1000, 0, 0, asset.NewScriptKey(testKey(1)), nil, + asset.WithAssetVersion(asset.V1), + ) + require.NoError(t, err) + version := commitment.TapCommitmentV2 + tapCommitment, err := commitment.FromAssets(&version, issued) + require.NoError(t, err) + _, inclusion, err := tapCommitment.Proof( + issued.TapCommitmentKey(), issued.AssetCommitmentKey(), + ) + require.NoError(t, err) + + anchorTx := wire.NewMsgTx(2) + anchorTx.AddTxIn(&wire.TxIn{PreviousOutPoint: genesis.FirstPrevOut}) + anchorTx.AddTxOut(&wire.TxOut{ + Value: 1000, PkScript: []byte{txscript.OP_TRUE}, + }) + + return proof.Proof{ + PrevOut: genesis.FirstPrevOut, + AnchorTx: *anchorTx, + Asset: *issued, + GenesisReveal: &genesis, + InclusionProof: proof.TaprootProof{ + InternalKey: testKey(2), + CommitmentProof: &proof.CommitmentProof{ + Proof: *inclusion, + }, + }, + } +} + +// transferProof returns an encodable proof that spends the issued asset and +// carries none of the evidence the activation rules require. +func transferProof(t *testing.T, issuance proof.Proof) proof.Proof { + t.Helper() + + transfer := issuance + transfer.Asset = *issuance.Asset.Copy() + transfer.Asset.PrevWitnesses = []asset.Witness{{ + PrevID: &asset.PrevID{ + OutPoint: issuance.OutPoint(), + ID: issuance.Asset.ID(), + ScriptKey: asset.ToSerialized( + issuance.Asset.ScriptKey.PubKey, + ), + }, + TxWitness: wire.TxWitness{{0x01}}, + }} + transfer.GenesisReveal = nil + transfer.PrevOut = issuance.OutPoint() + + return transfer +} + +// newFile encodes the proofs into a proof file. +func newFile(t *testing.T, proofs ...proof.Proof) *proof.File { + t.Helper() + + file, err := proof.NewFile(proof.V0, proofs...) + require.NoError(t, err) + + return file +} + +// nest returns a file whose single proof carries file as an additional input. +func nest(t *testing.T, issuance proof.Proof, file *proof.File) *proof.File { + t.Helper() + + parent := issuance + parent.AdditionalInputs = []proof.File{*file} + + return newFile(t, parent) +} + +// nested returns an issuance file wrapped depth times in additional inputs. +func nested(t *testing.T, issuance proof.Proof, depth int) *proof.File { + t.Helper() + + file := newFile(t, issuance) + for range depth { + file = nest(t, issuance, file) + } + + return file +} + +// TestActivationHeight uses the scheduled height of each network, and tapd's +// process-wide default for networks without one. +func TestActivationHeight(t *testing.T) { + scheduled := []struct { + params *address.ChainParams + height uint32 + }{ + {&address.MainNetTap, proof.MainNetActivationHeight}, + {&address.TestNet3Tap, proof.TestNet3ActivationHeight}, + {&address.TestNet4Tap, proof.TestNet4ActivationHeight}, + {&address.SigNetTap, proof.SigNetActivationHeight}, + } + for _, network := range scheduled { + height, activated := ActivationHeight(network.params) + require.True(t, activated) + require.Equal(t, network.height, height) + } + for _, params := range []*address.ChainParams{ + &address.RegressionNetTap, &address.SimNetTap, nil, + } { + _, activated := ActivationHeight(params) + require.False(t, activated) + } + + // Regtest follows the default, while scheduled heights take precedence. + proof.SetDefaultActivationHeight( + proof.NetworkActivationHeight(chaincfg.TestNet4Params.Name), + ) + t.Cleanup(func() { + proof.SetDefaultActivationHeight(proof.NetworkActivationHeight( + chaincfg.RegressionNetParams.Name, + )) + }) + height, activated := ActivationHeight(&address.RegressionNetTap) + require.True(t, activated) + require.Equal(t, proof.TestNet4ActivationHeight, height) + height, activated = ActivationHeight(&address.MainNetTap) + require.True(t, activated) + require.Equal(t, proof.MainNetActivationHeight, height) +} + +// TestValidateActivation mirrors each Taproot Assets v0.8.5 activation rule +// for root transfers, split roots and split assets. +func TestValidateActivation(t *testing.T) { + const activationHeight = proof.MainNetActivationHeight + + issuance := issuanceProof(t) + spenders := map[asset.SerializedKey]commitment.Proof{{0x02}: {}} + stxos := map[asset.SerializedKey]commitment.Proof{{0x03}: {}} + + // rootTransfer returns a version one root transfer proof with spender + // proofs, which mutate may strip. + rootTransfer := func(mutate func(*proof.Proof)) *proof.Proof { + p := transferProof(t, issuance) + p.Version = proof.TransitionV1 + p.InclusionProof.CommitmentProof = &proof.CommitmentProof{ + SpenderProofs: spenders, + } + if mutate != nil { + mutate(&p) + } + + return &p + } + + // splitRoot returns a split root asset proof with its root locator. + splitRoot := func(mutate func(*proof.Proof)) *proof.Proof { + return rootTransfer(func(p *proof.Proof) { + p.Asset.SplitCommitmentRoot = mssmt.NewComputedNode( + mssmt.NodeHash{0x04}, 1000, + ) + p.RootLocatorProof = &mssmt.Proof{} + if mutate != nil { + mutate(p) + } + }) + } + + // splitAsset returns a split asset proof whose split root proof + // carries its root's STXO and spender proofs. + splitAsset := func(mutate func(*proof.Proof)) *proof.Proof { + p := transferProof(t, issuance) + p.Version = proof.TransitionV1 + p.Asset.PrevWitnesses = []asset.Witness{{ + PrevID: &asset.ZeroPrevID, + SplitCommitment: &asset.SplitCommitment{ + RootAsset: *issuance.Asset.Copy(), + }, + }} + p.RootLocatorProof = &mssmt.Proof{} + p.SplitRootProof = &proof.TaprootProof{ + CommitmentProof: &proof.CommitmentProof{ + STXOProofs: stxos, + SpenderProofs: spenders, + }, + } + if mutate != nil { + mutate(&p) + } + + return &p + } + + tests := []struct { + name string + proof *proof.Proof + err error + }{ + { + name: "issuance", + proof: &issuance, + }, + { + name: "complete root transfer", + proof: rootTransfer(nil), + }, + { + name: "version zero transfer", + proof: rootTransfer(func(p *proof.Proof) { + p.Version = proof.TransitionV0 + }), + err: proof.ErrTransitionV1Required, + }, + { + name: "transfer without spender proofs", + proof: rootTransfer(func(p *proof.Proof) { + p.InclusionProof.CommitmentProof = nil + }), + err: proof.ErrMissingSpenderProofs, + }, + { + name: "complete split root", + proof: splitRoot(nil), + }, + { + name: "split root without root locator", + proof: splitRoot(func(p *proof.Proof) { + p.RootLocatorProof = nil + }), + err: proof.ErrMissingRootLocatorProof, + }, + { + name: "complete split asset", + proof: splitAsset(nil), + }, + { + name: "split asset without root locator", + proof: splitAsset(func(p *proof.Proof) { + p.RootLocatorProof = nil + }), + err: proof.ErrMissingRootLocatorProof, + }, + { + name: "split asset without split root STXO proofs", + proof: splitAsset(func(p *proof.Proof) { + p.SplitRootProof.CommitmentProof.STXOProofs = nil + }), + err: proof.ErrMissingSplitSTXOProofs, + }, + { + name: "split asset without split root spender proofs", + proof: splitAsset(func(p *proof.Proof) { + p.SplitRootProof.CommitmentProof.SpenderProofs = nil + }), + err: proof.ErrMissingSpenderProofs, + }, + { + name: "split asset without split root proof", + proof: splitAsset(func(p *proof.Proof) { + p.SplitRootProof = nil + }), + err: proof.ErrMissingSplitSTXOProofs, + }, + { + name: "claimed height before activation", + proof: rootTransfer(func(p *proof.Proof) { + p.Version = proof.TransitionV0 + p.BlockHeight = activationHeight - 1 + }), + }, + { + name: "claimed activation height", + proof: rootTransfer(func(p *proof.Proof) { + p.Version = proof.TransitionV0 + p.BlockHeight = activationHeight + }), + err: proof.ErrTransitionV1Required, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := ValidateActivation(test.proof, activationHeight) + if test.err == nil { + require.NoError(t, err) + + return + } + require.ErrorIs(t, err, test.err) + }) + } + + require.ErrorContains( + t, ValidateActivation(nil, activationHeight), "proof is required", + ) +} + +// TestValidateProvenance rejects histories without provenance anywhere in the +// input tree, and applies the activation rules of the given network. +func TestValidateProvenance(t *testing.T) { + issuance := issuanceProof(t) + transfer := transferProof(t, issuance) + ownership := transfer + ownership.ChallengeWitness = wire.TxWitness{{0x01}} + preActivation := transfer + preActivation.BlockHeight = proof.MainNetActivationHeight - 1 + + tests := []struct { + name string + file *proof.File + params *address.ChainParams + err string + errIs error + }{ + { + name: "issuance", + file: newFile(t, issuance), + }, + { + name: "transfer history", + file: newFile(t, issuance, transfer), + }, + { + name: "nested history", + file: nest(t, issuance, newFile(t, issuance, transfer)), + }, + { + name: "empty history", + file: newFile(t), + err: "history is empty", + }, + { + name: "ownership proof", + file: newFile(t, ownership), + err: testOwnershipChallenge, + }, + { + name: "later ownership proof", + file: newFile(t, issuance, ownership), + err: testOwnershipChallenge, + }, + { + name: "missing genesis", + file: newFile(t, transfer), + err: "must start at genesis", + }, + { + name: "nested ownership proof", + file: nest(t, issuance, nest(t, issuance, + newFile(t, ownership))), + err: testOwnershipChallenge, + }, + { + name: "nested input missing genesis", + file: nest(t, issuance, newFile(t, transfer)), + err: "must start at genesis", + }, + { + name: "nested empty input", + file: nest(t, issuance, newFile(t)), + err: "history is empty", + }, + { + name: "activated issuance", + file: newFile(t, issuance), + params: &address.MainNetTap, + }, + { + name: "activated transfer without evidence", + file: newFile(t, issuance, transfer), + params: &address.MainNetTap, + errIs: proof.ErrTransitionV1Required, + }, + { + name: "activated nested transfer without evidence", + file: nest(t, issuance, + newFile(t, issuance, transfer)), + params: &address.MainNetTap, + errIs: proof.ErrTransitionV1Required, + }, + { + name: "transfer claimed before activation", + file: newFile(t, issuance, preActivation), + params: &address.MainNetTap, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + params := test.params + if params == nil { + params = &address.RegressionNetTap + } + err := ValidateProvenance(t.Context(), test.file, params) + switch { + case test.err != "": + require.ErrorContains(t, err, test.err) + + case test.errIs != nil: + require.ErrorIs(t, err, test.errIs) + + default: + require.NoError(t, err) + } + }) + } + + file := newFile(t, issuance) + file.Version = ProofFileVersion + 1 + require.ErrorContains(t, ValidateProvenance( + t.Context(), file, &address.RegressionNetTap, + ), "unsupported proof file version") + require.ErrorContains(t, ValidateProvenance( + t.Context(), nil, &address.RegressionNetTap, + ), "proof file is required") +} + +// TestValidateProvenanceBounds stops nested input histories at the depth and +// decoding budget limits, which charge every nested copy that decoding makes. +func TestValidateProvenanceBounds(t *testing.T) { + issuance := issuanceProof(t) + params := &address.RegressionNetTap + + require.NoError(t, ValidateProvenance( + t.Context(), nested(t, issuance, MaxProvenanceDepth), params, + )) + require.ErrorContains(t, ValidateProvenance( + t.Context(), nested(t, issuance, MaxProvenanceDepth+1), params, + ), "levels deep") + + // The terminal proof alone fits the budget, but the copy of its nested + // input that decoding makes does not. + file := nested(t, issuance, 1) + terminal, err := file.RawProofAt(0) + require.NoError(t, err) + require.ErrorContains(t, validateProvenance( + t.Context(), file, params, MaxProvenanceDepth, len(terminal)+1, + ), "decoding budget") + require.NoError(t, validateProvenance( + t.Context(), file, params, MaxProvenanceDepth, 2*len(terminal), + )) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs( + t, ValidateProvenance(ctx, file, params), context.Canceled, + ) +} diff --git a/internal/assetpolicy/test_constants_test.go b/internal/assetpolicy/test_constants_test.go new file mode 100644 index 000000000..9fce3febc --- /dev/null +++ b/internal/assetpolicy/test_constants_test.go @@ -0,0 +1,3 @@ +package policy + +const testOwnershipChallenge = "ownership challenge" diff --git a/internal/assetpolicy/tlv_test.go b/internal/assetpolicy/tlv_test.go new file mode 100644 index 000000000..32a6a32d9 --- /dev/null +++ b/internal/assetpolicy/tlv_test.go @@ -0,0 +1,56 @@ +package policy + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "math" + "strconv" + "testing" + + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestMalformedProofRecordLength rejects truncated TLV records without +// allocating their declared length. The proof file itself fits comfortably +// within the provenance budget, including its valid checksum. +func TestMalformedProofRecordLength(t *testing.T) { + for _, length := range []uint64{math.MaxInt64, math.MaxUint64} { + t.Run(strconv.FormatUint(length, 10), func(t *testing.T) { + var raw bytes.Buffer + raw.Write(proof.PrefixMagicBytes[:]) + var scratch [8]byte + require.NoError(t, tlv.WriteVarInt(&raw, 65535, &scratch)) + require.NoError(t, tlv.WriteVarInt(&raw, length, &scratch)) + + var file bytes.Buffer + file.Write(proof.FilePrefixMagicBytes[:]) + require.NoError(t, binary.Write( + &file, binary.BigEndian, uint32(proof.V0), + )) + require.NoError(t, tlv.WriteVarInt(&file, 1, &scratch)) + require.NoError(t, tlv.WriteVarInt( + &file, uint64(raw.Len()), &scratch, + )) + file.Write(raw.Bytes()) + var previous [sha256.Size]byte + hash := sha256.New() + hash.Write(previous[:]) + hash.Write(raw.Bytes()) + file.Write(hash.Sum(nil)) + + require.NoError(t, CheckProofFileSize(file.Bytes())) + decoded, err := proof.DecodeFile(file.Bytes()) + require.NoError(t, err) + require.NotPanics(t, func() { + err = ValidateProvenance( + t.Context(), decoded, &address.RegressionNetTap, + ) + }) + require.Error(t, err) + }) + } +} diff --git a/internal/assetpolicy/versions.go b/internal/assetpolicy/versions.go new file mode 100644 index 000000000..616abbe6b --- /dev/null +++ b/internal/assetpolicy/versions.go @@ -0,0 +1,75 @@ +// Package policy freezes the wire versions of the legacy asset contracts and +// the structural rules for accepting their proof histories. New contracts use +// V1 assets and addresses with V2 commitments. Legacy input assets and +// transition proofs remain accepted under the explicit rules below. The +// Taproot Assets v0.8.5 activation rules are applied to each proof's claimed +// block height, which only the full verifier authenticates. +package policy + +import ( + "fmt" + + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/commitment" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tappsbt" + "github.com/lightninglabs/taproot-assets/taprpc" +) + +const ( + // AddressVersion is the predictable non-interactive address format. + AddressVersion = address.V1 + + // AssetVersion is used for newly created contract asset leaves. + AssetVersion = asset.V1 + + // PacketVersion selects the commitment format of newly funded contracts. + PacketVersion = tappsbt.V1 + + // CommitmentVersion is required by PacketVersion. + CommitmentVersion = commitment.TapCommitmentV2 + + // ProofFileVersion is the only supported proof history container. + ProofFileVersion = proof.V0 + + // TransitionVersion includes the activation-required STXO proofs. + TransitionVersion = proof.TransitionV1 + + // RPCAssetVersion explicitly selects AssetVersion in address requests. + RPCAssetVersion = taprpc.AssetVersion_ASSET_VERSION_V1 + + // RPCAddressVersion explicitly selects AddressVersion in address requests. + RPCAddressVersion = taprpc.AddrVersion_ADDR_VERSION_V1 +) + +// ValidateTransitionVersion rejects unrecognized transition proof semantics +// even when only the commitment proofs, rather than the history, are checked. +func ValidateTransitionVersion(version proof.TransitionVersion) error { + if version != proof.TransitionV0 && version != TransitionVersion { + return fmt.Errorf("unsupported transition proof version %d", version) + } + return nil +} + +// SupportsAssetVersion permits the two historical asset leaf encodings. +func SupportsAssetVersion(version asset.Version) bool { + return version == asset.V0 || version == AssetVersion +} + +// SupportsPacketVersion permits both historical virtual packet encodings. +func SupportsPacketVersion(version tappsbt.VPacketVersion) bool { + return version == tappsbt.V0 || version == PacketVersion +} + +// ValidateCommitmentVersion permits the three historical commitment formats. +func ValidateCommitmentVersion(version commitment.TapCommitmentVersion) error { + switch version { + case commitment.TapCommitmentV0, commitment.TapCommitmentV1, + CommitmentVersion: + + return nil + default: + return fmt.Errorf("unsupported asset commitment version %d", version) + } +} diff --git a/internal/assetproof/anchor.go b/internal/assetproof/anchor.go new file mode 100644 index 000000000..e413b65e3 --- /dev/null +++ b/internal/assetproof/anchor.go @@ -0,0 +1,190 @@ +// Package assetproof checks the input inventory required by Loop contracts. +// Generic lineage and transition verification belongs to the SDK and tapd. +package assetproof + +import ( + "bytes" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + policy "github.com/lightninglabs/loop/internal/assetpolicy" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/commitment" + "github.com/lightninglabs/taproot-assets/proof" +) + +// VerifyCompleteAnchor requires a proof to disclose its entire anchor +// commitment, including alt leaves. This prevents a negotiated contract from +// hiding co-anchored assets whose owner could bypass its Bitcoin policy. +// It does not verify history, confirmation or current unspent status. +func VerifyCompleteAnchor(assetProof *proof.Proof) (*commitment.TapCommitment, + error) { + + if err := validateInventoryProof(assetProof); err != nil { + return nil, err + } + provenCommitment, err := assetProof.VerifyProofs() + if err != nil { + return nil, fmt.Errorf("invalid asset proof: %w", err) + } + if err := policy.ValidateCommitmentVersion(provenCommitment.Version); err != nil { + return nil, err + } + + // Rebuild the disclosed on-chain leaf and alt leaves at version two + // and then at the legacy versions. + anchorAsset := assetProof.Asset.Copy() + if anchorAsset.HasSplitCommitmentWitness() && + *anchorAsset.PrevWitnesses[0].PrevID == asset.ZeroPrevID { + + anchorAsset.PrevWitnesses[0].SplitCommitment = nil + } + version := policy.CommitmentVersion + rebuiltCommitment, err := commitment.FromAssets(&version, anchorAsset) + if err != nil { + return nil, fmt.Errorf("unable to rebuild asset anchor: %w", err) + } + err = rebuiltCommitment.MergeAltLeaves( + asset.CopyAltLeaves(assetProof.AltLeaves), + ) + if err != nil { + return nil, fmt.Errorf("invalid asset proof alt leaves: %w", err) + } + + provenRoot := provenCommitment.TapscriptRoot(nil) + if rebuiltCommitment.TapscriptRoot(nil) == provenRoot { + return provenCommitment, nil + } + legacyCommitment, err := rebuiltCommitment.Downgrade() + if err != nil { + return nil, fmt.Errorf("unable to rebuild legacy asset anchor: %w", + err) + } + if legacyCommitment.TapscriptRoot(nil) != provenRoot { + return nil, fmt.Errorf("asset anchor does not match the proven " + + "asset and alt leaves") + } + + return provenCommitment, nil +} + +// VerifyCompleteAnchors checks the union of disclosed active and passive assets +// at each input anchor. A native SDK request can preserve known co-anchored +// assets by supplying all their proofs; an undisclosed asset is rejected. +// This does not establish lineage, chain inclusion or current spendability. +func VerifyCompleteAnchors(proofs []*proof.Proof) error { + if len(proofs) == 0 { + return fmt.Errorf("asset input inventory is required") + } + groups := make(map[wire.OutPoint][]*proof.Proof) + for _, p := range proofs { + if err := validateInventoryProof(p); err != nil { + return err + } + groups[p.OutPoint()] = append(groups[p.OutPoint()], p) + } + for point, group := range groups { + if len(group) == 1 { + if _, err := VerifyCompleteAnchor(group[0]); err != nil { + return fmt.Errorf("input anchor %v: %w", point, err) + } + continue + } + if err := verifyDisclosedAnchor(group); err != nil { + return fmt.Errorf("input anchor %v: %w", point, err) + } + } + return nil +} + +func validateInventoryProof(p *proof.Proof) error { + if p == nil || p.InclusionProof.CommitmentProof == nil || + p.InclusionProof.InternalKey == nil || p.Asset.ScriptKey.PubKey == nil { + + return fmt.Errorf("complete asset inclusion proof is required") + } + if err := policy.ValidateTransitionVersion(p.Version); err != nil { + return err + } + if !policy.SupportsAssetVersion(p.Asset.Version) { + return fmt.Errorf("unsupported input asset version %d", p.Asset.Version) + } + for _, witness := range p.Asset.PrevWitnesses { + if witness.SplitCommitment != nil && + (witness.PrevID == nil || + witness.SplitCommitment.RootAsset.ScriptKey.PubKey == nil) { + + return fmt.Errorf("split asset witness is incomplete") + } + } + return nil +} + +func verifyDisclosedAnchor(proofs []*proof.Proof) error { + var disclosed []*asset.Asset + altLeaves := make(map[[32]byte]asset.AltLeaf[asset.Asset]) + var expectedRoot chainhash.Hash + for idx, p := range proofs { + proven, err := p.VerifyProofs() + if err != nil { + return err + } + if err := policy.ValidateCommitmentVersion(proven.Version); err != nil { + return err + } + root := proven.TapscriptRoot(nil) + if idx == 0 { + expectedRoot = root + } else if root != expectedRoot { + return fmt.Errorf("disclosed assets disagree on their anchor root") + } + a := p.Asset.Copy() + if a.HasSplitCommitmentWitness() && + *a.PrevWitnesses[0].PrevID == asset.ZeroPrevID { + + a.PrevWitnesses[0].SplitCommitment = nil + } + disclosed = append(disclosed, a) + for _, alt := range p.AltLeaves { + key := alt.AssetCommitmentKey() + if previous, ok := altLeaves[key]; ok { + var old, next bytes.Buffer + if err := previous.EncodeAltLeaf(&old); err != nil { + return err + } + if err := alt.EncodeAltLeaf(&next); err != nil { + return err + } + if !bytes.Equal(old.Bytes(), next.Bytes()) { + return fmt.Errorf("conflicting disclosed alt leaves") + } + } else { + altLeaves[key] = alt.Copy() + } + } + } + version := policy.CommitmentVersion + rebuilt, err := commitment.FromAssets(&version, disclosed...) + if err != nil { + return err + } + leaves := make([]asset.AltLeaf[asset.Asset], 0, len(altLeaves)) + for _, leaf := range altLeaves { + leaves = append(leaves, leaf) + } + if err := rebuilt.MergeAltLeaves(leaves); err != nil { + return err + } + if rebuilt.TapscriptRoot(nil) == expectedRoot { + return nil + } + legacy, err := rebuilt.Downgrade() + if err != nil { + return err + } + if legacy.TapscriptRoot(nil) != expectedRoot { + return fmt.Errorf("input anchor contains undisclosed asset leaves") + } + return nil +} diff --git a/internal/assetproof/anchor_test.go b/internal/assetproof/anchor_test.go new file mode 100644 index 000000000..c98913ff3 --- /dev/null +++ b/internal/assetproof/anchor_test.go @@ -0,0 +1,95 @@ +package assetproof + +import ( + "slices" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/commitment" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/stretchr/testify/require" +) + +func inventoryProofs(t *testing.T, seed byte, count int) []*proof.Proof { + t.Helper() + genesis := asset.Genesis{ + FirstPrevOut: wire.OutPoint{Hash: chainhash.Hash{seed}, Index: 1}, + Tag: "inventory", Type: asset.Normal, + } + assets := make([]*asset.Asset, count) + for idx := range assets { + _, key := btcec.PrivKeyFromBytes([]byte{byte(idx + 1)}) + a, err := asset.New(genesis, 500, 0, 0, asset.NewScriptKey(key), + nil, asset.WithAssetVersion(asset.V1)) + require.NoError(t, err) + assets[idx] = a + } + version := commitment.TapCommitmentV2 + tree, err := commitment.FromAssets(&version, assets...) + require.NoError(t, err) + _, internal := btcec.PrivKeyFromBytes([]byte{20}) + root := tree.TapscriptRoot(nil) + key := txscript.ComputeTaprootOutputKey(internal, root[:]) + script, err := txscript.PayToTaprootScript(key) + require.NoError(t, err) + anchor := wire.NewMsgTx(2) + anchor.AddTxIn(wire.NewTxIn(&genesis.FirstPrevOut, nil, nil)) + anchor.AddTxOut(wire.NewTxOut(1_000, script)) + block := &wire.MsgBlock{Header: wire.BlockHeader{ + MerkleRoot: anchor.TxHash(), Nonce: uint32(seed), + }, Transactions: []*wire.MsgTx{anchor}} + blobs, err := proof.NewMintingBlobs(&proof.MintParams{ + Block: block, BlockHeight: 101, Tx: anchor, + TxIndex: 0, OutputIndex: 0, InternalKey: internal, + TaprootAssetRoot: tree, GenesisPoint: genesis.FirstPrevOut, + }, proof.MockVerifierCtx, + proof.WithGenOption(proof.WithVersion(proof.TransitionV1))) + require.NoError(t, err) + proofs := make([]*proof.Proof, count) + for idx, a := range assets { + proofs[idx] = blobs[asset.ToSerialized(a.ScriptKey.PubKey)] + require.NotNil(t, proofs[idx]) + } + return proofs +} + +// TestDisclosedInventory proves that a known passive asset can be preserved, +// while a hidden co-anchor cannot authorize a supposedly complete transfer. +func TestDisclosedInventory(t *testing.T) { + single := inventoryProofs(t, 1, 1) + require.NoError(t, VerifyCompleteAnchors(single)) + _, err := VerifyCompleteAnchor(single[0]) + require.NoError(t, err) + coanchored := inventoryProofs(t, 2, 2) + require.Error(t, VerifyCompleteAnchors(coanchored[:1])) + _, err = VerifyCompleteAnchor(coanchored[0]) + require.Error(t, err) + require.NoError(t, VerifyCompleteAnchors(coanchored)) + combined := slices.Concat(single, coanchored) + require.NoError(t, VerifyCompleteAnchors(combined)) + wrong := inventoryProofs(t, 3, 1) + require.Error(t, VerifyCompleteAnchors([]*proof.Proof{ + coanchored[0], wrong[0], + })) +} + +// TestMalformedInventory verifies rejection before commitment code can +// dereference omitted untrusted proof fields. +func TestMalformedInventory(t *testing.T) { + require.Error(t, VerifyCompleteAnchors(nil)) + malformedSplit := inventoryProofs(t, 1, 1)[0] + malformedSplit.Asset.PrevWitnesses = []asset.Witness{{ + SplitCommitment: &asset.SplitCommitment{}, + }} + for _, p := range []*proof.Proof{nil, {}, malformedSplit} { + require.NotPanics(t, func() { + _, err := VerifyCompleteAnchor(p) + require.Error(t, err) + require.Error(t, VerifyCompleteAnchors([]*proof.Proof{p})) + }) + } +} diff --git a/swapkit/asset_types.go b/swapkit/asset_types.go index aad7dee9c..a14fd54b6 100644 --- a/swapkit/asset_types.go +++ b/swapkit/asset_types.go @@ -8,3 +8,18 @@ var ( ErrInvalid = errors.New("invalid asset transaction") ErrPending = errors.New("asset transaction pending") ) + +// CreateDepositRequest binds idempotent deposit funding to wallet key locators. +// +//nolint:tagliatelle // Preserve the existing recovery and wire format. +type CreateDepositRequest struct { + RequestID [32]byte `json:"RequestID"` + GroupKey [33]byte `json:"GroupKey"` + Amount uint64 `json:"Amount"` + // #nosec G117 -- ClientKey is a public Bitcoin key. + ClientKey [33]byte `json:"ClientKey"` + ServerKey [33]byte `json:"ServerKey"` + CSV uint32 `json:"CSV"` + KeyFamily int32 `json:"KeyFamily"` + KeyIndex uint32 `json:"KeyIndex"` +} diff --git a/swapkit/tapsdk/asset.go b/swapkit/tapsdk/asset.go index 7cedffd98..4690da8c5 100644 --- a/swapkit/tapsdk/asset.go +++ b/swapkit/tapsdk/asset.go @@ -104,3 +104,35 @@ func OPTrueSpendInfo(output sdk.CustomAnchorAssetOutputSummary) ( } return spend, nil } + +// AssetInput supplies a proof-selected OP_TRUE asset to the native SDK. This +// binds the exact issuance, quantity, outpoint and script key before generic +// SDK proof verification; the FSM still checks the current chain and contract. +func AssetInput(output sdk.CustomAnchorAssetOutputSummary, + raw []byte) (sdk.CustomAssetInput, error) { + + spend, err := OPTrueSpendInfo(output) + if err != nil { + return sdk.CustomAssetInput{}, err + } + p, err := LastProof(raw) + if err != nil { + return sdk.CustomAssetInput{}, err + } + if sdk.AssetID(p.Asset.ID()) != output.IssuanceID || + p.Asset.Amount != output.Amount || + p.OutPoint() != SDKPoint(output.AnchorOutpoint) || + !bytes.Equal(p.Asset.ScriptKey.PubKey.SerializeCompressed()[1:], + output.ScriptKey[1:]) { + + return sdk.CustomAssetInput{}, swapkit.ErrInvalid + } + return sdk.CustomAssetInput{ + ID: output.LogicalOutputID, AssetRef: output.AssetRef, + Amount: output.Amount, ProofFile: bytes.Clone(raw), + Witness: sdk.CustomAssetWitnessPlan{ + Mode: sdk.CustomAssetWitnessCallerProvided, + Stack: spend.WitnessStack(), + }, + }, nil +} diff --git a/swapkit/tapsdk/binding_test.go b/swapkit/tapsdk/binding_test.go index 2bf489a73..aa01f0dd7 100644 --- a/swapkit/tapsdk/binding_test.go +++ b/swapkit/tapsdk/binding_test.go @@ -1,7 +1,206 @@ package tapsdk import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/commitment" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/stretchr/testify/require" ) +// bindingSDK models successful authenticated tapd proof verification. These +// tests exercise the Loop contract's additional policy and current-chain checks, +// which must reject even a cryptographically valid proof for another contract. +type bindingSDK struct { + sdk.Client + + valid bool +} + +func (s *bindingSDK) VerifyProof(_ context.Context, + raw []byte) (*sdk.VerifyProofResponse, error) { + + return fixtureVerified(raw, s.valid) +} + +type bindingChain struct { + Chain + + tip uint32 + blocks map[uint32]*wire.MsgBlock + unspent bool +} + +func (c *bindingChain) Height(context.Context) (uint32, error) { + return c.tip, nil +} + +func (c *bindingChain) Block(_ context.Context, + height uint32) (*wire.MsgBlock, error) { + + block := c.blocks[height] + if block == nil { + return nil, swapkit.ErrPending + } + return block, nil +} + +func (c *bindingChain) BlockHash(ctx context.Context, + height uint32) (chainhash.Hash, error) { + + block, err := c.Block(ctx, height) + if err != nil { + return chainhash.Hash{}, err + } + return block.BlockHash(), nil +} + +func (c *bindingChain) Unspent(context.Context, + wire.OutPoint) (bool, error) { + + return c.unspent, nil +} + +func bindingKey(scalar byte) *btcec.PrivateKey { + key, _ := btcec.PrivKeyFromBytes([]byte{scalar}) + return key +} + +func bindingPub(scalar byte) [33]byte { + var key [33]byte + copy(key[:], bindingKey(scalar).PubKey().SerializeCompressed()) + return key +} + +// bindingProof constructs a real isolated Taproot commitment; an optional +// second asset deliberately makes reconstruction from the disclosed one fail. +func bindingProof(t *testing.T, genesis asset.Genesis, script, + internal *btcec.PublicKey, sibling *commitment.TapscriptPreimage, + previous wire.OutPoint, height uint32, hidden bool) *proof.Proof { + + t.Helper() + group := &asset.GroupKey{GroupPubKey: *bindingKey(11).PubKey()} + a, err := asset.New(genesis, 1000, 0, 0, + asset.NewScriptKey(script), group, + asset.WithAssetVersion(asset.V1)) + require.NoError(t, err) + assets := []*asset.Asset{a} + if hidden { + otherGenesis := genesis + otherGenesis.Tag += "-hidden" + other, err := asset.New(otherGenesis, 1, 0, 0, + asset.NewScriptKey(bindingKey(9).PubKey()), nil, + asset.WithAssetVersion(asset.V1)) + require.NoError(t, err) + assets = append(assets, other) + } + version := commitment.TapCommitmentV2 + tree, err := commitment.FromAssets(&version, assets...) + require.NoError(t, err) + _, inclusion, err := tree.Proof(a.TapCommitmentKey(), + a.AssetCommitmentKey()) + require.NoError(t, err) + pkScript, _, _, err := tapsend.AnchorOutputScript( + internal, sibling, tree, + ) + require.NoError(t, err) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&previous, nil, nil)) + tx.AddTxOut(wire.NewTxOut(10_000, pkScript)) + return &proof.Proof{ + PrevOut: previous, AnchorTx: *tx, Asset: *a, + BlockHeight: height, + BlockHeader: wire.BlockHeader{MerkleRoot: tx.TxHash()}, + InclusionProof: proof.TaprootProof{ + InternalKey: internal, OutputIndex: 0, + CommitmentProof: &proof.CommitmentProof{ + Proof: *inclusion, TapSiblingPreimage: sibling, + }, + }, + GenesisReveal: &genesis, + } +} + +func bindingGenesis() asset.Genesis { + return asset.Genesis{ + FirstPrevOut: wire.OutPoint{Hash: chainhash.Hash{7}}, + Tag: "binding-test", Type: asset.Normal, + } +} + +func bindingRaw(t *testing.T, p *proof.Proof) []byte { + t.Helper() + raw, err := proof.EncodeAsProofFile(p) + require.NoError(t, err) + return raw +} + +func bindingBlock(p *proof.Proof) *wire.MsgBlock { + return &wire.MsgBlock{ + Header: p.BlockHeader, Transactions: []*wire.MsgTx{&p.AnchorTx}, + } +} + +// proofNode holds only test services. Tests call production primitives directly. +type proofNode struct { + client sdk.Client + chain Chain + minConfs uint32 +} + +func (n *proofNode) wallet() *sdk.Wallet { + return sdk.NewWallet(n.client, sdk.NetworkRegtest) +} + +func (s *bindingSDK) GetInfo( + context.Context, +) (*sdk.Info, error) { + + return fixtureNodeInfo(), nil +} + func fixtureNodeInfo() *sdk.Info { return &sdk.Info{Version: "0.8.8", Network: "regtest"} } + +func fixtureVerified(raw []byte, valid bool) (*sdk.VerifyProofResponse, error) { + p, err := LastProof(raw) + if err != nil { + return nil, err + } + key, err := sdk.ParsePubKey( + p.Asset.ScriptKey.PubKey.SerializeCompressed(), + ) + if err != nil { + return nil, err + } + var group *sdk.PubKey + if p.Asset.GroupKey != nil { + k, err := sdk.ParsePubKey( + p.Asset.GroupKey.GroupPubKey.SerializeCompressed(), + ) + if err != nil { + return nil, err + } + group = &k + } + return &sdk.VerifyProofResponse{ + Valid: valid, + DecodedProof: &sdk.DecodedProof{ + AssetRef: sdk.AssetRefFromTypedAsset( + sdk.AssetID(p.Asset.ID()), + group, + sdk.AssetType(p.Asset.Type), + ), + IssuanceID: sdk.AssetID(p.Asset.ID()), + Amount: p.Asset.Amount, ScriptKey: key, + Outpoint: sdk.Outpoint{Txid: sdk.Hash(p.OutPoint().Hash), Index: p.OutPoint().Index}, + }, + }, nil +} diff --git a/swapkit/tapsdk/bindings.go b/swapkit/tapsdk/bindings.go new file mode 100644 index 000000000..b2577b7be --- /dev/null +++ b/swapkit/tapsdk/bindings.go @@ -0,0 +1,314 @@ +package tapsdk + +import ( + "bytes" + "context" + "fmt" + "math" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + assetpolicy "github.com/lightninglabs/loop/internal/assetpolicy" + "github.com/lightninglabs/loop/internal/assetproof" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/proof" +) + +// InputBinding contains independently agreed terms for one exact issuance and +// anchor. A nil GroupKey requires an ungrouped asset. Anchor and OPTrue describe +// contract inputs; Spend optionally fixes a unilateral Bitcoin path. These are +// operation-local policy inputs, not another transfer or persistence format. +type InputBinding struct { + ID string + IssuanceID sdk.AssetID + GroupKey *sdk.PubKey + Amount uint64 + ScriptKey sdk.PubKey + AnchorOutpoint sdk.Outpoint + Anchor *swapkit.AnchorPlan + OPTrue *sdk.CustomAssetOPTrueSpendInfo + Spend *swapkit.SwapAnchorSpendInfo +} + +func (b InputBinding) clone() InputBinding { + if b.GroupKey != nil { + b.GroupKey = new(*b.GroupKey) + } + if b.Anchor != nil { + b.Anchor = cloneAnchor(*b.Anchor) + } + b.OPTrue = b.OPTrue.Clone() + if b.Spend != nil { + spend := *b.Spend + spend.PkScript = bytes.Clone(spend.PkScript) + spend.LeafScript = bytes.Clone(spend.LeafScript) + spend.ControlBlock = bytes.Clone(spend.ControlBlock) + b.Spend = &spend + } + return b +} + +func cloneAnchor(a swapkit.AnchorPlan) *swapkit.AnchorPlan { + leaves := make([][]byte, len(a.Leaves)) + for i := range a.Leaves { + leaves[i] = bytes.Clone(a.Leaves[i]) + } + a.Leaves = leaves + return &a +} + +func (b InputBinding) validate(p *proof.Proof) error { + if p == nil || b.Amount > math.MaxInt64 { + return swapkit.ErrInvalid + } + if err := p.Asset.Validate(); err != nil { + return err + } + if b.ID == "" || b.IssuanceID == (sdk.AssetID{}) || b.Amount == 0 || + sdk.AssetID(p.Asset.ID()) != b.IssuanceID || + p.Asset.Amount != b.Amount || + p.OutPoint() != SDKPoint(b.AnchorOutpoint) || + !bytes.Equal(p.Asset.ScriptKey.PubKey.SerializeCompressed()[1:], b.ScriptKey[1:]) { + + return fmt.Errorf( + "%w: asset differs from agreed input", + swapkit.ErrInvalid, + ) + } + if _, err := btcec.ParsePubKey(b.ScriptKey[:]); err != nil { + return err + } + group := p.Asset.GroupKey + if (group == nil) != (b.GroupKey == nil) || (group != nil && + !sameGroupKeys(group.GroupPubKey.SerializeCompressed(), b.GroupKey[:])) { + + return fmt.Errorf( + "%w: asset differs from agreed group", + swapkit.ErrInvalid, + ) + } + if p.Asset.ScriptVersion != asset.ScriptV0 || p.Asset.LockTime != 0 || + p.Asset.RelativeLockTime != 0 { + + return fmt.Errorf( + "%w: contract asset has unsupported timelocks", + swapkit.ErrInvalid, + ) + } + if b.Anchor == nil { + if b.Spend != nil || b.OPTrue != nil { + return fmt.Errorf( + "wallet input cannot select a contract spend", + ) + } + return nil + } + if _, err := OPTrueSpendInfo(sdk.CustomAnchorAssetOutputSummary{ + ScriptKey: b.ScriptKey, ScriptMode: sdk.CustomAssetScriptExternal, OPTrueSpend: b.OPTrue, + }); err != nil { + return err + } + assets, root, err := proofRoots(p) + if err != nil { + return err + } + script, err := anchorScript(*b.Anchor, assets) + if err != nil { + return err + } + point := p.OutPoint() + if point.Index >= uint32(len(p.AnchorTx.TxOut)) || + p.AnchorTx.TxOut[point.Index] == nil || !bytes.Equal(script, p.AnchorTx.TxOut[point.Index].PkScript) { + + return fmt.Errorf( + "%w: input anchor differs from contract", + swapkit.ErrInvalid, + ) + } + if b.Spend != nil && + (!bytes.Equal(b.Spend.PkScript, script) || sdk.Hash(b.Spend.TaprootMerkleRoot) != root) { + + return fmt.Errorf( + "%w: spend differs from contract root", + swapkit.ErrInvalid, + ) + } + return nil +} + +func anchorScript(a swapkit.AnchorPlan, assets sdk.Hash) ([]byte, error) { + internal, err := btcec.ParsePubKey(a.InternalKey[:]) + if err != nil { + return nil, err + } + root := chainhash.Hash(assets) + if len(a.Leaves) > 0 { + leaves := make([]txscript.TapLeaf, len(a.Leaves)) + for i, script := range a.Leaves { + if len(script) == 0 { + return nil, fmt.Errorf("empty contract leaf") + } + leaves[i] = txscript.NewBaseTapLeaf(script) + } + sibling := txscript.AssembleTaprootScriptTree( + leaves...).RootNode.TapHash() + root = asset.TapBranchHash(chainhash.Hash(assets), sibling) + } + return txscript.PayToTaprootScript( + txscript.ComputeTaprootOutputKey(internal, root[:]), + ) +} + +// IsolatedAssetRoot checks the exact input binding and requires the disclosed +// asset to open the entire anchor. It returns the asset commitment root for +// constructing a contract witness. The caller verifies history and chain state. +func (b InputBinding) IsolatedAssetRoot(p *proof.Proof) (sdk.Hash, error) { + if err := b.validate(p); err != nil { + return sdk.Hash{}, err + } + assets, _, err := isolatedRoots(p) + return assets, err +} + +// InspectInputProof checks exact contract terms and current-chain policy before +// native SDK Build verifies the full lineage. It does not authorize a spend. +func InspectInputProof( + ctx context.Context, + wallet *sdk.Wallet, + chain Chain, + minConfs uint32, + raw []byte, + binding InputBinding, + unspent bool, +) (*proof.Proof, error) { + + if err := assetpolicy.CheckProofFileSize(raw); err != nil { + return nil, err + } + raw = bytes.Clone(raw) + binding = binding.clone() + if err := checkProvenance(ctx, wallet, raw); err != nil { + return nil, err + } + p, err := LastProof(raw) + if err != nil { + return nil, err + } + if _, err := binding.IsolatedAssetRoot(p); err != nil { + return nil, err + } + if err := checkConfirmation(ctx, chain, minConfs, p, unspent); err != nil { + return nil, err + } + return p, nil +} + +// VerifyInputProof validates negotiated input policy and asks authenticated +// tapd to verify lineage exactly once, bracketed by current-chain checks. +func VerifyInputProof( + ctx context.Context, + wallet *sdk.Wallet, + chain Chain, + minConfs uint32, + raw []byte, + binding InputBinding, + unspent bool, +) (*proof.Proof, error) { + + if err := assetpolicy.CheckProofFileSize(raw); err != nil { + return nil, err + } + raw = bytes.Clone(raw) + p, err := InspectInputProof( + ctx, + wallet, + chain, + minConfs, + raw, + binding, + unspent, + ) + if err != nil { + return nil, err + } + result, err := wallet.Client().VerifyProof(ctx, bytes.Clone(raw)) + if err != nil { + return nil, err + } + if err := checkVerifiedSummary(result, p); err != nil { + return nil, err + } + if err := checkConfirmation(ctx, chain, minConfs, p, unspent); err != nil { + return nil, err + } + return p, nil +} + +// InspectInputProofs checks all active and passive bindings as a single anchor +// inventory. Unlike the single-input helper, it accepts disclosed co-anchored +// assets when their union opens the complete commitment. Include every active +// and passive proof and binding; omitted assets fail closed before SDK Build. +func InspectInputProofs( + ctx context.Context, + wallet *sdk.Wallet, + chain Chain, + minConfs uint32, + raws [][]byte, + bindings []InputBinding, + unspent bool, +) ([]*proof.Proof, error) { + + if len(raws) == 0 || len(raws) != len(bindings) { + return nil, swapkit.ErrInvalid + } + var total int + for _, raw := range raws { + if len(raw) > assetpolicy.MaxProvenanceBytes-total { + return nil, fmt.Errorf( + "proof inventory exceeds size budget", + ) + } + total += len(raw) + } + snapshots := make([]InputBinding, len(bindings)) + copied := make([][]byte, len(raws)) + for i, binding := range bindings { + snapshots[i] = binding.clone() + copied[i] = bytes.Clone(raws[i]) + } + bindings, raws = snapshots, copied + params, err := nodeParams(ctx, wallet) + if err != nil { + return nil, err + } + proofs := make([]*proof.Proof, len(raws)) + ids := make(map[string]bool) + for i, raw := range raws { + binding := bindings[i].clone() + if ids[binding.ID] { + return nil, fmt.Errorf("duplicate input binding") + } + ids[binding.ID] = true + if err := checkProvenanceWithParams(ctx, raw, params); err != nil { + return nil, err + } + p, err := LastProof(raw) + if err != nil { + return nil, err + } + if err := binding.validate(p); err != nil { + return nil, err + } + if err := checkConfirmation(ctx, chain, minConfs, p, unspent); err != nil { + return nil, err + } + proofs[i] = p + } + if err := assetproof.VerifyCompleteAnchors(proofs); err != nil { + return nil, err + } + return proofs, nil +} diff --git a/swapkit/tapsdk/chain.go b/swapkit/tapsdk/chain.go new file mode 100644 index 000000000..80dd5ddad --- /dev/null +++ b/swapkit/tapsdk/chain.go @@ -0,0 +1,173 @@ +package tapsdk + +import ( + "context" + "math" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/swapkit" +) + +// Chain uses the daemon's chain services. Unspent includes mempool spends; +// notifications may be restored from their original height hints after restart. +type Chain interface { + Height(context.Context) (uint32, error) + BlockHash(context.Context, uint32) (chainhash.Hash, error) + Unspent(context.Context, wire.OutPoint) (bool, error) + Block(context.Context, uint32) (*wire.MsgBlock, error) + Spend(context.Context, wire.OutPoint, []byte, uint32) ( + *wire.MsgBlock, uint32, error) +} + +// LNDChain reuses the normal daemon's authenticated LND services. The UTXO +// callback must query its Bitcoin backend with mempool lookup enabled: wallet +// ListUnspent does not cover every externally controlled asset anchor. Chain +// observations are read afresh on each call; the adapter keeps no cached state. +type LNDChain struct { + Kit lndclient.ChainKitClient + Notifier lndclient.ChainNotifierClient + UnspentOutput func(context.Context, wire.OutPoint) (bool, error) + // PrevOutput supplies authenticated full prevouts for fee review and can + // also back Unspent when no separate existence callback is configured. + PrevOutput func(context.Context, wire.OutPoint) (*wire.TxOut, error) +} + +// Height returns LND's current best-chain height. +func (c *LNDChain) Height(ctx context.Context) (uint32, error) { + if c == nil || c.Kit == nil { + return 0, swapkit.ErrInvalid + } + _, height, err := c.Kit.GetBestBlock(ctx) + if err != nil { + return 0, err + } + if height < 0 { + return 0, swapkit.ErrInvalid + } + return uint32(height), nil +} + +// BlockHash reads the block at a height from the current best chain without +// downloading its transactions. Proofs carry their own transaction merkle path. +func (c *LNDChain) BlockHash(ctx context.Context, + height uint32) (chainhash.Hash, error) { + + if c == nil || c.Kit == nil { + return chainhash.Hash{}, swapkit.ErrInvalid + } + return c.Kit.GetBlockHash(ctx, int64(height)) +} + +// Unspent checks the current chain and mempool through the configured backend. +func (c *LNDChain) Unspent(ctx context.Context, + point wire.OutPoint) (bool, error) { + + if c == nil { + return false, swapkit.ErrInvalid + } + if c.UnspentOutput != nil { + return c.UnspentOutput(ctx, point) + } + output, err := c.Prevout(ctx, point) + return output != nil, err +} + +// Prevout supplies the full current Bitcoin output for native funding review. +func (c *LNDChain) Prevout( + ctx context.Context, + point wire.OutPoint, +) (*wire.TxOut, error) { + + if c == nil || c.PrevOutput == nil { + return nil, swapkit.ErrInvalid + } + return c.PrevOutput(ctx, point) +} + +// Block obtains a block from the current best chain, checking its identity. +func (c *LNDChain) Block(ctx context.Context, + height uint32) (*wire.MsgBlock, error) { + + if c == nil || c.Kit == nil { + return nil, swapkit.ErrInvalid + } + hash, err := c.BlockHash(ctx, height) + if err != nil { + return nil, err + } + block, err := c.Kit.GetBlock(ctx, hash) + if err != nil { + return nil, err + } + if block == nil || block.BlockHash() != hash { + return nil, swapkit.ErrInvalid + } + return block, nil +} + +// Spend restores a spend watch from the persisted outpoint and height hint. +// The returned transaction is checked against the current best-chain block; +// a stale notifier response never becomes confirmation evidence. +func (c *LNDChain) Spend(ctx context.Context, point wire.OutPoint, + pkScript []byte, hint uint32) (*wire.MsgBlock, uint32, error) { + + if c == nil || c.Kit == nil || c.Notifier == nil || + hint > math.MaxInt32 || len(pkScript) == 0 { + + return nil, 0, swapkit.ErrInvalid + } + probe, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + spent, failed, err := c.Notifier.RegisterSpendNtfn( + probe, &point, pkScript, int32(hint), + ) + if err != nil { + return nil, 0, err + } + select { + case detail, ok := <-spent: + if !ok || detail == nil || detail.SpendingTx == nil || + detail.SpendingHeight < 0 { + + return nil, 0, swapkit.ErrInvalid + } + height := uint32(detail.SpendingHeight) + block, err := c.Block(ctx, height) + if err != nil { + return nil, 0, err + } + transaction := spendingTransaction(block, point) + if transaction == nil || + transaction.TxHash() != detail.SpendingTx.TxHash() { + + return nil, 0, swapkit.ErrPending + } + return block, height, nil + case err := <-failed: + if err == nil { + err = swapkit.ErrPending + } + return nil, 0, err + case <-probe.Done(): + if ctx.Err() != nil { + return nil, 0, ctx.Err() + } + return nil, 0, swapkit.ErrPending + } +} + +func spendingTransaction(block *wire.MsgBlock, + point wire.OutPoint) *wire.MsgTx { + + for _, tx := range block.Transactions { + for _, input := range tx.TxIn { + if input.PreviousOutPoint == point { + return tx + } + } + } + return nil +} diff --git a/swapkit/tapsdk/chain_guard_test.go b/swapkit/tapsdk/chain_guard_test.go new file mode 100644 index 000000000..75652f62e --- /dev/null +++ b/swapkit/tapsdk/chain_guard_test.go @@ -0,0 +1,97 @@ +package tapsdk + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" + "github.com/stretchr/testify/require" +) + +type currentChainKit struct { + *testChainKit + + height int32 +} + +func (k *currentChainKit) GetBestBlock(context.Context) ( + chainhash.Hash, int32, error) { + + return k.hash, k.height, nil +} + +func TestLNDChainReadsCurrentBackend(t *testing.T) { + chain, _, point := chainFixture() + kit := ¤tChainKit{ + testChainKit: chain.Kit.(*testChainKit), height: 200, + } + chain.Kit = kit + for _, height := range []int32{200, 201} { + kit.height = height + actual, err := chain.Height(t.Context()) + require.NoError(t, err) + require.EqualValues(t, height, actual) + } + block, err := chain.Block(t.Context(), 200) + require.NoError(t, err) + previousHash := block.BlockHash() + kit.block.Header.Nonce++ + kit.hash = kit.block.BlockHash() + block, err = chain.Block(t.Context(), 200) + require.NoError(t, err) + require.NotEqual(t, previousHash, block.BlockHash()) + + for _, available := range []bool{true, false} { + chain.UnspentOutput = func(_ context.Context, + actual wire.OutPoint) (bool, error) { + + require.Equal(t, point, actual) + return available, nil + } + chain.PrevOutput = func(_ context.Context, + actual wire.OutPoint) (*wire.TxOut, error) { + + require.Equal(t, point, actual) + if !available { + return nil, nil + } + return wire.NewTxOut(500, []byte{0x51}), nil + } + unspent, err := chain.Unspent(t.Context(), point) + require.NoError(t, err) + require.Equal(t, available, unspent) + // A full mempool-aware prevout lookup is enough to configure both + // current funding authentication and current spendability. + chain.UnspentOutput = nil + unspent, err = chain.Unspent(t.Context(), point) + require.NoError(t, err) + require.Equal(t, available, unspent) + previous, err := chain.Prevout(t.Context(), point) + require.NoError(t, err) + if available { + require.Equal(t, int64(500), previous.Value) + } else { + require.Nil(t, previous) + } + } +} + +func TestLNDChainRejectsMissingServices(t *testing.T) { + var missing *LNDChain + for _, chain := range []*LNDChain{missing, {}} { + _, err := chain.Height(t.Context()) + require.ErrorIs(t, err, swapkit.ErrInvalid) + _, err = chain.Block(t.Context(), 1) + require.ErrorIs(t, err, swapkit.ErrInvalid) + _, err = chain.BlockHash(t.Context(), 1) + require.ErrorIs(t, err, swapkit.ErrInvalid) + _, err = chain.Unspent(t.Context(), wire.OutPoint{}) + require.ErrorIs(t, err, swapkit.ErrInvalid) + _, err = chain.Prevout(t.Context(), wire.OutPoint{}) + require.ErrorIs(t, err, swapkit.ErrInvalid) + _, _, err = chain.Spend(t.Context(), wire.OutPoint{}, []byte{0x51}, 1) + require.ErrorIs(t, err, swapkit.ErrInvalid) + } +} diff --git a/swapkit/tapsdk/chain_test.go b/swapkit/tapsdk/chain_test.go new file mode 100644 index 000000000..ecaaf3ab2 --- /dev/null +++ b/swapkit/tapsdk/chain_test.go @@ -0,0 +1,147 @@ +package tapsdk + +import ( + "context" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/swapkit" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/stretchr/testify/require" +) + +type testChainKit struct { + lndclient.ChainKitClient + + block *wire.MsgBlock + hash chainhash.Hash +} + +func (k *testChainKit) GetBlockHash(context.Context, int64) ( + chainhash.Hash, error) { + + return k.hash, nil +} + +func (k *testChainKit) GetBlock(context.Context, chainhash.Hash) ( + *wire.MsgBlock, error) { + + return k.block, nil +} + +type testChainNotifier struct { + lndclient.ChainNotifierClient + + spend *chainntnfs.SpendDetail + point wire.OutPoint + script []byte + hint int32 + entered chan struct{} +} + +func (n *testChainNotifier) RegisterSpendNtfn(_ context.Context, + point *wire.OutPoint, script []byte, hint int32, + _ ...lndclient.NotifierOption) (chan *chainntnfs.SpendDetail, + chan error, error) { + + n.point, n.script, n.hint = *point, script, hint + spends := make(chan *chainntnfs.SpendDetail, 1) + if n.spend != nil { + spends <- n.spend + } + if n.entered != nil { + close(n.entered) + } + return spends, make(chan error), nil +} + +func chainFixture() (*LNDChain, *testChainNotifier, wire.OutPoint) { + point := wire.OutPoint{Hash: chainhash.Hash{7}, Index: 1} + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&point, nil, nil)) + tx.AddTxOut(wire.NewTxOut(500, []byte{0x51})) + block := &wire.MsgBlock{ + Header: wire.BlockHeader{MerkleRoot: tx.TxHash()}, + Transactions: []*wire.MsgTx{tx}, + } + n := &testChainNotifier{spend: &chainntnfs.SpendDetail{ + SpendingTx: tx, SpendingHeight: 200, + }} + return &LNDChain{ + Kit: &testChainKit{block: block, hash: block.BlockHash()}, + Notifier: n, + }, n, point +} + +// TestSpendRecoveryUsesNativeNotifier restores the watch from its durable +// outpoint/script/height, without a second journal or scanning block history. +func TestSpendRecoveryUsesNativeNotifier(t *testing.T) { + chain, notifier, point := chainFixture() + for range 2 { + block, height, err := chain.Spend( + t.Context(), point, []byte{0x51}, 150, + ) + require.NoError(t, err) + require.EqualValues(t, 200, height) + require.Equal(t, notifier.spend.SpendingTx, + spendingTransaction(block, point)) + require.Equal(t, point, notifier.point) + require.Equal(t, []byte{0x51}, notifier.script) + require.EqualValues(t, 150, notifier.hint) + // Recreate the adapter to model the normal daemon's restart. + chain = &LNDChain{Kit: chain.Kit, Notifier: notifier} + } +} + +func TestSpendRecoveryRejectsStaleChainEvidence(t *testing.T) { + chain, _, point := chainFixture() + kit := chain.Kit.(*testChainKit) + kit.block.Transactions = nil + _, _, err := chain.Spend(t.Context(), point, []byte{0x51}, 150) + require.ErrorIs(t, err, swapkit.ErrPending) + + kit.hash[0] ^= 1 + _, _, err = chain.Spend(t.Context(), point, []byte{0x51}, 150) + require.ErrorIs(t, err, swapkit.ErrInvalid) +} + +func TestSpendRecoveryHonorsShutdown(t *testing.T) { + chain, notifier, point := chainFixture() + notifier.spend, notifier.entered = nil, make(chan struct{}) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + _, _, err := chain.Spend(ctx, point, []byte{0x51}, 150) + done <- err + }() + <-notifier.entered + cancel() + select { + case err := <-done: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("spend recovery did not stop with its manager") + } +} + +// TestUnspentRequiresMempoolGate prevents wallet inventory or a confirmation +// notification being substituted for the current Bitcoin UTXO observation. +func TestUnspentRequiresMempoolGate(t *testing.T) { + chain, _, point := chainFixture() + _, err := chain.Unspent(t.Context(), point) + require.ErrorIs(t, err, swapkit.ErrInvalid) + for _, available := range []bool{true, false} { + chain.UnspentOutput = func(_ context.Context, + actual wire.OutPoint) (bool, error) { + + require.Equal(t, point, actual) + return available, nil + } + found, err := chain.Unspent(t.Context(), point) + require.NoError(t, err) + require.Equal(t, available, found) + } +} diff --git a/swapkit/tapsdk/confirmation_chain_test.go b/swapkit/tapsdk/confirmation_chain_test.go new file mode 100644 index 000000000..3e47dc5b2 --- /dev/null +++ b/swapkit/tapsdk/confirmation_chain_test.go @@ -0,0 +1,79 @@ +package tapsdk + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/stretchr/testify/require" +) + +type headerOnlyChain struct { + *bindingChain + + blockReads int + hashReads int +} + +func (c *headerOnlyChain) Block(context.Context, + uint32) (*wire.MsgBlock, error) { + + c.blockReads++ + return nil, swapkit.ErrInvalid +} + +func (c *headerOnlyChain) BlockHash(ctx context.Context, + height uint32) (chainhash.Hash, error) { + + c.hashReads++ + return c.bindingChain.BlockHash(ctx, height) +} + +func TestConfirmationUsesCurrentHashAndMerkleProof(t *testing.T) { + for _, test := range []struct { + name string + change func(*proof.Proof, *bindingChain) + want error + }{ + {name: testValid}, + {name: "reorganized", want: swapkit.ErrPending, + change: func(_ *proof.Proof, c *bindingChain) { + c.blocks[100].Header.Nonce++ + }}, + {name: "wrong merkle path", want: swapkit.ErrInvalid, + change: func(p *proof.Proof, _ *bindingChain) { + p.TxMerkleProof.Nodes = []chainhash.Hash{{1}} + p.TxMerkleProof.Bits = []bool{true} + }}, + {name: "incomplete merkle path", want: swapkit.ErrInvalid, + change: func(p *proof.Proof, _ *bindingChain) { + p.TxMerkleProof.Nodes = []chainhash.Hash{{1}} + }}, + } { + t.Run(test.name, func(t *testing.T) { + p := bindingProof(t, bindingGenesis(), bindingKey(2).PubKey(), + bindingKey(3).PubKey(), nil, bindingGenesis().FirstPrevOut, + 100, false) + chain := &headerOnlyChain{bindingChain: &bindingChain{ + tip: 101, unspent: true, + blocks: map[uint32]*wire.MsgBlock{100: bindingBlock(p)}, + }} + if test.change != nil { + test.change(p, chain.bindingChain) + } + for range 2 { + err := checkConfirmation(t.Context(), chain, 2, p, true) + if test.want == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.want) + } + } + require.Equal(t, 2, chain.hashReads) + require.Zero(t, chain.blockReads) + }) + } +} diff --git a/swapkit/tapsdk/deposit.go b/swapkit/tapsdk/deposit.go new file mode 100644 index 000000000..bdc4c0c7d --- /dev/null +++ b/swapkit/tapsdk/deposit.go @@ -0,0 +1,83 @@ +package tapsdk + +import ( + "bytes" + "context" + "errors" + + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" +) + +// FundingProof selects the exact confirmed wallet output for a deposit. +// It inspects Loop policy; the subsequent SDK Build verifies lineage once. +// ErrInvalid means the wallet holds no matching unspent output. A matching +// output that only waits on the chain returns ErrPending for outstanding +// confirmations, or the chain backend's own error, so the caller can retry. +func FundingProof(ctx context.Context, wallet *sdk.Wallet, + chain Chain, minConfs uint32, + request swapkit.CreateDepositRequest) ([]byte, error) { + + if err := CheckNodeVersion(ctx, wallet); err != nil { + return nil, err + } + ref := sdk.AssetRefFromGroupKey(request.GroupKey) + assets, err := wallet.Client().ListAssetRecords(ctx, + &sdk.ListAssetsRequest{AssetRef: &ref}) + if err != nil { + return nil, err + } + // waiting is the first chain-state failure of an exact candidate. It is + // reported only when no candidate is ready. + var waiting error + for _, row := range assets { + if row == nil || row.Amount != request.Amount || + row.AnchorOutpoint == nil { + + continue + } + file, err := wallet.ExportProofFile(ctx, + sdk.AssetRefFromAssetID(row.Genesis.IssuanceID), + row.ScriptKey.PubKey, row.AnchorOutpoint) + if err != nil || file == nil { + continue + } + if err := checkProvenance(ctx, wallet, file.RawProofFile); err != nil { + continue + } + p, err := inspectAsset(file.RawProofFile, request.GroupKey, + request.Amount) + if err != nil { + continue + } + scriptKey := p.Asset.ScriptKey.PubKey.SerializeCompressed() + if p.OutPoint() != SDKPoint(*row.AnchorOutpoint) || + sdk.AssetID(p.Asset.ID()) != row.Genesis.IssuanceID || + !bytes.Equal(scriptKey, row.ScriptKey.PubKey[:]) { + + continue + } + // This is the exact wallet output. An outstanding confirmation or + // a chain backend failure does not make the request invalid, + // while a spent output can never fund the deposit. + err = checkConfirmation(ctx, chain, minConfs, p, false) + if err == nil { + var unspent bool + unspent, err = chain.Unspent(ctx, p.OutPoint()) + if err == nil && !unspent { + continue + } + } + if err != nil { + if waiting == nil && !errors.Is(err, swapkit.ErrInvalid) { + waiting = err + } + continue + } + return file.RawProofFile, nil + } + if waiting != nil { + return nil, waiting + } + return nil, swapkit.ErrInvalid +} diff --git a/swapkit/tapsdk/funding.go b/swapkit/tapsdk/funding.go new file mode 100644 index 000000000..8f79a4fea --- /dev/null +++ b/swapkit/tapsdk/funding.go @@ -0,0 +1,64 @@ +package tapsdk + +import ( + "bytes" + "context" + "fmt" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" +) + +// PrevoutSource supplies current, authenticated Bitcoin funding outputs. Its +// lookup includes mempool spends and returns nil for a spent/unknown output. +// Wallet-only ListUnspent cannot authenticate external contract anchors. +type PrevoutSource interface { + Prevout(context.Context, wire.OutPoint) (*wire.TxOut, error) +} + +// CheckFundingInputs binds every native PSBT WitnessUtxo to its current Bitcoin +// backend output. Use plan.AnchorPSBT after Build and repeat before Commit, or +// check an already populated request.AnchorPSBT. This authenticates values and +// scripts in addition to Chain.Unspent's existence check. Compact children use +// ApprovedParents for their not-yet-broadcast parent outputs instead. +func CheckFundingInputs(ctx context.Context, source PrevoutSource, + anchorPSBT []byte) error { + + if source == nil { + return fmt.Errorf("authenticated Bitcoin prevouts are required") + } + packet, err := DecodePSBT(anchorPSBT) + if err != nil { + return err + } + seen := make(map[wire.OutPoint]bool) + for i, input := range packet.UnsignedTx.TxIn { + if input == nil || i >= len(packet.Inputs) || + packet.Inputs[i].WitnessUtxo == nil { + + return swapkit.ErrInvalid + } + point := input.PreviousOutPoint + if seen[point] { + return fmt.Errorf("duplicate Bitcoin funding input") + } + seen[point] = true + actual, err := source.Prevout(ctx, point) + if err != nil { + return err + } + if actual == nil { + return swapkit.ErrPending + } + expected := packet.Inputs[i].WitnessUtxo + if actual.Value < 0 || actual.Value != expected.Value || + !bytes.Equal(actual.PkScript, expected.PkScript) { + + return fmt.Errorf( + "%w: Bitcoin prevout differs from authenticated backend", + swapkit.ErrInvalid, + ) + } + } + return nil +} diff --git a/swapkit/tapsdk/funding_test.go b/swapkit/tapsdk/funding_test.go new file mode 100644 index 000000000..96fe92429 --- /dev/null +++ b/swapkit/tapsdk/funding_test.go @@ -0,0 +1,210 @@ +package tapsdk + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/stretchr/testify/require" +) + +type fundingSDK struct { + *bindingSDK + + rows []*sdk.AssetRecord + proofs map[sdk.Outpoint][]byte + exports []sdk.Outpoint +} + +func (s *fundingSDK) ListAssetRecords(context.Context, + *sdk.ListAssetsRequest) ([]*sdk.AssetRecord, error) { + + return s.rows, nil +} + +func (s *fundingSDK) ExportProof(_ context.Context, _ sdk.AssetRef, + _ sdk.PubKey, point *sdk.Outpoint) (*sdk.ProofFile, error) { + + if point == nil { + return nil, proof.ErrMultipleProofs + } + s.exports = append(s.exports, *point) + return &sdk.ProofFile{RawProofFile: s.proofs[*point]}, nil +} + +type fundingChain struct { + *bindingChain + + spent wire.OutPoint + heightErr error + unspentErr error +} + +func (c *fundingChain) Height(ctx context.Context) (uint32, error) { + if c.heightErr != nil { + return 0, c.heightErr + } + return c.bindingChain.Height(ctx) +} + +func (c *fundingChain) Unspent(_ context.Context, + point wire.OutPoint) (bool, error) { + + if c.unspentErr != nil { + return false, c.unspentErr + } + return point != c.spent, nil +} + +func TestFundingUsesExactOutpointWithReusedScriptKey(t *testing.T) { + first := bindingProof(t, bindingGenesis(), bindingKey(2).PubKey(), + bindingKey(3).PubKey(), nil, bindingGenesis().FirstPrevOut, + 100, false) + second := bindingProof(t, bindingGenesis(), bindingKey(2).PubKey(), + bindingKey(3).PubKey(), nil, + wire.OutPoint{Hash: chainhash.Hash{12}}, 101, false) + client := &fundingSDK{bindingSDK: &bindingSDK{valid: true}, + proofs: make(map[sdk.Outpoint][]byte)} + for _, p := range []*proof.Proof{first, second} { + point := sdk.Outpoint{Txid: sdk.Hash(p.OutPoint().Hash), + Index: p.OutPoint().Index} + client.rows = append(client.rows, &sdk.AssetRecord{ + Amount: 1000, AnchorOutpoint: &point, + Genesis: sdk.IssuanceGenesis{ + IssuanceID: sdk.AssetID(p.Asset.ID()), + }, + ScriptKey: sdk.ScriptKey{PubKey: bindingPub(2)}, + }) + client.proofs[point] = bindingRaw(t, p) + } + chain := &fundingChain{spent: first.OutPoint(), + bindingChain: &bindingChain{tip: 102, + blocks: map[uint32]*wire.MsgBlock{ + 100: bindingBlock(first), + 101: bindingBlock(second), + }}} + r := &proofNode{client: client, chain: chain, minConfs: 1} + request := swapkit.CreateDepositRequest{ + GroupKey: bindingPub(11), Amount: 1000, + } + raw, err := FundingProof( + t.Context(), + r.wallet(), + r.chain, + r.minConfs, + request, + ) + require.NoError(t, err) + require.Equal(t, bindingRaw(t, second), raw) + require.Len(t, client.exports, 2) + require.Equal(t, *client.rows[0].AnchorOutpoint, client.exports[0]) + require.Equal(t, *client.rows[1].AnchorOutpoint, client.exports[1]) + + // Even a valid same-group proof cannot replace the selected output. + client.rows = client.rows[1:] + client.proofs[*client.rows[0].AnchorOutpoint] = bindingRaw(t, first) + chain.spent = wire.OutPoint{} + _, err = FundingProof( + t.Context(), + r.wallet(), + r.chain, + r.minConfs, + request, + ) + require.ErrorIs(t, err, swapkit.ErrInvalid) + + client.rows[0].AnchorOutpoint = nil + client.exports = nil + _, err = FundingProof( + t.Context(), + r.wallet(), + r.chain, + r.minConfs, + request, + ) + require.ErrorIs(t, err, swapkit.ErrInvalid) + require.Empty(t, client.exports) +} + +// TestFundingProofReportsChainState checks that an exact wallet output which +// only waits on the chain is reported as retryable, not as an invalid request. +func TestFundingProofReportsChainState(t *testing.T) { + first := bindingProof(t, bindingGenesis(), bindingKey(2).PubKey(), + bindingKey(3).PubKey(), nil, bindingGenesis().FirstPrevOut, + 100, false) + second := bindingProof(t, bindingGenesis(), bindingKey(2).PubKey(), + bindingKey(3).PubKey(), nil, + wire.OutPoint{Hash: chainhash.Hash{12}}, 101, false) + row := func(p *proof.Proof) (*sdk.AssetRecord, sdk.Outpoint) { + point := sdk.Outpoint{Txid: sdk.Hash(p.OutPoint().Hash), + Index: p.OutPoint().Index} + return &sdk.AssetRecord{ + Amount: 1000, AnchorOutpoint: &point, + Genesis: sdk.IssuanceGenesis{ + IssuanceID: sdk.AssetID(p.Asset.ID()), + }, + ScriptKey: sdk.ScriptKey{PubKey: bindingPub(2)}, + }, point + } + firstRow, firstPoint := row(first) + secondRow, secondPoint := row(second) + client := &fundingSDK{bindingSDK: &bindingSDK{valid: true}, + rows: []*sdk.AssetRecord{secondRow}, + proofs: map[sdk.Outpoint][]byte{ + firstPoint: bindingRaw(t, first), + secondPoint: bindingRaw(t, second), + }} + chain := &fundingChain{bindingChain: &bindingChain{tip: 101, + blocks: map[uint32]*wire.MsgBlock{ + 100: bindingBlock(first), + 101: bindingBlock(second), + }}} + r := &proofNode{client: client, chain: chain} + request := swapkit.CreateDepositRequest{ + GroupKey: bindingPub(11), Amount: 1000, + } + funding := func(minConfs uint32) ([]byte, error) { + return FundingProof( + t.Context(), r.wallet(), r.chain, minConfs, request, + ) + } + + // The only candidate has one of three required confirmations. + _, err := funding(3) + require.ErrorIs(t, err, swapkit.ErrPending) + require.NotErrorIs(t, err, swapkit.ErrInvalid) + + // A chain backend failure is reported as such. + chain.heightErr = errors.New("chain backend unavailable") + _, err = funding(1) + require.ErrorIs(t, err, chain.heightErr) + chain.heightErr = nil + chain.unspentErr = errors.New("mempool lookup unavailable") + _, err = funding(1) + require.ErrorIs(t, err, chain.unspentErr) + chain.unspentErr = nil + + // A spent output can never fund the deposit and stays invalid. + chain.spent = second.OutPoint() + _, err = funding(1) + require.ErrorIs(t, err, swapkit.ErrInvalid) + chain.spent = wire.OutPoint{} + + // A waiting candidate does not hide another one that is ready. + client.rows = []*sdk.AssetRecord{secondRow, firstRow} + raw, err := funding(2) + require.NoError(t, err) + require.Equal(t, bindingRaw(t, first), raw) + + // The waiting candidate is selected once it has confirmed. + client.rows = []*sdk.AssetRecord{secondRow} + chain.tip = 103 + raw, err = funding(3) + require.NoError(t, err) + require.Equal(t, bindingRaw(t, second), raw) +} diff --git a/swapkit/tapsdk/group.go b/swapkit/tapsdk/group.go new file mode 100644 index 000000000..9956c7d26 --- /dev/null +++ b/swapkit/tapsdk/group.go @@ -0,0 +1,21 @@ +package tapsdk + +import sdk "github.com/lightninglabs/tap-sdk" + +// SameGroupKey compares valid compressed group keys by their x-only identity. +// It preserves the caller's serialized key metadata and rejects malformed keys. +func SameGroupKey(first, second []byte) bool { + a, err := sdk.ParsePubKey(first) + if err != nil { + return false + } + b, err := sdk.ParsePubKey(second) + if err != nil { + return false + } + return sdk.AssetRefFromGroupKey(a).Equivalent(sdk.AssetRefFromGroupKey(b)) +} + +func sameGroupKeys(first, second []byte) bool { + return SameGroupKey(first, second) +} diff --git a/swapkit/tapsdk/group_proofs.go b/swapkit/tapsdk/group_proofs.go new file mode 100644 index 000000000..dc36245b0 --- /dev/null +++ b/swapkit/tapsdk/group_proofs.go @@ -0,0 +1,93 @@ +package tapsdk + +import ( + "bytes" + "context" + + assetpolicy "github.com/lightninglabs/loop/internal/assetpolicy" + "github.com/lightninglabs/loop/swapkit" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/taprpc" +) + +// IsGroupMember checks the fungible asset's declared group identity. It does +// not verify issuance authorization or replace full proof verification. +func IsGroupMember(a *asset.Asset, key []byte) bool { + return a != nil && a.Type == asset.Normal && a.GroupKey != nil && + SameGroupKey(a.GroupKey.GroupPubKey.SerializeCompressed(), key) +} + +// VerifyGroupMembership verifies an anchor-first set of genesis proofs for +// the required fungible issuances. The verifier authenticates each proof with +// tapd; matching a group key alone is insufficient. Callers supply the required +// issuances and their aggregate proof-byte limit, independently of peer input. +// Node errors remain distinguishable from invalid evidence. +func VerifyGroupMembership(ctx context.Context, verifier RPCProofVerifier, + params *address.ChainParams, groupKey []byte, required []asset.ID, + issuanceProofs [][]byte, maxProofBytes int) error { + + if verifier == nil || !SameGroupKey(groupKey, groupKey) || + len(required) == 0 || maxProofBytes <= 0 { + + return swapkit.ErrInvalid + } + needed := make(map[asset.ID]bool, len(required)) + for _, id := range required { + if id == asset.ZeroID { + return swapkit.ErrInvalid + } + needed[id] = true + } + if len(issuanceProofs) == 0 || len(issuanceProofs) > len(needed)+1 { + return swapkit.ErrInvalid + } + var total int + for i, raw := range issuanceProofs { + if len(raw) == 0 || len(raw) > maxProofBytes-total { + return swapkit.ErrInvalid + } + total += len(raw) + p, err := proof.Decode(raw) + if err != nil || !p.Asset.HasGenesisWitnessForGroup() || + !IsGroupMember(&p.Asset, groupKey) || + (i == 0) != (p.GroupKeyReveal != nil) { + + return swapkit.ErrInvalid + } + id := p.Asset.ID() + if i > 0 && !needed[id] { + return swapkit.ErrInvalid + } + delete(needed, id) + file, err := proof.NewFile(proof.V0, *p) + if err != nil { + return swapkit.ErrInvalid + } + if err := assetpolicy.ValidateProvenance(ctx, file, params); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return swapkit.ErrInvalid + } + data, err := proof.EncodeFile(file) + if err != nil { + return swapkit.ErrInvalid + } + result, err := verifier.VerifyProof(ctx, &taprpc.ProofFile{RawProofFile: data}) + if err != nil { + return err + } + a := result.GetDecodedProof().GetAsset() + if !result.GetValid() || !bytes.Equal(a.GetAssetGenesis().GetAssetId(), id[:]) || + !SameGroupKey(a.GetAssetGroup().GetTweakedGroupKey(), groupKey) { + + return swapkit.ErrInvalid + } + } + if len(needed) != 0 { + return swapkit.ErrInvalid + } + return nil +} diff --git a/swapkit/tapsdk/group_proofs_test.go b/swapkit/tapsdk/group_proofs_test.go new file mode 100644 index 000000000..c1ba9d032 --- /dev/null +++ b/swapkit/tapsdk/group_proofs_test.go @@ -0,0 +1,90 @@ +package tapsdk + +import ( + "errors" + "testing" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +func groupEvidence(t *testing.T, tag string, anchor bool) *proof.Proof { + t.Helper() + g := bindingGenesis() + g.Tag = tag + p := bindingProof(t, g, bindingKey(2).PubKey(), bindingKey(3).PubKey(), nil, g.FirstPrevOut, 100, false) + p.Asset.PrevWitnesses[0].TxWitness = wire.TxWitness{make([]byte, 64)} + if anchor { + var err error + p.GroupKeyReveal, err = asset.NewGroupKeyReveal( + asset.GroupKey{ + RawKey: keychain.KeyDescriptor{ + PubKey: bindingKey(11).PubKey(), + }, + }, g.ID(), + ) + require.NoError(t, err) + } + return p +} + +func TestVerifyGroupMembership(t *testing.T) { + anchor := groupEvidence(t, "anchor", true) + fund := groupEvidence(t, "fund", false) + pay := groupEvidence(t, "pay", false) + encode := func(p *proof.Proof) []byte { + raw, err := p.Bytes() + require.NoError(t, err) + return raw + } + proofs := [][]byte{encode(anchor), encode(fund), encode(pay)} + key := anchor.Asset.GroupKey.GroupPubKey.SerializeCompressed() + needed := []asset.ID{fund.Asset.ID(), pay.Asset.ID()} + limit := len(proofs[0]) + len(proofs[1]) + len(proofs[2]) + check := func(node *evidenceNode, ids []asset.ID, data [][]byte, + maxBytes int) error { + + return VerifyGroupMembership( + t.Context(), node, &address.RegressionNetTap, key, ids, + data, maxBytes, + ) + } + node := &evidenceNode{} + require.NoError(t, check(node, needed, proofs, limit)) + require.Equal(t, []string{verifyProofCall, verifyProofCall, verifyProofCall}, node.calls) + require.NoError(t, check(&evidenceNode{}, []asset.ID{fund.Asset.ID(), fund.Asset.ID()}, proofs[:2], limit)) + require.NoError(t, check(&evidenceNode{}, []asset.ID{anchor.Asset.ID()}, proofs[:1], limit)) + for _, test := range []struct { + name string + data [][]byte + node *evidenceNode + max int + }{ + {"missing issuance", proofs[:2], &evidenceNode{}, limit}, + {"wrong order", [][]byte{proofs[1], proofs[0], proofs[2]}, &evidenceNode{}, limit}, + {"duplicate issuance", [][]byte{proofs[0], proofs[1], proofs[1]}, &evidenceNode{}, limit}, + {"byte limit", proofs, &evidenceNode{}, limit - 1}, + {"invalid proof", proofs, &evidenceNode{invalid: true}, limit}, + {"wrong decoded issuance", proofs, &evidenceNode{wrongID: true}, limit}, + {"wrong decoded group", proofs, &evidenceNode{wrongGroup: true}, limit}, + } { + t.Run(test.name, func(t *testing.T) { + require.ErrorIs(t, check(test.node, needed, test.data, test.max), swapkit.ErrInvalid) + }) + } + node = &evidenceNode{verifyErr: errors.New("node unavailable")} + require.ErrorIs(t, check(node, needed, proofs, limit), node.verifyErr) + require.Len(t, node.calls, 1) + collectible := *fund + collectible.Asset = *fund.Asset.Copy() + collectible.Asset.Type = asset.Collectible + require.ErrorIs(t, check( + &evidenceNode{}, needed, + [][]byte{proofs[0], encode(&collectible), proofs[2]}, limit+1000, + ), swapkit.ErrInvalid) +} diff --git a/swapkit/tapsdk/group_test.go b/swapkit/tapsdk/group_test.go new file mode 100644 index 000000000..f39f12901 --- /dev/null +++ b/swapkit/tapsdk/group_test.go @@ -0,0 +1,72 @@ +package tapsdk + +import ( + "bytes" + "testing" + + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +func TestSameGroupKeys(t *testing.T) { + group := bindingPub(11) + opposite := group + opposite[0] ^= 1 + other := bindingPub(12) + invalidPrefix := group + invalidPrefix[0] = 4 + for _, test := range []struct { + name string + key []byte + want bool + }{ + {name: "same", key: group[:], want: true}, + {name: "opposite parity", key: opposite[:], want: true}, + {name: "different", key: other[:]}, + {name: "missing", key: nil}, + {name: "x-only", key: group[1:]}, + {name: "invalid prefix", key: invalidPrefix[:]}, + {name: "invalid point", key: make([]byte, 33)}, + } { + t.Run(test.name, func(t *testing.T) { + before := bytes.Clone(test.key) + require.Equal(t, test.want, sameGroupKeys(group[:], test.key)) + require.Equal(t, test.want, sameGroupKeys(test.key, group[:])) + require.Equal(t, before, test.key) + }) + } +} + +func TestInputBindingAcceptsGroupParity(t *testing.T) { + p := bindingProof(t, bindingGenesis(), bindingKey(2).PubKey(), + bindingKey(3).PubKey(), nil, bindingGenesis().FirstPrevOut, + 100, false) + group := sdk.PubKey(bindingPub(11)) + group[0] ^= 1 + binding := InputBinding{ + ID: "grouped input", IssuanceID: sdk.AssetID(p.Asset.ID()), + GroupKey: &group, Amount: p.Asset.Amount, ScriptKey: bindingPub(2), + AnchorOutpoint: sdk.Outpoint{ + Txid: sdk.Hash(p.OutPoint().Hash), Index: p.OutPoint().Index, + }, + } + require.NoError(t, binding.validate(p)) + other := sdk.PubKey(bindingPub(12)) + binding.GroupKey = &other + require.ErrorIs(t, binding.validate(p), swapkit.ErrInvalid) +} + +func TestVerifiedSummaryAcceptsGroupParity(t *testing.T) { + p := bindingProof(t, bindingGenesis(), bindingKey(2).PubKey(), + bindingKey(3).PubKey(), nil, bindingGenesis().FirstPrevOut, + 100, false) + response, err := fixtureVerified(bindingRaw(t, p), true) + require.NoError(t, err) + group := bindingPub(11) + group[0] ^= 1 + response.DecodedProof.AssetRef = sdk.AssetRefFromGroupKey(group) + require.NoError(t, checkVerifiedSummary(response, p)) + response.DecodedProof.AssetRef = sdk.AssetRefFromGroupKey(bindingPub(12)) + require.ErrorIs(t, checkVerifiedSummary(response, p), swapkit.ErrInvalid) +} diff --git a/swapkit/tapsdk/inputs.go b/swapkit/tapsdk/inputs.go new file mode 100644 index 000000000..eb3549331 --- /dev/null +++ b/swapkit/tapsdk/inputs.go @@ -0,0 +1,100 @@ +package tapsdk + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/internal/assetproof" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/proof" +) + +// CheckInputsCurrent repeats chain checks after SDK Build's verification RPCs. +// Call it before Commit so verification cannot authorize a now stale input. +func CheckInputsCurrent(ctx context.Context, chain Chain, minConfs uint32, + request *sdk.CustomAnchorRequest, unspent bool) error { + + if request == nil { + return swapkit.ErrInvalid + } + raws := make( + [][]byte, + 0, + len(request.Inputs)+len(request.PassiveAssets.Packets), + ) + for _, input := range request.Inputs { + raw := input.ProofFile + if input.ProofPath != nil { + if len(input.ProofPath.Steps) > 0 { + return fmt.Errorf( + "compact inputs require ApprovedParents.CheckCurrent", + ) + } + raw = input.ProofPath.ConfirmedBaseProof + } + raws = append(raws, raw) + } + for _, passive := range request.PassiveAssets.Packets { + raws = append(raws, passive.ProofFile) + } + var inventory []*proof.Proof + for _, raw := range raws { + p, err := LastProof(raw) + if err != nil { + return err + } + inventory = append(inventory, p) + if err := checkConfirmation(ctx, chain, minConfs, p, unspent); err != nil { + return err + } + } + if err := assetproof.VerifyCompleteAnchors(inventory); err != nil { + return err + } + if unspent { + packet, err := DecodePSBT(request.AnchorPSBT) + if err != nil { + return err + } + return checkFundingCurrent(ctx, chain, packet, nil) + } + + return nil +} + +// checkFundingCurrent checks all asset and Bitcoin-only funding inputs. Skip +// contains only the outputs supplied by independently approved signed parents. +func checkFundingCurrent(ctx context.Context, chain Chain, packet *psbt.Packet, + skip map[wire.OutPoint]bool) error { + + if chain == nil || packet == nil || packet.UnsignedTx == nil || + len(packet.Inputs) != len(packet.UnsignedTx.TxIn) { + + return swapkit.ErrInvalid + } + seen := make(map[wire.OutPoint]bool) + for _, input := range packet.UnsignedTx.TxIn { + if input == nil { + return swapkit.ErrInvalid + } + point := input.PreviousOutPoint + if seen[point] { + return fmt.Errorf("duplicate Bitcoin funding input") + } + seen[point] = true + if skip[point] { + continue + } + available, err := chain.Unspent(ctx, point) + if err != nil { + return err + } + if !available { + return swapkit.ErrPending + } + } + return nil +} diff --git a/swapkit/tapsdk/issuance.go b/swapkit/tapsdk/issuance.go new file mode 100644 index 000000000..08ce1f21a --- /dev/null +++ b/swapkit/tapsdk/issuance.go @@ -0,0 +1,98 @@ +package tapsdk + +import ( + "context" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + assetpolicy "github.com/lightninglabs/loop/internal/assetpolicy" + "github.com/lightninglabs/loop/swapkit" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightninglabs/taproot-assets/taprpc/universerpc" + "google.golang.org/grpc" +) + +// RPCProofVerifier verifies full proof files with tapd. Verification alone +// does not establish the caller's expected asset, amount, contract or chain state. +type RPCProofVerifier interface { + VerifyProof(context.Context, *taprpc.ProofFile, + ...grpc.CallOption) (*taprpc.VerifyProofResponse, error) +} + +// IssuanceProofClient records verified issuance metadata and verifies history. +// Inserting issuance metadata does not register a received wallet asset. +type IssuanceProofClient interface { + RPCProofVerifier + InsertProof(context.Context, *universerpc.AssetProof, + ...grpc.CallOption) (*universerpc.AssetProofResponse, error) +} + +// VerifyProofWithIssuance registers the first issuance before asking tapd to +// verify the full history. This supports assets unknown to the local node and +// safely repeats after restart. Callers enforce their transport limits and +// expected output before calling; node errors are returned unchanged. +func VerifyProofWithIssuance(ctx context.Context, tap IssuanceProofClient, + req *taprpc.ProofFile, opts ...grpc.CallOption) ( + *taprpc.VerifyProofResponse, error) { + + if tap == nil || req == nil || len(req.RawProofFile) == 0 { + return nil, swapkit.ErrInvalid + } + if err := assetpolicy.CheckProofFileSize(req.RawProofFile); err != nil { + return nil, swapkit.ErrInvalid + } + file, err := proof.DecodeFile(req.RawProofFile) + if err != nil { + return nil, swapkit.ErrInvalid + } + first, err := file.ProofAt(0) + if err != nil || !first.Asset.IsGenesisAsset() || + first.Asset.ScriptKey.PubKey == nil { + + return nil, swapkit.ErrInvalid + } + last, err := file.LastProof() + if err != nil || first.Asset.ID() != last.Asset.ID() { + return nil, swapkit.ErrInvalid + } + raw, err := file.RawProofAt(0) + if err != nil { + return nil, swapkit.ErrInvalid + } + assetID := first.Asset.ID() + id := &universerpc.ID{ + Id: &universerpc.ID_AssetId{ + AssetId: assetID[:], + }, + ProofType: universerpc.ProofType_PROOF_TYPE_ISSUANCE, + } + if first.Asset.GroupKey != nil { + id.Id = &universerpc.ID_GroupKey{ + GroupKey: schnorr.SerializePubKey( + &first.Asset.GroupKey.GroupPubKey, + ), + } + } + _, err = tap.InsertProof(ctx, &universerpc.AssetProof{ + Key: &universerpc.UniverseKey{ + Id: id, + LeafKey: &universerpc.AssetKey{ + Outpoint: &universerpc.AssetKey_OpStr{ + OpStr: first.OutPoint().String(), + }, + ScriptKey: &universerpc.AssetKey_ScriptKeyBytes{ + ScriptKeyBytes: schnorr.SerializePubKey( + first.Asset.ScriptKey.PubKey, + ), + }, + }, + }, + AssetLeaf: &universerpc.AssetLeaf{ + Proof: raw, + }, + }, opts...) + if err != nil { + return nil, err + } + return tap.VerifyProof(ctx, req, opts...) +} diff --git a/swapkit/tapsdk/issuance_test.go b/swapkit/tapsdk/issuance_test.go new file mode 100644 index 000000000..4e8a35dda --- /dev/null +++ b/swapkit/tapsdk/issuance_test.go @@ -0,0 +1,116 @@ +package tapsdk + +import ( + "context" + "errors" + "testing" + + "github.com/lightninglabs/loop/swapkit" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightninglabs/taproot-assets/taprpc/universerpc" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +const ( + insertIssuanceCall = "insert issuance" + verifyProofCall = "verify proof" +) + +// evidenceNode models tapd's authenticated RPC boundary. The tests exercise +// evidence selection and call ordering; tapd performs cryptographic verification. +type evidenceNode struct { + calls []string + inserted *universerpc.AssetProof + insertErr error + verifyErr error + invalid bool + wrongID bool + wrongGroup bool +} + +func (n *evidenceNode) InsertProof(_ context.Context, req *universerpc.AssetProof, + _ ...grpc.CallOption) (*universerpc.AssetProofResponse, error) { + + n.calls = append(n.calls, insertIssuanceCall) + n.inserted = req + return &universerpc.AssetProofResponse{}, n.insertErr +} + +func (n *evidenceNode) VerifyProof(_ context.Context, req *taprpc.ProofFile, + _ ...grpc.CallOption) (*taprpc.VerifyProofResponse, error) { + + n.calls = append(n.calls, verifyProofCall) + if n.verifyErr != nil { + return nil, n.verifyErr + } + f, err := proof.DecodeFile(req.RawProofFile) + if err != nil { + return nil, err + } + p, err := f.LastProof() + if err != nil { + return nil, err + } + id := p.Asset.ID() + if n.wrongID { + id = asset.ID{9} + } + a := &taprpc.Asset{AssetGenesis: &taprpc.GenesisInfo{AssetId: id[:]}} + if p.Asset.GroupKey != nil { + key := p.Asset.GroupKey.GroupPubKey.SerializeCompressed() + if n.wrongGroup { + key = bindingKey(12).PubKey().SerializeCompressed() + } + a.AssetGroup = &taprpc.AssetGroup{TweakedGroupKey: key} + } + return &taprpc.VerifyProofResponse{Valid: !n.invalid, DecodedProof: &taprpc.DecodedProof{Asset: a}}, nil +} + +func TestVerifyProofWithIssuance(t *testing.T) { + p := bindingProof( + t, bindingGenesis(), bindingKey(2).PubKey(), + bindingKey(3).PubKey(), nil, bindingGenesis().FirstPrevOut, + 100, false, + ) + raw := bindingRaw(t, p) + node := &evidenceNode{} + req := &taprpc.ProofFile{RawProofFile: raw} + for range 2 { + node.calls = nil + result, err := VerifyProofWithIssuance(t.Context(), node, req) + require.NoError(t, err) + require.True(t, result.Valid) + require.Equal(t, []string{insertIssuanceCall, verifyProofCall}, node.calls) + require.Equal(t, universerpc.ProofType_PROOF_TYPE_ISSUANCE, node.inserted.Key.Id.ProofType) + require.Equal(t, p.Asset.GroupKey.GroupPubKey.SerializeCompressed()[1:], node.inserted.Key.Id.GetGroupKey()) + require.Equal(t, p.OutPoint().String(), node.inserted.Key.LeafKey.GetOpStr()) + } + node.calls = nil + node.insertErr = errors.New("issuance unavailable") + _, err := VerifyProofWithIssuance(t.Context(), node, req) + require.ErrorIs(t, err, node.insertErr) + require.Equal(t, []string{insertIssuanceCall}, node.calls) + node.calls = nil + node.insertErr = nil + node.verifyErr = errors.New("verification unavailable") + _, err = VerifyProofWithIssuance(t.Context(), node, req) + require.ErrorIs(t, err, node.verifyErr) + require.Equal(t, []string{insertIssuanceCall, verifyProofCall}, node.calls) + node.calls = nil + _, err = VerifyProofWithIssuance(t.Context(), node, &taprpc.ProofFile{RawProofFile: []byte{1}}) + require.ErrorIs(t, err, swapkit.ErrInvalid) + require.Empty(t, node.calls) + changed := *p + changed.Asset = *p.Asset.Copy() + changed.Asset.Genesis.Tag += "-other" + f, err := proof.NewFile(proof.V0, *p, changed) + require.NoError(t, err) + bytes, err := proof.EncodeFile(f) + require.NoError(t, err) + _, err = VerifyProofWithIssuance(t.Context(), node, &taprpc.ProofFile{RawProofFile: bytes}) + require.ErrorIs(t, err, swapkit.ErrInvalid) + require.Empty(t, node.calls) +} diff --git a/swapkit/tapsdk/policy.go b/swapkit/tapsdk/policy.go new file mode 100644 index 000000000..ca62243d5 --- /dev/null +++ b/swapkit/tapsdk/policy.go @@ -0,0 +1,135 @@ +package tapsdk + +import ( + "bytes" + "context" + "fmt" + + assetpolicy "github.com/lightninglabs/loop/internal/assetpolicy" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/proof" +) + +// CheckNodeVersion checks the live tapd compatibility floor for new on-chain +// asset operations. Local decoding and recovery of saved Bitcoin witnesses do +// not require a running node or change already funded contracts. +func CheckNodeVersion(ctx context.Context, wallet *sdk.Wallet) error { + _, err := nodeParams(ctx, wallet) + return err +} + +func nodeParams(ctx context.Context, wallet *sdk.Wallet) ( + *address.ChainParams, error) { + + if wallet == nil || wallet.Client() == nil { + return nil, fmt.Errorf("tapd wallet is required") + } + info, err := wallet.Client().GetInfo(ctx) + if err != nil { + return nil, err + } + if info == nil { + return nil, fmt.Errorf("tapd omitted node information") + } + if err := assetpolicy.CheckOnchainSwapTapdVersion(info.Version); err != nil { + return nil, err + } + var params address.ChainParams + switch info.Network { + case "mainnet": + params = address.MainNetTap + case "testnet", "testnet3": + params = address.TestNet3Tap + case "testnet4": + params = address.TestNet4Tap + case "regtest": + params = address.RegressionNetTap + case "signet": + params = address.SigNetTap + case "simnet": + params = address.SimNetTap + default: + return nil, fmt.Errorf( + "unsupported tapd network %q", + info.Network, + ) + } + return assetpolicy.CanonicalAddressParams(¶ms) +} + +func checkProvenance( + ctx context.Context, + wallet *sdk.Wallet, + raw []byte, +) error { + + if err := assetpolicy.CheckProofFileSize(raw); err != nil { + return err + } + params, err := nodeParams(ctx, wallet) + if err != nil { + return err + } + return checkProvenanceWithParams(ctx, raw, params) +} + +func checkProvenanceWithParams( + ctx context.Context, + raw []byte, + params *address.ChainParams, +) error { + + if err := assetpolicy.CheckProofFileSize(raw); err != nil { + return err + } + file, err := proof.DecodeFile(raw) + if err != nil { + return err + } + return assetpolicy.ValidateProvenance(ctx, file, params) +} + +func checkVerifiedSummary( + result *sdk.VerifyProofResponse, + p *proof.Proof, +) error { + + if result == nil || !result.Valid || result.DecodedProof == nil || + p == nil { + + return fmt.Errorf( + "%w: tapd did not verify the selected proof", + swapkit.ErrInvalid, + ) + } + decoded := result.DecodedProof + var group *sdk.PubKey + if p.Asset.GroupKey != nil { + key, err := sdk.ParsePubKey( + p.Asset.GroupKey.GroupPubKey.SerializeCompressed(), + ) + if err != nil { + return err + } + group = &key + } + ref := sdk.AssetRefFromTypedAsset( + sdk.AssetID(p.Asset.ID()), + group, + sdk.AssetType(p.Asset.Type), + ) + if decoded.IssuanceID != sdk.AssetID(p.Asset.ID()) || + !decoded.AssetRef.Equivalent(ref) || + decoded.Amount != p.Asset.Amount || + SDKPoint(decoded.Outpoint) != p.OutPoint() || + !bytes.Equal(decoded.ScriptKey[1:], p.Asset.ScriptKey.PubKey.SerializeCompressed()[1:]) { + + return fmt.Errorf( + "%w: tapd verified a different asset output", + swapkit.ErrInvalid, + ) + } + return nil +} diff --git a/swapkit/tapsdk/proofs.go b/swapkit/tapsdk/proofs.go new file mode 100644 index 000000000..965e991ea --- /dev/null +++ b/swapkit/tapsdk/proofs.go @@ -0,0 +1,184 @@ +package tapsdk + +import ( + "bytes" + "context" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + assetpolicy "github.com/lightninglabs/loop/internal/assetpolicy" + "github.com/lightninglabs/loop/internal/assetproof" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/commitment" + "github.com/lightninglabs/taproot-assets/proof" +) + +// LastProof decodes the final transition in a native asset proof file. +func LastProof(raw []byte) (*proof.Proof, error) { + if err := assetpolicy.CheckProofFileSize(raw); err != nil { + return nil, err + } + file, err := proof.DecodeFile(raw) + if err != nil { + return nil, err + } + p, err := file.LastProof() + if err != nil { + return nil, err + } + if err := p.Asset.Validate(); err != nil { + return nil, err + } + return p, nil +} + +func isolatedRoots(p *proof.Proof) (sdk.Hash, sdk.Hash, error) { + if p == nil || p.InclusionProof.CommitmentProof == nil || + p.InclusionProof.InternalKey == nil { + + return sdk.Hash{}, sdk.Hash{}, swapkit.ErrInvalid + } + tc, err := assetproof.VerifyCompleteAnchor(p) + if err != nil { + return sdk.Hash{}, sdk.Hash{}, err + } + return commitmentRoots(p, tc) +} + +func proofRoots(p *proof.Proof) (sdk.Hash, sdk.Hash, error) { + if p == nil { + return sdk.Hash{}, sdk.Hash{}, swapkit.ErrInvalid + } + tc, err := p.VerifyProofs() + if err != nil { + return sdk.Hash{}, sdk.Hash{}, err + } + return commitmentRoots(p, tc) +} + +func commitmentRoots( + p *proof.Proof, + tc *commitment.TapCommitment, +) (sdk.Hash, sdk.Hash, error) { + + _, sibling, err := commitment.MaybeEncodeTapscriptPreimage( + p.InclusionProof.CommitmentProof.TapSiblingPreimage) + if err != nil { + return sdk.Hash{}, sdk.Hash{}, err + } + root, assets := tc.TapscriptRoot(sibling), tc.TapscriptRoot(nil) + script, err := txscript.PayToTaprootScript( + txscript.ComputeTaprootOutputKey( + p.InclusionProof.InternalKey, + root[:], + ), + ) + index := p.OutPoint().Index + if err != nil || index >= uint32(len(p.AnchorTx.TxOut)) || + !bytes.Equal(script, p.AnchorTx.TxOut[index].PkScript) { + + return sdk.Hash{}, sdk.Hash{}, swapkit.ErrInvalid + } + return sdk.Hash(assets), sdk.Hash(root), nil +} + +// checkConfirmation requires current-chain inclusion and optional spendability. +func checkConfirmation(ctx context.Context, chain Chain, + minConfs uint32, p *proof.Proof, + unspent bool) error { + + if chain == nil || p == nil || minConfs == 0 || p.BlockHeight == 0 || + p.OutPoint().Index >= uint32(len(p.AnchorTx.TxOut)) { + + return swapkit.ErrInvalid + } + tip, err := chain.Height(ctx) + if err != nil { + return err + } + if tip < p.BlockHeight || tip-p.BlockHeight+1 < minConfs { + return swapkit.ErrPending + } + hash, err := chain.BlockHash(ctx, p.BlockHeight) + if err != nil { + return err + } + if hash != p.BlockHeader.BlockHash() { + return swapkit.ErrPending + } + if len(p.TxMerkleProof.Nodes) != len(p.TxMerkleProof.Bits) || + !p.TxMerkleProof.Verify(&p.AnchorTx, p.BlockHeader.MerkleRoot) { + + return swapkit.ErrInvalid + } + if unspent { + available, err := chain.Unspent(ctx, p.OutPoint()) + if err != nil { + return err + } + if !available { + return swapkit.ErrPending + } + } + return nil +} + +// DecodePSBT reads the native Bitcoin PSBT carried by an SDK transfer. +func DecodePSBT(raw []byte) (*psbt.Packet, error) { + return psbt.NewFromRawBytes(bytes.NewReader(raw), false) +} + +// SDKPoint converts a native SDK outpoint to the Bitcoin wire type. +func SDKPoint(p sdk.Outpoint) wire.OutPoint { + return wire.OutPoint{Hash: chainhash.Hash(p.Txid), Index: p.Index} +} + +// checkAsset checks negotiated native quantity and group membership. +func checkAsset(p *proof.Proof, group [33]byte, amount uint64) error { + if p == nil || p.Asset.GroupKey == nil || p.Asset.Amount != amount || + p.Asset.LockTime != 0 || p.Asset.RelativeLockTime != 0 || + !sameGroupKeys( + p.Asset.GroupKey.GroupPubKey.SerializeCompressed(), + group[:], + ) { + + return swapkit.ErrInvalid + } + return nil +} + +// inspectProof checks Loop policy and the current chain before SDK Build. +// SDK Build is responsible for generic proof verification on building paths. +func inspectProof(ctx context.Context, chain Chain, minConfs uint32, raw []byte, + group [33]byte, amount uint64, unspent bool) (*proof.Proof, error) { + + p, err := inspectAsset(raw, group, amount) + if err != nil { + return nil, err + } + if err := checkConfirmation(ctx, chain, minConfs, p, unspent); err != nil { + return nil, err + } + return p, nil +} + +// inspectAsset checks Loop's asset policy and isolated anchor of a proof +// without reading the chain. A failure is a property of the proof itself. +func inspectAsset(raw []byte, group [33]byte, amount uint64) (*proof.Proof, + error) { + + p, err := LastProof(raw) + if err != nil { + return nil, err + } + if err := checkAsset(p, group, amount); err != nil { + return nil, err + } + if _, _, err := isolatedRoots(p); err != nil { + return nil, err + } + return p, nil +} diff --git a/swapkit/tapsdk/test_constants_test.go b/swapkit/tapsdk/test_constants_test.go new file mode 100644 index 000000000..2b5aa75ee --- /dev/null +++ b/swapkit/tapsdk/test_constants_test.go @@ -0,0 +1,11 @@ +package tapsdk + +const ( + testSpoofedFeeValue = "spoofed fee value" + testValid = "valid" + testSpentOutput = "spent output" + testWrongSigner = "wrong signer" + testReorg = "reorg" + testSpentRefund = "spent refund" + testEven = "even" +) From d464aa59432979bd688e08681dcc060b053c6f83 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 3 Nov 2026 11:13:24 +0100 Subject: [PATCH 05/25] tapsdk: Approve transfer plans against agreed terms Prepare confirmed inputs for native Build and check its allocations, Bitcoin template, change policy and fee before granting authority. Verify wallet-funded fees again after native Commit. --- swapkit/tapsdk/approval.go | 331 +++++++++++++++++++++++++ swapkit/tapsdk/approval_test.go | 214 ++++++++++++++++ swapkit/tapsdk/bindings_test.go | 101 ++++++++ swapkit/tapsdk/deposit.go | 80 ++++++ swapkit/tapsdk/deposit_cache_test.go | 65 +++++ swapkit/tapsdk/funding_guard_test.go | 60 +++++ swapkit/tapsdk/inputs.go | 140 +++++++++++ swapkit/tapsdk/paths_test.go | 335 ++++++++++++++++++++++++++ swapkit/tapsdk/proofs.go | 79 ++++++ swapkit/tapsdk/signing.go | 8 + swapkit/tapsdk/signing_policy.go | 63 +++++ swapkit/tapsdk/signing_policy_test.go | 25 ++ 12 files changed, 1501 insertions(+) create mode 100644 swapkit/tapsdk/approval.go create mode 100644 swapkit/tapsdk/approval_test.go create mode 100644 swapkit/tapsdk/bindings_test.go create mode 100644 swapkit/tapsdk/deposit_cache_test.go create mode 100644 swapkit/tapsdk/funding_guard_test.go create mode 100644 swapkit/tapsdk/paths_test.go create mode 100644 swapkit/tapsdk/signing.go create mode 100644 swapkit/tapsdk/signing_policy.go create mode 100644 swapkit/tapsdk/signing_policy_test.go diff --git a/swapkit/tapsdk/approval.go b/swapkit/tapsdk/approval.go new file mode 100644 index 000000000..ab6d4a5c0 --- /dev/null +++ b/swapkit/tapsdk/approval.go @@ -0,0 +1,331 @@ +package tapsdk + +import ( + "bytes" + "fmt" + "math" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/tappsbt" +) + +// OutputBinding is an independently approved concrete asset allocation. A +// grouped allocation expanded into multiple issuances needs one entry per +// issuance. The caller retains these terms before accepting a peer request. +// ScriptKey includes the raw derivation so a peer cannot change later wallet +// recovery authority while retaining the same public output key. +type OutputBinding struct { + ID string + IssuanceID sdk.AssetID + AssetRef sdk.AssetRef + Amount uint64 + AnchorOutputIndex uint32 + AnchorValueSat int64 + ScriptMode sdk.CustomAssetScriptMode + ScriptKey sdk.ScriptKey + Anchor swapkit.AnchorPlan + OPTrue *sdk.CustomAssetOPTrueSpendInfo +} + +// ApprovePlan checks every native allocation and the whole Bitcoin template +// before Commit can sign virtual inputs. The independent template fixes every +// input, sequence, lock time, output value and non-asset output script; asset +// scripts are derived from approved contracts and native SDK commitment +// previews. No output ordering or fee is inferred from a peer's proposal. +// Native Build/Commit and durable approval remain owned by the caller's FSM. +func ApprovePlan(plan *sdk.CustomAnchorPlan, template *wire.MsgTx, + outputs []OutputBinding, maxFee uint64) error { + + return approvePlan(plan, template, outputs, maxFee, false) +} + +// ApproveWalletFundedPlan checks fixed allocations and template terms before +// wallet funding. A deficit template cannot establish a final fee or wallet +// change. Set the native request MaxFeeSat from independent policy and call +// CheckFundedPackage on Commit's result before signing, releasing or publishing +// its Bitcoin anchor. Only newly added wallet-generated change is supported. +func ApproveWalletFundedPlan(plan *sdk.CustomAnchorPlan, template *wire.MsgTx, + outputs []OutputBinding) error { + + return approvePlan(plan, template, outputs, 0, true) +} + +func approvePlan(plan *sdk.CustomAnchorPlan, template *wire.MsgTx, + outputs []OutputBinding, maxFee uint64, deferredFunding bool) error { + + if plan == nil || template == nil { + return swapkit.ErrInvalid + } + actual, err := DecodePSBT(plan.AnchorPSBT()) + if err != nil { + return err + } + request := plan.Request() + if request == nil { + return swapkit.ErrInvalid + } + if err := checkSigningPlans(actual, request.SigningPlans); err != nil { + return err + } + if deferredFunding || request.Funding.Mode == + sdk.CustomAnchorFundingWalletFunded { + + if err := checkWalletFunding(request.Funding); err != nil { + return err + } + } + tx := actual.UnsignedTx + if tx.Version != template.Version || tx.LockTime != template.LockTime || + len( + tx.TxIn, + ) != len( + template.TxIn, + ) || len(tx.TxOut) != len(template.TxOut) || + len(actual.Inputs) != len(tx.TxIn) { + + return fmt.Errorf("Bitcoin template differs from approval") + } + for i, input := range tx.TxIn { + expected := template.TxIn[i] + if input == nil || expected == nil || + input.PreviousOutPoint != expected.PreviousOutPoint || + input.Sequence != expected.Sequence { + + return fmt.Errorf( + "Bitcoin funding differs from approval", + ) + } + } + planned := plan.Outputs() + previews, err := plan.PreviewOutputCommitments() + if err != nil { + return err + } + if len(planned) != len(outputs) || len(previews) != len(outputs) { + return fmt.Errorf( + "asset allocation count differs from approval", + ) + } + active, passive := plan.ActiveVirtualPSBTs(), plan.PassiveVirtualPSBTs() + anchors := make(map[uint32][]byte) + matched := make([]bool, len(outputs)) + for i, out := range planned { + index := -1 + for j, expected := range outputs { + if !matched[j] && out.LogicalOutputID == expected.ID && + out.IssuanceID == expected.IssuanceID { + + index = j + break + } + } + if index < 0 { + return fmt.Errorf( + "unapproved asset issuance or allocation", + ) + } + matched[index] = true + expected := outputs[index] + if expected.ID == "" || + expected.IssuanceID == (sdk.AssetID{}) || + expected.Amount == 0 || + !out.AssetRef.Equivalent(expected.AssetRef) || + out.Amount != expected.Amount || + out.ScriptMode != expected.ScriptMode || + out.ScriptKey != expected.ScriptKey.PubKey || + out.AnchorOutputIndex != expected.AnchorOutputIndex || + out.AnchorValueSat != uint64(expected.AnchorValueSat) || + expected.AnchorValueSat < 330 { + + return fmt.Errorf( + "asset allocation differs from approval", + ) + } + preview := previews[i] + if preview.LogicalOutputID != out.LogicalOutputID || + preview.PacketIndex != out.PacketIndex || + preview.VirtualOutputIndex != out.VirtualOutputIndex || + preview.AnchorOutputIndex != out.AnchorOutputIndex { + + return swapkit.ErrInvalid + } + script, err := anchorScript( + expected.Anchor, + preview.TaprootAssetRoot, + ) + if err != nil { + return err + } + // Preview's full root incorporates the peer's anchor plan; deriving its + // output key from the independent plan catches a substituted Bitcoin tree. + actualScript, err := previewAnchorScript( + expected.Anchor.InternalKey, + preview.TaprootMerkleRoot, + ) + if err != nil { + return err + } + if !bytes.Equal(script, actualScript) { + return fmt.Errorf( + "output contract differs from approval", + ) + } + if prior := anchors[out.AnchorOutputIndex]; prior != nil && + !bytes.Equal(prior, script) { + + return fmt.Errorf( + "asset output has conflicting contracts", + ) + } + anchors[out.AnchorOutputIndex] = script + packets := active + if out.PacketRole == sdk.CustomAnchorPacketRolePassive { + packets = passive + } + if out.PacketIndex >= uint32(len(packets)) { + return swapkit.ErrInvalid + } + packet, err := tappsbt.Decode(packets[out.PacketIndex]) + if err != nil { + return err + } + if out.VirtualOutputIndex >= uint32(len(packet.Outputs)) { + return swapkit.ErrInvalid + } + virtual := packet.Outputs[out.VirtualOutputIndex] + if virtual.AnchorOutputInternalKey == nil || + !bytes.Equal( + virtual.AnchorOutputInternalKey.SerializeCompressed(), + expected.Anchor.InternalKey[:], + ) || + virtual.LockTime != 0 || + virtual.RelativeLockTime != 0 { + + return fmt.Errorf( + "asset anchor authority or timelocks differ from approval", + ) + } + key := virtual.ScriptKey + if key.TweakedScriptKey == nil || key.RawKey.PubKey == nil || + !bytes.Equal( + key.RawKey.PubKey.SerializeCompressed(), + expected.ScriptKey.KeyDesc.RawKeyBytes[:], + ) || + uint32( + key.RawKey.Family, + ) != expected.ScriptKey.KeyDesc.KeyLocator.Family || + key.RawKey.Index != expected.ScriptKey.KeyDesc.KeyLocator.Index || + !bytes.Equal(key.Tweak, expected.ScriptKey.TapTweak) { + + return fmt.Errorf( + "asset key derivation differs from approval", + ) + } + if expected.OPTrue != nil { + if err := expected.OPTrue.Validate(out.ScriptKey); err != nil { + return err + } + } + } + var inputValue, outputValue uint64 + for _, input := range actual.Inputs { + if input.WitnessUtxo == nil || input.WitnessUtxo.Value < 0 || + uint64( + input.WitnessUtxo.Value, + ) > math.MaxUint64-inputValue { + + return swapkit.ErrInvalid + } + inputValue += uint64(input.WitnessUtxo.Value) + } + for i, output := range tx.TxOut { + expected := template.TxOut[i] + if output == nil || expected == nil || output.Value < 0 || + output.Value != expected.Value || + uint64(output.Value) > math.MaxUint64-outputValue { + + return swapkit.ErrInvalid + } + // The SDK installs asset commitments during Commit. The template still + // carries the caller's placeholder; the approved contract was checked above. + if anchors[uint32(i)] == nil && + !bytes.Equal(output.PkScript, expected.PkScript) { + + return fmt.Errorf( + "Bitcoin recipient differs from approval", + ) + } + outputValue += uint64(output.Value) + } + if !deferredFunding && + (outputValue > inputValue || inputValue-outputValue > maxFee) { + + return fmt.Errorf("Bitcoin fee exceeds approval") + } + return nil +} + +func previewAnchorScript(internalKey [33]byte, root sdk.Hash) ([]byte, error) { + internal, err := btcec.ParsePubKey(internalKey[:]) + if err != nil { + return nil, err + } + return txscript.PayToTaprootScript( + txscript.ComputeTaprootOutputKey(internal, root[:]), + ) +} + +// CheckFundedPackage validates native wallet funding and its independently held +// absolute fee cap. It supplements fixed-allocation preapproval; the caller +// also retains the SDK committed digest and owns anchor signing/publication. +func CheckFundedPackage( + pkg *sdk.CustomAnchorTransferPackage, + maxFee uint64, +) error { + + if pkg == nil || maxFee == 0 { + return swapkit.ErrInvalid + } + if err := CheckSigningPolicy(pkg); err != nil { + return err + } + if pkg.Funding.Mode != sdk.CustomAnchorFundingWalletFunded || + pkg.Funding.MaxFeeSat != maxFee || pkg.Funding.ActualFeeSat > maxFee { + + return fmt.Errorf( + "native wallet funding differs from approved fee cap", + ) + } + if pkg.ChangeOutputIndex >= 0 { + packet, err := DecodePSBT(pkg.CommittedAnchorPsbt) + if err != nil { + return err + } + if int(pkg.ChangeOutputIndex) != len(packet.UnsignedTx.TxOut)-1 { + return fmt.Errorf("wallet change must be the added final output") + } + for _, output := range pkg.Outputs { + if output.AnchorOutputIndex == uint32(pkg.ChangeOutputIndex) { + return fmt.Errorf("wallet change cannot replace an asset output") + } + } + } + return nil +} + +// checkWalletFunding retains fixed approved recipients by allowing only a new +// wallet-generated change output. Existing and NoNew can change the accounting +// of an independently approved recipient without changing its script. +func checkWalletFunding(funding sdk.CustomAnchorFundingPlan) error { + if funding.Mode != sdk.CustomAnchorFundingWalletFunded || + funding.WalletFunded == nil || funding.WalletFunded.ChangeOutput.Mode != + sdk.AnchorChangeOutputAdd { + + return fmt.Errorf("wallet funding requires newly added wallet change") + } + return nil +} diff --git a/swapkit/tapsdk/approval_test.go b/swapkit/tapsdk/approval_test.go new file mode 100644 index 000000000..3a756badc --- /dev/null +++ b/swapkit/tapsdk/approval_test.go @@ -0,0 +1,214 @@ +package tapsdk + +import ( + "bytes" + "fmt" + "testing" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +func newPlanApprovalFixture(t *testing.T) (*builderFixture, + *psbt.Packet, OutputBinding) { + + t.Helper() + fixture := newCustomAnchorBuilderFixture(t) + kit, err := swapkit.NewSwapKit(swapKitTestParams(t)) + require.NoError(t, err) + fixture.request.Outputs[0], err = LegacyOPTrueOutput( + fixture.request.Outputs[0], + kit.AnchorPlan(), + ) + require.NoError(t, err) + template, err := DecodePSBT(fixture.request.AnchorPSBT) + require.NoError(t, err) + input, err := LastProof(fixture.request.Inputs[0].ProofFile) + require.NoError(t, err) + key, spend, err := LegacyOPTrueDescriptor() + require.NoError(t, err) + output := fixture.request.Outputs[0] + approval := OutputBinding{ + ID: output.ID, + IssuanceID: sdk.AssetID(input.Asset.ID()), + AssetRef: output.AssetRef, + Amount: output.Amount, + AnchorOutputIndex: output.AnchorOutputIndex, + AnchorValueSat: int64(output.AnchorValueSat), + ScriptMode: sdk.CustomAssetScriptExternal, + ScriptKey: key, + Anchor: kit.AnchorPlan(), + OPTrue: spend, + } + return fixture, template, approval +} + +func TestPlanRequiresIndependentApproval(t *testing.T) { + fixture, template, approval := newPlanApprovalFixture(t) + wallet := sdk.NewWallet(fixture.client, sdk.NetworkRegtest) + plan, err := wallet.NewCustomAnchorTxBuilder(). + Build(t.Context(), fixture.request) + require.NoError(t, err) + require.NoError( + t, + ApprovePlan( + plan, + template.UnsignedTx, + []OutputBinding{approval}, + 100_000, + ), + ) + for _, test := range []struct { + name string + change func(*OutputBinding) + }{ + {"issuance", func(a *OutputBinding) { a.IssuanceID[0] ^= 1 }}, + {"amount", func(a *OutputBinding) { a.Amount++ }}, + {"script key", func(a *OutputBinding) { a.ScriptKey.PubKey = testKey(t, 12) }}, + {"raw key parity", func(a *OutputBinding) { a.ScriptKey.KeyDesc.RawKeyBytes[0] ^= 1 }}, + {"wallet locator", func(a *OutputBinding) { a.ScriptKey.KeyDesc.KeyLocator.Index++ }}, + {"Bitcoin contract", func(a *OutputBinding) { a.Anchor.InternalKey = testKey(t, 11) }}, + } { + t.Run(test.name, func(t *testing.T) { + altered := approval + test.change(&altered) + require.Error( + t, + ApprovePlan( + plan, + template.UnsignedTx, + []OutputBinding{altered}, + 100_000, + ), + ) + }) + } + changed := template.UnsignedTx.Copy() + changed.TxIn[0].Sequence-- + require.Error( + t, + ApprovePlan(plan, changed, []OutputBinding{approval}, 100_000), + ) + changed = template.UnsignedTx.Copy() + changed.TxOut[0].Value-- + require.Error( + t, + ApprovePlan(plan, changed, []OutputBinding{approval}, 100_000), + ) + require.NoError( + t, + ApprovePlan( + plan, + template.UnsignedTx, + []OutputBinding{approval}, + 0, + ), + ) + // A deferred wallet approval must actually select wallet funding. + require.Error( + t, + ApproveWalletFundedPlan( + plan, + template.UnsignedTx, + []OutputBinding{approval}, + ), + ) + require.Equal(t, 1, fixture.client.verifyCalls) +} + +func TestPlanRequiresDefaultSigningPolicy(t *testing.T) { + for _, hash := range []txscript.SigHashType{ + txscript.SigHashAll, txscript.SigHashNone, + txscript.SigHashSingle, + txscript.SigHashAll | txscript.SigHashAnyOneCanPay, + } { + t.Run(fmt.Sprintf("hash%d", hash), func(t *testing.T) { + fixture, template, approval := newPlanApprovalFixture(t) + template.Inputs[0].SighashType = hash + var err error + fixture.request.AnchorPSBT, err = EncodePSBT(template) + require.NoError(t, err) + wallet := sdk.NewWallet(fixture.client, sdk.NetworkRegtest) + plan, err := wallet.NewCustomAnchorTxBuilder().Build( + t.Context(), fixture.request, + ) + require.NoError(t, err) + require.ErrorIs(t, ApprovePlan(plan, template.UnsignedTx, + []OutputBinding{approval}, 100_000), ErrSigningRequest) + require.ErrorIs(t, ApproveWalletFundedPlan(plan, + template.UnsignedTx, []OutputBinding{approval}), + ErrSigningRequest) + }) + } +} + +func TestWalletFundingChangeApproval(t *testing.T) { + for name, mode := range map[string]sdk.AnchorChangeOutputMode{ + "new wallet change": sdk.AnchorChangeOutputAdd, + "existing output": sdk.AnchorChangeOutputExisting, + "no new output": sdk.AnchorChangeOutputNoNew, + } { + t.Run(name, func(t *testing.T) { + fixture, template, approval := newPlanApprovalFixture(t) + fee, err := sdk.NewFeeRateSatPerVByte(1) + require.NoError(t, err) + fixture.request.Funding = sdk.CustomAnchorFundingPlan{ + Mode: sdk.CustomAnchorFundingWalletFunded, + WalletFunded: &sdk.CustomAnchorWalletFunding{ + ChangeOutput: sdk.AnchorChangeOutput{ + Mode: mode, ExistingOutputIndex: 2, + }, + Fee: sdk.AnchorFee{ + Mode: sdk.AnchorFeeSatPerVByte, FeeRate: fee, + }, + MaxFeeSat: 10_000, + CustomLockID: bytes.Repeat([]byte{1}, 32), + }, + } + wallet := sdk.NewWallet(fixture.client, sdk.NetworkRegtest) + plan, err := wallet.NewCustomAnchorTxBuilder().Build( + t.Context(), fixture.request, + ) + require.NoError(t, err) + err = ApproveWalletFundedPlan(plan, template.UnsignedTx, + []OutputBinding{approval}) + if mode == sdk.AnchorChangeOutputAdd { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, "newly added wallet change") + } + }) + } +} + +func TestFundedPackagePolicy(t *testing.T) { + f := newPathFixture(t) + plan, err := f.wallet.NewCustomAnchorTxBuilder().Build( + t.Context(), f.request, + ) + require.NoError(t, err) + pkg, err := plan.Commit(t.Context()) + require.NoError(t, err) + require.Error(t, CheckFundedPackage(pkg, 2_000)) + pkg.Funding.Mode = sdk.CustomAnchorFundingWalletFunded + pkg.Funding.MaxFeeSat = 2_000 + pkg.FundingLock.CustomLockID = bytes.Repeat([]byte{1}, 32) + pkg, err = pkg.Seal() + require.NoError(t, err) + require.NoError(t, CheckFundedPackage(pkg, 2_000)) + require.Error(t, CheckFundedPackage(pkg, 1_999)) + changedHash := withSigningHash(t, pkg, pkg.SigningPlans[0].InputIndex, + txscript.SigHashAll) + require.ErrorIs(t, CheckFundedPackage(changedHash, 2_000), + ErrSigningRequest) + for _, index := range []int32{0, 1} { + changed := pkg.Clone() + changed.ChangeOutputIndex = index + changed, err = changed.Seal() + require.NoError(t, err) + require.Error(t, CheckFundedPackage(changed, 2_000)) + } +} diff --git a/swapkit/tapsdk/bindings_test.go b/swapkit/tapsdk/bindings_test.go new file mode 100644 index 000000000..ddf48d33a --- /dev/null +++ b/swapkit/tapsdk/bindings_test.go @@ -0,0 +1,101 @@ +package tapsdk + +import ( + "testing" + + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +func TestDirectionNeutralProofBindings(t *testing.T) { + for _, test := range []struct { + name string + change func(*InputBinding) + }{ + {name: testValid}, + {"issuance", func(b *InputBinding) { b.IssuanceID[0] ^= 1 }}, + {"amount", func(b *InputBinding) { b.Amount++ }}, + {"unexpected group", func(b *InputBinding) { k := testKey(t, 11); b.GroupKey = &k }}, + {"outpoint", func(b *InputBinding) { b.AnchorOutpoint.Index++ }}, + {"script key", func(b *InputBinding) { b.ScriptKey = testKey(t, 12) }}, + {"contract authority", func(b *InputBinding) { b.Anchor.InternalKey = testKey(t, 12) }}, + {"spend root", func(b *InputBinding) { b.Spend.TaprootMerkleRoot[0] ^= 1 }}, + } { + t.Run(test.name, func(t *testing.T) { + f := newPathFixture(t) + binding := f.binding.clone() + if test.change != nil { + test.change(&binding) + } + _, err := VerifyInputProof( + t.Context(), + f.wallet, + f.chain, + 2, + f.request.Inputs[0].ProofFile, + binding, + true, + ) + if test.change == nil { + require.NoError(t, err) + require.Equal(t, 1, f.client.verifies) + } else { + require.Error(t, err) + require.Zero(t, f.client.verifies) + } + }) + } +} + +func TestApprovalChecksBitcoinOnlyFunding(t *testing.T) { + f := newPathFixture(t) + require.NoError( + t, + CheckInputsCurrent(t.Context(), f.chain, 2, f.request, true), + ) + delete(f.chain.utxos, requestPoint(t, f.request, 0)) + require.Error( + t, + CheckInputsCurrent(t.Context(), f.chain, 2, f.request, true), + ) + // Exact plan approval also caps a real nonzero native transfer fee. + f = newPathFixture(t) + template, err := DecodePSBT(f.request.AnchorPSBT) + require.NoError(t, err) + key, spend, err := LegacyOPTrueDescriptor() + require.NoError(t, err) + output := f.request.Outputs[0] + approved := OutputBinding{ + ID: output.ID, + IssuanceID: f.binding.IssuanceID, + AssetRef: output.AssetRef, + Amount: output.Amount, + AnchorOutputIndex: output.AnchorOutputIndex, + AnchorValueSat: int64(output.AnchorValueSat), + ScriptMode: sdk.CustomAssetScriptExternal, + ScriptKey: key, + Anchor: f.kit.AnchorPlan(), + OPTrue: spend, + } + plan, err := f.wallet.NewCustomAnchorTxBuilder(). + Build(t.Context(), f.request) + require.NoError(t, err) + require.NoError( + t, + ApprovePlan( + plan, + template.UnsignedTx, + []OutputBinding{approved}, + 2_000, + ), + ) + require.Error( + t, + ApprovePlan( + plan, + template.UnsignedTx, + []OutputBinding{approved}, + 1_999, + ), + ) +} diff --git a/swapkit/tapsdk/deposit.go b/swapkit/tapsdk/deposit.go index bdc4c0c7d..efd8ac803 100644 --- a/swapkit/tapsdk/deposit.go +++ b/swapkit/tapsdk/deposit.go @@ -5,10 +5,90 @@ import ( "context" "errors" + "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/loop/swapkit" sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/proof" ) +// AnchorTemplate constructs the Bitcoin transaction for one asset input. +func AnchorTemplate(p *proof.Proof, receiver sdk.InternalKey, value int64, + spend *swapkit.SwapAnchorSpendInfo) ([]byte, error) { + + if value < 330 || p == nil || + p.OutPoint().Index >= uint32(len(p.AnchorTx.TxOut)) || + p.AnchorTx.TxOut[p.OutPoint().Index] == nil { + + return nil, swapkit.ErrInvalid + } + internal, err := btcec.ParsePubKey(receiver.PubKey[:]) + if err != nil { + return nil, err + } + placeholder, err := txscript.PayToTaprootScript( + txscript.ComputeTaprootKeyNoScript(internal)) + if err != nil { + return nil, err + } + point := p.OutPoint() + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&point, nil, nil)) + tx.AddTxOut(wire.NewTxOut(value, placeholder)) + tx.TxIn[0].Sequence = 0xfffffffc + prev := p.AnchorTx.TxOut[point.Index] + if spend != nil { + if !bytes.Equal(prev.PkScript, spend.PkScript) { + return nil, swapkit.ErrInvalid + } + tx.LockTime = spend.LockTime + tx.TxIn[0].Sequence = spend.Sequence + } + packet, err := psbt.NewFromUnsignedTx(tx) + if err != nil { + return nil, err + } + packet.Inputs[0].WitnessUtxo = prev + if spend != nil { + packet.Inputs[0].TaprootInternalKey = spend.InternalKey[1:] + packet.Inputs[0].TaprootMerkleRoot = spend.TaprootMerkleRoot[:] + leaf := &psbt.TaprootTapLeafScript{ + ControlBlock: spend.ControlBlock, + Script: spend.LeafScript, + LeafVersion: txscript.BaseLeafVersion, + } + leaves := []*psbt.TaprootTapLeafScript{leaf} + packet.Inputs[0].TaprootLeafScript = leaves + } + return EncodePSBT(packet) +} + +// CustomRequest fixes one input/output with no passive assets or asset loss. +func CustomRequest(input sdk.CustomAssetInput, + output sdk.CustomAssetOutput, anchor []byte, + signing sdk.CustomAnchorInputSigningPlan) *sdk.CustomAnchorRequest { + + exact := &sdk.CustomAnchorCallerFundedExact{} + mode := sdk.CustomAnchorFundingCallerFundedExact + return &sdk.CustomAnchorRequest{ + Inputs: []sdk.CustomAssetInput{input}, + Outputs: []sdk.CustomAssetOutput{output}, AnchorPSBT: anchor, + Funding: sdk.CustomAnchorFundingPlan{ + Mode: mode, + CallerFundedExact: exact, + }, + PassiveAssets: sdk.CustomAnchorPassiveAssets{ + Policy: sdk.CustomAnchorPassiveReject, + }, + LossPolicy: sdk.CustomAnchorLossPolicy{ + Mode: sdk.CustomAnchorLossReject, + }, + SigningPlans: []sdk.CustomAnchorInputSigningPlan{signing}, + } +} + // FundingProof selects the exact confirmed wallet output for a deposit. // It inspects Loop policy; the subsequent SDK Build verifies lineage once. // ErrInvalid means the wallet holds no matching unspent output. A matching diff --git a/swapkit/tapsdk/deposit_cache_test.go b/swapkit/tapsdk/deposit_cache_test.go new file mode 100644 index 000000000..96f270a68 --- /dev/null +++ b/swapkit/tapsdk/deposit_cache_test.go @@ -0,0 +1,65 @@ +package tapsdk + +import ( + "testing" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +// TestSDKBuilderOwnsInputVerification covers the real SDK builder call. The +// Loop checks on each side must not call verifyProof a second time, and the +// post-build check rejects chain changes during the SDK verification RPC. +func TestSDKBuilderOwnsInputVerification(t *testing.T) { + for _, change := range []string{"none", testReorg, "mempool spend"} { + t.Run(change, func(t *testing.T) { + fixture := newCustomAnchorBuilderFixture(t) + p, err := LastProof(fixture.request.Inputs[0].ProofFile) + require.NoError(t, err) + chain := &bindingChain{ + tip: p.BlockHeight + 1, + unspent: true, + blocks: map[uint32]*wire.MsgBlock{ + p.BlockHeight: bindingBlock(p), + }, + } + fixture.client.afterVerify = func() { + switch change { + case testReorg: + chain.blocks[p.BlockHeight].Header.Nonce++ + case "mempool spend": + chain.unspent = false + } + } + require.NoError( + t, + CheckInputsCurrent(t.Context(), chain, 1, + fixture.request, true), + ) + require.Zero(t, fixture.client.verifyCalls) + wallet := sdk.NewWallet( + fixture.client, + sdk.NetworkRegtest, + ) + _, err = wallet.NewCustomAnchorTxBuilder(). + Build(t.Context(), fixture.request) + require.NoError(t, err) + require.Equal(t, 1, fixture.client.verifyCalls) + err = CheckInputsCurrent( + t.Context(), + chain, + 1, + fixture.request, + true, + ) + if change == "none" { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, swapkit.ErrPending) + } + require.Equal(t, 1, fixture.client.verifyCalls) + }) + } +} diff --git a/swapkit/tapsdk/funding_guard_test.go b/swapkit/tapsdk/funding_guard_test.go new file mode 100644 index 000000000..707db9f05 --- /dev/null +++ b/swapkit/tapsdk/funding_guard_test.go @@ -0,0 +1,60 @@ +package tapsdk + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +type fundingPrevouts map[wire.OutPoint]*wire.TxOut + +func (p fundingPrevouts) Prevout( + _ context.Context, + point wire.OutPoint, +) (*wire.TxOut, error) { + + return p[point], nil +} + +func TestFundingInputsRequireAuthenticatedPrevouts(t *testing.T) { + f := newPathFixture(t) + plan, err := f.wallet.NewCustomAnchorTxBuilder(). + Build(t.Context(), f.request) + require.NoError(t, err) + source := fundingPrevouts(f.chain.utxos) + require.NoError( + t, + CheckFundingInputs(t.Context(), source, plan.AnchorPSBT()), + ) + for _, test := range []struct { + name string + change func(*wire.TxOut) + }{ + {"fee amount", func(output *wire.TxOut) { output.Value++ }}, + {"fee script", func(output *wire.TxOut) { output.PkScript = []byte{0x51} }}, + } { + t.Run(test.name, func(t *testing.T) { + point := requestPoint(t, f.request, 0) + original := source[point] + fake := wire.NewTxOut(original.Value, original.PkScript) + test.change(fake) + source[point] = fake + require.Error( + t, + CheckFundingInputs( + t.Context(), + source, + plan.AnchorPSBT(), + ), + ) + source[point] = original + }) + } + delete(source, requestPoint(t, f.request, 0)) + require.Error( + t, + CheckFundingInputs(t.Context(), source, plan.AnchorPSBT()), + ) +} diff --git a/swapkit/tapsdk/inputs.go b/swapkit/tapsdk/inputs.go index eb3549331..2ee321573 100644 --- a/swapkit/tapsdk/inputs.go +++ b/swapkit/tapsdk/inputs.go @@ -1,17 +1,129 @@ package tapsdk import ( + "bytes" "context" "fmt" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/wire" + assetpolicy "github.com/lightninglabs/loop/internal/assetpolicy" "github.com/lightninglabs/loop/internal/assetproof" "github.com/lightninglabs/loop/swapkit" sdk "github.com/lightninglabs/tap-sdk" "github.com/lightninglabs/taproot-assets/proof" ) +// PrepareInputs restores proof lineage and declares OP_TRUE inputs in tapd. +// The SDK builder owns full input proof verification; this does not repeat it. +func PrepareInputs(ctx context.Context, wallet *sdk.Wallet, + request *sdk.CustomAnchorRequest) error { + + if request == nil { + return swapkit.ErrInvalid + } + // Bound the complete request before decoding or importing any history. + var size int + var raws [][]byte + for _, input := range request.Inputs { + raw := input.ProofFile + if input.ProofPath != nil { + if len(raw) != 0 { + return swapkit.ErrInvalid + } + if err := input.ProofPath.Validate(); err != nil { + return err + } + encoded, err := input.ProofPath.MarshalBinary() + if err != nil { + return err + } + if len(encoded) > assetpolicy.MaxProvenanceBytes-size { + return swapkit.ErrInvalid + } + size += len(encoded) + raw = input.ProofPath.ConfirmedBaseProof + } else { + if len(raw) > assetpolicy.MaxProvenanceBytes-size { + return swapkit.ErrInvalid + } + size += len(raw) + } + raws = append(raws, raw) + } + for _, passive := range request.PassiveAssets.Packets { + if len( + passive.ProofFile, + ) > assetpolicy.MaxProvenanceBytes-size { + + return swapkit.ErrInvalid + } + size += len(passive.ProofFile) + raws = append(raws, passive.ProofFile) + } + request = request.Clone() + for i := range raws { + raws[i] = bytes.Clone(raws[i]) + } + params, err := nodeParams(ctx, wallet) + if err != nil { + return err + } + var inventory []*proof.Proof + for _, raw := range raws { + if err := checkProvenanceWithParams(ctx, raw, params); err != nil { + return err + } + p, err := LastProof(raw) + if err != nil { + return err + } + inventory = append(inventory, p) + } + if err := assetproof.VerifyCompleteAnchors(inventory); err != nil { + return err + } + for _, input := range request.Inputs { + raw := input.ProofFile + var p *proof.Proof + if input.ProofPath != nil { + raw = input.ProofPath.ConfirmedBaseProof + if len(input.ProofPath.Steps) > 0 { + p, err = proof.Decode( + input.ProofPath.Steps[len(input.ProofPath.Steps)-1].TransitionProof, + ) + } + } + if p == nil && err == nil { + p, err = LastProof(raw) + } + if err != nil { + return err + } + if input.Witness.Mode == sdk.CustomAssetWitnessCallerProvided { + pub, err := sdk.ParsePubKey( + p.Asset.ScriptKey.PubKey.SerializeCompressed(), + ) + if err != nil { + return err + } + if err := declareOPTrue(ctx, wallet, pub, input.Witness.Stack); err != nil { + return err + } + } + if err := importProof(ctx, wallet, raw); err != nil { + return err + } + } + for _, passive := range request.PassiveAssets.Packets { + if err := importProof(ctx, wallet, passive.ProofFile); err != nil { + return err + } + } + + return nil +} + // CheckInputsCurrent repeats chain checks after SDK Build's verification RPCs. // Call it before Commit so verification cannot authorize a now stale input. func CheckInputsCurrent(ctx context.Context, chain Chain, minConfs uint32, @@ -65,6 +177,34 @@ func CheckInputsCurrent(ctx context.Context, chain Chain, minConfs uint32, return nil } +// commit rebuilds an already observed transfer's proof. It never signs a +// wallet transaction; the caller applies the observed Bitcoin witness. +func commit( + ctx context.Context, + wallet *sdk.Wallet, + chain Chain, + minConfs uint32, + request *sdk.CustomAnchorRequest, +) (*sdk.CustomAnchorTransferPackage, error) { + + if err := PrepareInputs(ctx, wallet, request); err != nil { + return nil, err + } + plan, err := wallet.NewCustomAnchorTxBuilder().Build(ctx, request) + if err != nil { + return nil, err + } + err = CheckInputsCurrent(ctx, chain, minConfs, request, false) + if err != nil { + return nil, err + } + return plan.Commit(ctx, sdk.CustomAnchorCommitOptions{ + Publish: sdk.CustomAnchorPublishMetadata{ + Label: "Loop asset transfer", + }, + }) +} + // checkFundingCurrent checks all asset and Bitcoin-only funding inputs. Skip // contains only the outputs supplied by independently approved signed parents. func checkFundingCurrent(ctx context.Context, chain Chain, packet *psbt.Packet, diff --git a/swapkit/tapsdk/paths_test.go b/swapkit/tapsdk/paths_test.go new file mode 100644 index 000000000..fb10bbe11 --- /dev/null +++ b/swapkit/tapsdk/paths_test.go @@ -0,0 +1,335 @@ +package tapsdk + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "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/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/commitment" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tappsbt" + "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/stretchr/testify/require" +) + +type pathChain struct { + Chain + + blocks map[uint32]*wire.MsgBlock + utxos map[wire.OutPoint]*wire.TxOut +} + +func (c *pathChain) Prevout(_ context.Context, point wire.OutPoint) (*wire.TxOut, error) { + return c.utxos[point], nil +} + +func (c *pathChain) Height(context.Context) (uint32, error) { return 200, nil } + +func (c *pathChain) Block(_ context.Context, h uint32) (*wire.MsgBlock, error) { + return c.blocks[h], nil +} + +func (c *pathChain) BlockHash(_ context.Context, + height uint32) (chainhash.Hash, error) { + + block := c.blocks[height] + if block == nil { + return chainhash.Hash{}, swapkit.ErrPending + } + return block.BlockHash(), nil +} + +func (c *pathChain) Unspent(_ context.Context, p wire.OutPoint) (bool, error) { + return c.utxos[p] != nil, nil +} + +type pathClient struct { + sdk.Client + + t *testing.T + verifies, commits, publishes int + afterVerify func() + afterInfo func() +} + +func (c *pathClient) GetInfo(context.Context) (*sdk.Info, error) { + if c.afterInfo != nil { + c.afterInfo() + } + return fixtureNodeInfo(), nil +} + +func (c *pathClient) VerifyProof(_ context.Context, raw []byte) (*sdk.VerifyProofResponse, error) { + c.verifies++ + if c.afterVerify != nil { + c.afterVerify() + } + return fixtureVerified(raw, true) +} + +func (c *pathClient) CustomAnchorCapabilities(context.Context) (*sdk.CustomAnchorCapabilities, error) { + v := sdk.DefaultTapdCustomAnchorCapabilities() + return &v, nil +} + +func (c *pathClient) PublishAndLogTransferWithRequest(context.Context, + *sdk.PublishAndLogTransferRequest) (*sdk.AssetPacket, error) { + + c.publishes++ + return nil, fmt.Errorf("publication is outside this test") +} + +// The actual SDK builds and validates transitions and verifies this deterministic +// test backend's commit response. Production uses tapd's implementation. +func (c *pathClient) CommitVirtualPsbtsWithRequest(_ context.Context, + r *sdk.CommitVirtualPsbtsRequest) (*sdk.CommitVirtualPsbtsResponse, error) { + + c.commits++ + t := c.t + anchor, err := DecodePSBT(r.AnchorPsbt) + require.NoError(t, err) + decode := func(raws [][]byte) []*tappsbt.VPacket { + v := make([]*tappsbt.VPacket, len(raws)) + for i, raw := range raws { + v[i], err = tappsbt.Decode(raw) + require.NoError(t, err) + } + return v + } + active, passive := decode(r.VirtualPsbts), decode(r.PassiveAssetPsbts) + all := append(append([]*tappsbt.VPacket(nil), active...), passive...) + commits, err := tapsend.CreateOutputCommitments(all, tapsend.WithSpenderLeaves()) + require.NoError(t, err) + for _, p := range all { + require.NoError(t, tapsend.UpdateTaprootOutputKeys(anchor, p, commits)) + } + for _, p := range all { + for i := range p.Outputs { + p.Outputs[i].ProofSuffix, err = tapsend.CreateProofSuffix( + anchor.UnsignedTx, anchor.Outputs, p, commits, i, all, + proof.WithVersion(proof.TransitionV1), + ) + require.NoError(t, err) + } + } + encode := func(packets []*tappsbt.VPacket) [][]byte { + raw := make([][]byte, len(packets)) + for i, p := range packets { + raw[i], err = tappsbt.Encode(p) + require.NoError(t, err) + } + return raw + } + raw, err := EncodePSBT(anchor) + require.NoError(t, err) + return &sdk.CommitVirtualPsbtsResponse{ + AnchorPsbt: raw, VirtualPsbts: encode(active), + PassiveAssetPsbts: encode(passive), ChangeOutputIndex: -1, + }, nil +} + +type pathFixture struct { + wallet *sdk.Wallet + client *pathClient + chain *pathChain + request *sdk.CustomAnchorRequest + binding InputBinding + kit *swapkit.SwapKit +} + +func newPathFixture(t *testing.T) *pathFixture { + t.Helper() + f := &pathFixture{client: &pathClient{t: t}, chain: &pathChain{ + blocks: make(map[uint32]*wire.MsgBlock), + utxos: make(map[wire.OutPoint]*wire.TxOut), + }} + f.wallet = sdk.NewWallet(f.client, sdk.NetworkRegtest) + deposit, err := swapkit.NewDepositKit(swapkit.DepositKitParams{ + ClientKey: bindingPub(1), ServerKey: bindingPub(2), CSVExpiry: 144, + }) + require.NoError(t, err) + params := swapKitTestParams(t) + params.Policy, params.Expiry = swapkit.SwapHTLCCLTV, 1_000_000 + f.kit, err = swapkit.NewSwapKit(params) + require.NoError(t, err) + key, optrue, err := LegacyOPTrueDescriptor() + require.NoError(t, err) + pub, err := btcec.ParsePubKey(key.PubKey[:]) + require.NoError(t, err) + genesis := asset.Genesis{ + FirstPrevOut: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 1}, + Tag: "native-path", OutputIndex: 0, Type: asset.Normal, + } + a, err := asset.New(genesis, 1_000, 0, 0, asset.NewScriptKey(pub), nil, asset.WithAssetVersion(asset.V1)) + require.NoError(t, err) + version := commitment.TapCommitmentV2 + tc, err := commitment.FromAssets(&version, a) + require.NoError(t, err) + root := tc.TapscriptRoot(nil) + spend, err := deposit.TimeoutSpend(swapkit.Hash(root)) + require.NoError(t, err) + mint := wire.NewMsgTx(2) + mint.AddTxIn(wire.NewTxIn(&genesis.FirstPrevOut, nil, nil)) + mint.AddTxOut(wire.NewTxOut(1_000, spend.PkScript)) + block := &wire.MsgBlock{Header: wire.BlockHeader{MerkleRoot: mint.TxHash()}, Transactions: []*wire.MsgTx{mint}} + plan := deposit.AnchorPlan() + internal, err := btcec.ParsePubKey(plan.InternalKey[:]) + require.NoError(t, err) + sibling, err := commitment.NewPreimageFromLeaf(txscript.NewBaseTapLeaf(plan.Leaves[0])) + require.NoError(t, err) + generated, err := proof.NewMintingBlobs(&proof.MintParams{ + Block: block, BlockHeight: 101, Tx: mint, TxIndex: 0, + OutputIndex: 0, InternalKey: internal, TaprootAssetRoot: tc, + TapscriptSibling: sibling, GenesisPoint: genesis.FirstPrevOut, + }, proof.MockVerifierCtx, proof.WithSiblingPreimage(sibling), + proof.WithGenOption(proof.WithVersion(proof.TransitionV1))) + require.NoError(t, err) + p := generated[asset.ToSerialized(a.ScriptKey.PubKey)] + raw, err := proof.EncodeAsProofFile(p) + require.NoError(t, err) + f.chain.blocks[101] = block + f.chain.utxos[p.OutPoint()] = mint.TxOut[0] + f.binding = InputBinding{ + ID: "asset", IssuanceID: sdk.AssetID(a.ID()), Amount: a.Amount, + ScriptKey: key.PubKey, Anchor: &plan, Spend: spend, + AnchorOutpoint: sdk.Outpoint{Txid: sdk.Hash(p.OutPoint().Hash), Index: 0}, + } + output, err := LegacyOPTrueOutput(sdk.CustomAssetOutput{ + ID: "next", AssetRef: sdk.AssetRefFromAssetID(sdk.AssetID(a.ID())), + Amount: a.Amount, AnchorOutputIndex: 1, AnchorValueSat: 1_000, + }, f.kit.AnchorPlan()) + require.NoError(t, err) + input := sdk.CustomAssetInput{ + ID: "asset", AssetRef: output.AssetRef, Amount: a.Amount, ProofFile: raw, + Witness: sdk.CustomAssetWitnessPlan{ + Mode: sdk.CustomAssetWitnessCallerProvided, Stack: optrue.WitnessStack(), + }, + } + f.request = f.requestFor(t, p, spend, input, output, wire.OutPoint{Hash: chainhash.Hash{99}}, 50_000) + return f +} + +func (f *pathFixture) requestFor(t *testing.T, p *proof.Proof, + spend *swapkit.SwapAnchorSpendInfo, input sdk.CustomAssetInput, + output sdk.CustomAssetOutput, feePoint wire.OutPoint, + feeValue int64) *sdk.CustomAnchorRequest { + + t.Helper() + feeKey := bindingKey(5) + feeScript, err := txscript.PayToTaprootScript(txscript.ComputeTaprootKeyNoScript(feeKey.PubKey())) + require.NoError(t, err) + feeOut := wire.NewTxOut(feeValue, feeScript) + if f.chain.utxos[feePoint] == nil { + f.chain.utxos[feePoint] = feeOut + } + tx := wire.NewMsgTx(2) + tx.LockTime = spend.LockTime + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: feePoint, Sequence: wire.MaxTxInSequenceNum - 1}) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: p.OutPoint(), Sequence: spend.Sequence}) + tx.AddTxOut(wire.NewTxOut(feeValue-2_000, feeScript)) + tx.AddTxOut(wire.NewTxOut(1_000, feeScript)) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + packet.Inputs[0].WitnessUtxo = feeOut + pub := feeKey.PubKey().SerializeCompressed() + packet.Inputs[0].TaprootInternalKey = bytes.Clone(pub[1:]) + packet.Outputs[0].TaprootInternalKey = bytes.Clone(pub[1:]) + packet.Inputs[1].WitnessUtxo = p.AnchorTx.TxOut[p.OutPoint().Index] + packet.Inputs[1].TaprootInternalKey = bytes.Clone(spend.InternalKey[1:]) + packet.Inputs[1].TaprootMerkleRoot = bytes.Clone(spend.TaprootMerkleRoot[:]) + packet.Inputs[1].TaprootLeafScript = []*psbt.TaprootTapLeafScript{{ + ControlBlock: spend.ControlBlock, Script: spend.LeafScript, + LeafVersion: txscript.BaseLeafVersion, + }} + raw, err := EncodePSBT(packet) + require.NoError(t, err) + return &sdk.CustomAnchorRequest{ + Inputs: []sdk.CustomAssetInput{input}, Outputs: []sdk.CustomAssetOutput{output}, + AnchorPSBT: raw, Funding: sdk.CustomAnchorFundingPlan{ + Mode: sdk.CustomAnchorFundingCallerFundedExact, + CallerFundedExact: &sdk.CustomAnchorCallerFundedExact{}, + }, + SigningPlans: []sdk.CustomAnchorInputSigningPlan{{InputIndex: 0, + KeyPath: &sdk.CustomAnchorKeyPathSigningPlan{Signer: sdk.XOnlyPubKey(pub[1:])}}, + ScriptSigningPlan(spend, 1)}, + } +} + +func pathFinal(t *testing.T, pkg *sdk.CustomAnchorTransferPackage) *sdk.CustomAnchorTransferPackage { + t.Helper() + packet, err := DecodePSBT(pkg.AnchorPsbt) + require.NoError(t, err) + requests, err := pkg.SigningRequests() + require.NoError(t, err) + for _, req := range requests.KeyPath { + key := txscript.TweakTaprootPrivKey(*bindingKey(5), req.TaprootMerkleRoot) + sig, err := schnorr.Sign(key, req.Sighash[:]) + require.NoError(t, err) + packet.Inputs[req.InputIndex].TaprootKeySpendSig = sig.Serialize() + } + for _, req := range requests.ScriptPath { + sig, err := schnorr.Sign(bindingKey(1), req.Sighash[:]) + require.NoError(t, err) + var b bytes.Buffer + require.NoError(t, psbt.WriteTxWitness(&b, [][]byte{sig.Serialize(), req.LeafScript, req.ControlBlock})) + packet.Inputs[req.InputIndex].FinalScriptWitness = b.Bytes() + } + require.NoError(t, psbt.MaybeFinalizeAll(packet)) + raw, err := EncodePSBT(packet) + require.NoError(t, err) + final, err := pkg.WithFinalAnchorPSBT(raw) + require.NoError(t, err) + return final +} + +func (f *pathFixture) parent(t *testing.T) (*sdk.CustomAnchorTransferPackage, *sdk.CustomAnchorTransferPackage) { + t.Helper() + plan, err := f.wallet.NewCustomAnchorTxBuilder().Build(t.Context(), f.request) + require.NoError(t, err) + unsigned, err := plan.Commit(t.Context(), sdk.CustomAnchorCommitOptions{}) + require.NoError(t, err) + return unsigned, pathFinal(t, unsigned) +} + +func (f *pathFixture) child(t *testing.T, + parent *sdk.CustomAnchorTransferPackage) (*sdk.CustomAnchorRequest, InputBinding) { + + t.Helper() + p, err := proof.Decode(parent.ProofUpdates[0].ProofBlob) + require.NoError(t, err) + root, _, err := isolatedRoots(p) + require.NoError(t, err) + spend, err := f.kit.TimeoutSpend(swapkit.Hash(root)) + require.NoError(t, err) + binding := f.binding + plan := f.kit.AnchorPlan() + binding.Anchor, binding.Spend = &plan, spend + binding.AnchorOutpoint = sdk.Outpoint{Txid: sdk.Hash(p.OutPoint().Hash), Index: p.OutPoint().Index} + input := f.request.Inputs[0] + input.ProofPath = &sdk.AssetProofPath{ + ConfirmedBaseProof: input.ProofFile, + Steps: []sdk.AssetProofPathStep{{TransitionProof: parent.ProofUpdates[0].ProofBlob}}, + } + input.ProofFile = nil + request := f.requestFor(t, p, spend, input, f.request.Outputs[0], + wire.OutPoint{Hash: p.OutPoint().Hash, Index: 0}, 48_000) + delete(f.chain.utxos, requestPoint(t, request, 0)) + return request, binding +} + +func requestPoint(t *testing.T, request *sdk.CustomAnchorRequest, index int) wire.OutPoint { + t.Helper() + p, err := DecodePSBT(request.AnchorPSBT) + require.NoError(t, err) + return p.UnsignedTx.TxIn[index].PreviousOutPoint +} diff --git a/swapkit/tapsdk/proofs.go b/swapkit/tapsdk/proofs.go index 965e991ea..b1cb924bb 100644 --- a/swapkit/tapsdk/proofs.go +++ b/swapkit/tapsdk/proofs.go @@ -131,6 +131,85 @@ func DecodePSBT(raw []byte) (*psbt.Packet, error) { return psbt.NewFromRawBytes(bytes.NewReader(raw), false) } +// EncodePSBT serializes a native Bitcoin PSBT. +func EncodePSBT(p *psbt.Packet) ([]byte, error) { + var b bytes.Buffer + err := p.Serialize(&b) + return b.Bytes(), err +} + +// MaxPackageBytes bounds native JSON package decoding, including base64 proof +// files and PSBTs. The encoded package must fit before signing and on recovery. +const MaxPackageBytes = 2 * assetpolicy.MaxProvenanceBytes + +// importProof restores lineage missing from tapd's exact-output proof archive. +func importProof(ctx context.Context, wallet *sdk.Wallet, raw []byte) error { + p, err := LastProof(raw) + if err != nil { + return err + } + key, err := sdk.ParsePubKey( + p.Asset.ScriptKey.PubKey.SerializeCompressed(), + ) + if err != nil { + return err + } + point := sdk.Outpoint{Txid: sdk.Hash(p.OutPoint().Hash), + Index: p.OutPoint().Index} + file, err := wallet.ExportProofFile(ctx, + sdk.AssetRefFromAssetID(sdk.AssetID(p.Asset.ID())), key, &point) + if err == nil && file != nil { + existing, err := LastProof(file.RawProofFile) + if err != nil { + return err + } + if existing.OutPoint() != p.OutPoint() || + existing.Asset.ID() != p.Asset.ID() || + existing.Asset.Amount != p.Asset.Amount || + !existing.Asset.ScriptKey.PubKey.IsEqual( + p.Asset.ScriptKey.PubKey, + ) { + + return swapkit.ErrInvalid + } + return nil + } + _, err = wallet.ImportProofFile(ctx, + &sdk.ProofFile{RawProofFile: raw}) + return err +} + +// declareOPTrue registers the exact public asset script tree with tapd. +func declareOPTrue(ctx context.Context, wallet *sdk.Wallet, + scriptKey sdk.PubKey, + witness [][]byte) error { + + if len(witness) != 2 || + !bytes.Equal(witness[0], []byte{txscript.OP_TRUE}) { + + return swapkit.ErrInvalid + } + control, err := txscript.ParseControlBlock(witness[1]) + if err != nil { + return err + } + internal, err := sdk.ParsePubKey( + control.InternalKey.SerializeCompressed(), + ) + if err != nil { + return err + } + _, err = wallet.Client(). + DeclareScriptKey(ctx, &sdk.DeclareScriptKeyRequest{ + ScriptKey: sdk.ScriptKey{PubKey: scriptKey, + KeyDesc: sdk.KeyDescriptor{ + RawKeyBytes: internal, + }, + TapTweak: control.RootHash(witness[0])}, + }) + return err +} + // SDKPoint converts a native SDK outpoint to the Bitcoin wire type. func SDKPoint(p sdk.Outpoint) wire.OutPoint { return wire.OutPoint{Hash: chainhash.Hash(p.Txid), Index: p.Index} diff --git a/swapkit/tapsdk/signing.go b/swapkit/tapsdk/signing.go new file mode 100644 index 000000000..0e1c90c53 --- /dev/null +++ b/swapkit/tapsdk/signing.go @@ -0,0 +1,8 @@ +package tapsdk + +import ( + "errors" +) + +// ErrSigningRequest means an SDK signing request failed its fixed binding. +var ErrSigningRequest = errors.New("invalid asset signing request") diff --git a/swapkit/tapsdk/signing_policy.go b/swapkit/tapsdk/signing_policy.go new file mode 100644 index 000000000..893bcf39d --- /dev/null +++ b/swapkit/tapsdk/signing_policy.go @@ -0,0 +1,63 @@ +package tapsdk + +import ( + "fmt" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + sdk "github.com/lightninglabs/tap-sdk" +) + +// CheckSigningPolicy validates a native package and requires SIGHASH_DEFAULT +// for every caller-declared signing plan. Call it before creating a MuSig2 +// session or signing a caller-funded package. The native serialized package +// must also fit DecodePackage's recovery limit before signatures are exposed. +// Backend-managed wallet fee inputs retain the native wallet's signing policy. +func CheckSigningPolicy(pkg *sdk.CustomAnchorTransferPackage) error { + if pkg == nil { + return ErrSigningRequest + } + if err := pkg.Validate(); err != nil { + return err + } + // Finalized PSBTs may omit sighash metadata. The immutable committed + // PSBT is the source used by the SDK's signing requests. + packet, err := DecodePSBT(pkg.CommittedAnchorPsbt) + if err != nil { + return err + } + if err := checkSigningPlans(packet, pkg.SigningPlans); err != nil { + return err + } + raw, err := pkg.MarshalBinary() + if err != nil { + return err + } + if len(raw) > MaxPackageBytes { + return fmt.Errorf("%w: native package exceeds %d-byte recovery limit", + ErrSigningRequest, MaxPackageBytes) + } + return nil +} + +func checkSigningPlans(packet *psbt.Packet, + plans []sdk.CustomAnchorInputSigningPlan) error { + + if packet == nil || packet.UnsignedTx == nil || + len(packet.Inputs) != len(packet.UnsignedTx.TxIn) { + + return ErrSigningRequest + } + for _, plan := range plans { + if plan.InputIndex >= uint32(len(packet.Inputs)) { + return ErrSigningRequest + } + if packet.Inputs[plan.InputIndex].SighashType != + txscript.SigHashDefault { + + return fmt.Errorf("%w: input %d requires SIGHASH_DEFAULT", + ErrSigningRequest, plan.InputIndex) + } + } + return nil +} diff --git a/swapkit/tapsdk/signing_policy_test.go b/swapkit/tapsdk/signing_policy_test.go new file mode 100644 index 000000000..5fb2f61ff --- /dev/null +++ b/swapkit/tapsdk/signing_policy_test.go @@ -0,0 +1,25 @@ +package tapsdk + +import ( + "testing" + + "github.com/btcsuite/btcd/txscript" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +func withSigningHash(t *testing.T, pkg *sdk.CustomAnchorTransferPackage, + index uint32, hash txscript.SigHashType) *sdk.CustomAnchorTransferPackage { + + t.Helper() + pkg = pkg.Clone() + packet, err := DecodePSBT(pkg.CommittedAnchorPsbt) + require.NoError(t, err) + packet.Inputs[index].SighashType = hash + raw, err := EncodePSBT(packet) + require.NoError(t, err) + pkg.CommittedAnchorPsbt, pkg.AnchorPsbt = raw, raw + pkg, err = pkg.Seal() + require.NoError(t, err) + return pkg +} From 79e4171c89d1d292f9494bb6efa0daf21dc7489b Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 3 Nov 2026 11:13:30 +0100 Subject: [PATCH 06/25] tapsdk: Bind signing to the approved package Check signing policy and the saved package before using LND, and accept peer witnesses only for the approved transaction. Preserve durable recovery while ephemeral signing sessions remain caller-owned. Separate contract-input signing from finalization so wallet-funded callers can sign fee inputs before sealing the final anchor transaction. --- staticaddr/script/signing.go | 62 +++ swapkit/tapsdk/approval_test.go | 25 ++ swapkit/tapsdk/bindings_test.go | 23 ++ .../tapsdk/native_package_regression_test.go | 128 ++++++ swapkit/tapsdk/package_decode_test.go | 54 +++ swapkit/tapsdk/proofs.go | 44 +++ swapkit/tapsdk/signing.go | 86 ++++ swapkit/tapsdk/signing_policy_test.go | 68 ++++ swapkit/tapsdk/signing_test.go | 366 ++++++++++++++++++ swapkit/tapsdk/spend_test.go | 28 ++ swapkit/tapsdk/testdata/musig.package | Bin 0 -> 14402 bytes swapkit/tapsdk/testdata/script.package | Bin 0 -> 14467 bytes 12 files changed, 884 insertions(+) create mode 100644 staticaddr/script/signing.go create mode 100644 swapkit/tapsdk/native_package_regression_test.go create mode 100644 swapkit/tapsdk/package_decode_test.go create mode 100644 swapkit/tapsdk/signing_test.go create mode 100644 swapkit/tapsdk/spend_test.go create mode 100644 swapkit/tapsdk/testdata/musig.package create mode 100644 swapkit/tapsdk/testdata/script.package diff --git a/staticaddr/script/signing.go b/staticaddr/script/signing.go new file mode 100644 index 000000000..47658ce47 --- /dev/null +++ b/staticaddr/script/signing.go @@ -0,0 +1,62 @@ +package script + +import ( + "bytes" + "context" + "errors" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/lightninglabs/lndclient" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" +) + +// CreateMuSig2Session starts a normal LND session for the complete Taproot +// root. Asset deposits include the asset commitment alongside the CSV leaf. +// The caller owns this ephemeral session and retires it after any ambiguity. +func CreateMuSig2Session(ctx context.Context, signer lndclient.SignerClient, + key keychain.KeyLocator, participants [][]byte, root []byte) ( + *input.MuSig2SessionInfo, error) { + + keys := make([]*btcec.PublicKey, len(participants)) + for i, raw := range participants { + var err error + keys[i], err = btcec.ParsePubKey(raw) + if err != nil { + return nil, err + } + } + expected, err := input.MuSig2CombineKeys(input.MuSig2Version100RC2, + keys, true, &input.MuSig2Tweaks{ + TaprootTweak: root, TaprootBIP0086Tweak: len(root) == 0, + }) + if err != nil { + return nil, err + } + session, err := signer.MuSig2CreateSession( + ctx, + input.MuSig2Version100RC2, + &key, + participants, + lndclient.MuSig2TaprootTweakOpt(root, len(root) == 0), + ) + if err != nil { + return nil, err + } + if session != nil && session.CombinedKey != nil && + bytes.Equal(schnorr.SerializePubKey(session.CombinedKey), + schnorr.SerializePubKey(expected.FinalKey)) { + + return session, nil + } + if session != nil { + cleanup, cancel := context.WithTimeout( + context.WithoutCancel(ctx), 5*time.Second, + ) + defer cancel() + _ = signer.MuSig2Cleanup(cleanup, session.SessionID) + } + return nil, errors.New("LND returned an unexpected MuSig2 output key") +} diff --git a/swapkit/tapsdk/approval_test.go b/swapkit/tapsdk/approval_test.go index 3a756badc..8919fd842 100644 --- a/swapkit/tapsdk/approval_test.go +++ b/swapkit/tapsdk/approval_test.go @@ -212,3 +212,28 @@ func TestFundedPackagePolicy(t *testing.T) { require.Error(t, CheckFundedPackage(changed, 2_000)) } } + +func TestBackendFeeSigningPolicy(t *testing.T) { + f := newPathFixture(t) + plan, err := f.wallet.NewCustomAnchorTxBuilder().Build( + t.Context(), f.request, + ) + require.NoError(t, err) + pkg, err := plan.Commit(t.Context()) + require.NoError(t, err) + packet, err := DecodePSBT(pkg.CommittedAnchorPsbt) + require.NoError(t, err) + fee := packet.UnsignedTx.TxIn[0].PreviousOutPoint + pkg.Funding.Mode = sdk.CustomAnchorFundingWalletFunded + pkg.Funding.MaxFeeSat = 2_000 + pkg.FundingLock.CustomLockID = bytes.Repeat([]byte{1}, 32) + pkg.BackendManagedInputIndices = []uint32{0} + pkg.LockedUTXOs = []sdk.CustomAnchorLockedUTXO{{ + Outpoint: sdk.Outpoint{Txid: sdk.Hash(fee.Hash), Index: fee.Index}, + }} + pkg.SigningPlans = pkg.SigningPlans[1:] + // The wallet owns its fee-input signatures; the explicit contract plan + // still uses SIGHASH_DEFAULT. + pkg = withSigningHash(t, pkg, 0, txscript.SigHashAll) + require.NoError(t, CheckFundedPackage(pkg, 2_000)) +} diff --git a/swapkit/tapsdk/bindings_test.go b/swapkit/tapsdk/bindings_test.go index ddf48d33a..79a7f31ef 100644 --- a/swapkit/tapsdk/bindings_test.go +++ b/swapkit/tapsdk/bindings_test.go @@ -1,6 +1,7 @@ package tapsdk import ( + "context" "testing" sdk "github.com/lightninglabs/tap-sdk" @@ -47,6 +48,28 @@ func TestDirectionNeutralProofBindings(t *testing.T) { } } +type unsupportedNode struct{ sdk.Client } + +func (*unsupportedNode) GetInfo(context.Context) (*sdk.Info, error) { + return &sdk.Info{Version: "0.8.7", Network: "regtest"}, nil +} + +func TestLiveFloorPreservesLocalRecovery(t *testing.T) { + wallet := sdk.NewWallet(&unsupportedNode{}, sdk.NetworkRegtest) + require.ErrorContains(t, CheckNodeVersion(t.Context(), wallet), "0.8.8") + // Native saved-package and witness decoding has no tapd-version gate. + _, final := nativeSignedFixture(t) + raw, err := final.MarshalBinary() + require.NoError(t, err) + recovered, err := DecodeFinalPackage(raw) + require.NoError(t, err) + require.Equal( + t, + final.CommittedPackageDigest, + recovered.CommittedPackageDigest, + ) +} + func TestApprovalChecksBitcoinOnlyFunding(t *testing.T) { f := newPathFixture(t) require.NoError( diff --git a/swapkit/tapsdk/native_package_regression_test.go b/swapkit/tapsdk/native_package_regression_test.go new file mode 100644 index 000000000..359322f49 --- /dev/null +++ b/swapkit/tapsdk/native_package_regression_test.go @@ -0,0 +1,128 @@ +package tapsdk + +import ( + "bytes" + "testing" + + "github.com/btcsuite/btcd/btcutil/psbt" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +// nativeSignedFixture completes the public package using the existing LND +// session fixture. Both packages retain the same committed transaction. +func nativeSignedFixture(t *testing.T) (*sdk.CustomAnchorTransferPackage, + *sdk.CustomAnchorTransferPackage) { + + t.Helper() + approved := signingPackage(t) + signed, err := signTestPackage( + t.Context(), + signingWallet(t, 2), + signingWallet(t, 3), + approved, + ) + require.NoError(t, err) + finalized, err := approved.WithFinalAnchorPSBT( + finishSigningFixture(t, signed), + ) + require.NoError(t, err) + return approved, finalized +} + +// TestNativeFinalPackageBindsApprovedPolicy checks the claim-acceptance helper +// used by VerifyClaim. A valid final witness and matching unsigned transaction +// do not authorize replacing the approved signing policy or package metadata. +func TestNativeFinalPackageBindsApprovedPolicy(t *testing.T) { + approved, finalized := nativeSignedFixture(t) + raw, err := finalized.MarshalBinary() + require.NoError(t, err) + bound, err := BindFinalPackage(approved, raw) + require.NoError(t, err) + require.Equal(t, finalized.PackageDigest, bound.PackageDigest) + require.Equal(t, approved.CommittedPackageDigest, + bound.CommittedPackageDigest) + require.NotEqual(t, approved.PackageDigest, bound.PackageDigest) + + for _, test := range []struct { + name string + change func(*sdk.CustomAnchorTransferPackage) + }{ + { + name: "publish metadata", + change: func(p *sdk.CustomAnchorTransferPackage) { + p.Publish.Label += " changed" + }, + }, + { + name: "signing session context", + change: func(p *sdk.CustomAnchorTransferPackage) { + for i := range p.SigningPlans { + plan := &p.SigningPlans[i] + if plan.MuSig2 != nil { + plan.MuSig2.SessionContext = []byte( + "another approved operation", + ) + } + } + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + changed := finalized.Clone() + test.change(changed) + changed, err := changed.Seal() + require.NoError(t, err) + require.Equal(t, approved.UnsignedTxDigest, + changed.UnsignedTxDigest) + require.NotEqual(t, approved.CommittedPackageDigest, + changed.CommittedPackageDigest) + // The substitute is internally valid, including its final + // witnesses. Only binding to the approved policy rejects it. + _, err = changed.WithFinalAnchorPSBT(changed.AnchorPsbt) + require.NoError(t, err) + raw, err := changed.MarshalBinary() + require.NoError(t, err) + bound, err := BindFinalPackage(approved, raw) + require.Error(t, err) + require.Nil(t, bound) + }) + } +} + +// TestNativeClaimPackageRequiresFinalWitness prevents validated SDK package +// decoding from being mistaken for full witness validation at claim acceptance. +func TestNativeClaimPackageRequiresFinalWitness(t *testing.T) { + approved, finalized := nativeSignedFixture(t) + for _, name := range []string{"unsigned", "partial", "invalid witness"} { + t.Run(name, func(t *testing.T) { + proposed := approved.Clone() + switch name { + case "unsigned": + proposed.AnchorPsbt = proposed.CommittedAnchorPsbt + case "invalid witness": + proposed = finalized.Clone() + packet, err := DecodePSBT(proposed.AnchorPsbt) + require.NoError(t, err) + var witness bytes.Buffer + require.NoError(t, psbt.WriteTxWitness( + &witness, [][]byte{{1}}, + )) + packet.Inputs[0].FinalScriptWitness = witness.Bytes() + proposed.AnchorPsbt, err = EncodePSBT(packet) + require.NoError(t, err) + } + proposed, err := proposed.Seal() + require.NoError(t, err) + raw, err := proposed.MarshalBinary() + require.NoError(t, err) + var decoded sdk.CustomAnchorTransferPackage + require.NoError(t, decoded.UnmarshalBinary(raw)) + require.Equal(t, approved.CommittedPackageDigest, + decoded.CommittedPackageDigest) + bound, err := BindFinalPackage(approved, raw) + require.Error(t, err) + require.Nil(t, bound) + }) + } +} diff --git a/swapkit/tapsdk/package_decode_test.go b/swapkit/tapsdk/package_decode_test.go new file mode 100644 index 000000000..4169b4a78 --- /dev/null +++ b/swapkit/tapsdk/package_decode_test.go @@ -0,0 +1,54 @@ +package tapsdk + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "testing" + + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +func TestDecodePackage(t *testing.T) { + approved, final := nativeSignedFixture(t) + for _, pkg := range []*sdk.CustomAnchorTransferPackage{approved, final} { + raw, err := pkg.MarshalBinary() + require.NoError(t, err) + decoded, err := DecodePackage(raw) + require.NoError(t, err) + require.Equal(t, pkg.PackageDigest, decoded.PackageDigest) + require.Equal(t, pkg.CommittedPackageDigest, + decoded.CommittedPackageDigest) + } + + changed := approved.Clone() + changed.Outputs[0].Amount++ + raw, err := approved.MarshalBinary() + require.NoError(t, err) + // Encode an inconsistent DTO without the SDK's validating marshaler. + type packageJSON sdk.CustomAnchorTransferPackage + // Preserve the existing SDK/contract JSON field names. + //nolint:musttag + payload, err := json.Marshal((*packageJSON)(changed)) + require.NoError(t, err) + headerSize := bytes.IndexByte(raw, '{') + require.Positive(t, headerSize) + raw = append(raw[:headerSize:headerSize], payload...) + binary.BigEndian.PutUint32(raw[headerSize-4:headerSize], + uint32(len(payload))) + decoded, err := DecodePackage(raw) + require.Error(t, err) + require.Nil(t, decoded) + + _, err = DecodePackage(raw[:len(raw)-1]) + require.Error(t, err) + _, err = DecodePackage(nil) + require.Error(t, err) +} + +func TestDecodePackageSizeLimit(t *testing.T) { + _, err := DecodePackage(make([]byte, MaxPackageBytes+1)) + require.ErrorIs(t, err, swapkit.ErrInvalid) +} diff --git a/swapkit/tapsdk/proofs.go b/swapkit/tapsdk/proofs.go index b1cb924bb..6522d3307 100644 --- a/swapkit/tapsdk/proofs.go +++ b/swapkit/tapsdk/proofs.go @@ -142,6 +142,50 @@ func EncodePSBT(p *psbt.Packet) ([]byte, error) { // files and PSBTs. The encoded package must fit before signing and on recovery. const MaxPackageBytes = 2 * assetpolicy.MaxProvenanceBytes +// DecodePackage bounds decoding and checks native integrity and signing policy. +func DecodePackage(raw []byte) (*sdk.CustomAnchorTransferPackage, error) { + if len(raw) > MaxPackageBytes { + return nil, swapkit.ErrInvalid + } + var pkg sdk.CustomAnchorTransferPackage + if err := pkg.UnmarshalBinary(raw); err != nil { + return nil, err + } + if err := CheckSigningPolicy(&pkg); err != nil { + return nil, err + } + return &pkg, nil +} + +// DecodeFinalPackage also verifies the complete Bitcoin witness. +func DecodeFinalPackage(raw []byte) (*sdk.CustomAnchorTransferPackage, error) { + pkg, err := DecodePackage(raw) + if err != nil { + return nil, err + } + return pkg.WithFinalAnchorPSBT(pkg.AnchorPsbt) +} + +// BindFinalPackage accepts only the approved transfer's finalized witness. +// The SDK committed digest binds transaction metadata and signing policy. +func BindFinalPackage(approved *sdk.CustomAnchorTransferPackage, + raw []byte) (*sdk.CustomAnchorTransferPackage, error) { + + if err := CheckSigningPolicy(approved); err != nil { + return nil, err + } + proposed, err := DecodePackage(raw) + if err != nil { + return nil, err + } + if proposed.CommittedPackageDigest != + approved.CommittedPackageDigest { + + return nil, swapkit.ErrInvalid + } + return approved.WithFinalAnchorPSBT(proposed.AnchorPsbt) +} + // importProof restores lineage missing from tapd's exact-output proof archive. func importProof(ctx context.Context, wallet *sdk.Wallet, raw []byte) error { p, err := LastProof(raw) diff --git a/swapkit/tapsdk/signing.go b/swapkit/tapsdk/signing.go index 0e1c90c53..e416f9352 100644 --- a/swapkit/tapsdk/signing.go +++ b/swapkit/tapsdk/signing.go @@ -1,8 +1,94 @@ package tapsdk import ( + "bytes" + "context" "errors" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" ) // ErrSigningRequest means an SDK signing request failed its fixed binding. var ErrSigningRequest = errors.New("invalid asset signing request") + +// FinalizeScriptSpend uses the normal LND signer for a unilateral spend. +// The SDK checks the resulting complete witness against its sealed package. +func FinalizeScriptSpend(ctx context.Context, signer lndclient.SignerClient, + key keychain.KeyLocator, pkg *sdk.CustomAnchorTransferPackage, + preimage *[32]byte) (*sdk.CustomAnchorTransferPackage, error) { + + final, err := SignScriptSpend(ctx, signer, key, pkg, preimage) + if err != nil { + return nil, err + } + packet, err := DecodePSBT(final.AnchorPsbt) + if err != nil { + return nil, err + } + if err := psbt.MaybeFinalizeAll(packet); err != nil { + return nil, err + } + raw, err := EncodePSBT(packet) + if err != nil { + return nil, err + } + return pkg.WithFinalAnchorPSBT(raw) +} + +// SignScriptSpend signs the one bound contract input. A wallet-funded +// caller can then sign its fee inputs before sealing the final anchor PSBT. +func SignScriptSpend(ctx context.Context, signer lndclient.SignerClient, + key keychain.KeyLocator, pkg *sdk.CustomAnchorTransferPackage, + preimage *[32]byte) (*sdk.CustomAnchorTransferPackage, error) { + + if err := CheckSigningPolicy(pkg); err != nil { + return nil, err + } + requests, err := pkg.SigningRequests() + if err != nil || len(requests.ScriptPath) != 1 { + return nil, ErrSigningRequest + } + request := requests.ScriptPath[0] + packet, err := psbt.NewFromRawBytes( + bytes.NewReader(pkg.AnchorPsbt), + false, + ) + if err != nil { + return nil, err + } + previous := make([]*wire.TxOut, len(packet.Inputs)) + for i := range packet.Inputs { + previous[i] = packet.Inputs[i].WitnessUtxo + if previous[i] == nil { + return nil, ErrSigningRequest + } + } + signatures, err := signer.SignOutputRawKeyLocator(ctx, + packet.UnsignedTx, []*lndclient.SignDescriptor{{ + KeyDesc: keychain.KeyDescriptor{KeyLocator: key}, + SignMethod: input.TaprootScriptSpendSignMethod, + WitnessScript: request.LeafScript, + HashType: txscript.SigHashType( + request.SighashType, + ), + Output: previous[request.InputIndex], + InputIndex: int(request.InputIndex), + }}, previous) + if err != nil { + return nil, err + } + if len(signatures) != 1 { + return nil, ErrSigningRequest + } + unlock := [][]byte{signatures[0]} + if preimage != nil { + unlock = append([][]byte{preimage[:]}, unlock...) + } + return pkg.ApplyScriptPathWitness(request.ID, unlock) +} diff --git a/swapkit/tapsdk/signing_policy_test.go b/swapkit/tapsdk/signing_policy_test.go index 5fb2f61ff..bb851d0d5 100644 --- a/swapkit/tapsdk/signing_policy_test.go +++ b/swapkit/tapsdk/signing_policy_test.go @@ -1,13 +1,27 @@ package tapsdk import ( + "fmt" "testing" "github.com/btcsuite/btcd/txscript" sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/require" ) +func signingPolicyPackage( + t *testing.T, file string, +) *sdk.CustomAnchorTransferPackage { + + t.Helper() + raw, err := signingFixtures.ReadFile("testdata/" + file + ".package") + require.NoError(t, err) + var pkg sdk.CustomAnchorTransferPackage + require.NoError(t, pkg.UnmarshalBinary(raw)) + return &pkg +} + func withSigningHash(t *testing.T, pkg *sdk.CustomAnchorTransferPackage, index uint32, hash txscript.SigHashType) *sdk.CustomAnchorTransferPackage { @@ -23,3 +37,57 @@ func withSigningHash(t *testing.T, pkg *sdk.CustomAnchorTransferPackage, require.NoError(t, err) return pkg } + +func TestPackageSigningPolicy(t *testing.T) { + require.ErrorIs(t, CheckSigningPolicy(nil), ErrSigningRequest) + for _, file := range []string{"musig", "script"} { + original := signingPolicyPackage(t, file) + for _, plan := range original.SigningPlans { + for _, hash := range []txscript.SigHashType{ + txscript.SigHashDefault, txscript.SigHashAll, + txscript.SigHashNone, txscript.SigHashSingle, + txscript.SigHashAll | txscript.SigHashAnyOneCanPay, + } { + name := fmt.Sprintf("%s/input%d/hash%d", file, + plan.InputIndex, hash) + t.Run(name, func(t *testing.T) { + pkg := withSigningHash(t, original, + plan.InputIndex, hash) + err := CheckSigningPolicy(pkg) + if hash == txscript.SigHashDefault { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, ErrSigningRequest) + } + }) + } + } + } +} + +func TestScriptSigningChecksPolicyBeforeSigner(t *testing.T) { + pkg := signingPolicyPackage(t, "script") + var index uint32 + for _, plan := range pkg.SigningPlans { + if plan.ScriptPath != nil { + index = plan.InputIndex + break + } + } + pkg = withSigningHash(t, pkg, index, txscript.SigHashAll) + wallet := &scriptWallet{} + final, err := FinalizeScriptSpend(t.Context(), wallet, + keychain.KeyLocator{}, pkg, nil) + require.ErrorIs(t, err, ErrSigningRequest) + require.Nil(t, final) + require.Zero(t, wallet.calls) +} + +func TestCallerProvidedSigningPolicy(t *testing.T) { + pkg := signingPolicyPackage(t, "musig") + plan := &pkg.SigningPlans[0] + plan.KeyPath, plan.MuSig2, plan.ScriptPath = nil, nil, nil + plan.CallerSigned = &sdk.CustomAnchorCallerSignedPlan{} + pkg = withSigningHash(t, pkg, plan.InputIndex, txscript.SigHashAll) + require.ErrorIs(t, CheckSigningPolicy(pkg), ErrSigningRequest) +} diff --git a/swapkit/tapsdk/signing_test.go b/swapkit/tapsdk/signing_test.go new file mode 100644 index 000000000..08eab927e --- /dev/null +++ b/swapkit/tapsdk/signing_test.go @@ -0,0 +1,366 @@ +package tapsdk + +import ( + "bytes" + "context" + "embed" + "errors" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/script" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc/signrpc" + "github.com/stretchr/testify/require" +) + +// Public packages generated with the SDK builder and deterministic test keys. +// They contain no private keys or secret nonces. +// +//go:embed testdata/*.package +var signingFixtures embed.FS + +var errLostSigningReply = errors.New("signing reply lost") + +// sessionWallet uses LND's real session manager. It never imports or exports a +// private nonce; reconstructing this wallet discards all unfinished sessions. +type sessionWallet struct { + lndclient.SignerClient + + t *testing.T + manager *input.MusigSessionManager + loseSign, loseCombine bool + wrongKey bool +} + +func signingWallet(t *testing.T, value byte) *sessionWallet { + t.Helper() + key, _ := btcec.PrivKeyFromBytes([]byte{value}) + return &sessionWallet{t: t, manager: input.NewMusigSessionManager( + func(*keychain.KeyDescriptor) (*btcec.PrivateKey, error) { + return key, nil + })} +} + +func (w *sessionWallet) MuSig2CreateSession(_ context.Context, + version input.MuSig2Version, key *keychain.KeyLocator, + pubkeys [][]byte, opts ...lndclient.MuSig2SessionOpts) ( + *input.MuSig2SessionInfo, error) { + + request := &signrpc.MuSig2SessionRequest{} + for _, opt := range opts { + opt(request) + } + require.Empty(w.t, request.PregeneratedLocalNonce) + keys := make([]*btcec.PublicKey, len(pubkeys)) + for i, raw := range pubkeys { + var err error + keys[i], err = btcec.ParsePubKey(raw) + require.NoError(w.t, err) + } + session, err := w.manager.MuSig2CreateSession(version, *key, keys, + &input.MuSig2Tweaks{ + TaprootBIP0086Tweak: request.TaprootTweak.KeySpendOnly, + TaprootTweak: request.TaprootTweak.ScriptRoot, + }, nil, nil) + if err != nil { + return nil, err + } + // Match the signrpc/lndclient boundary, which returns an x-only key. + result := *session + result.CombinedKey, err = schnorr.ParsePubKey( + schnorr.SerializePubKey(session.CombinedKey), + ) + if w.wrongKey { + key, _ := btcec.PrivKeyFromBytes([]byte{4}) + result.CombinedKey = key.PubKey() + } + return &result, err +} + +func (w *sessionWallet) MuSig2RegisterNonces(_ context.Context, + id [32]byte, nonces [][66]byte) (bool, error) { + + return w.manager.MuSig2RegisterNonces(id, nonces) +} + +func (w *sessionWallet) MuSig2Sign(_ context.Context, + id [32]byte, digest [32]byte, cleanup bool) ([]byte, error) { + + partial, err := w.manager.MuSig2Sign(id, digest, cleanup) + if err != nil { + return nil, err + } + if w.loseSign { + return nil, errLostSigningReply + } + var raw bytes.Buffer + err = partial.Encode(&raw) + return raw.Bytes(), err +} + +func (w *sessionWallet) MuSig2CombineSig(_ context.Context, + id [32]byte, raw [][]byte) (bool, []byte, error) { + + partials := make([]*musig2.PartialSignature, len(raw)) + for i := range raw { + partials[i] = &musig2.PartialSignature{} + if err := partials[i].Decode(bytes.NewReader(raw[i])); err != nil { + return false, nil, err + } + } + signature, all, err := w.manager.MuSig2CombineSig(id, partials) + if err != nil { + return false, nil, err + } + if w.loseCombine { + return false, nil, errLostSigningReply + } + return all, signature.Serialize(), nil +} + +func (w *sessionWallet) MuSig2Cleanup(_ context.Context, + id [32]byte) error { + + return w.manager.MuSig2Cleanup(id) +} + +func signingPackage(t *testing.T) *sdk.CustomAnchorTransferPackage { + t.Helper() + raw, err := signingFixtures.ReadFile("testdata/musig.package") + require.NoError(t, err) + var pkg sdk.CustomAnchorTransferPackage + require.NoError(t, pkg.UnmarshalBinary(raw)) + // This SDK fixture has two independent inputs in addition to MuSig2. + // Complete those first so this test exercises the real full anchor. + requests, err := pkg.SigningRequests() + require.NoError(t, err) + key, _ := btcec.PrivKeyFromBytes([]byte{2}) + keyRequest := requests.KeyPath[0] + tweaked := txscript.TweakTaprootPrivKey( + *key, + keyRequest.TaprootMerkleRoot, + ) + sig, err := schnorr.Sign(tweaked, keyRequest.Sighash[:]) + require.NoError(t, err) + complete, err := pkg.ApplyKeyPathSignature( + keyRequest.ID, + sig.Serialize(), + ) + require.NoError(t, err) + return complete +} + +func TestLNDSessionXOnlyOutputKey(t *testing.T) { + for name, parity := range map[string]byte{testEven: 2, "odd": 3} { + t.Run(name, func(t *testing.T) { + pkg := signingPackage(t) + requests, err := pkg.SigningRequests() + require.NoError(t, err) + request := requests.MuSig2[0] + internal, err := schnorr.ParsePubKey( + request.InternalKey[:], + ) + require.NoError(t, err) + packet, err := DecodePSBT(pkg.CommittedAnchorPsbt) + require.NoError(t, err) + + // This fixture's MuSig2 input is an external Bitcoin input. + // Vary its root to exercise both output parities without + // changing the participants or asset transfer. + var root [32]byte + var output *btcec.PublicKey + for i := range 256 { + root[0] = byte(i) + output = txscript.ComputeTaprootOutputKey( + internal, root[:], + ) + if output.SerializeCompressed()[0] == parity { + break + } + } + require.Equal( + t, + parity, + output.SerializeCompressed()[0], + ) + input := &packet.Inputs[request.InputIndex] + input.TaprootMerkleRoot = root[:] + input.WitnessUtxo.PkScript, err = + txscript.PayToTaprootScript(output) + require.NoError(t, err) + raw, err := EncodePSBT(packet) + require.NoError(t, err) + pkg.CommittedAnchorPsbt, pkg.AnchorPsbt = raw, raw + pkg, err = pkg.Seal() + require.NoError(t, err) + + wallet := signingWallet(t, 2) + round, _, err := newTestSession( + t.Context(), + wallet, + pkg, + ) + require.NoError(t, err) + require.Equal(t, byte(2), + round.CombinedKey.SerializeCompressed()[0]) + require.NoError( + t, + wallet.MuSig2Cleanup( + t.Context(), + round.SessionID, + ), + ) + + wallet.wrongKey = true + _, _, err = newTestSession(t.Context(), wallet, pkg) + require.Error(t, err) + }) + } +} + +// newTestSession uses the same ordinary helper as Bitcoin/asset FSM actions. +func newTestSession(ctx context.Context, signer lndclient.SignerClient, + pkg *sdk.CustomAnchorTransferPackage) (*input.MuSig2SessionInfo, + sdk.CustomAnchorMuSig2SigningRequest, error) { + + requests, err := pkg.SigningRequests() + if err != nil || len(requests.MuSig2) != 1 { + return nil, sdk.CustomAnchorMuSig2SigningRequest{}, ErrSigningRequest + } + request := requests.MuSig2[0] + participants := make([][]byte, len(request.Participants)) + for i := range participants { + participants[i] = request.Participants[i][:] + } + session, err := script.CreateMuSig2Session( + ctx, + signer, + keychain.KeyLocator{}, + participants, + request.TaprootMerkleRoot, + ) + return session, request, err +} + +// signTestPackage supplies native LND signing fixtures, not a production +// session wrapper. Both session objects are discarded on any failed RPC. +func signTestPackage( + ctx context.Context, + client, server *sessionWallet, + pkg *sdk.CustomAnchorTransferPackage, +) (*sdk.CustomAnchorTransferPackage, error) { + + c, request, err := newTestSession(ctx, client, pkg) + if err != nil { + return nil, err + } + defer func() { _ = client.MuSig2Cleanup(ctx, c.SessionID) }() + s, _, err := newTestSession(ctx, server, pkg) + if err != nil { + return nil, err + } + defer func() { _ = server.MuSig2Cleanup(ctx, s.SessionID) }() + if _, err := server.MuSig2RegisterNonces(ctx, s.SessionID, [][66]byte{c.PublicNonce}); err != nil { + return nil, err + } + partial, err := server.MuSig2Sign( + ctx, + s.SessionID, + request.Sighash, + true, + ) + if err != nil { + return nil, err + } + if _, err := client.MuSig2RegisterNonces(ctx, c.SessionID, [][66]byte{s.PublicNonce}); err != nil { + return nil, err + } + if _, err := client.MuSig2Sign(ctx, c.SessionID, request.Sighash, false); err != nil { + return nil, err + } + all, signature, err := client.MuSig2CombineSig( + ctx, + c.SessionID, + [][]byte{partial}, + ) + if err != nil { + return nil, err + } + if !all { + return nil, ErrSigningRequest + } + return pkg.ApplyKeyPathSignature(request.ID, signature) +} + +func TestFreshLNDSessionAfterLostReplies(t *testing.T) { + pkg := signingPackage(t) + for _, failure := range []string{"server partial", "client partial", "combine"} { + t.Run(failure, func(t *testing.T) { + client, server := signingWallet( + t, + 2, + ), signingWallet( + t, + 3, + ) + server.loseSign = failure == "server partial" + client.loseSign = failure == "client partial" + client.loseCombine = failure == "combine" + _, err := signTestPackage( + t.Context(), + client, + server, + pkg, + ) + require.ErrorIs(t, err, errLostSigningReply) + // Recreated normal LND session managers generate fresh secret nonces; + // the persisted unsigned SDK package still fixes the authorization. + signed, err := signTestPackage( + t.Context(), + signingWallet(t, 2), + signingWallet(t, 3), + pkg, + ) + require.NoError(t, err) + require.NoError( + t, + pkg.VerifyFinalAnchorPSBT( + finishSigningFixture(t, signed), + ), + ) + }) + } +} + +// finishSigningFixture finalizes all inputs together. The remaining unrelated +// script input is OP_TRUE; the caller, rather than the signer, owns witnesses. +func finishSigningFixture(t *testing.T, + pkg *sdk.CustomAnchorTransferPackage) []byte { + + t.Helper() + requests, err := pkg.SigningRequests() + require.NoError(t, err) + request := requests.ScriptPath[0] + packet, err := psbt.NewFromRawBytes( + bytes.NewReader(pkg.AnchorPsbt), + false, + ) + require.NoError(t, err) + var witness bytes.Buffer + require.NoError(t, psbt.WriteTxWitness(&witness, [][]byte{ + request.LeafScript, request.ControlBlock, + })) + packet.Inputs[request.InputIndex].FinalScriptWitness = witness.Bytes() + require.NoError(t, psbt.MaybeFinalizeAll(packet)) + var raw bytes.Buffer + require.NoError(t, packet.Serialize(&raw)) + return raw.Bytes() +} diff --git a/swapkit/tapsdk/spend_test.go b/swapkit/tapsdk/spend_test.go new file mode 100644 index 000000000..6c2d679ad --- /dev/null +++ b/swapkit/tapsdk/spend_test.go @@ -0,0 +1,28 @@ +package tapsdk + +import ( + "context" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" +) + +type scriptWallet struct { + lndclient.SignerClient + + signature []byte + failure error + calls int +} + +func (w *scriptWallet) SignOutputRawKeyLocator(context.Context, *wire.MsgTx, + []*lndclient.SignDescriptor, []*wire.TxOut) ([][]byte, error) { + + w.calls++ + if w.failure != nil { + err := w.failure + w.failure = nil + return nil, err + } + return [][]byte{w.signature}, nil +} diff --git a/swapkit/tapsdk/testdata/musig.package b/swapkit/tapsdk/testdata/musig.package new file mode 100644 index 0000000000000000000000000000000000000000..2cea2383f2197e8e98f7f82524cb654904b4f110 GIT binary patch literal 14402 zcmeHOX_KQ!lAZl4`Bj?b5(3(6K5z>iNCx=Q~UYqmsON!Pd^dEry{Yl=g52d z>7UeiNRY&kA&10>7?PtQ%M1w${!jrs9MMCPV28w*8WId#j)x=#)kwI6-^2)q-{_iz zYS4&7tthyLdoof2@8j>9JOAiSansV&FhEHyurcqt{-g9BQu^sr&UKIFM4MGtt zKBTE34lT$b^hdBD0?iF+W=KId&=uJWOATQVf*C;t=ozPm?0CqMLo@<0q}hI4f`qo* z5DNN9Kyw!U;3hFJ`qP8w0WlZmZYQLLccBvqIU zHil1djwL9DI^MRE`(Z!vm*(hxUCsQmfo(I{mp!+DBr4B?%i-X#Zij#jjru%UiGHH9PJ+r|snmV2x z{js6vVt`~DWg;K%ebvG9$Y$pv-qhiIP-NNYpjPK%J1v_l+jJ76w!FZb*PL%(MiZxN zP2O)V8EZy^?K%p5)?DJ!&S|!5-W)Sn;}2$5G&crV%G9bB?ge_fa#&Z^gOz@m#IYQ= z)kau!l}z)?(PSrktIOsxILr6-Lfp8p3^yr`xog)NB+n9oZyKg0c7}XcTPx<7z&ot* zL1C8Tg{8|a=x!D|nsK6HXL<>j-oX*i%Z{x>O=K)wNMur)8q)nh1@)1DSqOs3UwMBY z?iV>Rv9Wt74Jhf}Ud8b03I6UHX+`GC%zb3!7v4}>O{8{axGRG(mw74KmDJ+s?^pGn zHl$#+xXX!jx#7}srPB$&mxH-?oGErAm83^Hj(T;wgmwqSDdCqqdtaEFWjQw%BbG`x zAkIJj@ict;>w*01f&A-%{4+g}ZXI3y=WA5fjh*zCvH}eAZY!0M*q?|W?8nOq`MIy% zdqZSfx}>Y|8=v3C7IBzP4SjkljQhoD(oo-~D`SVnWt0ynO|BmO@8MiFlup>MLw7R8 z`Mn0$d?W-@`kEdG1MkL^$}Tt*`(@Zx6TY~zFDoK<56*FIPzGNY6M1@E`O37| z`*WL5X2vKkNpdk0s3f1qV%_N{@&q_4gme7?K9FKIx!^`%2ZbHqUQ$l4V{X|^jalN^ zLSc9FlW|lUY^=vxrNHv-9UbHMi}&_mgFI0dyI_qa@}xfP@OHfsn#2`YQt7C3+$@;; zExU}{X|wIxtl0>j$?w!4#1}TrW?N@86)<^O9|m}7pK3?2B=V@%n@KjFv!hL*jCfw5 zIb^$ysSW$$D<45tmAgxB@%di3IV6|Dcj&2~2Y0W739zSxK#4^%7LKiwyq5pL-%Hr+ zybSs$W7kMxZnQ}gZ&c^vrjR1@>Kxj9;_rgSm{Br;9kIP-%7TBTLOxjw8wD1xpMWe* z(KGXvkFoyRN^OPSae{&u&PQ=B9IWk@ zyq_LeQG;=3f9=74tNQC)`=o{ysW`< z0-n|U{36F=XdX1LdgqjfcKgP7Ri#cea& zD{?;88u{vVVOpN%7kyh)v2>-SSQv=%-1WxH&8NKCbUVKfcC%ce=yH1KPJ&Ci7{PG< zA{c~hFX^w@V;60yy$pLpxqYzw0@$mNk63~B758@DcqO)!SB$$_iR?_}aD}!9T9kSt z>$(cX2>j%|!!29r%aFz0`?>LNH~$ZXYaw(gJ@G`MbyMzojEMHIU*|v6`^^Fa&aZ=T zR>Z=LKov}vPtU^JD3ib=6-sg_DqYE$H-oV!1 zTy-&?-?$h3(}}MtwX1gE&tAFG*lvui06V=mBmy59cD-w7Wa^9^&K1OSyxt@c&Pn53 zSnO5(T+~gpm#8=x93QqoMxO5f-6v__(0Tg^)n9R}*Ep64&$oERh2@_G3yyK&0$A`X zxCSlk7wpzoaGhiX3U7)PUL097-!QzY40wJ%tJEW2>o+`?uXR>6RUj=Ac9pbUc+%?G zoWsLxMhaHZD0fz*?6FOq@1u++_K96M*N}5uzV{Ce&6ulum9jMPJTF$;AWnEZ86?uO zXvVfo><*mZ49sSBX57GR?;KxLZ}?I(v@gV|Rve#>eiu7$^>%(b10Tb16+fJLPOdMk zc+So`=Vam1RlS(Dfq4rtu4i31U$Xo82MqdYaKNw=x|PM~d>1m^*cw81DE6t+>eH8`iYtxv1%+GZJG zy|Hf3;#oW~5-G^!R6naqR3{6C1b@E(oCL4KNVuv%%-s}+LR_jHIj|@=<^TdXf zE8~NiXum^;ihi|!`9SH zX<}_{skCHvJ`LoUziEQDiX}qCP8-b2G<>$4WM+J<=gyMZELKdSp9S8>RJ<+Z^I8f3 zJPLfspK~I=7#G76H%rkq59Es132|^b``dfBZOn43Qq+C4-fdle-+n}#!pZ1S=*xCd z2EL(XZ~f*u+aB*5Wy2rYi$PW6V??Wm2)|G(JvCPgIn)-OzRj2Q!fLCOHw{S(lCjmrZ!rv*^moaU|7czV^f#kaLDq!iwdz2@ToDHcGL5L<*Ch_ z9_aLbrR?Zgy>U*bRj3@dE&tRrV^#I<0&R${3hfN|!@Q}Brhbkx&+ndo`aAHPN$$o# z>NRzjLrgAfl4SUFYBDd%f@gH^>8Ec`Zd28H+F$mR=%W_8`|Ni`RN6JLrq7k{=9veb z;6J~0fm)}z=Rf^K0dYs#-w8lMu-@%sf+yd9u52LC@ZnQxchB$F&{>p$&?sW~4a5y1 z7h;HgQ7_DsT~&Vl_r3sv|KC-3OcXjrtfBul^Y?->Pe3|Q#A|f&rRTSXFKogO#c0&$ z(mw&`SKCENTrQXEY1Gy>Ri29O+StvN>N-3~u5=RI7`CHEH`2RZMkNzh&9zIpdzI3c zv*%W41_Y0)s)4cm2kA_3Ksyq^Kq3|r2ucPRNT7%b#GulMkpzkrh|KoTpsGnp123rEr>cAo+*16|( z8R!MLXBd*e=^=+WT%<{udS8ZUb|?Tsyl37~BMu}%d^rrrA@O@fBT5~#0_y5j2FyB| z69eoz62?yR2B`y)!cRq+=cnf?Z%P-m_U$Q#r>oyl@Ho_d273-=5ugP`Qug@(AR}}| zKp6>yKM9ILsRD=xN6X%)1U$rryGSNrozaRTf*Z95j+=lPfwDlku(D7LISGg=hhKdU zFc1TBI!W%p{@lunv3?wB0%puZbPYZP7TKsTDUXHup{t^U;+kNr#6(48n1!Rw=HHV`5B4LteJKf?j>zyrTDt*;mXPOTT)`Y8EeTwkla z8Ocvu*B@GbFfs4I1kCZn68*omuV9qmIEN!2j44hGAxjCYK7p)06oTzQHiHB+KqE^D z#+Cwe0Ea$GZbD{cjI0f`MkXI+qCqYci7Xqc3Vrox02w1>Yai)OG)k>{F|x$ZZ>Ilh zC(R2oV_?`G>g0L-{IWdo6Kq?b0ypZ7Dl$Cqhw{N7v#Za)>aoGA#e*`iqeu)$itB%Z zy+=7_kO~HJqTDMmy(ozcCWOo*2n!Gh89)SPprlVFBS2V$x{xTTU&e|5jDG)NvU~?g zkWx{iA3z$Uc9f!r%r;CAO$5{hMjB-Yq9i`32Gb?MdXFJ>4R8=FDZ)Vrrh&%c7l1hu za2Dj}p>#WhOklN9IvmOrgueT%xL-h$7jfQ^cMnlKKLPKc1l08Hsr22bh+fgj6Fl24 z0GP-xpKZ@8{R)l*$oNCmE59tphNGjfFR(RIzqUQQwjaI&Y@ED! zu1mCM3LoJhfb6~v@TkXZ(CNnLb9DN`_k2rG76_2lfX1Hzv>>rta+JuJ-0AioE z2*v1$f@LCLaS$@a(YnHuhSn1$Huk6tz9C~0r92`;3vd=K%wxG>KO?9OP!uJHzTh~L zWdGG4zYplx6re!=uS59va&rCz59=dk6dA+s{Hj0v+<$Dz501k}N8zi1=KsJ^_;tMb z%e&t>3eM!=C@j9>Z1g>%gj?gk!dZ&fsEl6yqzSY?R}=N$tycDu)1a0d7C?w zu?JKi&5l&KglO|@7RVB2OdEa^_v@^EAz@1pzvke43?6SL!ag^%-B_|@TExU^N8udL zbeA}dSCqvUbF(^__p5Tl-=Bd`J5vKp7`Oueg+Ob^E0Dkq&q5@LV?UF8-D@Dpn2zY< z!64Uix-G~O8_C$fxKA*?80jP5Hr7D8Or*=fZO+2(;AqrM--TJiu5C`T$Ewa`NY|c)a=FY6wm`>bZ zGc1wbKJSG8t_06Z(hM)uG#|Nd!S~M-W!*}=9fT>rhcwoW;l^w5MQ4#Gm;s}NkX7sUL!#=Y|v+7&GLKl^vYz{Q1^vtT5&*f0xlbsjHd!Av0( zfF=hHZ0`euoV`CwAzukO#*fGZa&Td9pm+u70KK0D%T4#x0Q3j%Uy%gN4uaaf-{c@N zC-a_DJA=34yjL~|SSuI#Q4fBqXq+TMGQy^eqvH7`(gd%sIc08pu8m%#L#`^2Mz-T8 YppggB_4h|j@Du&+#ZUaM#m_(g4-SMzBLDyZ literal 0 HcmV?d00001 diff --git a/swapkit/tapsdk/testdata/script.package b/swapkit/tapsdk/testdata/script.package new file mode 100644 index 0000000000000000000000000000000000000000..4f9b0141354b2b843e25f76a17f242d9e5dac55c GIT binary patch literal 14467 zcmeHOX_Ko~lI{5`^;PO&7vPBgz$zgLBqX$UbX2ej5UW@O(hL9h$%|_9v%{|HiMBm4 z){A<;P21WgP{ddRUuf`UJkzzu0;ND|zT7*Rulg~!p5q#zp!k8n@WINYOW60$)d z9+jft8QwAQuFucHl^&8j)XNMB4vO;JzlKk4o~BV<``&Y5yRjYk&t4SxRSiNBBPat^ zGEm(Js=}!uM?j582zJN~p&1e$NDz=yAp}>e6f}p4df(6O=Av6fj)DSYG z`dAur_I*RulQa&ZG+7k|8Awuh??R9?l9(Pse+d{R7#ardMm$J{57E03Ii#RM5EKcB zghT!7yAD@qj%9{Wo}lm{^nrjscn8wJ==Ot1p&o#U@B+q+Wk8Uhj|7Y^>KI8we{d*B zz&*}E_tC__2;&5rBOX%v!G#nwc`yRpXo%{EMu{Qb&o77%67>@>f?9cI$imQo6StSuJTOuDPrBd@^ykoJPG^2K>5L>UJI8nXHV{tOrm_ETf zmZ+NJ(YBr35BrI~Skw3Ga^{z%kTUy;{(94m2KZA87R z6h>Iklbzx%FPqEYtlU@nS%hoI2obq(aik3;J9McRFadp?vzbR z9j5Fi$1#8HT9XtwBJkJd+LAg`xsO{b7TCZ$tnfjxE=Rg$C@qZhtXeht=y1_0-X{kSHKj@{GllOC%-Frc#v$~|K@tcs}Miz0HPEBKaD$M)E zY0}Wxrb}~&#buNaC{3vz_3z=lH`PwuuR?b+#f7~F&q5>yQ|6i;2LtcMmg+7z6#GTk zRuiGPa^#I#%#7s7<*K7zgMBJKfWQEBav&%#YWvZxmrjIgmJW^?%TV*C+S+22xTA^G zx>AbWow-Zr(9I1BHIc{|Je#;j*nm zPen{w)Q16H+NatPEtx!y>&+w^%{h7#sI(xc4Bw(PGPPk{eB~!9WA*NmTYSD3Zw|?) za1Fij=RxCZVv#@O1%;T~J{B$=EfMn5Q>s;?Z?3Z3;PBU!6mn zPyAibm@`Ttup_p&){5v~sZdB(;zotZ>pP%GQ?zHk@-tUon+d3UTy86=F494Y?FBPu zl#Vb>S-6|^IgjWy zPmRZR%{4i(OK;`^i^u(x%8by0u7thSpNt=Oo}j3j^!fTU)4fJKV~hhgh>0Cu+%~hl zs^lZBQLbJWrsZjVF}6h&%U4E@#et;EU2nwRe9D_mxAXg8H_KIuDW`|-B)X)F5iIZP z!64*%Nqm7ru_975$$2W%73lMFTXS4{5pt7 zp{WJC68|*V0oO!R2})zOJ1G}+(Td?I5Tsivrrz9g>zO!bkLHv-{$KG z^R(lw4mKkvbTOeOo3y66!F1qlncZc_(x)8-#^;MJ`vQIxeG}$;pF7@;DZF%h16_Y} z$NFe~<6p*~N&K-|yWOr4R#yP|ZCSDPfld3ltJ z`hIMji@J&SG8G4d<3krH$SVHN{gMU_owuJ@{pBBf^^ZxgzojcKF8(Z7aDtr)fg|$@S#gSX*8&(*r13{S2s__x8jT@dT*E*}3DvYbA)dt_7RJDoAaKCuhu8uE@S^!A~tne*{}Oj(+Ao)^n)5GMkj3=(-! zG$UIfb_ZT`2J2>aX5C=j-g%*@-teVnYG3eaEqNiOe-}M(^?H6f0~^D1RX?10PHyN{ zJm+SebFy&xI=-xJ6Z3Q_u4i31UvT^R2MqdYaKLgCrd6cqd>1pr+?rx`D`fu;4$Sx% z&DW*Nkw=Yd*I+*5#4Z@&cLj7AI;JE|pBE$7pb3(hsZKgil2AS>>*Fr7uUKGVjI5tH zDGR8ODMSTSjk_qq+elZBVRb%fggy56om%2XnICLAAa5sPusVZVT6bPOtgtFSk8@7# zbcw7EAx3Sjq^=AJadE*&jbG|%e!?u=n$`(j$_ZL=DS zh+Wp|groNQ`opj-Kid@^6Jo$HrY51bI%Pg?CL3i`Vuv%%-RuzKmn%KRX;U&lJ#iuB zD)?X~+3(Qd`02I=m>N75>6nTE%f9a4*=(tgT|(Rn zd17sCxwI5+J`I#uxM`xcjAcT?P8-b2G<>$0Wb61?&z%Lk(U)vuoJGOM#&}yO=an1) zcoc~HVgwpo|kF-6_!)o$ww`}QN^6i;T4LSL4P zI`B;`d#g9kx%PP9s2kzPT}-MP9V2FZi0})wG}84_S3*tqjBUQCb*rsXVQtD4$GmZd28H+CTOz>mwI>`|Ni`RN6HVvCoz7=9veT z;6J`qfl8;j=Rf^G0UJo#-w8m1W8dw4f+yd9u52LY@ZnQxchB$7P+63L(8y!>4a5y1 z7kr3)QZLMtT~&Vl_dWrLg6}dsItrB{D$;-J`Flc{CmN-3~u1pf#Sgxb!8|mFHbjik5bL~?8UZu?C z?77vM1;L}LYM?Fu!DACVP_)4El0e0hK-aQ3u&Tg*B3hO~tTphqKz{cBlR(>&zm*;zO7#|IR2Sqe7i8y4$vqE_s@<7#y3`Q(BdO2hec{}9DAqN>a#Gund;F*yq zNKkkSR4)TmG%62rAeI*;0vilGG_d$6GfshI+RKE%a;V%gD(dIhzrm7#Mxk+_#t|V6 z&9hJo5Zcfrfdi2YqX(j*EFc4J4f06*J`*t5XpCUE`XK{8 z9`y;>dI||+r+I^h1Du(#vdTO^Jy&^Cx-e?ro?_Tt{fdI^Q2QCIITVex?>bOp%x4?zb5M%ar<+HKju=P4`X3x&TUi8gx!DE0{qzUuiwAw^|`(3q@6{DYkNyYWH4RFi|LH7 z&r;wqw!{dfYwh`r_YBRvonCBTh@kkA-yYm(>a?GSNLC2*%-KBf;sznY>BlR#CHI;i~tgR8t-5)ct?y?;*VM*qMZ$PnLNs} z&81cgD=F#oEsPm$%~D6HU>HI$=xR2Q!1)cF`j0=t0r9{CKUJ--7y(YL7v1_u`Jh~1 zv%D$EPixnomHeP$-hm0I7ArMIsG>)D#3Zp};B71E2;_ z+!O)=6sQ9@^dYpWS0f{&ZJ;z#`6#%JMLH1PqpVO@j|Px3Lb?`Zf$CAb){BxQetc8? zS37B5kQocj_E0C!>*ts0fm_gReN-LYD^;X;;130vq2Me)FGy4My1mzUkcL9O;DRE- z&@JeDQ1>Xl4Dy0tB&c2#nFbv~>Jfy6N{|9XUXb=+yR(F zF>*ZQf;c^dOklQAj2()7gSz`ryPrUk7jfQ^cMnlKKLP8Y1l08Hsr22bh+fgj6YOml z08HeU&$j24eg#JYgb||bm0uR_4;a?Nod5PaJ1qb)!_iUL7g!ppU)!Et+Yj3T7EWF~ z*Ckprg^zF$Kz82-c+_JysB~lY@jQLvdk7~C76_2lfX1Hzv>>zNsr`c2zg}|en!1%h zL2fS8ZEGL6O(hffK1{c(5l8Wc<#J2vGP5)kO$1ohFEcPBUNI!v2>s@Q(S*$f0I`pS zgk-d%V46?>CPJn-npfCqXg*Q2WRJ?=%AuVJFVU6+7zY!BmTkZP5Yz@Jih>wla2!c; z|LTw52Xt%-7(oB8L-_YHa{dIH^%07Sl;L-F)gONDKbGVN!{MW$@Ks0izhNl+I^O*0 z+wTkoXYw!<^sg8jeT^uPSG&ABx_;Ws&!3csZwm$TuHJ$~f1d68lm{ocq`+gx$(r@3 z1Tu*mGkyyQMgdHCSlq>YdAm8&yyF%-noWqtyC1U(m?q3Cn9ypveY|n-kA}`_CB)2q z*4s|K(ZUNB1UUUgBK|x8>CZ(X>X1pBXFi%F!lPw;VTbbB8kb zfa;^!kqQ^!ZJx~nMaIl&BW&V+p0zIoY>Cp>44jY7 zB~IfdWeLT6T^-ih45XsWW&t%{58VE>cBIbB7 z$(53B3$ny$1sj<62__V@LHoA30@7t7Uk+|_7Iz0nqi)77%o1*8^Rhh}j~mUXGX4}F zsA_*6Kj{BQcRuTK$0$yECwL}`S=qE_T9+#yEv@_XGQn_Oxj9xSXt*lKw~5zU7M)Mu zKPnL8Pq)Q#Q$+B5-f8^5Ab8fUQ5=jY-j$)8V&i*YuN|yrayZ+#oSQVwon5UkgSfv& zSfX+Jyb=Dp5$8=Ho3GB3FOW-*VEBiQ{ypG4@)4kKi4YrrKC*g%)bD-afTH1} zr#eJvzyI>61VaqjK*+NSh!||U(ZkZ~4N$O(PzsOWFad~~Kwlpra|z5)5c0 zT0vfOJ&&$BFT}Rk0gd1Fly)ntiJzf1d)@yH)KF+f`=(D{Yw*-Knp!) z6V3V`GM^7C$wAaj<~^r&2A|b=uiOuCR4%fb9&A+6I7x(t4VGXW70)kzCHMl)DRbL% kZS*A`GE9LYvK>DG1M@*^{r!;>tU|wgu?oK{@#ByG0~f4WHvj+t literal 0 HcmV?d00001 From 9e4c1e6c85fc51a079bc4429d738c059e40c342d Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 3 Nov 2026 11:13:38 +0100 Subject: [PATCH 07/25] tapsdk: Verify presigned descendants through confirmation Bind native compact paths to approved signed parents. After parents confirm, replace proof metadata with authenticated histories without altering the transaction or final witnesses. --- swapkit/tapsdk/confirmation_handoff.go | 388 +++++++++++++++++ swapkit/tapsdk/confirmation_handoff_test.go | 182 ++++++++ swapkit/tapsdk/paths.go | 441 ++++++++++++++++++++ swapkit/tapsdk/paths_test.go | 125 +++++- 4 files changed, 1130 insertions(+), 6 deletions(-) create mode 100644 swapkit/tapsdk/confirmation_handoff.go create mode 100644 swapkit/tapsdk/confirmation_handoff_test.go create mode 100644 swapkit/tapsdk/paths.go diff --git a/swapkit/tapsdk/confirmation_handoff.go b/swapkit/tapsdk/confirmation_handoff.go new file mode 100644 index 000000000..929618f75 --- /dev/null +++ b/swapkit/tapsdk/confirmation_handoff.go @@ -0,0 +1,388 @@ +package tapsdk + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "reflect" + + "github.com/btcsuite/btcd/btcutil/psbt" + assetpolicy "github.com/lightninglabs/loop/internal/assetpolicy" + "github.com/lightninglabs/loop/internal/assetproof" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tappsbt" +) + +// ConfirmPackageInputs replaces a pre-signed child's compact input histories +// with verified confirmed histories. The saved digest identifies independently +// approved package terms. The package must also carry complete, valid Bitcoin +// witnesses, which are checked separately by the native SDK. Only proof +// metadata changes: Bitcoin signatures, asset state, allocations, commitments, +// fees and signing policy remain unchanged. Confirmation evidence is checked +// against both the current chain and the authenticated SDK proof verifier. +// +// The returned native package has a new CommittedPackageDigest because its +// proof histories changed. Persist the association with savedDigest and the +// updated package before publishing. The FSM must import each returned native +// ProofFile through wallet.ImportProofFile before PublishCustomAnchorTransfer; +// tapd's proof archive needs those histories to construct successor proofs. +// Import and publication retry/ambiguity remain owned by the caller. The chain +// must also implement PrevoutSource for authenticated Bitcoin funding outputs. +func ConfirmPackageInputs(ctx context.Context, wallet *sdk.Wallet, chain Chain, + minConfs uint32, approved *sdk.CustomAnchorTransferPackage, + savedDigest sdk.Hash, confirmations map[sdk.Hash]sdk.AnchorConfirmation, + bindings []InputBinding) (*sdk.CustomAnchorTransferPackage, + []*sdk.ProofFile, error) { + + if approved == nil || savedDigest == (sdk.Hash{}) || + approved.CommittedPackageDigest != savedDigest { + + return nil, nil, fmt.Errorf( + "child differs from saved committed digest", + ) + } + if err := CheckSigningPolicy(approved); err != nil { + return nil, nil, err + } + original, err := approved.WithFinalAnchorPSBT(approved.AnchorPsbt) + if err != nil { + return nil, nil, fmt.Errorf( + "child Bitcoin authorization: %w", err, + ) + } + if minConfs == 0 { + return nil, nil, fmt.Errorf("confirmation policy is required") + } + if chain == nil { + return nil, nil, fmt.Errorf("current chain backend is required") + } + if len(bindings) != len(original.Inputs) { + return nil, nil, fmt.Errorf( + "every asset input needs its agreed binding", + ) + } + byID := make(map[string]InputBinding, len(bindings)) + for _, binding := range bindings { + if binding.ID == "" { + return nil, nil, fmt.Errorf( + "asset input binding ID is required", + ) + } + if _, exists := byID[binding.ID]; exists { + return nil, nil, fmt.Errorf( + "duplicate asset input binding", + ) + } + byID[binding.ID] = binding.clone() + } + confirmations, err = cloneConfirmations(confirmations) + if err != nil { + return nil, nil, err + } + if err := CheckNodeVersion(ctx, wallet); err != nil { + return nil, nil, err + } + anchor, err := DecodePSBT(original.CommittedAnchorPsbt) + if err != nil { + return nil, nil, err + } + updated := original.Clone() + proofs := make([]*proof.Proof, len(original.Inputs)) + raws := make([][]byte, len(original.Inputs)) + var totalBytes uint64 + for idx, input := range original.Inputs { + binding, ok := byID[input.LogicalInputID] + if !ok { + return nil, nil, fmt.Errorf( + "asset input binding is missing", + ) + } + delete(byID, input.LogicalInputID) + raw, source, err := confirmInputSource(input.ProofSource, + confirmations) + if err != nil { + return nil, nil, err + } + size := uint64(len(raw)) + if size > assetpolicy.MaxProvenanceBytes-totalBytes { + return nil, nil, fmt.Errorf( + "confirmed histories exceed size budget", + ) + } + totalBytes += size + err = checkProvenance(ctx, wallet, raw) + if err != nil { + return nil, nil, err + } + p, err := LastProof(raw) + if err != nil { + return nil, nil, err + } + if err := binding.validate(p); err != nil { + return nil, nil, err + } + if err := checkPathSigning(binding, anchor, + original.SigningPlans); err != nil { + return nil, nil, err + } + if err := replaceInputProof(updated, input, p); err != nil { + return nil, nil, err + } + updated.Inputs[idx].ProofSource = source + proofs[idx], raws[idx] = p, raw + } + if err := assetproof.VerifyCompleteAnchors(proofs); err != nil { + return nil, nil, err + } + if err := checkConfirmedPackageCurrent(ctx, wallet, chain, minConfs, proofs, anchor); err != nil { + return nil, nil, err + } + // Verification reuse is scoped to this operation and exact history + // bytes. Every binding and current outpoint is checked independently. + verified := make(map[[32]byte]bool) + var files []*sdk.ProofFile + for idx, p := range proofs { + if err := checkConfirmation(ctx, chain, + minConfs, p, true); err != nil { + return nil, nil, err + } + digest := sha256.Sum256(raws[idx]) + if !verified[digest] { + result, err := wallet.Client().VerifyProof(ctx, + bytes.Clone(raws[idx])) + if err != nil { + return nil, nil, err + } + if err := checkVerifiedSummary(result, p); err != nil { + return nil, nil, err + } + verified[digest] = true + genesis := p.Asset.Genesis.FirstPrevOut + files = append(files, &sdk.ProofFile{ + RawProofFile: bytes.Clone(raws[idx]), + GenesisPoint: sdk.Outpoint{ + Txid: sdk.Hash(genesis.Hash), + Index: genesis.Index, + }, + }) + } + if err := checkConfirmation(ctx, chain, + minConfs, p, true); err != nil { + return nil, nil, err + } + } + if err := checkConfirmedPackageCurrent(ctx, wallet, chain, minConfs, proofs, anchor); err != nil { + return nil, nil, err + } + updated, err = updated.Seal() + if err != nil { + return nil, nil, fmt.Errorf( + "seal confirmed input metadata: %w", err, + ) + } + updated, err = updated.WithFinalAnchorPSBT(original.AnchorPsbt) + if err != nil { + return nil, nil, err + } + if err := checkConfirmationOnly(original, updated); err != nil { + return nil, nil, err + } + if err := checkConfirmedPackageCurrent(ctx, wallet, chain, minConfs, proofs, anchor); err != nil { + return nil, nil, err + } + return updated, files, nil +} + +func confirmInputSource(source sdk.CustomAnchorProofSourceSummary, + confirmations map[sdk.Hash]sdk.AnchorConfirmation) ([]byte, + sdk.CustomAnchorProofSourceSummary, error) { + + if source.Kind == sdk.CustomAnchorProofSourceConfirmedFile { + return bytes.Clone(source.Blob), source, nil + } + if source.Kind != sdk.CustomAnchorProofSourceCompactPath { + return nil, source, fmt.Errorf("unsupported input proof source") + } + var path sdk.AssetProofPath + if err := path.UnmarshalBinary(source.Blob); err != nil { + return nil, source, err + } + raw, err := path.ConfirmProofFile(confirmations) + if err != nil { + return nil, source, err + } + // A zero-step native path is a confirmed proof source accepted by the + // SDK publisher. Its public ContentID avoids duplicating a private hash + // domain or introducing another package/proof serialization. + confirmed := sdk.AssetProofPath{ConfirmedBaseProof: raw} + id, err := confirmed.ContentID() + if err != nil { + return nil, source, err + } + blob, err := confirmed.MarshalBinary() + if err != nil { + return nil, source, err + } + return raw, sdk.CustomAnchorProofSourceSummary{ + Kind: sdk.CustomAnchorProofSourceCompactPath, + ContentID: id, Blob: blob, + }, nil +} + +func replaceInputProof(pkg *sdk.CustomAnchorTransferPackage, + input sdk.CustomAnchorAssetInputSummary, confirmed *proof.Proof) error { + + packets := pkg.ActiveVirtualPsbts + if input.PacketRole == sdk.CustomAnchorPacketRolePassive { + packets = pkg.PassiveVirtualPsbts + } + if input.PacketIndex >= uint32(len(packets)) { + return fmt.Errorf("asset packet index is out of range") + } + packet, err := tappsbt.Decode(packets[input.PacketIndex]) + if err != nil { + return err + } + if input.VirtualInputIndex >= uint32(len(packet.Inputs)) { + return fmt.Errorf("asset input index is out of range") + } + selected := packet.Inputs[input.VirtualInputIndex] + if selected.Proof == nil || + selected.Proof.OutPoint() != confirmed.OutPoint() { + + return fmt.Errorf("confirmed proof selects another anchor") + } + var oldAsset, newAsset bytes.Buffer + if err := selected.Proof.Asset.Encode(&oldAsset); err != nil { + return err + } + if err := confirmed.Asset.Encode(&newAsset); err != nil { + return err + } + if !bytes.Equal(oldAsset.Bytes(), newAsset.Bytes()) { + return fmt.Errorf("confirmation changes the input asset state") + } + selected.Proof = confirmed + packets[input.PacketIndex], err = tappsbt.Encode(packet) + return err +} + +// checkConfirmationOnly compares native snapshots after removing only the +// permitted input-proof changes and the digests that necessarily cover them. +func checkConfirmationOnly(original, + updated *sdk.CustomAnchorTransferPackage) error { + + if original.UnsignedTxDigest != updated.UnsignedTxDigest || + !bytes.Equal(original.AnchorPsbt, updated.AnchorPsbt) || + !bytes.Equal(original.CommittedAnchorPsbt, + updated.CommittedAnchorPsbt) { + + return fmt.Errorf( + "confirmation changes the approved Bitcoin transaction", + ) + } + a, b := original.Clone(), updated.Clone() + if len(a.Inputs) != len(b.Inputs) { + return fmt.Errorf("confirmation changes asset input count") + } + for idx := range b.Inputs { + b.Inputs[idx].ProofSource = a.Inputs[idx].ProofSource + } + for _, pair := range [][2][][]byte{ + {a.ActiveVirtualPsbts, b.ActiveVirtualPsbts}, + {a.PassiveVirtualPsbts, b.PassiveVirtualPsbts}, + } { + if len(pair[0]) != len(pair[1]) { + return fmt.Errorf( + "confirmation changes virtual packet count", + ) + } + for idx, originalRaw := range pair[0] { + before, err := tappsbt.Decode(originalRaw) + if err != nil { + return err + } + after, err := tappsbt.Decode(pair[1][idx]) + if err != nil { + return err + } + if len(before.Inputs) != len(after.Inputs) { + return fmt.Errorf( + "confirmation changes virtual inputs", + ) + } + for j := range after.Inputs { + after.Inputs[j].Proof = before.Inputs[j].Proof + } + oldRaw, err := tappsbt.Encode(before) + if err != nil { + return err + } + newRaw, err := tappsbt.Encode(after) + if err != nil { + return err + } + if !bytes.Equal(oldRaw, newRaw) { + return fmt.Errorf( + "confirmation changes virtual state", + ) + } + pair[1][idx] = bytes.Clone(originalRaw) + } + } + b.CommittedPackageDigest = a.CommittedPackageDigest + b.PackageDigest = a.PackageDigest + if !reflect.DeepEqual(a, b) { + return fmt.Errorf( + "confirmation changes approved package policy", + ) + } + return nil +} + +// checkConfirmedPackageCurrent includes independent fee inputs, which are not +// selected by asset proof bindings, and repeats the live-node compatibility gate. +func checkConfirmedPackageCurrent(ctx context.Context, wallet *sdk.Wallet, + chain Chain, minConfs uint32, proofs []*proof.Proof, anchor *psbt.Packet) error { + + if err := CheckNodeVersion(ctx, wallet); err != nil { + return err + } + for _, p := range proofs { + if err := checkConfirmation(ctx, chain, minConfs, p, true); err != nil { + return err + } + } + source, ok := chain.(PrevoutSource) + if !ok { + return fmt.Errorf("confirmation handoff requires authenticated Bitcoin prevouts") + } + raw, err := EncodePSBT(anchor) + if err != nil { + return err + } + if err := CheckFundingInputs(ctx, source, raw); err != nil { + return err + } + return checkFundingCurrent(ctx, chain, anchor, nil) +} + +func cloneConfirmations(confirmations map[sdk.Hash]sdk.AnchorConfirmation) ( + map[sdk.Hash]sdk.AnchorConfirmation, error) { + + var total int + for _, c := range confirmations { + if len(c.Block) > assetpolicy.MaxProvenanceBytes-total { + return nil, fmt.Errorf("confirmation evidence exceeds size budget") + } + total += len(c.Block) + } + result := make(map[sdk.Hash]sdk.AnchorConfirmation, len(confirmations)) + for id, c := range confirmations { + c.Block = bytes.Clone(c.Block) + result[id] = c + } + return result, nil +} diff --git a/swapkit/tapsdk/confirmation_handoff_test.go b/swapkit/tapsdk/confirmation_handoff_test.go new file mode 100644 index 000000000..10d11be79 --- /dev/null +++ b/swapkit/tapsdk/confirmation_handoff_test.go @@ -0,0 +1,182 @@ +package tapsdk + +import ( + "bytes" + "testing" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/stretchr/testify/require" +) + +type handoffFixture struct { + *pathFixture + + child *sdk.CustomAnchorTransferPackage + binding InputBinding + confirmations map[sdk.Hash]sdk.AnchorConfirmation +} + +func newHandoffFixture(t *testing.T) handoffFixture { + t.Helper() + f := newPathFixture(t) + _, parent := f.parent(t) + request, binding := f.child(t, parent) + parents, err := NewApprovedParents(t.Context(), f.wallet, f.chain, 2, + request, []InputBinding{binding}, []ParentApproval{{Package: parent, + Digest: parent.CommittedPackageDigest}}) + require.NoError(t, err) + builder := f.wallet.NewCustomAnchorTxBuilder() + builder.SetConfirmedProofVerifier(parents) + plan, err := builder.Build(t.Context(), request) + require.NoError(t, err) + child, err := plan.Commit(t.Context(), sdk.CustomAnchorCommitOptions{}) + require.NoError(t, err) + child = pathFinal(t, child) + packet, err := DecodePSBT(parent.AnchorPsbt) + require.NoError(t, err) + tx, err := psbt.Extract(packet) + require.NoError(t, err) + block := &wire.MsgBlock{Header: wire.BlockHeader{MerkleRoot: tx.TxHash()}, Transactions: []*wire.MsgTx{tx}} + f.chain.blocks[150] = block + for _, input := range tx.TxIn { + delete(f.chain.utxos, input.PreviousOutPoint) + } + for i, output := range tx.TxOut { + f.chain.utxos[wire.OutPoint{Hash: tx.TxHash(), Index: uint32(i)}] = output + } + var raw bytes.Buffer + require.NoError(t, block.Serialize(&raw)) + f.client.verifies = 0 + return handoffFixture{pathFixture: f, child: child, binding: binding, + confirmations: map[sdk.Hash]sdk.AnchorConfirmation{sdk.Hash(tx.TxHash()): {BlockHeight: 150, Block: raw.Bytes()}}} +} + +// TestConfirmPackageInputsNativePublish preserves every approved transaction, +// asset and policy field while installing native confirmed input histories. +// The real SDK publisher accepts the replacement without new signatures; its +// backend is a mock and publication/import retry state remains caller-owned. +func TestConfirmPackageInputsNativePublish(t *testing.T) { + f := newHandoffFixture(t) + before := f.child.Clone() + updated, files, err := ConfirmPackageInputs(t.Context(), f.wallet, f.chain, 2, + f.child, f.child.CommittedPackageDigest, f.confirmations, []InputBinding{f.binding}) + require.NoError(t, err) + require.Equal(t, before, f.child) + require.NoError(t, checkConfirmationOnly(before, updated)) + require.NotEqual(t, before.CommittedPackageDigest, updated.CommittedPackageDigest) + require.Equal(t, before.AnchorPsbt, updated.AnchorPsbt) + require.Equal(t, before.Outputs, updated.Outputs) + require.Len(t, files, 1) + require.Equal(t, 1, f.client.verifies) + var path sdk.AssetProofPath + require.NoError(t, path.UnmarshalBinary(updated.Inputs[0].ProofSource.Blob)) + require.Empty(t, path.Steps) + require.Equal(t, files[0].RawProofFile, path.ConfirmedBaseProof) + p, err := LastProof(files[0].RawProofFile) + require.NoError(t, err) + require.Equal(t, sdk.Outpoint{Txid: sdk.Hash(p.Asset.Genesis.FirstPrevOut.Hash), + Index: p.Asset.Genesis.FirstPrevOut.Index}, files[0].GenesisPoint) + _, err = f.wallet.PublishCustomAnchorTransfer(t.Context(), before, before.AnchorPsbt) + require.ErrorIs(t, err, sdk.ErrUnconfirmedCustomAnchorPublish) + require.Zero(t, f.client.publishes) + _, err = f.wallet.PublishCustomAnchorTransfer(t.Context(), updated, updated.AnchorPsbt) + require.ErrorContains(t, err, "publication is outside this test") + require.Equal(t, 1, f.client.publishes) + f.client.verifies = 0 + retried, _, err := ConfirmPackageInputs(t.Context(), f.wallet, f.chain, 2, + updated, updated.CommittedPackageDigest, nil, []InputBinding{f.binding}) + require.NoError(t, err) + require.Equal(t, updated.CommittedPackageDigest, retried.CommittedPackageDigest) + require.Equal(t, 1, f.client.verifies) +} + +func TestConfirmPackageInputsRejectsChanges(t *testing.T) { + const spentDuringVerification = "spent during verify" + for _, name := range []string{"saved digest", "unsigned child", "missing confirmation", + "fake block", "wrong asset", "spent fee input", testSpoofedFeeValue, + "spoofed fee script", spentDuringVerification} { + t.Run(name, func(t *testing.T) { + f := newHandoffFixture(t) + digest := f.child.CommittedPackageDigest + switch name { + case "saved digest": + digest[0] ^= 1 + case "unsigned child": + f.child = f.child.Clone() + f.child.AnchorPsbt = f.child.CommittedAnchorPsbt + var err error + f.child, err = f.child.Seal() + require.NoError(t, err) + case "missing confirmation": + f.confirmations = nil + case "fake block": + for id, c := range f.confirmations { + var block wire.MsgBlock + require.NoError(t, block.Deserialize(bytes.NewReader(c.Block))) + block.Header.Nonce++ + var raw bytes.Buffer + require.NoError(t, block.Serialize(&raw)) + c.Block = raw.Bytes() + f.confirmations[id] = c + } + case "wrong asset": + f.binding.Amount++ + case "spent fee input": + p, err := DecodePSBT(f.child.AnchorPsbt) + require.NoError(t, err) + delete(f.chain.utxos, p.UnsignedTx.TxIn[0].PreviousOutPoint) + case testSpoofedFeeValue, "spoofed fee script": + p, err := DecodePSBT(f.child.AnchorPsbt) + require.NoError(t, err) + point := p.UnsignedTx.TxIn[0].PreviousOutPoint + if name == testSpoofedFeeValue { + f.chain.utxos[point].Value++ + } else { + f.chain.utxos[point].PkScript = []byte{1} + } + case spentDuringVerification: + f.client.afterVerify = func() { clear(f.chain.utxos) } + } + updated, files, err := ConfirmPackageInputs(t.Context(), f.wallet, f.chain, 2, + f.child, digest, f.confirmations, []InputBinding{f.binding}) + require.Error(t, err) + require.Nil(t, updated) + require.Nil(t, files) + if name != spentDuringVerification { + require.Zero(t, f.client.verifies) + } + }) + } +} + +func TestConfirmationSnapshotRejectsOtherMetadata(t *testing.T) { + f := newHandoffFixture(t) + for _, mutate := range []func(*sdk.CustomAnchorTransferPackage){ + func(p *sdk.CustomAnchorTransferPackage) { p.Publish.Label += "changed" }, + func(p *sdk.CustomAnchorTransferPackage) { p.Outputs[0].Amount++ }, + } { + changed := f.child.Clone() + mutate(changed) + require.Error(t, checkConfirmationOnly(f.child, changed)) + } + // A proof for another input state cannot be installed as metadata. + p, err := proof.Decode(f.child.ProofUpdates[0].ProofBlob) + require.NoError(t, err) + p.Asset.Amount++ + require.Error(t, replaceInputProof(f.child.Clone(), f.child.Inputs[0], p)) +} + +func TestConfirmationSnapshotsBeforeRPC(t *testing.T) { + f := newHandoffFixture(t) + f.client.afterInfo = func() { + f.binding.Anchor.Leaves[0][0] ^= 1 + clear(f.confirmations) + } + _, files, err := ConfirmPackageInputs(t.Context(), f.wallet, f.chain, 2, + f.child, f.child.CommittedPackageDigest, f.confirmations, []InputBinding{f.binding}) + require.NoError(t, err) + require.Len(t, files, 1) +} diff --git a/swapkit/tapsdk/paths.go b/swapkit/tapsdk/paths.go new file mode 100644 index 000000000..250d2ee54 --- /dev/null +++ b/swapkit/tapsdk/paths.go @@ -0,0 +1,441 @@ +package tapsdk + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "reflect" + "slices" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + assetpolicy "github.com/lightninglabs/loop/internal/assetpolicy" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/proof" +) + +// ParentApproval identifies independently approved native package terms. Its +// committed digest excludes final witness encoding; the complete Bitcoin +// witnesses are validated separately before any child is authorized. +type ParentApproval struct { + Package *sdk.CustomAnchorTransferPackage + Digest sdk.Hash +} + +// ApprovedParents implements the native SDK's compact-path verification +// interfaces for one build. The SDK retains ownership of virtual transitions +// and proof-path verification. Create a fresh verifier for each operation; +// install it with builder.SetConfirmedProofVerifier before native Build, then +// call CheckPlan before Commit. Persist parent approvals in the FSM. +// Compact paths currently require isolated asset anchors in the native SDK. +type ApprovedParents struct { + wallet *sdk.Wallet + chain Chain + source PrevoutSource + minConfs uint32 + parents map[chainhash.Hash]*wire.MsgTx + outputs map[wire.OutPoint]*wire.TxOut + prevouts map[wire.OutPoint]*wire.TxOut + bases map[[32]byte]*proof.Proof + verified map[[32]byte]bool + anchor *psbt.Packet + request *sdk.CustomAnchorRequest +} + +// NewApprovedParents binds every request input to independently agreed terms, +// authenticates fully signed parents, and checks current root funding. It does +// not build, commit, sign, publish or persist a transfer. Root funding must +// remain unspent (including mempool lookup) while pre-signing descendants. The +// chain must also implement PrevoutSource to authenticate their values/scripts. +func NewApprovedParents(ctx context.Context, wallet *sdk.Wallet, chain Chain, + minConfs uint32, request *sdk.CustomAnchorRequest, bindings []InputBinding, + approvals []ParentApproval) (*ApprovedParents, error) { + + if request == nil || chain == nil || minConfs == 0 { + return nil, fmt.Errorf("request, current chain and confirmation policy are required") + } + source, ok := chain.(PrevoutSource) + if !ok { + return nil, fmt.Errorf("compact parents require authenticated Bitcoin prevouts") + } + if err := checkCompactRequestBudget(request); err != nil { + return nil, err + } + request = request.Clone() + g := &ApprovedParents{wallet: wallet, chain: chain, source: source, + minConfs: minConfs, request: request, + parents: make(map[chainhash.Hash]*wire.MsgTx), outputs: make(map[wire.OutPoint]*wire.TxOut), + prevouts: make(map[wire.OutPoint]*wire.TxOut), bases: make(map[[32]byte]*proof.Proof), + verified: make(map[[32]byte]bool)} + if err := g.addParents(approvals); err != nil { + return nil, err + } + var err error + g.anchor, err = DecodePSBT(request.AnchorPSBT) + if err != nil { + return nil, err + } + byID := make(map[string]InputBinding, len(bindings)) + for _, binding := range bindings { + if binding.ID == "" { + return nil, fmt.Errorf("input binding ID is required") + } + if _, duplicate := byID[binding.ID]; duplicate { + return nil, fmt.Errorf("duplicate input binding") + } + byID[binding.ID] = binding.clone() + } + if err := CheckNodeVersion(ctx, wallet); err != nil { + return nil, err + } + inspect := func(id string, raw []byte, path *sdk.AssetProofPath) error { + binding, ok := byID[id] + if !ok { + return fmt.Errorf("input binding is missing") + } + delete(byID, id) + var p *proof.Proof + var err error + if path != nil { + if len(raw) != 0 { + return fmt.Errorf("input cannot select both proof sources") + } + p, err = g.inspectPath(ctx, path) + } else { + p, err = InspectInputProof(ctx, wallet, chain, minConfs, raw, binding, true) + if err == nil { + g.bases[sha256.Sum256(raw)] = p + } + } + if err != nil { + return err + } + if err := binding.validate(p); err != nil { + return err + } + return checkPathSigning(binding, g.anchor, request.SigningPlans) + } + for _, input := range request.Inputs { + if err := inspect(input.ID, input.ProofFile, input.ProofPath); err != nil { + return nil, err + } + } + for _, input := range request.PassiveAssets.Packets { + if err := inspect(input.ID, input.ProofFile, nil); err != nil { + return nil, err + } + } + if len(byID) != 0 { + return nil, fmt.Errorf("unused input bindings") + } + if err := g.CheckCurrent(ctx); err != nil { + return nil, err + } + return g, nil +} + +func (g *ApprovedParents) addParents(approvals []ParentApproval) error { + spenders := make(map[wire.OutPoint]chainhash.Hash) + for _, approval := range approvals { + if approval.Package == nil || approval.Digest == (sdk.Hash{}) || + approval.Package.CommittedPackageDigest != approval.Digest { + + return fmt.Errorf("parent differs from saved committed digest") + } + if err := CheckSigningPolicy(approval.Package); err != nil { + return err + } + parent, err := approval.Package.WithFinalAnchorPSBT(approval.Package.AnchorPsbt) + if err != nil { + return fmt.Errorf("parent Bitcoin authorization: %w", err) + } + packet, err := DecodePSBT(parent.AnchorPsbt) + if err != nil { + return err + } + tx, err := psbt.Extract(packet) + if err != nil { + return err + } + id := tx.TxHash() + if g.parents[id] != nil { + return fmt.Errorf("duplicate approved parent transaction") + } + g.parents[id] = tx + for idx, output := range tx.TxOut { + g.outputs[wire.OutPoint{Hash: id, Index: uint32(idx)}] = output + } + for idx, input := range tx.TxIn { + point := input.PreviousOutPoint + if previous, ok := spenders[point]; ok && previous != id { + return fmt.Errorf("approved parents double-spend an input") + } + spenders[point] = id + if packet.Inputs[idx].WitnessUtxo == nil { + return fmt.Errorf("approved parent previous output is missing") + } + g.prevouts[point] = packet.Inputs[idx].WitnessUtxo + } + } + return nil +} + +func (g *ApprovedParents) inspectPath(ctx context.Context, + path *sdk.AssetProofPath) (*proof.Proof, error) { + + if err := path.Validate(); err != nil { + return nil, err + } + params, err := nodeParams(ctx, g.wallet) + if err != nil { + return nil, err + } + pending := []*sdk.AssetProofPath{path} + var tip *proof.Proof + for len(pending) != 0 { + current := pending[len(pending)-1] + pending = pending[:len(pending)-1] + if err := checkProvenance(ctx, g.wallet, current.ConfirmedBaseProof); err != nil { + return nil, err + } + last, err := LastProof(current.ConfirmedBaseProof) + if err != nil { + return nil, err + } + if _, _, err := isolatedRoots(last); err != nil { + return nil, fmt.Errorf("compact parent paths require isolated asset anchors: %w", err) + } + g.bases[sha256.Sum256(current.ConfirmedBaseProof)] = last + for _, step := range current.Steps { + last, err = proof.Decode(step.TransitionProof) + if err != nil { + return nil, err + } + if last.ChallengeWitness != nil { + return nil, fmt.Errorf("compact path contains an ownership challenge") + } + if err := assetpolicy.ValidateTransitionVersion(last.Version); err != nil { + return nil, err + } + if height, active := assetpolicy.ActivationHeight(params); active { + if err := assetpolicy.ValidateActivation(last, height); err != nil { + return nil, err + } + } + pending = append(pending, step.CoInputPaths...) + } + if current == path { + tip = last + } + } + return tip, nil +} + +// VerifyConfirmedProof implements the native SDK confirmed-base boundary. +// Verification reuse is local to this factory instance and exact base bytes; +// every current-chain check repeats before and after authenticated tapd RPCs. +func (g *ApprovedParents) VerifyConfirmedProof(ctx context.Context, + raw []byte) (*sdk.ConfirmedProofVerification, error) { + + digest := sha256.Sum256(raw) + p := g.bases[digest] + if p == nil { + return nil, fmt.Errorf("compact base was not inspected") + } + if err := g.CheckCurrent(ctx); err != nil { + return nil, err + } + if !g.verified[digest] { + result, err := g.wallet.Client().VerifyProof(ctx, bytes.Clone(raw)) + if err != nil { + return nil, err + } + if err := checkVerifiedSummary(result, p); err != nil { + return nil, err + } + g.verified[digest] = true + } + if err := g.CheckCurrent(ctx); err != nil { + return nil, err + } + return &sdk.ConfirmedProofVerification{AnchorAssetInventoryComplete: true}, nil +} + +// VerifyUnconfirmedAnchor binds SDK-derived transitions to exact independently +// approved parents after executing their complete Bitcoin witnesses. +func (g *ApprovedParents) VerifyUnconfirmedAnchor(_ context.Context, + transition sdk.UnconfirmedAnchorVerification) error { + + var tx wire.MsgTx + if err := tx.Deserialize(bytes.NewReader(transition.AnchorTransaction)); err != nil { + return err + } + parent := g.parents[tx.TxHash()] + if parent == nil || transition.AnchorOutpoint.Txid != sdk.Hash(parent.TxHash()) || + transition.AnchorOutpoint.Index >= uint32(len(parent.TxOut)) { + + return fmt.Errorf("compact transition has no approved signed parent") + } + var actual, approved bytes.Buffer + if err := tx.SerializeNoWitness(&actual); err != nil { + return err + } + if err := parent.SerializeNoWitness(&approved); err != nil { + return err + } + if !bytes.Equal(actual.Bytes(), approved.Bytes()) { + return fmt.Errorf("compact transition differs from approved parent") + } + for _, input := range transition.PreviousAnchorOutpoints { + point := SDKPoint(input) + if !slices.ContainsFunc(parent.TxIn, func(in *wire.TxIn) bool { + return in.PreviousOutPoint == point + }) { + + return fmt.Errorf("compact transition uses an unapproved parent input") + } + } + return nil +} + +// CheckCurrent rechecks live node compatibility, current confirmed bases and +// every external Bitcoin input of the parent graph and child, including fees. +func (g *ApprovedParents) CheckCurrent(ctx context.Context) error { + if err := CheckNodeVersion(ctx, g.wallet); err != nil { + return err + } + for _, p := range g.bases { + if err := checkConfirmation(ctx, g.chain, g.minConfs, p, true); err != nil { + return err + } + } + skip := make(map[wire.OutPoint]bool, len(g.outputs)) + for point, output := range g.outputs { + skip[point] = true + if previous := g.prevouts[point]; previous != nil && + (previous.Value != output.Value || !bytes.Equal(previous.PkScript, output.PkScript)) { + + return fmt.Errorf("parent input differs from approved graph output") + } + } + for point, expected := range g.prevouts { + if skip[point] { + continue + } + actual, err := g.source.Prevout(ctx, point) + if err != nil { + return err + } + if !sameFundingOutput(expected, actual) { + return fmt.Errorf("parent root differs from authenticated Bitcoin prevout") + } + unspent, err := g.chain.Unspent(ctx, point) + if err != nil { + return err + } + if !unspent { + return fmt.Errorf("approved parent root funding is spent") + } + } + for idx, input := range g.anchor.UnsignedTx.TxIn { + if g.prevouts[input.PreviousOutPoint] != nil { + return fmt.Errorf("child double-spends an approved parent input") + } + if output := g.outputs[input.PreviousOutPoint]; output != nil { + previous := g.anchor.Inputs[idx].WitnessUtxo + if previous == nil || previous.Value != output.Value || + !bytes.Equal(previous.PkScript, output.PkScript) { + + return fmt.Errorf("child input differs from approved parent output") + } + } else { + actual, err := g.source.Prevout(ctx, input.PreviousOutPoint) + if err != nil { + return err + } + if !sameFundingOutput(g.anchor.Inputs[idx].WitnessUtxo, actual) { + return fmt.Errorf("child funding differs from authenticated Bitcoin prevout") + } + } + } + return checkFundingCurrent(ctx, g.chain, g.anchor, skip) +} + +func checkPathSigning(binding InputBinding, packet *psbt.Packet, + plans []sdk.CustomAnchorInputSigningPlan) error { + + if binding.Spend == nil { + return nil + } + spend := binding.Spend + index := slices.IndexFunc(packet.UnsignedTx.TxIn, func(in *wire.TxIn) bool { + return in.PreviousOutPoint == SDKPoint(binding.AnchorOutpoint) + }) + if index < 0 || packet.UnsignedTx.Version < 2 || + packet.UnsignedTx.TxIn[index].Sequence != spend.Sequence || + packet.UnsignedTx.LockTime < spend.LockTime || + (spend.LockTime != 0 && packet.UnsignedTx.LockTime >= txscript.LockTimeThreshold) { + + return fmt.Errorf("contract sequence and locktime must be set before native Build") + } + leaf := sdk.Hash(txscript.NewBaseTapLeaf(spend.LeafScript).TapHash()) + for _, plan := range plans { + if plan.InputIndex == uint32(index) && plan.ScriptPath != nil && + plan.ScriptPath.LeafHash == leaf && slices.Equal(plan.ScriptPath.RequiredSigners, + []sdk.XOnlyPubKey{sdk.XOnlyPubKey(spend.RequiredSigner)}) { + + return nil + } + } + return fmt.Errorf("contract signing plan differs from agreed spend") +} + +func checkCompactRequestBudget(request *sdk.CustomAnchorRequest) error { + var total int + add := func(size int) error { + if size > assetpolicy.MaxProvenanceBytes-total { + return fmt.Errorf("compact request histories exceed size budget") + } + total += size + return nil + } + for _, input := range request.Inputs { + if input.ProofPath == nil { + if err := add(len(input.ProofFile)); err != nil { + return err + } + continue + } + raw, err := input.ProofPath.MarshalBinary() + if err != nil { + return err + } + if err := add(len(raw)); err != nil { + return err + } + } + for _, input := range request.PassiveAssets.Packets { + if err := add(len(input.ProofFile)); err != nil { + return err + } + } + return nil +} + +// CheckPlan binds the native built plan to the exact request inspected by this +// verifier, then repeats current funding checks. Call before native Commit; a +// different request must obtain fresh independent bindings and a new verifier. +func (g *ApprovedParents) CheckPlan(ctx context.Context, plan *sdk.CustomAnchorPlan) error { + if plan == nil || !reflect.DeepEqual(plan.Request(), g.request) { + return fmt.Errorf("native plan differs from inspected compact request") + } + return g.CheckCurrent(ctx) +} + +func sameFundingOutput(expected, actual *wire.TxOut) bool { + return expected != nil && actual != nil && actual.Value >= 0 && + expected.Value == actual.Value && bytes.Equal(expected.PkScript, actual.PkScript) +} diff --git a/swapkit/tapsdk/paths_test.go b/swapkit/tapsdk/paths_test.go index fb10bbe11..96ed08c5d 100644 --- a/swapkit/tapsdk/paths_test.go +++ b/swapkit/tapsdk/paths_test.go @@ -34,11 +34,9 @@ func (c *pathChain) Prevout(_ context.Context, point wire.OutPoint) (*wire.TxOut } func (c *pathChain) Height(context.Context) (uint32, error) { return 200, nil } - func (c *pathChain) Block(_ context.Context, h uint32) (*wire.MsgBlock, error) { return c.blocks[h], nil } - func (c *pathChain) BlockHash(_ context.Context, height uint32) (chainhash.Hash, error) { @@ -48,7 +46,6 @@ func (c *pathChain) BlockHash(_ context.Context, } return block.BlockHash(), nil } - func (c *pathChain) Unspent(_ context.Context, p wire.OutPoint) (bool, error) { return c.utxos[p] != nil, nil } @@ -68,7 +65,6 @@ func (c *pathClient) GetInfo(context.Context) (*sdk.Info, error) { } return fixtureNodeInfo(), nil } - func (c *pathClient) VerifyProof(_ context.Context, raw []byte) (*sdk.VerifyProofResponse, error) { c.verifies++ if c.afterVerify != nil { @@ -76,12 +72,10 @@ func (c *pathClient) VerifyProof(_ context.Context, raw []byte) (*sdk.VerifyProo } return fixtureVerified(raw, true) } - func (c *pathClient) CustomAnchorCapabilities(context.Context) (*sdk.CustomAnchorCapabilities, error) { v := sdk.DefaultTapdCustomAnchorCapabilities() return &v, nil } - func (c *pathClient) PublishAndLogTransferWithRequest(context.Context, *sdk.PublishAndLogTransferRequest) (*sdk.AssetPacket, error) { @@ -333,3 +327,122 @@ func requestPoint(t *testing.T, request *sdk.CustomAnchorRequest, index int) wir require.NoError(t, err) return p.UnsignedTx.TxIn[index].PreviousOutPoint } + +func TestApprovedParentsNativeBuild(t *testing.T) { + f := newPathFixture(t) + unsigned, parent := f.parent(t) + request, binding := f.child(t, parent) + for _, approval := range []ParentApproval{ + {Package: unsigned, Digest: unsigned.CommittedPackageDigest}, + {Package: parent, Digest: sdk.Hash{1}}, + } { + _, err := NewApprovedParents(t.Context(), f.wallet, f.chain, 2, + request, []InputBinding{binding}, []ParentApproval{approval}) + require.Error(t, err) + } + parents, err := NewApprovedParents(t.Context(), f.wallet, f.chain, 2, + request, []InputBinding{binding}, []ParentApproval{{Package: parent, + Digest: parent.CommittedPackageDigest}}) + require.NoError(t, err) + f.client.verifies = 0 + builder := f.wallet.NewCustomAnchorTxBuilder() + builder.SetConfirmedProofVerifier(parents) + plan, err := builder.Build(t.Context(), request) + require.NoError(t, err) + require.Equal(t, 1, f.client.verifies) + require.NoError(t, parents.CheckPlan(t.Context(), plan)) + child, err := plan.Commit(t.Context(), sdk.CustomAnchorCommitOptions{}) + require.NoError(t, err) + require.NoError(t, child.Validate()) + require.Equal(t, sdk.CustomAnchorProofSourceCompactPath, child.Inputs[0].ProofSource.Kind) + delete(f.chain.utxos, requestPoint(t, f.request, 0)) + require.Error(t, parents.CheckCurrent(t.Context())) +} + +func TestApprovedParentsRejectUnapprovedTransition(t *testing.T) { + f := newPathFixture(t) + _, parent := f.parent(t) + request, binding := f.child(t, parent) + _, err := NewApprovedParents(t.Context(), f.wallet, f.chain, 2, + request, []InputBinding{binding}, nil) + require.Error(t, err) +} + +func TestApprovedParentsRequireIsolatedBases(t *testing.T) { + f := newPathFixture(t) + p := bindingProof(t, bindingGenesis(), bindingKey(2).PubKey(), + bindingKey(3).PubKey(), nil, bindingGenesis().FirstPrevOut, 100, true) + request := f.request.Clone() + request.Inputs[0].ProofFile = nil + request.Inputs[0].ProofPath = &sdk.AssetProofPath{ConfirmedBaseProof: bindingRaw(t, p)} + _, err := NewApprovedParents(t.Context(), f.wallet, f.chain, 2, + request, []InputBinding{f.binding}, nil) + require.ErrorContains(t, err, "compact parent paths require isolated asset anchors") + require.Zero(t, f.client.verifies) +} + +func TestApprovedParentsRecheckFundingAfterVerification(t *testing.T) { + f := newPathFixture(t) + _, parent := f.parent(t) + request, binding := f.child(t, parent) + parents, err := NewApprovedParents(t.Context(), f.wallet, f.chain, 2, + request, []InputBinding{binding}, []ParentApproval{{Package: parent, + Digest: parent.CommittedPackageDigest}}) + require.NoError(t, err) + f.client.verifies = 0 + f.client.afterVerify = func() { clear(f.chain.utxos) } + builder := f.wallet.NewCustomAnchorTxBuilder() + builder.SetConfirmedProofVerifier(parents) + _, err = builder.Build(t.Context(), request) + require.Error(t, err) + require.Equal(t, 1, f.client.verifies) +} + +func TestApprovedParentsSnapshotBeforeRPC(t *testing.T) { + f := newPathFixture(t) + _, parent := f.parent(t) + request, binding := f.child(t, parent) + f.client.afterInfo = func() { + binding.Anchor.Leaves[0][0] ^= 1 + request.Inputs[0].ProofPath.ConfirmedBaseProof[0] ^= 1 + } + _, err := NewApprovedParents(t.Context(), f.wallet, f.chain, 2, + request, []InputBinding{binding}, []ParentApproval{{Package: parent, + Digest: parent.CommittedPackageDigest}}) + require.NoError(t, err) +} + +func TestApprovedParentsBindNativePlan(t *testing.T) { + f := newPathFixture(t) + _, parent := f.parent(t) + request, binding := f.child(t, parent) + parents, err := NewApprovedParents(t.Context(), f.wallet, f.chain, 2, + request, []InputBinding{binding}, []ParentApproval{{Package: parent, + Digest: parent.CommittedPackageDigest}}) + require.NoError(t, err) + changed := request.Clone() + changed.Outputs[0].ProofDelivery.RecipientID = "substituted delivery" + builder := f.wallet.NewCustomAnchorTxBuilder() + builder.SetConfirmedProofVerifier(parents) + plan, err := builder.Build(t.Context(), changed) + require.NoError(t, err) + require.ErrorContains(t, parents.CheckPlan(t.Context(), plan), "differs from inspected") +} + +func TestApprovedParentsAuthenticateRootFunding(t *testing.T) { + for _, script := range []bool{false, true} { + f := newPathFixture(t) + _, parent := f.parent(t) + request, binding := f.child(t, parent) + point := requestPoint(t, f.request, 0) + if script { + f.chain.utxos[point].PkScript = []byte{txscript.OP_TRUE} + } else { + f.chain.utxos[point].Value++ + } + _, err := NewApprovedParents(t.Context(), f.wallet, f.chain, 2, + request, []InputBinding{binding}, []ParentApproval{{Package: parent, + Digest: parent.CommittedPackageDigest}}) + require.ErrorContains(t, err, "authenticated Bitcoin prevout") + } +} From 7ce20983278b7412911c5f7de444c64bad9d2a8c Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 11 Nov 2026 14:19:42 +0100 Subject: [PATCH 08/25] tapsdk: Recover claims and refunds from observed spends Recover contract spends through native SDK packages and exact output proofs. Preserve the original contract descriptors, asset quantities and confirmation evidence across claims and refunds. Make publication retries consult exact archived proofs and the tapd transfer log before logging again. Report pending confirmations without creating duplicate transfer records or repeating their anchor fees. --- docs/release-notes/release-notes-next.md | 6 + swapkit/asset_types.go | 159 +++++++ swapkit/roles_test.go | 8 + swapkit/tapsdk/README.md | 177 ++++++++ swapkit/tapsdk/binding_test.go | 398 +++++++++++++++++- swapkit/tapsdk/confirmation_test.go | 109 +++++ swapkit/tapsdk/context.go | 230 ++++++++++ swapkit/tapsdk/deposit_cache_test.go | 109 +++++ swapkit/tapsdk/fallback.go | 152 +++++++ swapkit/tapsdk/group_test.go | 29 ++ .../tapsdk/native_package_regression_test.go | 87 ++++ swapkit/tapsdk/observed_refund_test.go | 131 ++++++ swapkit/tapsdk/original_refund.go | 110 +++++ swapkit/tapsdk/original_refund_test.go | 170 ++++++++ swapkit/tapsdk/output_key_test.go | 79 ++++ swapkit/tapsdk/partial_recovery_test.go | 262 ++++++++++++ swapkit/tapsdk/proof_archive_test.go | 94 +++++ swapkit/tapsdk/proofs.go | 237 +++++++++++ swapkit/tapsdk/publish_retry_test.go | 189 +++++++++ swapkit/tapsdk/recovery.go | 305 ++++++++++++++ swapkit/tapsdk/spend.go | 66 +++ swapkit/tapsdk/spend_test.go | 141 +++++++ swapkit/tapsdk/wallet_spend.go | 173 ++++++++ 23 files changed, 3420 insertions(+), 1 deletion(-) create mode 100644 swapkit/tapsdk/README.md create mode 100644 swapkit/tapsdk/confirmation_test.go create mode 100644 swapkit/tapsdk/context.go create mode 100644 swapkit/tapsdk/fallback.go create mode 100644 swapkit/tapsdk/observed_refund_test.go create mode 100644 swapkit/tapsdk/original_refund.go create mode 100644 swapkit/tapsdk/original_refund_test.go create mode 100644 swapkit/tapsdk/output_key_test.go create mode 100644 swapkit/tapsdk/partial_recovery_test.go create mode 100644 swapkit/tapsdk/proof_archive_test.go create mode 100644 swapkit/tapsdk/publish_retry_test.go create mode 100644 swapkit/tapsdk/recovery.go create mode 100644 swapkit/tapsdk/spend.go create mode 100644 swapkit/tapsdk/wallet_spend.go diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 8ea78eedd..c95916444 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -2,6 +2,12 @@ #### New Features +* Share asset deposit, reservation and CSV/CLTV HTLC contracts through + `swapkit`, with transfer, signing, proof and recovery helpers backed by + `github.com/lightninglabs/tap-sdk` v0.1.1 and Taproot Assets v0.8.8. New on-chain + asset swaps require tapd v0.8.8 or later. Shared helpers also authenticate + group issuance evidence and register issuance metadata before verification. + #### Breaking Changes #### Bug Fixes diff --git a/swapkit/asset_types.go b/swapkit/asset_types.go index a14fd54b6..66096387e 100644 --- a/swapkit/asset_types.go +++ b/swapkit/asset_types.go @@ -1,7 +1,11 @@ package swapkit import ( + "bytes" + "encoding/json" "errors" + "io" + "time" ) var ( @@ -9,6 +13,126 @@ var ( ErrPending = errors.New("asset transaction pending") ) +// Terms contains the agreed asset swap terms. Admission is private +// server recovery data containing authenticated registration and input proofs. +// +//nolint:tagliatelle // Preserve the existing recovery and wire format. +type Terms struct { + Version uint32 `json:"Version"` + RequestID [32]byte `json:"RequestID"` + RequestDigest [32]byte `json:"RequestDigest"` + Owner [32]byte `json:"Owner"` + GroupKey [33]byte `json:"GroupKey"` + DepositOutpoints []string `json:"DepositOutpoints"` + DepositAmount uint64 `json:"DepositAmount"` + PayoutAmount uint64 `json:"PayoutAmount"` + AssetFee uint64 `json:"AssetFee"` + ChangeAmount uint64 `json:"ChangeAmount"` + MaxDebit uint64 `json:"MaxDebit"` + RefundHeight uint32 `json:"RefundHeight"` + MinPayoutExpiry uint32 `json:"MinPayoutExpiry"` + RecoveryDelta uint32 `json:"RecoveryDelta"` + ExecutionDelta uint32 `json:"ExecutionDelta"` + OfferExpiry time.Time `json:"OfferExpiry"` + Contract []byte `json:"Contract"` + Admission []byte `json:",omitempty"` +} + +// Offer commits to the server payment hash and exact native asset terms. +// +//nolint:tagliatelle // Preserve the existing recovery and wire format. +type Offer struct { + Hash [32]byte `json:"Hash"` + Terms Terms `json:"Terms"` +} + +// Registration is loaded from the authenticated owner's static address row. +// The manager must bind every deposit policy to these exact keys and CSV. +// +//nolint:tagliatelle // Preserve the existing recovery and wire format. +type Registration struct { + Owner [32]byte `json:"Owner"` + // #nosec G117 -- ClientKey is a public Bitcoin key. + ClientKey [33]byte `json:"ClientKey"` + ServerKey [33]byte `json:"ServerKey"` + CSV uint32 `json:"CSV"` + KeyFamily int32 `json:"KeyFamily"` + KeyIndex uint32 `json:"KeyIndex"` +} + +// Deposit preserves the proof and contract needed after process restart. +// +//nolint:tagliatelle // Preserve the existing recovery and wire format. +type Deposit struct { + Outpoint string `json:"Outpoint"` + GroupKey [33]byte `json:"GroupKey"` + Amount uint64 `json:"Amount"` + Proof []byte `json:"Proof"` + Contract []byte `json:"Contract"` + ConfirmationHeight uint32 `json:"ConfirmationHeight"` + RefundHeight uint32 `json:"RefundHeight"` + ValueSat uint64 `json:"ValueSat"` +} + +// Admission binds client-supplied proofs to server-authenticated registration +// and server policy before the normal Loop In flow reserves any deposit. +// +//nolint:tagliatelle // Preserve the existing recovery and wire format. +type Admission struct { + Registration Registration `json:"Registration"` + RequestID [32]byte `json:"RequestID"` + GroupKey [33]byte `json:"GroupKey"` + Deposits []Deposit `json:"Deposits"` + Contract []byte `json:"Contract"` + RefundHeight uint32 `json:"RefundHeight"` + MinConfirmations uint32 `json:"MinConfirmations"` +} + +// AdmissionResult contains the native total verified from all input proofs. +// +//nolint:tagliatelle // Preserve the existing recovery and wire format. +type AdmissionResult struct { + DepositAmount uint64 `json:"DepositAmount"` +} + +// Preparation binds the invoice and transaction recovery request. +// +//nolint:tagliatelle // Preserve the existing recovery and wire format. +type Preparation struct { + Invoice string `json:"Invoice"` + PaymentPlan []byte `json:"PaymentPlan"` + RecoveryRequest []byte `json:"RecoveryRequest"` +} + +// Bundle and Claim use the SDK adapter's versioned serialized transaction data. +// +//nolint:tagliatelle // Preserve the existing recovery and wire format. +type Bundle struct { + Data []byte `json:"Data"` +} + +//nolint:tagliatelle // Preserve the existing recovery and wire format. +type Claim struct { + Data []byte `json:"Data"` +} + +// Resolution requires confirmed spends and valid spendable asset proofs. +// +//nolint:tagliatelle // Preserve the existing recovery and wire format. +type Resolution struct { + Claimed bool `json:"Claimed"` + Refunded bool `json:"Refunded"` + Evidence []byte `json:"Evidence"` +} + +// PreimageResult distinguishes a verified chain disclosure from no disclosure. +// +//nolint:tagliatelle // Preserve the existing recovery and wire format. +type PreimageResult struct { + Preimage [32]byte `json:"Preimage"` + Found bool `json:"Found"` +} + // CreateDepositRequest binds idempotent deposit funding to wallet key locators. // //nolint:tagliatelle // Preserve the existing recovery and wire format. @@ -23,3 +147,38 @@ type CreateDepositRequest struct { KeyFamily int32 `json:"KeyFamily"` KeyIndex uint32 `json:"KeyIndex"` } + +// Decode rejects unknown fields and trailing data in peer-supplied packages. +func Decode(raw []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + return ErrInvalid + } + return nil +} + +// RefundDepositRequest selects a wallet-owned mature static asset deposit. +// The manager must reject deposits reserved by an active signing operation. +// +//nolint:tagliatelle // Preserve the existing recovery and wire format. +type RefundDepositRequest struct { + Outpoint string `json:"Outpoint"` +} + +// PublicRPCMaxBytes bounds complete external protobuf requests and responses. +// Complete proofs and packages must fit within the external RPC limit. +const PublicRPCMaxBytes = 16 << 20 + +// FallbackSignature binds one public MuSig2 partial to the agreed package. +// +//nolint:tagliatelle // Preserve the public signing response encoding. +type FallbackSignature struct { + PackageDigest [32]byte `json:"PackageDigest"` + ClientNonce [66]byte `json:"ClientNonce"` + ServerNonce [66]byte `json:"ServerNonce"` + Partial []byte `json:"Partial"` +} diff --git a/swapkit/roles_test.go b/swapkit/roles_test.go index 5864cb976..7eba0712b 100644 --- a/swapkit/roles_test.go +++ b/swapkit/roles_test.go @@ -257,3 +257,11 @@ func TestSwapKitSnapshots(t *testing.T) { require.NotEqual(t, spend.PkScript, fresh.PkScript) require.NotEqual(t, spend.ControlBlock, fresh.ControlBlock) } + +func TestFlowRecordDecode(t *testing.T) { + var offer Offer + require.NoError(t, Decode([]byte(`{"Hash":[1],"Terms":{"Version":1}}`), &offer)) + require.Equal(t, uint32(1), offer.Terms.Version) + require.Error(t, Decode([]byte(`{"Terms":{"Unknown":1}}`), &offer)) + require.Error(t, Decode([]byte(`{} {}`), &offer)) +} diff --git a/swapkit/tapsdk/README.md b/swapkit/tapsdk/README.md new file mode 100644 index 000000000..fbb7f4c8b --- /dev/null +++ b/swapkit/tapsdk/README.md @@ -0,0 +1,177 @@ +# Swapkit SDK integration + +This adapter uses the released `github.com/lightninglabs/tap-sdk` v0.1.1. +That release provides the v0.3.0 API on the pre-btcd-v2 dependency graph. +Loop does not maintain a copy of the SDK or depend on the migration PRs. + +`swapkit` owns Bitcoin contracts. This package adapts them to the native Taproot +Assets SDK and checks Loop's independently agreed terms. The ordinary caller's +FSM owns decisions, persistence, key locators, ephemeral LND sessions, signing, +proof import and publication. These helpers do not introduce another manager or +transaction engine, and this toolkit change does not install Sputnik's complete +production Loop In FSM. + +The release includes the exact proof-export locator and received-key metadata +fixes needed by these flows. Loop pins Taproot Assets v0.8.8 and the bounded +legacy TLV decoder from the same tag. Consumers must +copy the TLV replacement from Loop's `go.mod`, since Go does not inherit it. + +For an existing authenticated tapd connection, construct the wallet with +`sdk.NewWallet(tapsdk.NewClientFromConn(conn, timeout), network)`. This +adapter composes the released SDK RPC clients, preserves custom-anchor and +grouped-registration capabilities, and removes the SDK's empty macaroon +header so existing credentials remain authoritative. The caller +owns credentials and connection shutdown; closing this wallet does not close +the borrowed connection. Consumers do not need a separate SDK client wrapper. + +## Asset and Bitcoin contracts + +`LegacyOPTrueOutput` preserves the existing fixed NUMS internal key and single +`OP_TRUE` asset leaf using native External mode. `LegacyOPTrueDescriptor` returns +its exact key derivation and witness. New deposit, reservation and fallback +callers should select this policy. Contract changes never implicitly migrate +already funded asset script keys. + +`OPTrueOutput` retains Sputnik's explicit supplied-key SDK tree constructor; +`SDKOPTrueOutput` is its clearly named alias. They require deliberate selection. +`OPTrueSpendInfo` validates saved native SDK witnesses and reconstructs only the +exact legacy External tree when a saved output has no native OP_TRUE descriptor. +Ordinary External wallet outputs cannot be promoted to public witnesses. Native +External package summaries remain unchanged and correctly have a nil +`OPTrueSpend` field. + +`AnchorPlan`, `CooperativeSigningPlan` and `ScriptSigningPlan` accept the pure +core's contracts: CSV deposit, CLTV reservation, CSV HTLC and Sputnik's CLTV HTLC. +`InputBinding` records exact issuance, optional full group key, quantity, script +key, outpoint and optional Bitcoin contract/spend terms. It has no client/server +role assumptions, so the server-funded reservation and client-funded deposit use +the same policy boundary. The older `DepositContext`/`SwapContext` helpers retain +Sputnik's specific Loop In negotiation semantics. + +## Native construction and approval + +Use the following order for a confirmed-input transaction: + +1. Retain independent input/output terms and the Bitcoin template. Obtain every + funding `WitnessUtxo` from an authenticated Bitcoin backend; a peer's PSBT is + not its source of authority. `Chain.Unspent` includes mempool spends. Use `CheckFundingInputs` with + `PrevoutSource` to authenticate every value and script against the current + Bitcoin backend; `LNDChain.PrevOutput` supplies this optional full lookup. + Confirmation checks verify the proof's transaction Merkle path against a + fresh block hash, without downloading full blocks. `PrevOutput` can also + back `Unspent` when no separate callback is supplied. Callbacks + must use the current mempool-inclusive view; availability is never cached + across operations. +2. `InspectInputProof` checks one isolated input. `InspectInputProofs` checks a + complete batch of active and passive bindings and their union commitment. + Both enforce bounded genesis provenance and the live tapd v0.8.8 floor. +3. `PrepareInputs` restores exact confirmed proof lineage and declares public + asset witnesses. Call the native `wallet.NewCustomAnchorTxBuilder().Build`. + Native SDK Build verifies lineage and constructs allocations and commitments; + do not repeat generic proof verification before Build. +4. Call `ApprovePlan` with the independently retained Bitcoin template, concrete + `OutputBinding` allocations and absolute fee cap. It checks every issuance, + allocation, asset key derivation, Bitcoin contract and complete transaction + terms using native commitment previews. Group allocations expanded into + several issuances need a binding for each concrete issuance. +5. Immediately before direct native `plan.Commit`, repeat `CheckNodeVersion`, + `CheckFundingInputs` on the native plan PSBT, and `CheckInputsCurrent`. The latter checks complete active/passive inventory, + current chain inclusion, confirmation depth and every funding outpoint. +6. Call `CheckSigningPolicy` on the committed package before opening signing + sessions or signing through native SDK requests. Every declared input must + use SIGHASH_DEFAULT; wallet-managed fee inputs retain wallet policy. The + encoded package must fit the 128 MiB recovery bound before releasing authority. +7. Persist the native committed package and its independently approved + `CommittedPackageDigest` before exposing signatures. Use normal LND signing + and the SDK's signing requests/final-witness APIs. Persist completion before + external effects. + +For native WalletFunded mode, set `MaxFeeSat` and the deterministic lock ID from +independent caller policy. `ApproveWalletFundedPlan` checks the fixed template +and allocations before funding, when the final fee/change cannot yet be known. +Use `AnchorChangeOutputAdd` for newly generated wallet change. Existing-output +and NoNew change policies are rejected so funding cannot change fixed recipients. +`CheckFundedPackage` validates the returned native funding summary against the +independent cap before anchor signing, release or publication. Exact/P2A plans +use `ApprovePlan`, which also verifies the complete actual fee, including a zero +fee when explicitly approved. + +Validation-only acceptance uses `VerifyInputProof` or Sputnik's `VerifyDeposit`/ +outcome helpers instead. They bind authenticated SDK verification results to +the exact local proof and bracket the verification RPC with current-chain +checks. Verification reuse is confined to one operation. Never carry validation +across actions, retries or restarts. Local saved-package decoding and Bitcoin +witness recovery do not depend on the live node version. + +Known co-anchored active/passive assets remain supported by the batch inventory +boundary and native SDK passive policy. Omitting an asset fails closed. The +single-input Loop In contexts and SDK compact paths require isolated anchors. + +## Group evidence and issuance metadata + +`VerifyGroupMembership` authenticates an anchor-first set of genesis proofs +against an independently supplied group and required issuance IDs. The caller +sets the aggregate proof-byte limit and decides which issuances its quote needs. +`SameGroupKey` uses the SDK's x-only identity comparison after validating both +compressed keys. `IsGroupMember` checks declared fungible group identity only; +it is not proof verification. + +`VerifyProofWithIssuance` inserts the verified genesis proof into the local +universe before asking tapd to verify the full history. It does not import a +wallet asset or register a received transfer. The caller must still enforce +its message limit and bind the result to the expected contract and output. +These helpers accept authenticated tapd RPC clients and preserve node errors. + +## Presigned descendants and publication + +`NewApprovedParents` implements the SDK's native confirmed-proof and unconfirmed +anchor verification interfaces for one operation. It accepts exact saved parent +committed digests, validates complete Bitcoin witnesses and checks the signed +transaction graph and current root funding. Install it with +`builder.SetConfirmedProofVerifier(parents)`, call native Build, then repeat +`parents.CheckPlan(ctx, plan)` and the independent output/Bitcoin approval before +Commit. `CheckPlan` binds the exact native request snapshot and repeats current +root-funding checks; a verifier must not be reused for a substituted request. +The chain passed to compact-parent and confirmation-handoff helpers must also +implement `PrevoutSource`: external Bitcoin fee and asset inputs require full +authenticated value/script comparisons, as well as mempool-inclusive unspent +checks. Known parent edges use the exact independently approved signed outputs. + +The SDK owns compact asset transition validation. Do not replace this adapter +with an attestation that only checks a parent transaction ID. + +Native Publish rejects a child that still contains unconfirmed compact steps. +After the parents confirm, `ConfirmPackageInputs` requires the same authenticated +prevout source and materializes native confirmed histories, verifies their exact +bindings/current chain and updates only proof +metadata. It preserves all asset state, Bitcoin commitments and final +witnesses. The resulting native package has a new committed digest: persist the +old-to-new digest association and updated package first, then import every +returned native `ProofFile` through `wallet.ImportProofFile`, and finally call +`wallet.PublishCustomAnchorTransfer`. Import/publication ambiguity and retry +remain owned by the FSM. + +`LogAndExport` retains Sputnik's single-input/output publication/recovery +helper. It registers exact descriptors, uses exact issuance/script/outpoint +proof locators, and logs already observed Bitcoin spends without rebroadcast. +tapd records every publication request as a new transfer, so a retry never +logs again: an archived exact proof returns its own confirmation result, and an +anchor already in tapd's transfer log returns `ErrPending` until its proof is +archived. Call it again to poll; serialize invocations for one package, since +the lookup and the publication are not atomic. +Generic callers can use the native publication and proof APIs directly. +`BindFinalPackage` accepts a peer's final Bitcoin witnesses only for the saved +approved native package. `DecodePackage` rejects packages over 128 MiB before +JSON decoding and validates native consistency and signing policy. Saved-package +validation uses the committed PSBT because finalization can remove its sighash +metadata. Recovery of an observed refund preserves its mined version, locktime +and sequence, including older non-RBF refunds. A digest does not itself cover final witnesses: those +are independently validated by the native SDK. + +## Validation + +Run `go test ./swapkit/... ./internal/assetproof ./internal/assetpolicy` with the +pinned Go toolchain. The tests cover native Build/Commit and package encoding, +legacy script vectors, exact policy substitution, signed compact parents, +confirmation handoff and native publication boundaries. Run daemon-backed +asset lifecycle cases in the Loop server integration harness. diff --git a/swapkit/tapsdk/binding_test.go b/swapkit/tapsdk/binding_test.go index aa01f0dd7..6f319f3b4 100644 --- a/swapkit/tapsdk/binding_test.go +++ b/swapkit/tapsdk/binding_test.go @@ -2,10 +2,14 @@ package tapsdk import ( "context" + "crypto/sha256" + "encoding/json" "testing" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/swapkit" sdk "github.com/lightninglabs/tap-sdk" @@ -13,6 +17,7 @@ import ( "github.com/lightninglabs/taproot-assets/commitment" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/require" ) @@ -149,6 +154,398 @@ func bindingBlock(p *proof.Proof) *wire.MsgBlock { } } +func TestProofRequiresExactPolicyAndCurrentChain(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*bindingSDK, *bindingChain, *proof.Proof, + *[33]byte, *uint64) + hidden bool + }{ + {name: testValid}, + {name: "hidden co-anchored asset", hidden: true}, + {name: "invalid SDK proof", mutate: func(s *bindingSDK, + _ *bindingChain, _ *proof.Proof, + _ *[33]byte, _ *uint64) { + + s.valid = false + }}, + {name: "opposite group parity", mutate: func(_ *bindingSDK, + _ *bindingChain, _ *proof.Proof, + g *[33]byte, _ *uint64) { + + g[0] ^= 1 + }}, + {name: "different amount", mutate: func(_ *bindingSDK, + _ *bindingChain, _ *proof.Proof, + _ *[33]byte, a *uint64) { + + *a++ + }}, + {name: "insufficient depth", mutate: func(_ *bindingSDK, + c *bindingChain, _ *proof.Proof, + _ *[33]byte, _ *uint64) { + + c.tip = 100 + }}, + {name: "tip below proof", mutate: func(_ *bindingSDK, + c *bindingChain, _ *proof.Proof, + _ *[33]byte, _ *uint64) { + + c.tip = 99 + }}, + {name: "reorged proof", mutate: func(_ *bindingSDK, + c *bindingChain, _ *proof.Proof, + _ *[33]byte, _ *uint64) { + + c.blocks[100].Header.Nonce++ + }}, + {name: "invalid transaction merkle proof", mutate: func(_ *bindingSDK, + _ *bindingChain, p *proof.Proof, + _ *[33]byte, _ *uint64) { + + p.TxMerkleProof.Nodes = []chainhash.Hash{{1}} + p.TxMerkleProof.Bits = []bool{true} + }}, + {name: testSpentOutput, mutate: func(_ *bindingSDK, + c *bindingChain, _ *proof.Proof, + _ *[33]byte, _ *uint64) { + + c.unspent = false + }}, + } { + t.Run(test.name, func(t *testing.T) { + p := bindingProof(t, bindingGenesis(), + bindingKey(2).PubKey(), bindingKey(3).PubKey(), + nil, bindingGenesis().FirstPrevOut, + 100, test.hidden) + client := &bindingSDK{valid: true} + chain := &bindingChain{tip: 101, unspent: true, + blocks: map[uint32]*wire.MsgBlock{ + 100: bindingBlock(p), + }} + group, amount := bindingPub(11), uint64(1000) + if test.mutate != nil { + test.mutate(client, chain, p, &group, &amount) + } + r := &proofNode{ + client: client, + chain: chain, + minConfs: 2, + } + _, err := verifyProof( + t.Context(), + r.wallet(), + r.chain, + r.minConfs, + bindingRaw(t, p), + group, + amount, + true, + ) + if test.name == testValid || test.name == "opposite group parity" { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func bindingOutcome(t *testing.T, refund bool) (*proofNode, *SwapContext, + *proof.Proof, *bindingChain) { + + t.Helper() + clientKey, serverKey := bindingPub(2), bindingPub(3) + dk, err := swapkit.NewDepositKit(swapkit.DepositKitParams{ + ClientKey: clientKey, ServerKey: serverKey, CSVExpiry: 100, + }) + require.NoError(t, err) + leaf := txscript.NewBaseTapLeaf([]byte{txscript.OP_TRUE}) + assetTree := txscript.AssembleTaprootScriptTree(leaf) + assetRoot := assetTree.RootNode.TapHash() + assetKey := bindingKey(5).PubKey() + assetOutputKey := txscript.ComputeTaprootOutputKey( + assetKey, assetRoot[:], + ) + assetOutputKey, err = schnorr.ParsePubKey( + schnorr.SerializePubKey(assetOutputKey)) + require.NoError(t, err) + control := assetTree.LeafMerkleProofs[0].ToControlBlock(assetKey) + controlRaw, err := control.ToBytes() + require.NoError(t, err) + spend := &sdk.CustomAssetOPTrueSpendInfo{ + LeafScript: leaf.Script, LeafVersion: uint8(leaf.LeafVersion), + InternalKey: bindingPub(5), ControlBlock: controlRaw, + } + timeout, err := dk.TimeoutSpend(sdk.Hash{1}) + require.NoError(t, err) + sibling, err := commitment.NewPreimageFromLeaf( + txscript.NewBaseTapLeaf(timeout.LeafScript)) + require.NoError(t, err) + anchorPlan := dk.AnchorPlan() + internal, err := btcec.ParsePubKey(anchorPlan.InternalKey[:]) + require.NoError(t, err) + deposit := bindingProof(t, bindingGenesis(), assetOutputKey, internal, + sibling, bindingGenesis().FirstPrevOut, 100, false) + assets, merkle, err := isolatedRoots(deposit) + require.NoError(t, err) + var script sdk.PubKey + copy(script[:], assetOutputKey.SerializeCompressed()) + ref := sdk.AssetRefFromGroupKey(bindingPub(11)) + c := DepositContract{Version: 1, ClientKey: clientKey, + ServerKey: serverKey, CSV: 100, + Output: sdk.CustomAnchorAssetOutputSummary{ + ScriptKey: script, OPTrueSpend: spend, AssetRef: ref, + IssuanceID: sdk.AssetID(deposit.Asset.ID()), + AssetType: sdk.AssetType(deposit.Asset.Genesis.Type), + Amount: 1000, AnchorValueSat: 10_000, + TaprootAssetRoot: assets, TaprootMerkleRoot: merkle, + AnchorOutpoint: sdk.Outpoint{Txid: sdk.Hash( + deposit.AnchorTx.TxHash())}, + }} + // Preserve the existing SDK/contract JSON field names. + //nolint:musttag + contract, err := json.Marshal(c) + require.NoError(t, err) + preimage := [32]byte{99} + s := &SwapContext{Offer: swapkit.Offer{Hash: sha256.Sum256(preimage[:]), + Terms: swapkit.Terms{Version: 1, GroupKey: bindingPub(11), + DepositOutpoints: []string{deposit.OutPoint().String()}, + DepositAmount: 1000, PayoutAmount: 900, + AssetFee: 100, + RefundHeight: 150, RecoveryDelta: 10, + Contract: contract}}} + prior := sdk.CustomAnchorAssetOutputSummary{ + AssetRef: ref, Amount: 1000, + IssuanceID: sdk.AssetID(deposit.Asset.ID()), + AnchorValueSat: 9000, TaprootAssetRoot: sdk.Hash{42}, + AnchorOutpoint: sdk.Outpoint{Txid: sdk.Hash{88}}, + } + s.Fallback = &sdk.CustomAnchorTransferPackage{ + Outputs: []sdk.CustomAnchorAssetOutputSummary{prior}, + } + policy, err := HTLC(s.Offer, c) + require.NoError(t, err) + path, err := policy.SuccessSpend(prior.TaprootAssetRoot) + require.NoError(t, err) + receiver := bindingKey(3) + if refund { + receiver = bindingKey(2) + path, err = policy.TimeoutSpend(prior.TaprootAssetRoot) + require.NoError(t, err) + } + outputKey := txscript.ComputeTaprootKeyNoScript(receiver.PubKey()) + outputKey, err = schnorr.ParsePubKey(schnorr.SerializePubKey(outputKey)) + require.NoError(t, err) + outcome := bindingProof(t, bindingGenesis(), outputKey, + receiver.PubKey(), nil, SDKPoint(prior.AnchorOutpoint), + 160, false) + outcome.AnchorTx.TxIn[0].Sequence = path.Sequence + outcome.AnchorTx.LockTime = path.LockTime + outcome.AnchorTx.TxOut[0].Value = 8000 + fetch := txscript.NewCannedPrevOutputFetcher(path.PkScript, 9000) + sig, err := txscript.RawTxInTapscriptSignature(&outcome.AnchorTx, + txscript.NewTxSigHashes(&outcome.AnchorTx, fetch), 0, 9000, + path.PkScript, txscript.NewBaseTapLeaf(path.LeafScript), + txscript.SigHashDefault, receiver) + require.NoError(t, err) + witness, err := policy.SuccessWitness( + prior.TaprootAssetRoot, sig, preimage, + ) + if refund { + witness, err = policy.TimeoutWitness( + prior.TaprootAssetRoot, sig, + ) + } + require.NoError(t, err) + outcome.AnchorTx.TxIn[0].Witness = witness + outcome.BlockHeader.MerkleRoot = outcome.AnchorTx.TxHash() + chain := &bindingChain{tip: 161, unspent: true, + blocks: map[uint32]*wire.MsgBlock{ + 100: bindingBlock(deposit), 160: bindingBlock(outcome), + }} + + r := &proofNode{ + client: &bindingSDK{valid: true}, + chain: chain, + minConfs: 1, + } + d := swapkit.Deposit{Outpoint: deposit.OutPoint().String(), + Proof: bindingRaw( + t, + deposit, + ), Contract: contract, GroupKey: bindingPub(11), Amount: 1000} + s.Deposit, err = LoadDeposit(d, keychain.KeyLocator{}) + require.NoError(t, err) + require.NoError( + t, + VerifyDeposit( + t.Context(), + r.wallet(), + chain, + 1, + s.Deposit, + false, + ), + ) + + return r, s, outcome, chain +} + +func TestOutcomeRequiresExactSpendAndFreshRefund(t *testing.T) { + for _, refund := range []bool{false, true} { + name := "claim" + if refund { + name = "refund" + } + for _, mutation := range []string{testValid, testWrongSigner, + "wrong control", "other prevout", "other issuance", + "wrong recipient", "other group", "other amount", testReorg, + testSpentRefund} { + if mutation == testSpentRefund && !refund { + continue + } + t.Run(name+"/"+mutation, func(t *testing.T) { + testOutcomeBinding(t, refund, mutation) + }) + } + } +} + +func testOutcomeBinding(t *testing.T, refund bool, mutation string) { + t.Helper() + r, s, p, chain := bindingOutcome(t, refund) + switch mutation { + case testWrongSigner: + p.AnchorTx.TxIn[0].Witness[0][0] ^= 1 + case "wrong control": + w := p.AnchorTx.TxIn[0].Witness + w[len(w)-1][0] ^= 1 + case "other prevout": + p.AnchorTx.TxIn[0].PreviousOutPoint.Index++ + case "other issuance": + s.Fallback.Outputs[0].IssuanceID[0] ^= 1 + case "other group": + s.Fallback.Outputs[0].AssetRef = + sdk.AssetRefFromGroupKey(bindingPub(12)) + case "other amount": + s.Fallback.Outputs[0].Amount++ + case "wrong recipient": + old := p + p = bindingProof(t, bindingGenesis(), bindingKey(9).PubKey(), + bindingKey(9).PubKey(), nil, old.PrevOut, 160, false) + p.AnchorTx.TxIn[0].Sequence = old.AnchorTx.TxIn[0].Sequence + p.AnchorTx.TxIn[0].Witness = old.AnchorTx.TxIn[0].Witness + p.AnchorTx.LockTime = old.AnchorTx.LockTime + p.BlockHeader.MerkleRoot = p.AnchorTx.TxHash() + chain.blocks[160] = bindingBlock(p) + // The wrong recipient still has a coherent asset commitment and + // confirmed transaction. The contract gate must reject it. + _, err := verifyProof( + t.Context(), + r.wallet(), + r.chain, + r.minConfs, + bindingRaw(t, p), + s.Offer.Terms.GroupKey, + 1000, + refund, + ) + require.NoError(t, err) + case testReorg: + chain.blocks[160].Header.Nonce++ + case testSpentRefund: + chain.unspent = false + } + err := VerifyOutcome( + t.Context(), + r.wallet(), + r.chain, + r.minConfs, + s, + bindingRaw(t, p), + refund, + refund, + ) + if mutation == testValid { + require.NoError(t, err) + } else { + require.Error(t, err) + } +} + +func TestPersistedRefundProofRecheckedAfterReorg(t *testing.T) { + r, s, p, chain := bindingOutcome(t, true) + raw := bindingRaw(t, p) + require.NoError( + t, + VerifyOutcome( + t.Context(), + r.wallet(), + r.chain, + r.minConfs, + s, + raw, + true, + true, + ), + ) + stored := append([]byte(nil), raw...) + + // Persisting a proof does not freeze its current-chain validity. + chain.blocks[160].Header.Nonce++ + err := VerifyOutcome( + t.Context(), + r.wallet(), + r.chain, + r.minConfs, + s, + stored, + true, + true, + ) + require.ErrorIs(t, err, swapkit.ErrPending) +} + +func TestReorgClassificationPrecedesSDKVerification(t *testing.T) { + p := bindingProof(t, bindingGenesis(), bindingKey(2).PubKey(), + bindingKey(3).PubKey(), nil, bindingGenesis().FirstPrevOut, + 100, false) + chain := &bindingChain{tip: 101, unspent: true, + blocks: map[uint32]*wire.MsgBlock{100: bindingBlock(p)}} + r := &proofNode{ + client: &bindingSDK{valid: false}, + chain: chain, + minConfs: 1, + } + chain.blocks[100].Header.Nonce++ + _, err := verifyProof( + t.Context(), + r.wallet(), + r.chain, + r.minConfs, + bindingRaw(t, p), + bindingPub(11), + 1000, + true, + ) + require.ErrorIs(t, err, swapkit.ErrPending) + + // Restoring the chain match never bypasses full SDK verification. + chain.blocks[100].Header.Nonce-- + _, err = verifyProof( + t.Context(), + r.wallet(), + r.chain, + r.minConfs, + bindingRaw(t, p), + bindingPub(11), + 1000, + true, + ) + require.ErrorIs(t, err, swapkit.ErrInvalid) +} + // proofNode holds only test services. Tests call production primitives directly. type proofNode struct { client sdk.Client @@ -168,7 +565,6 @@ func (s *bindingSDK) GetInfo( } func fixtureNodeInfo() *sdk.Info { return &sdk.Info{Version: "0.8.8", Network: "regtest"} } - func fixtureVerified(raw []byte, valid bool) (*sdk.VerifyProofResponse, error) { p, err := LastProof(raw) if err != nil { diff --git a/swapkit/tapsdk/confirmation_test.go b/swapkit/tapsdk/confirmation_test.go new file mode 100644 index 000000000..25052a77d --- /dev/null +++ b/swapkit/tapsdk/confirmation_test.go @@ -0,0 +1,109 @@ +package tapsdk + +import ( + "os" + "testing" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +// scriptPackage loads the public SDK signing fixture. Only tests know the +// deterministic private key; production always uses the normal LND signer. +func scriptPackage(t *testing.T) *sdk.CustomAnchorTransferPackage { + t.Helper() + raw, err := os.ReadFile("testdata/script.package") + require.NoError(t, err) + var pkg sdk.CustomAnchorTransferPackage + require.NoError(t, pkg.UnmarshalBinary(raw)) + // The fixture has two independent key-path inputs. Complete those + // before exercising the production script signer. + requests, err := pkg.SigningRequests() + require.NoError(t, err) + result := &pkg + for i, request := range requests.KeyPath { + key := bindingKey([]byte{2, 9}[i]) + tweaked := txscript.TweakTaprootPrivKey(*key, + request.TaprootMerkleRoot) + signature, err := schnorr.Sign(tweaked, request.Sighash[:]) + require.NoError(t, err) + result, err = result.ApplyKeyPathSignature(request.ID, + signature.Serialize()) + require.NoError(t, err) + } + packet, err := DecodePSBT(result.AnchorPsbt) + require.NoError(t, err) + for _, request := range requests.KeyPath { + complete, err := psbt.MaybeFinalize( + packet, + int(request.InputIndex), + ) + require.NoError(t, err) + require.True(t, complete) + } + result.AnchorPsbt, err = EncodePSBT(packet) + require.NoError(t, err) + result, err = result.Seal() + require.NoError(t, err) + return result +} + +func TestOutcomeRelocationWithoutPeer(t *testing.T) { + for _, refund := range []bool{false, true} { + r, s, p, chain := bindingOutcome(t, refund) + old := bindingRaw(t, p) + require.NoError( + t, + VerifyOutcome( + t.Context(), + r.wallet(), + chain, + 1, + s, + old, + refund, + refund, + ), + ) + chain.blocks[160] = &wire.MsgBlock{ + Header: wire.BlockHeader{Nonce: 1}, + } + p.BlockHeight = 161 + p.BlockHeader.Nonce = 2 + chain.blocks[161] = bindingBlock(p) + chain.tip = 162 + fresh := bindingRaw(t, p) + require.NoError( + t, + VerifyOutcome( + t.Context(), + r.wallet(), + chain, + 1, + s, + fresh, + refund, + refund, + ), + ) + require.ErrorIs( + t, + VerifyOutcome( + t.Context(), + r.wallet(), + chain, + 1, + s, + old, + refund, + refund, + ), + swapkit.ErrPending, + ) + } +} diff --git a/swapkit/tapsdk/context.go b/swapkit/tapsdk/context.go new file mode 100644 index 000000000..0cffc29f0 --- /dev/null +++ b/swapkit/tapsdk/context.go @@ -0,0 +1,230 @@ +package tapsdk + +import ( + "bytes" + "context" + "math" + + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightningnetwork/lnd/keychain" +) + +// DepositContract binds a public static deposit to its OP_TRUE asset output. +// +//nolint:tagliatelle // Preserve existing serialized contract field names. +type DepositContract struct { + Version uint32 `json:"Version"` + // #nosec G117 -- Both keys are public Bitcoin keys. + ClientKey [33]byte `json:"ClientKey"` + ServerKey [33]byte `json:"ServerKey"` + CSV uint32 `json:"CSV"` + Output sdk.CustomAnchorAssetOutputSummary `json:"Output"` +} + +// DepositContext is operation-local policy and proof material. It owns no +// services, lifecycle or persistence; the ordinary deposit/FSM owns the record. +type DepositContext struct { + Deposit swapkit.Deposit + Contract DepositContract + Proof *proof.Proof + Key keychain.KeyLocator +} + +// SwapContext is reconstructed from one ordinary Loop In record for an action. +// Generic proof validation belongs to the SDK; Loop checks negotiated policy. +type SwapContext struct { + Offer swapkit.Offer + Deposit *DepositContext + Fallback *sdk.CustomAnchorTransferPackage +} + +// LoadDeposit decodes the normal deposit record and its existing wallet key. +func LoadDeposit(d swapkit.Deposit, key keychain.KeyLocator) ( + *DepositContext, error) { + + var c DepositContract + if err := swapkit.Decode(d.Contract, &c); err != nil { + return nil, err + } + return &DepositContext{Deposit: d, Contract: c, Key: key}, nil +} + +// CheckKey checks that contract authority belongs to the daemon's normal key. +func CheckKey(ctx context.Context, walletKit lndclient.WalletKitClient, + loc keychain.KeyLocator, expected [33]byte) error { + + if loc.Index > math.MaxInt32 || loc.Family > math.MaxInt32 { + return swapkit.ErrInvalid + } + key, err := walletKit.DeriveKey(ctx, &loc) + if err != nil { + return err + } + if key == nil || key.PubKey == nil || key.KeyLocator != loc || + !bytes.Equal(key.PubKey.SerializeCompressed(), expected[:]) { + + return swapkit.ErrInvalid + } + return nil +} + +// DepositFromOffer binds the admitted deposit and registration to the server +// offer. The caller verifies its wallet key and on-chain proof before acting. +func DepositFromOffer(offer swapkit.Offer) (*DepositContext, error) { + t := offer.Terms + var a swapkit.Admission + if swapkit.Decode(t.Admission, &a) != nil || + a.Registration.Owner != t.Owner || a.RequestID != t.RequestID || + !sameGroupKeys(a.GroupKey[:], t.GroupKey[:]) || + !bytes.Equal(a.Contract, t.Contract) || + len(a.Deposits) != 1 || len(t.DepositOutpoints) != 1 || + a.Deposits[0].Outpoint != t.DepositOutpoints[0] || + a.RefundHeight != t.RefundHeight { + + return nil, swapkit.ErrInvalid + } + d := a.Deposits[0] + d.Contract = t.Contract + d.Amount, d.GroupKey = t.DepositAmount, t.GroupKey + record, err := LoadDeposit(d, keychain.KeyLocator{ + Family: keychain.KeyFamily(a.Registration.KeyFamily), + Index: a.Registration.KeyIndex}) + if err != nil { + return nil, err + } + c := record.Contract + if c.ClientKey != a.Registration.ClientKey || + c.ServerKey != a.Registration.ServerKey || c.CSV != a.Registration.CSV { + + return nil, swapkit.ErrInvalid + } + return record, nil +} + +// InspectDeposit checks Loop's contract and current chain before SDK Build +// verifies the input lineage. It does not authorize a spend by itself. +func InspectDeposit(ctx context.Context, chain Chain, minConfs uint32, + d *DepositContext, unspent bool) error { + + p, err := LastProof(d.Deposit.Proof) + if err != nil { + return err + } + if err := checkAsset(p, d.Deposit.GroupKey, d.Deposit.Amount); err != nil { + return err + } + if err := checkDepositContract(d.Deposit, d.Contract, p); err != nil { + return err + } + if err := checkConfirmation(ctx, chain, minConfs, p, unspent); err != nil { + return err + } + d.Proof = p + return nil +} + +// VerifyDeposit verifies a deposit outside a builder operation, retaining +// current-chain checks on both sides of the SDK proof verification RPC. +func VerifyDeposit(ctx context.Context, wallet *sdk.Wallet, chain Chain, + minConfs uint32, d *DepositContext, unspent bool) error { + + if err := checkProvenance(ctx, wallet, d.Deposit.Proof); err != nil { + return err + } + if err := InspectDeposit(ctx, chain, minConfs, d, unspent); err != nil { + return err + } + verified, err := wallet.Client().VerifyProof(ctx, d.Deposit.Proof) + if err != nil { + return err + } + if err := checkVerifiedSummary(verified, d.Proof); err != nil { + return err + } + return checkConfirmation(ctx, chain, minConfs, d.Proof, unspent) +} + +// NewSwap binds the offered policy and SDK fallback to a validated deposit. +func NewSwap(offer swapkit.Offer, d *DepositContext, unsigned []byte) ( + *SwapContext, error) { + + t := offer.Terms + if d == nil || d.Proof == nil || t.Version != 1 || + len( + t.DepositOutpoints, + ) != 1 || t.DepositAmount == 0 || t.ChangeAmount != 0 || + t.PayoutAmount > t.DepositAmount || + t.AssetFee != t.DepositAmount-t.PayoutAmount || + d.Deposit.Outpoint != t.DepositOutpoints[0] || + !bytes.Equal(d.Deposit.Contract, t.Contract) || + !sameGroupKeys(d.Deposit.GroupKey[:], t.GroupKey[:]) || + d.Deposit.Amount != t.DepositAmount || + uint64(d.Proof.BlockHeight)+uint64(d.Contract.CSV) <= + uint64(t.RefundHeight)+uint64(t.RecoveryDelta) { + + return nil, swapkit.ErrInvalid + } + s := &SwapContext{Offer: offer, Deposit: d} + if len(unsigned) > 0 { + var err error + s.Fallback, err = validateRecoveryPackage(offer, d, unsigned) + if err != nil { + return nil, err + } + } + return s, nil +} +func checkDepositContract(d swapkit.Deposit, c DepositContract, + p *proof.Proof) error { + + if p == nil || c.Version != 1 || c.Output.Amount != d.Amount || + p.Asset.LockTime != 0 || p.Asset.RelativeLockTime != 0 { + + return swapkit.ErrInvalid + } + group, grouped := c.Output.AssetRef.GroupKey() + if !grouped || !sameGroupKeys(group[:], d.GroupKey[:]) || + c.Output.IssuanceID != sdk.AssetID(p.Asset.ID()) || + c.Output.AssetType != sdk.AssetType(p.Asset.Genesis.Type) { + + return swapkit.ErrInvalid + } + assets, merkle, err := isolatedRoots(p) + if err != nil { + return err + } + previous := p.AnchorTx.TxOut[p.OutPoint().Index] + if p.OutPoint().String() != d.Outpoint || + SDKPoint(c.Output.AnchorOutpoint) != p.OutPoint() || + c.Output.TaprootAssetRoot != assets || + c.Output.TaprootMerkleRoot != merkle || + c.Output.AnchorValueSat != previous.Value || + !bytes.Equal(p.Asset.ScriptKey.PubKey.SerializeCompressed(), + c.Output.ScriptKey[:]) { + + return swapkit.ErrInvalid + } + dk, err := swapkit.NewDepositKit(swapkit.DepositKitParams{ + ClientKey: c.ClientKey, ServerKey: c.ServerKey, + CSVExpiry: c.CSV, + }) + if err != nil { + return err + } + spend, err := dk.TimeoutSpend(assets) + if err != nil { + return err + } + if !bytes.Equal(spend.PkScript, previous.PkScript) { + return swapkit.ErrInvalid + } + // Validate the exact asset witness, not just a caller's OP_TRUE label. + _, err = OPTrueSpendInfo(c.Output) + if err != nil { + return err + } + return nil +} diff --git a/swapkit/tapsdk/deposit_cache_test.go b/swapkit/tapsdk/deposit_cache_test.go index 96f270a68..bc5600440 100644 --- a/swapkit/tapsdk/deposit_cache_test.go +++ b/swapkit/tapsdk/deposit_cache_test.go @@ -1,14 +1,123 @@ package tapsdk import ( + "context" "testing" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/swapkit" sdk "github.com/lightninglabs/tap-sdk" "github.com/stretchr/testify/require" ) +type countedChain struct { + *bindingChain + + heights []uint32 +} + +func (c *countedChain) BlockHash(ctx context.Context, + height uint32) (chainhash.Hash, error) { + + c.heights = append(c.heights, height) + return c.bindingChain.BlockHash(ctx, height) +} + +// TestDepositInventoryRechecksConfirmation verifies that an old, spent deposit +// remains recognizable in normal inventory without a block-history scanner. +// A later action still rejects a reorganized confirmation. +func TestDepositInventoryRechecksConfirmation(t *testing.T) { + for _, reorg := range []bool{false, true} { + name := "current" + if reorg { + name = "reorganized" + } + t.Run(name, func(t *testing.T) { + r, swap, _, chain := bindingOutcome(t, false) + record := swap.Deposit + chain.tip, chain.unspent = 1_000_000, false + counted := &countedChain{bindingChain: chain} + r.chain = counted + if reorg { + chain.blocks[100].Header.Nonce++ + } + err := VerifyDeposit( + t.Context(), + r.wallet(), + counted, + 1, + record, + false, + ) + if reorg { + require.ErrorIs(t, err, swapkit.ErrPending) + } else { + require.NoError(t, err) + require.Equal(t, record.Deposit.Outpoint, + record.Proof.OutPoint().String()) + } + expected := []uint32{100, 100} + if reorg { + expected = expected[:1] + } + require.Equal(t, expected, counted.heights) + }) + } +} + +// countedProofSDK records externally verified lineages in one operation. +type countedProofSDK struct { + *bindingSDK + + proofs [][]byte +} + +func (s *countedProofSDK) VerifyProof(ctx context.Context, + raw []byte) (*sdk.VerifyProofResponse, error) { + + s.proofs = append(s.proofs, raw) + return s.bindingSDK.VerifyProof(ctx, raw) +} + +// TestRefundOperationVerifiesEachProofOnce checks reuse within an operation, +// while a fresh operation after restart must revalidate both lineages. +func TestRefundOperationVerifiesEachProofOnce(t *testing.T) { + r, swap, outcome, _ := originalRefundFixture(t) + client := &countedProofSDK{bindingSDK: &bindingSDK{valid: true}} + r.client = client + evidence := bindingRaw(t, outcome) + + for range 2 { + client.proofs = nil + require.NoError( + t, + VerifyDeposit( + t.Context(), + r.wallet(), + r.chain, + 1, + swap.Deposit, + false, + ), + ) + require.NoError( + t, + VerifyRefundOutcome( + t.Context(), + r.wallet(), + r.chain, + 1, + swap, + evidence, + ), + ) + require.Equal(t, [][]byte{ + swap.Deposit.Deposit.Proof, evidence, + }, client.proofs) + } +} + // TestSDKBuilderOwnsInputVerification covers the real SDK builder call. The // Loop checks on each side must not call verifyProof a second time, and the // post-build check rejects chain changes during the SDK verification RPC. diff --git a/swapkit/tapsdk/fallback.go b/swapkit/tapsdk/fallback.go new file mode 100644 index 000000000..b6b451d2c --- /dev/null +++ b/swapkit/tapsdk/fallback.go @@ -0,0 +1,152 @@ +package tapsdk + +import ( + "bytes" + "reflect" + + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" +) + +// HTLC constructs the existing V3 Bitcoin contract for the offered swap. +func HTLC(offer swapkit.Offer, c DepositContract) (*swapkit.SwapKit, error) { + return swapkit.NewSwapKit(swapkit.SwapKitParams{ + Policy: swapkit.SwapHTLCStaticLoopInV3, + SenderKey: c.ClientKey, ReceiverKey: c.ServerKey, + SenderInternalKey: c.ClientKey, + ReceiverInternalKey: c.ServerKey, + PaymentHash: offer.Hash, + Expiry: offer.Terms.RefundHeight, + }) +} + +// validateRecoveryPackage binds a native SDK transfer to the offered fallback. +func validateRecoveryPackage(offer swapkit.Offer, d *DepositContext, + raw []byte) (*sdk.CustomAnchorTransferPackage, error) { + + pkg, err := DecodePackage(raw) + if err != nil { + return nil, err + } + requests, err := pkg.SigningRequests() + if err != nil || len(requests.MuSig2) != 1 || + requests.MuSig2[0].SighashType != 0 { + + return nil, swapkit.ErrInvalid + } + if len(pkg.Inputs) != 1 || len(pkg.Outputs) != 1 || + len(pkg.PassiveVirtualPsbts) != 0 || + len(pkg.SigningPlans) != 1 || + len(pkg.BackendManagedInputIndices) != 0 { + + return nil, swapkit.ErrInvalid + } + c := d.Contract + in, out := pkg.Inputs[0], pkg.Outputs[0] + if !bytes.Equal(in.ProofSource.Blob, d.Deposit.Proof) || + in.AnchorOutpoint != c.Output.AnchorOutpoint || + in.ScriptKey != c.Output.ScriptKey || + in.Amount != c.Output.Amount || + out.Amount != in.Amount || out.IssuanceID != in.IssuanceID || + !out.AssetRef.Equivalent(c.Output.AssetRef) || + out.AnchorOutputIndex != 0 || out.AnchorValueSat < 330 { + + return nil, swapkit.ErrInvalid + } + packet, err := DecodePSBT(pkg.AnchorPsbt) + if err != nil { + return nil, err + } + tx := packet.UnsignedTx + fee := c.Output.AnchorValueSat - out.AnchorValueSat + if len(tx.TxIn) != 1 || len(tx.TxOut) != 1 || tx.Version != 2 || + tx.LockTime != 0 || tx.TxIn[0].Sequence != 0xfffffffc || + fee <= 0 || out.AnchorValueSat-330 < fee { + + return nil, swapkit.ErrInvalid + } + policy, err := HTLC(offer, c) + if err != nil { + return nil, err + } + spend, err := policy.TimeoutSpend(out.TaprootAssetRoot) + if err != nil { + return nil, err + } + if !bytes.Equal(spend.PkScript, tx.TxOut[0].PkScript) || + spend.TaprootMerkleRoot != out.TaprootMerkleRoot { + + return nil, swapkit.ErrInvalid + } + dk, err := swapkit.NewDepositKit(swapkit.DepositKitParams{ + ClientKey: c.ClientKey, ServerKey: c.ServerKey, + CSVExpiry: c.CSV, + }) + if err != nil { + return nil, err + } + signing := CooperativeSigningPlan( + dk.CooperativeParticipants(), 0, offer.Hash[:], + ) + if !reflect.DeepEqual(pkg.SigningPlans[0], signing) { + return nil, swapkit.ErrInvalid + } + if _, err := OPTrueSpendInfo(out); err != nil { + return nil, err + } + return pkg, nil +} + +// FallbackRequest constructs the unsigned SDK request for one validated offer. +func FallbackRequest(s *SwapContext, fee, maxFee uint64) ( + *sdk.CustomAnchorRequest, error) { + + d, offer := s.Deposit, s.Offer + c := d.Contract + policy, err := HTLC(offer, c) + if err != nil { + return nil, err + } + value := c.Output.AnchorValueSat - int64(fee) + if value < 330+int64(maxFee) { + return nil, swapkit.ErrInvalid + } + output, err := LegacyOPTrueOutput(sdk.CustomAssetOutput{ + ID: "HTLC", AssetRef: c.Output.AssetRef, + Amount: c.Output.Amount, AnchorValueSat: uint64(value), + }, policy.AnchorPlan()) + if err != nil { + return nil, err + } + p := d.Proof + anchor, err := AnchorTemplate(p, output.Anchor.InternalKey, value, nil) + if err != nil { + return nil, err + } + packet, err := DecodePSBT(anchor) + if err != nil { + return nil, err + } + dk, err := swapkit.NewDepositKit(swapkit.DepositKitParams{ + ClientKey: c.ClientKey, ServerKey: c.ServerKey, CSVExpiry: c.CSV, + }) + if err != nil { + return nil, err + } + internal := dk.AnchorPlan().InternalKey + packet.Inputs[0].TaprootInternalKey = internal[1:] + packet.Inputs[0].TaprootMerkleRoot = c.Output.TaprootMerkleRoot[:] + anchor, err = EncodePSBT(packet) + if err != nil { + return nil, err + } + input, err := AssetInput(c.Output, d.Deposit.Proof) + if err != nil { + return nil, err + } + plan := CustomRequest(input, + output, anchor, CooperativeSigningPlan( + dk.CooperativeParticipants(), 0, offer.Hash[:], + )) + return plan, nil +} diff --git a/swapkit/tapsdk/group_test.go b/swapkit/tapsdk/group_test.go index f39f12901..41dcd90e1 100644 --- a/swapkit/tapsdk/group_test.go +++ b/swapkit/tapsdk/group_test.go @@ -70,3 +70,32 @@ func TestVerifiedSummaryAcceptsGroupParity(t *testing.T) { response.DecodedProof.AssetRef = sdk.AssetRefFromGroupKey(bindingPub(12)) require.ErrorIs(t, checkVerifiedSummary(response, p), swapkit.ErrInvalid) } + +func TestDepositAndOfferAcceptGroupParity(t *testing.T) { + _, s, _, _ := bindingOutcome(t, false) + d := s.Deposit + p := d.Proof + contract := d.Contract + group, ok := contract.Output.AssetRef.GroupKey() + require.True(t, ok) + group[0] ^= 1 + contract.Output.AssetRef = sdk.AssetRefFromGroupKey(group) + require.NoError(t, checkDepositContract(d.Deposit, contract, p)) + s.Offer.Terms.GroupKey[0] ^= 1 + _, err := NewSwap(s.Offer, d, nil) + require.NoError(t, err) + + for _, relative := range []bool{false, true} { + changed := *p + changed.Asset = *p.Asset.Copy() + if relative { + changed.Asset.RelativeLockTime = 1 + } else { + changed.Asset.LockTime = 1 + } + require.ErrorIs(t, + checkDepositContract(d.Deposit, contract, &changed), + swapkit.ErrInvalid, + ) + } +} diff --git a/swapkit/tapsdk/native_package_regression_test.go b/swapkit/tapsdk/native_package_regression_test.go index 359322f49..0c9e7c519 100644 --- a/swapkit/tapsdk/native_package_regression_test.go +++ b/swapkit/tapsdk/native_package_regression_test.go @@ -2,13 +2,93 @@ package tapsdk import ( "bytes" + "context" "testing" "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" sdk "github.com/lightninglabs/tap-sdk" "github.com/stretchr/testify/require" ) +type changingProofSDK struct { + sdk.Client + + change func() + calls int +} + +func (s *changingProofSDK) VerifyProof(_ context.Context, + raw []byte) (*sdk.VerifyProofResponse, error) { + + s.calls++ + s.change() + return fixtureVerified(raw, true) +} + +// TestProofFreshnessAfterSDKVerification changes chain state while tapd is +// verifying the proof. Validation before that RPC cannot authorize a stale +// confirmation or an output that was spent before verification completed. +func TestProofFreshnessAfterSDKVerification(t *testing.T) { + for _, test := range []struct { + name string + change func(*bindingChain) + }{ + { + name: testReorg, + change: func(c *bindingChain) { + c.blocks[100].Header.Nonce++ + }, + }, + { + name: "lost confirmation depth", + change: func(c *bindingChain) { + c.tip = 100 + }, + }, + { + name: testSpentOutput, + change: func(c *bindingChain) { + c.unspent = false + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + p := bindingProof(t, bindingGenesis(), + bindingKey(2).PubKey(), bindingKey(3).PubKey(), + nil, bindingGenesis().FirstPrevOut, 100, false) + chain := &bindingChain{ + tip: 101, unspent: true, + blocks: map[uint32]*wire.MsgBlock{ + 100: bindingBlock(p), + }, + } + client := &changingProofSDK{change: func() { + test.change(chain) + }} + node := &proofNode{ + client: client, + chain: chain, + minConfs: 2, + } + verified, err := verifyProof( + t.Context(), + node.wallet(), + node.chain, + node.minConfs, + bindingRaw(t, p), + bindingPub(11), + 1000, + true, + ) + require.ErrorIs(t, err, swapkit.ErrPending) + require.Nil(t, verified) + require.Equal(t, 1, client.calls) + }) + } +} + // nativeSignedFixture completes the public package using the existing LND // session fixture. Both packages retain the same committed transaction. func nativeSignedFixture(t *testing.T) (*sdk.CustomAnchorTransferPackage, @@ -126,3 +206,10 @@ func TestNativeClaimPackageRequiresFinalWitness(t *testing.T) { }) } } + +func (s *changingProofSDK) GetInfo( + context.Context, +) (*sdk.Info, error) { + + return fixtureNodeInfo(), nil +} diff --git a/swapkit/tapsdk/observed_refund_test.go b/swapkit/tapsdk/observed_refund_test.go new file mode 100644 index 000000000..b3e4e4abd --- /dev/null +++ b/swapkit/tapsdk/observed_refund_test.go @@ -0,0 +1,131 @@ +package tapsdk + +import ( + "context" + "errors" + "testing" + + "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/btcsuite/btcd/wire" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/commitment" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/stretchr/testify/require" +) + +type observedRefundSDK struct { + *observedRecoverySDK + + backend *pathClient +} + +func (c *observedRefundSDK) CommitVirtualPsbtsWithRequest( + ctx context.Context, request *sdk.CommitVirtualPsbtsRequest) ( + *sdk.CommitVirtualPsbtsResponse, error) { + + return c.backend.CommitVirtualPsbtsWithRequest(ctx, request) +} + +// TestObservedCLTVRefundPreservesTransaction reconstructs a valid historical +// non-RBF refund after new refund templates have started opting into RBF. +func TestObservedCLTVRefundPreservesTransaction(t *testing.T) { + f := newPathFixture(t) + p, err := LastProof(f.request.Inputs[0].ProofFile) + require.NoError(t, err) + root, _, err := isolatedRoots(p) + require.NoError(t, err) + spend, err := f.kit.TimeoutSpend(root) + require.NoError(t, err) + require.EqualValues(t, 0xfffffffd, spend.Sequence) + + // Mint the fixture's same asset under the actual HTLC anchor policy. + plan := f.kit.AnchorPlan() + internal, err := btcec.ParsePubKey(plan.InternalKey[:]) + require.NoError(t, err) + branch := txscript.NewTapBranch( + txscript.NewBaseTapLeaf(plan.Leaves[0]), + txscript.NewBaseTapLeaf(plan.Leaves[1]), + ) + sibling := commitment.NewPreimageFromBranch(branch) + p.InclusionProof.InternalKey = internal + p.InclusionProof.CommitmentProof.TapSiblingPreimage = &sibling + p.AnchorTx.TxOut[0].PkScript = spend.PkScript + p.BlockHeader.MerkleRoot = p.AnchorTx.TxHash() + inputRaw, err := proof.EncodeAsProofFile(p) + require.NoError(t, err) + f.chain.blocks[p.BlockHeight] = bindingBlock(p) + point := sdk.Outpoint{Txid: sdk.Hash(p.OutPoint().Hash), + Index: p.OutPoint().Index} + _, optrue, err := LegacyOPTrueDescriptor() + require.NoError(t, err) + prior := sdk.CustomAnchorAssetOutputSummary{ + LogicalOutputID: "observed refund", + AssetRef: f.request.Inputs[0].AssetRef, + IssuanceID: sdk.AssetID(p.Asset.ID()), + Amount: p.Asset.Amount, ScriptKey: f.binding.ScriptKey, + OPTrueSpend: optrue, TaprootAssetRoot: root, + TaprootMerkleRoot: spend.TaprootMerkleRoot, + AnchorValueSat: p.AnchorTx.TxOut[0].Value, + AnchorOutpoint: point, + } + client := &observedRefundSDK{ + observedRecoverySDK: &observedRecoverySDK{ + partialRecoverySDK: &partialRecoverySDK{ + bindingSDK: &bindingSDK{valid: true}, + }, + input: &sdk.ProofFile{RawProofFile: inputRaw}, + point: point, stopErr: errors.New("reached observer log"), + }, + backend: f.client, + } + wallet := sdk.NewWallet(client, sdk.NetworkRegtest) + public := bindingPub(1) + recipient, err := walletOutput(public, 0, 0, + prior.AssetRef, prior.Amount, 900) + require.NoError(t, err) + anchor, err := AnchorTemplate(p, recipient.Anchor.InternalKey, 900, spend) + require.NoError(t, err) + packet, err := DecodePSBT(anchor) + require.NoError(t, err) + packet.UnsignedTx.LockTime = spend.LockTime + 1 + packet.UnsignedTx.TxIn[0].Sequence = wire.MaxTxInSequenceNum - 1 + anchor, err = EncodePSBT(packet) + require.NoError(t, err) + input, err := AssetInput(prior, inputRaw) + require.NoError(t, err) + pkg, err := commit(t.Context(), wallet, f.chain, 1, + CustomRequest(input, recipient, anchor, ScriptSigningPlan(spend, 0))) + require.NoError(t, err) + requests, err := pkg.SigningRequests() + require.NoError(t, err) + require.Len(t, requests.ScriptPath, 1) + signature, err := schnorr.Sign( + bindingKey(1), requests.ScriptPath[0].Sighash[:], + ) + require.NoError(t, err) + final, err := pkg.ApplyScriptPathWitness(requests.ScriptPath[0].ID, + [][]byte{signature.Serialize()}) + require.NoError(t, err) + packet, err = DecodePSBT(final.AnchorPsbt) + require.NoError(t, err) + observed, err := psbt.Extract(packet) + require.NoError(t, err) + + _, err = rebuildObservedSpend(t.Context(), wallet, f.chain, 1, + prior, inputRaw, public, spend, observed) + require.ErrorIs(t, err, client.stopErr) + require.NotNil(t, client.logged) + require.True(t, client.logged.SkipAnchorTxBroadcast) + logged, err := DecodePSBT(client.logged.AnchorPsbt) + require.NoError(t, err) + recovered, err := psbt.Extract(logged) + require.NoError(t, err) + require.Equal(t, observed.TxHash(), recovered.TxHash()) + require.Equal(t, observed.LockTime, recovered.LockTime) + require.Equal(t, observed.Version, recovered.Version) + require.EqualValues(t, 0xfffffffe, recovered.TxIn[0].Sequence) + require.Equal(t, observed.TxIn[0].Witness, recovered.TxIn[0].Witness) +} diff --git a/swapkit/tapsdk/original_refund.go b/swapkit/tapsdk/original_refund.go new file mode 100644 index 000000000..3ed2e141e --- /dev/null +++ b/swapkit/tapsdk/original_refund.go @@ -0,0 +1,110 @@ +package tapsdk + +import ( + "context" + "math" + + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" +) + +// OriginalRefundPolicy selects the original deposit's unilateral CSV leaf. +func OriginalRefundPolicy(d *DepositContext) (DepositContract, + *swapkit.SwapAnchorSpendInfo, error) { + + c := d.Contract + policy, err := swapkit.NewDepositKit(swapkit.DepositKitParams{ + ClientKey: c.ClientKey, ServerKey: c.ServerKey, + CSVExpiry: c.CSV, + }) + if err != nil { + return c, nil, err + } + spend, err := policy.TimeoutSpend(c.Output.TaprootAssetRoot) + return c, spend, err +} + +// verifyOriginalRefund verifies the original deposit's CSV recovery proof. +func verifyOriginalRefund(ctx context.Context, wallet *sdk.Wallet, + chain Chain, minConfs uint32, + s *SwapContext, raw []byte, requireUnspent bool) error { + + d := s.Deposit + base := d.Proof + c, spend, err := OriginalRefundPolicy(d) + if err != nil { + return err + } + err = VerifyWalletSpend( + ctx, wallet, chain, minConfs, raw, c.Output, c.ClientKey, + spend, requireUnspent, uint64(base.BlockHeight)+uint64(c.CSV), + ) + return err +} + +// DiscoverOriginalRefund reconstructs an offline client's CSV recovery proof. +func DiscoverOriginalRefund(ctx context.Context, wallet *sdk.Wallet, + chain Chain, minConfs uint32, + s *SwapContext) ([]byte, error) { + + d := s.Deposit + base := d.Proof + c, spend, err := OriginalRefundPolicy(d) + if err != nil { + return nil, err + } + start := uint64(base.BlockHeight) + uint64(c.CSV) + if start > math.MaxUint32 { + return nil, swapkit.ErrInvalid + } + block, _, err := chain.Spend( + ctx, + base.OutPoint(), + base.AnchorTx.TxOut[base.OutPoint().Index].PkScript, + uint32(start), + ) + if err != nil { + return nil, err + } + tx := spendingTransaction(block, base.OutPoint()) + if len(tx.TxIn) != 1 || len(tx.TxOut) != 1 { + return nil, swapkit.ErrPending + } + if !spendsLeaf(tx, c.Output, spend) { + return nil, swapkit.ErrPending + } + raw, err := rebuildObservedSpend( + ctx, wallet, chain, minConfs, c.Output, d.Deposit.Proof, + c.ClientKey, spend, tx, + ) + if err != nil { + return nil, err + } + return raw, nil +} + +// VerifyRefundOutcome recognizes either refund after it is spent again. +// A later wallet spend cannot restore the server's expired claim authority. +func VerifyRefundOutcome(ctx context.Context, wallet *sdk.Wallet, + chain Chain, minConfs uint32, s *SwapContext, + raw []byte) error { + + p, err := LastProof(raw) + if err != nil { + return err + } + if len(p.AnchorTx.TxIn) == 1 && p.AnchorTx.TxIn[0].PreviousOutPoint == + s.Deposit.Proof.OutPoint() { + + return verifyOriginalRefund( + ctx, + wallet, + chain, + minConfs, + s, + raw, + false, + ) + } + return VerifyOutcome(ctx, wallet, chain, minConfs, s, raw, true, false) +} diff --git a/swapkit/tapsdk/original_refund_test.go b/swapkit/tapsdk/original_refund_test.go new file mode 100644 index 000000000..4065ceda5 --- /dev/null +++ b/swapkit/tapsdk/original_refund_test.go @@ -0,0 +1,170 @@ +package tapsdk + +import ( + "encoding/json" + "testing" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/loop/swapkit" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/stretchr/testify/require" +) + +func originalRefundFixture(t *testing.T) (*proofNode, *SwapContext, + *proof.Proof, *bindingChain) { + + t.Helper() + r, s, _, chain := bindingOutcome(t, true) + // The server never obtained the client's completed fallback. + s.Fallback = nil + d := s.Deposit + base, err := LastProof(d.Deposit.Proof) + require.NoError(t, err) + _, spend, err := OriginalRefundPolicy(d) + require.NoError(t, err) + key := bindingKey(2) + output := txscript.ComputeTaprootKeyNoScript(key.PubKey()) + output, err = schnorr.ParsePubKey(schnorr.SerializePubKey(output)) + require.NoError(t, err) + p := bindingProof(t, bindingGenesis(), output, key.PubKey(), + nil, base.OutPoint(), 201, false) + p.AnchorTx.TxIn[0].Sequence = spend.Sequence + p.AnchorTx.LockTime = spend.LockTime + p.AnchorTx.TxOut[0].Value = 9000 + fetch := txscript.NewCannedPrevOutputFetcher(spend.PkScript, 10_000) + sig, err := txscript.RawTxInTapscriptSignature(&p.AnchorTx, + txscript.NewTxSigHashes(&p.AnchorTx, fetch), 0, 10_000, + spend.PkScript, txscript.NewBaseTapLeaf(spend.LeafScript), + txscript.SigHashDefault, key) + require.NoError(t, err) + p.AnchorTx.TxIn[0].Witness = [][]byte{ + sig, spend.LeafScript, spend.ControlBlock, + } + p.BlockHeader.MerkleRoot = p.AnchorTx.TxHash() + chain.tip, chain.blocks[201] = 202, bindingBlock(p) + return r, s, p, chain +} + +func TestOriginalCSVRefundWithoutCompletedFallback(t *testing.T) { + r, s, p, chain := originalRefundFixture(t) + raw := bindingRaw(t, p) + require.NoError( + t, + VerifyRefundOutcome(t.Context(), r.wallet(), chain, 1, s, raw), + ) + require.Nil(t, s.Fallback) + chain.blocks[201].Header.Nonce++ + require.ErrorIs( + t, + VerifyRefundOutcome(t.Context(), r.wallet(), chain, 1, s, raw), + swapkit.ErrPending, + ) +} + +func TestOriginalRefundRejectsWrongSpend(t *testing.T) { + for _, name := range []string{testWrongSigner, "wrong prevout", + "wrong leaf", testSpentOutput, "too early"} { + t.Run(name, func(t *testing.T) { + r, s, p, chain := originalRefundFixture(t) + input := p.AnchorTx.TxIn[0] + switch name { + case testWrongSigner: + input.Witness[0][0] ^= 1 + case "wrong prevout": + input.PreviousOutPoint.Index++ + case "wrong leaf": + input.Witness[1][0] ^= 1 + case testSpentOutput: + chain.unspent = false + case "too early": + p.BlockHeight = 199 + chain.blocks[199] = bindingBlock(p) + } + err := verifyOriginalRefund( + t.Context(), + r.wallet(), + r.chain, + 1, + s, + bindingRaw(t, p), + true, + ) + require.Error(t, err) + }) + } +} + +func TestDepositContractRejectsIdentitySubstitution(t *testing.T) { + _, s, _, _ := bindingOutcome(t, false) + d := s.Deposit + for _, mutate := range []func(*DepositContract){ + func(c *DepositContract) { c.Output.IssuanceID[0] ^= 1 }, + func(c *DepositContract) { c.Output.AssetType++ }, + func(c *DepositContract) { c.Output.Amount++ }, + } { + var c DepositContract + // Preserve the existing SDK/contract JSON field names. + //nolint:musttag + require.NoError(t, json.Unmarshal(d.Deposit.Contract, &c)) + mutate(&c) + err := checkDepositContract(d.Deposit, c, d.Proof) + require.ErrorIs(t, err, swapkit.ErrInvalid) + } +} + +func TestServerCanReconcileSpentRefundButClientNeedsSpendability(t *testing.T) { + for _, original := range []bool{false, true} { + name := "HTLC" + if original { + name = "original deposit" + } + t.Run(name, func(t *testing.T) { + var r *proofNode + var s *SwapContext + var p *proof.Proof + var chain *bindingChain + if original { + r, s, p, chain = originalRefundFixture(t) + } else { + r, s, p, chain = bindingOutcome(t, true) + } + raw := bindingRaw(t, p) + chain.unspent = false + err := VerifyRefundOutcome( + t.Context(), + r.wallet(), + r.chain, + r.minConfs, + s, + raw, + ) + require.NoError(t, err) + if original { + err = verifyOriginalRefund( + t.Context(), + r.wallet(), + r.chain, + 1, + s, + raw, + true, + ) + } else { + err = VerifyOutcome(t.Context(), r.wallet(), r.chain, r.minConfs, s, raw, true, true) + } + require.ErrorIs(t, err, swapkit.ErrPending) + + chain.blocks[p.BlockHeight].Header.Nonce++ + err = VerifyRefundOutcome( + t.Context(), + r.wallet(), + r.chain, + r.minConfs, + s, + raw, + ) + require.ErrorIs(t, err, swapkit.ErrPending) + }) + } +} diff --git a/swapkit/tapsdk/output_key_test.go b/swapkit/tapsdk/output_key_test.go new file mode 100644 index 000000000..28f6532c5 --- /dev/null +++ b/swapkit/tapsdk/output_key_test.go @@ -0,0 +1,79 @@ +package tapsdk + +import ( + "context" + "testing" + + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +type declaredOutputClient struct { + sdk.Client + + key sdk.ScriptKey +} + +func (c *declaredOutputClient) DeclareScriptKey(_ context.Context, + request *sdk.DeclareScriptKeyRequest) (*sdk.ScriptKey, error) { + + c.key = request.ScriptKey + return &c.key, nil +} + +// TestDeclareOutputPreservesKey checks that declaring the native OP_TRUE output +// retains both compressed-key parity and the wallet locator. In particular, an +// even key must not be first registered with the control block's zero locator: +// tapd retains that first derivation when the same key later receives a refund. +func TestDeclareOutputPreservesKey(t *testing.T) { + for name, scalar := range map[string]byte{testEven: 1, "odd": 6} { + t.Run(name, func(t *testing.T) { + fixture := newCustomAnchorBuilderFixture(t) + kit, err := swapkit.NewSwapKit(swapKitTestParams(t)) + require.NoError(t, err) + key := sdk.KeyDescriptor{ + RawKeyBytes: testKey(t, scalar), + KeyLocator: sdk.KeyLocator{ + Family: 42060, Index: 7, + }, + } + if name == testEven { + require.Equal(t, byte(2), key.RawKeyBytes[0]) + } else { + require.Equal(t, byte(3), key.RawKeyBytes[0]) + } + fixture.request.Outputs[0] = OPTrueOutput( + fixture.request.Outputs[0], + key, + kit.AnchorPlan(), + ) + wallet := sdk.NewWallet( + fixture.client, + sdk.NetworkRegtest, + ) + plan, err := wallet.NewCustomAnchorTxBuilder().Build( + t.Context(), fixture.request, + ) + require.NoError(t, err) + out := plan.Outputs()[0] + pkg := &sdk.CustomAnchorTransferPackage{ + ActiveVirtualPsbts: plan.ActiveVirtualPSBTs(), + } + client := &declaredOutputClient{} + wallet = sdk.NewWallet(client, sdk.NetworkRegtest) + require.NoError( + t, + declareOutput(t.Context(), wallet, pkg, + sdk.CustomAnchorAssetOutputSummary{ + PacketIndex: out.PacketIndex, + VirtualOutputIndex: out.VirtualOutputIndex, + ScriptKey: out.ScriptKey, + }), + ) + require.Equal(t, key, client.key.KeyDesc) + require.Equal(t, out.ScriptKey, client.key.PubKey) + require.Len(t, client.key.TapTweak, 32) + }) + } +} diff --git a/swapkit/tapsdk/partial_recovery_test.go b/swapkit/tapsdk/partial_recovery_test.go new file mode 100644 index 000000000..b2c744c23 --- /dev/null +++ b/swapkit/tapsdk/partial_recovery_test.go @@ -0,0 +1,262 @@ +package tapsdk + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +type partialRecoveryChain struct { + *bindingChain + + spend func(wire.OutPoint) (*wire.MsgBlock, uint32, error) +} + +func (c *partialRecoveryChain) Spend(_ context.Context, point wire.OutPoint, + _ []byte, _ uint32) (*wire.MsgBlock, uint32, error) { + + return c.spend(point) +} + +type partialRecoverySDK struct { + *bindingSDK + + declared error +} + +func (s *partialRecoverySDK) DeclareScriptKey(context.Context, + *sdk.DeclareScriptKeyRequest) (*sdk.ScriptKey, error) { + + return nil, s.declared +} + +// TestPartialFallbackRecoveryDiscovery checks the server can discover either +// refund path with only the unsigned fallback saved. The marker stops at the +// proof reconstruction boundary; native tests cover the actual tapd archive. +func TestPartialFallbackRecoveryDiscovery(t *testing.T) { + for _, original := range []bool{false, true} { + name := "HTLC refund" + if original { + name = "original deposit refund" + } + t.Run(name, func(t *testing.T) { + node, swap, outcome, chain := bindingOutcome(t, true) + if original { + node, swap, outcome, chain = + originalRefundFixture(t) + } + deposit := swap.Deposit + base, err := LastProof(deposit.Deposit.Proof) + require.NoError(t, err) + + // The persisted public package fixes the anchor even + // though no completed Bitcoin witness was received. + anchor := wire.NewMsgTx(2) + point := base.OutPoint() + anchor.AddTxIn(wire.NewTxIn(&point, nil, nil)) + anchor.AddTxOut(wire.NewTxOut(9000, []byte{0x51})) + packet, err := psbt.NewFromUnsignedTx(anchor) + require.NoError(t, err) + raw, err := EncodePSBT(packet) + require.NoError(t, err) + if original { + swap.Fallback = + &sdk.CustomAnchorTransferPackage{ + Outputs: []sdk.CustomAnchorAssetOutputSummary{ + { + AnchorOutpoint: sdk.Outpoint{ + Txid: sdk.Hash( + anchor.TxHash(), + ), + }, + }, + }, + } + } + swap.Fallback.AnchorPsbt = raw + prior := &swap.Fallback.Outputs[0] + prior.AnchorOutpoint.Txid = sdk.Hash(anchor.TxHash()) + htlcPoint := SDKPoint(prior.AnchorOutpoint) + if !original { + outcome.AnchorTx.TxIn[0].PreviousOutPoint = htlcPoint + } + marker := errors.New("reached proof reconstruction") + node.client = &partialRecoverySDK{ + bindingSDK: &bindingSDK{valid: true}, + declared: marker, + } + var watched []wire.OutPoint + node.chain = &partialRecoveryChain{ + bindingChain: chain, + spend: func(point wire.OutPoint) ( + *wire.MsgBlock, uint32, error) { + + watched = append(watched, point) + if original && point == htlcPoint { + return nil, 0, swapkit.ErrPending + } + return bindingBlock(outcome), + outcome.BlockHeight, nil + }, + } + // Each invocation reuses only the current operation's data. + _, err = DiscoverOutcome( + t.Context(), + node.wallet(), + node.chain, + node.minConfs, + swap, + true, + ) + if original { + require.ErrorIs(t, err, swapkit.ErrPending) + _, err = DiscoverOriginalRefund( + t.Context(), + node.wallet(), + node.chain, + node.minConfs, + swap, + ) + require.Equal(t, []wire.OutPoint{ + htlcPoint, base.OutPoint(), + }, watched) + } else { + require.Equal(t, []wire.OutPoint{ + htlcPoint, + }, watched) + } + require.ErrorIs(t, err, marker) + }) + } +} + +// observedRecoverySDK records the exact tapd logging request, then stops before +// proof reconstruction, which is covered by the real-node tests. +type observedRecoverySDK struct { + *partialRecoverySDK + sdk.CustomAnchorWalletKitClient + + input *sdk.ProofFile + point sdk.Outpoint + logged *sdk.PublishAndLogTransferRequest + stopErr error + + transfers []*sdk.AssetTransfer + transfersErr error + lookups []string +} + +// ListTransfers returns the stubbed transfer log and records each anchor +// filter, like tapd's lookup of already logged publications. +func (s *observedRecoverySDK) ListTransfers(_ context.Context, + request *sdk.ListTransfersRequest) ([]*sdk.AssetTransfer, error) { + + s.lookups = append(s.lookups, request.AnchorTxid) + return s.transfers, s.transfersErr +} + +func (s *observedRecoverySDK) ExportProof(_ context.Context, _ sdk.AssetRef, + _ sdk.PubKey, point *sdk.Outpoint) (*sdk.ProofFile, error) { + + if point != nil && *point == s.point { + return s.input, nil + } + return nil, errors.New("proof not yet logged") +} + +func (*observedRecoverySDK) CustomAnchorCapabilities(context.Context) ( + *sdk.CustomAnchorCapabilities, error) { + + capabilities := sdk.DefaultTapdCustomAnchorCapabilities() + return &capabilities, nil +} + +func (s *observedRecoverySDK) PublishAndLogTransferWithRequest( + _ context.Context, request *sdk.PublishAndLogTransferRequest) ( + *sdk.AssetPacket, error) { + + s.logged = request + return nil, s.stopErr +} + +// TestObservedSignedFallbackDoesNotRebroadcast exercises the discovery path +// used by InspectRefund when a server restarts after its peer's refund mined. +// Even a complete signed fallback must use observer logging for its lineage. +func TestObservedSignedFallbackDoesNotRebroadcast(t *testing.T) { + node, swap, outcome, chain := bindingOutcome(t, true) + pkg := signingPackage(t) + pkg.Publish.SkipAnchorTxBroadcast = false + pkg.Publish.ExternalBroadcast = false + pkg, err := pkg.Seal() + require.NoError(t, err) + signed, err := signTestPackage( + t.Context(), + signingWallet(t, 2), + signingWallet(t, 3), + pkg, + ) + require.NoError(t, err) + fallback, err := pkg.WithFinalAnchorPSBT( + finishSigningFixture(t, signed), + ) + require.NoError(t, err) + swap.Fallback = fallback + + // Reuse the existing package's fully signed anchor. The chain stub + // reports the agreed timeout leaf spending that anchor's asset output. + prior := fallback.Outputs[0] + var contract DepositContract + // Preserve the existing SDK/contract JSON field names. + //nolint:musttag + require.NoError(t, json.Unmarshal(swap.Offer.Terms.Contract, &contract)) + policy, err := HTLC(swap.Offer, contract) + require.NoError(t, err) + spend, err := policy.TimeoutSpend(prior.TaprootAssetRoot) + require.NoError(t, err) + point := SDKPoint(prior.AnchorOutpoint) + outcome.AnchorTx.TxIn[0].PreviousOutPoint = point + outcome.AnchorTx.TxIn[0].Witness = [][]byte{ + make([]byte, 64), spend.LeafScript, spend.ControlBlock, + } + node.chain = &partialRecoveryChain{ + bindingChain: chain, + spend: func(actual wire.OutPoint) (*wire.MsgBlock, uint32, error) { + require.Equal(t, point, actual) + return bindingBlock(outcome), outcome.BlockHeight, nil + }, + } + + inputRaw := fallback.Inputs[0].ProofSource.Blob + inputProof, err := LastProof(inputRaw) + require.NoError(t, err) + client := &observedRecoverySDK{ + partialRecoverySDK: &partialRecoverySDK{ + bindingSDK: &bindingSDK{valid: true}, + }, + input: &sdk.ProofFile{RawProofFile: inputRaw}, + point: sdk.Outpoint{Txid: sdk.Hash(inputProof.OutPoint().Hash), + Index: inputProof.OutPoint().Index}, + stopErr: errors.New("reached observer log"), + } + node.client = client + _, err = DiscoverOutcome( + t.Context(), + node.wallet(), + node.chain, + node.minConfs, + swap, + true, + ) + require.ErrorIs(t, err, client.stopErr) + require.NotNil(t, client.logged) + require.True(t, client.logged.SkipAnchorTxBroadcast) + // Logging flags belong to this observation, not the persisted package. + require.False(t, fallback.Publish.SkipAnchorTxBroadcast) +} diff --git a/swapkit/tapsdk/proof_archive_test.go b/swapkit/tapsdk/proof_archive_test.go new file mode 100644 index 000000000..5a09f8405 --- /dev/null +++ b/swapkit/tapsdk/proof_archive_test.go @@ -0,0 +1,94 @@ +package tapsdk + +import ( + "context" + "errors" + "testing" + + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +type archiveSDK struct { + sdk.Client + + file *sdk.ProofFile + exportErr error + importErr error + imports int + ref sdk.AssetRef + key sdk.PubKey + point sdk.Outpoint +} + +func (a *archiveSDK) ExportProof(_ context.Context, ref sdk.AssetRef, + key sdk.PubKey, point *sdk.Outpoint) (*sdk.ProofFile, error) { + + a.ref, a.key, a.point = ref, key, *point + return a.file, a.exportErr +} + +func (a *archiveSDK) UnpackProofFile(context.Context, []byte) ( + [][]byte, error) { + + a.imports++ + return nil, a.importErr +} + +func TestProofArchiveRetryUsesExactOutput(t *testing.T) { + p := bindingProof(t, bindingGenesis(), bindingKey(2).PubKey(), + bindingKey(3).PubKey(), nil, bindingGenesis().FirstPrevOut, + 100, false) + raw := bindingRaw(t, p) + client := &archiveSDK{file: &sdk.ProofFile{RawProofFile: raw}} + wallet := sdk.NewWallet(client, sdk.NetworkRegtest) + for range 2 { + require.NoError(t, importProof(t.Context(), wallet, raw)) + } + require.Zero(t, client.imports) + require.Equal(t, sdk.AssetRefFromAssetID(sdk.AssetID(p.Asset.ID())), + client.ref) + require.Equal(t, sdk.Outpoint{Txid: sdk.Hash(p.OutPoint().Hash), + Index: p.OutPoint().Index}, client.point) + require.Equal(t, bindingPub(2), [33]byte(client.key)) + + // A returned proof for another output must not authorize a skipped + // import, even when the backend reused the same asset script key. + p.AnchorTx.TxIn[0].PreviousOutPoint.Index++ + client.file = &sdk.ProofFile{RawProofFile: bindingRaw(t, p)} + require.ErrorIs( + t, + importProof(t.Context(), wallet, raw), + swapkit.ErrInvalid, + ) + require.Zero(t, client.imports) + + // A missing archive entry still requires a successful import; an + // ambiguous import response is retried through the archive next time. + lost := errors.New("lost import response") + client.exportErr, client.importErr = errors.New("not found"), lost + require.ErrorIs(t, importProof(t.Context(), wallet, raw), lost) + require.Equal(t, 1, client.imports) + client.exportErr = nil + client.file = &sdk.ProofFile{RawProofFile: raw} + require.NoError(t, importProof(t.Context(), wallet, raw)) + require.Equal(t, 1, client.imports) +} + +func TestConfirmedNativeSourceArchiveHandoff(t *testing.T) { + fixture := newCustomAnchorBuilderFixture(t) + raw := fixture.request.Inputs[0].ProofFile + path := sdk.AssetProofPath{ConfirmedBaseProof: raw} + blob, err := path.MarshalBinary() + require.NoError(t, err) + decoded, err := confirmedInputProof(sdk.CustomAnchorProofSourceSummary{ + Kind: sdk.CustomAnchorProofSourceCompactPath, Blob: blob, + }) + require.NoError(t, err) + require.Equal(t, raw, decoded) + decoded[0] ^= 1 + require.NotEqual(t, raw, decoded) + _, err = confirmedInputProof(sdk.CustomAnchorProofSourceSummary{}) + require.ErrorIs(t, err, swapkit.ErrInvalid) +} diff --git a/swapkit/tapsdk/proofs.go b/swapkit/tapsdk/proofs.go index 6522d3307..cf0008931 100644 --- a/swapkit/tapsdk/proofs.go +++ b/swapkit/tapsdk/proofs.go @@ -14,6 +14,7 @@ import ( sdk "github.com/lightninglabs/tap-sdk" "github.com/lightninglabs/taproot-assets/commitment" "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tappsbt" ) // LastProof decodes the final transition in a native asset proof file. @@ -85,6 +86,42 @@ func commitmentRoots( return sdk.Hash(assets), sdk.Hash(root), nil } +// verifyProof checks Loop policy and SDK lineage between current-chain checks. +func verifyProof(ctx context.Context, wallet *sdk.Wallet, + chain Chain, minConfs uint32, raw []byte, + group [33]byte, amount uint64, unspent bool) (*proof.Proof, error) { + + if err := CheckNodeVersion(ctx, wallet); err != nil { + return nil, err + } + if err := checkProvenance(ctx, wallet, raw); err != nil { + return nil, err + } + p, err := inspectProof( + ctx, + chain, + minConfs, + raw, + group, + amount, + unspent, + ) + if err != nil { + return nil, err + } + verified, err := wallet.Client().VerifyProof(ctx, raw) + if err != nil { + return nil, err + } + if err := checkVerifiedSummary(verified, p); err != nil { + return nil, err + } + if err := checkConfirmation(ctx, chain, minConfs, p, unspent); err != nil { + return nil, err + } + return p, nil +} + // checkConfirmation requires current-chain inclusion and optional spendability. func checkConfirmation(ctx context.Context, chain Chain, minConfs uint32, p *proof.Proof, @@ -186,6 +223,185 @@ func BindFinalPackage(approved *sdk.CustomAnchorTransferPackage, return approved.WithFinalAnchorPSBT(proposed.AnchorPsbt) } +// LogAndExport publishes or records an observed transfer through tapd and +// returns its exact output proof once the confirmation is current. tapd logs +// every publication request as a new transfer, so a retry must not log again. +// An archived exact proof returns its own confirmation result, and an anchor +// that tapd already logged returns ErrPending. The lookup and the publication +// are not atomic; the caller serializes invocations for one package. +func LogAndExport(ctx context.Context, wallet *sdk.Wallet, + chain Chain, minConfs uint32, + pkg *sdk.CustomAnchorTransferPackage, observed bool) ([]byte, error) { + + if err := CheckNodeVersion(ctx, wallet); err != nil { + return nil, err + } + if err := CheckSigningPolicy(pkg); err != nil { + return nil, err + } + if len(pkg.Inputs) != 1 || len(pkg.Outputs) != 1 || + len(pkg.ProofUpdates) != 1 { + + return nil, swapkit.ErrInvalid + } + out := pkg.Outputs[0] + // The second result reports that tapd archived the exact output proof, + // which it only does for a transfer it has already logged. + export := func() ([]byte, bool, error) { + file, err := wallet.ExportProofFile(ctx, + sdk.AssetRefFromAssetID(out.IssuanceID), out.ScriptKey, + &out.AnchorOutpoint) + if err != nil { + return nil, false, err + } + if file == nil { + return nil, false, swapkit.ErrInvalid + } + p, err := LastProof(file.RawProofFile) + if err != nil { + return nil, false, err + } + if sdk.AssetID(p.Asset.ID()) != out.IssuanceID || + p.OutPoint() != SDKPoint(out.AnchorOutpoint) || + !bytes.Equal( + p.Asset.ScriptKey.PubKey.SerializeCompressed(), + out.ScriptKey[:], + ) { + + return nil, false, swapkit.ErrInvalid + } + // The caller verifies the proof with its contract exactly once. + // Here a current confirmation distinguishes archive lag from success. + err = checkConfirmation(ctx, chain, minConfs, p, false) + if err != nil { + return nil, true, err + } + return file.RawProofFile, true, nil + } + raw, archived, err := export() + if err == nil { + return raw, nil + } + // Only the confirmation of the archived proof is outstanding. Report it + // instead of logging the same transfer again. + if archived { + return nil, err + } + // tapd archives the proof only after the anchor confirms, so an earlier + // invocation may already have logged this transfer. + logged, err := anchorLogged(ctx, wallet, out.AnchorOutpoint) + if err != nil { + return nil, err + } + if logged { + return nil, swapkit.ErrPending + } + if _, err := OPTrueSpendInfo(out); err == nil { + if err := declareOutput(ctx, wallet, pkg, out); err != nil { + return nil, err + } + } + rawInput, err := confirmedInputProof(pkg.Inputs[0].ProofSource) + if err != nil { + return nil, err + } + err = importProof(ctx, wallet, rawInput) + if err != nil { + return nil, err + } + if observed { + pkg = pkg.Clone() + pkg.Publish.SkipAnchorTxBroadcast = true + pkg.Publish.ExternalBroadcast = true + pkg, err = pkg.Seal() + if err != nil { + return nil, err + } + } + _, err = wallet.PublishCustomAnchorTransfer(ctx, pkg, pkg.AnchorPsbt) + if err != nil { + // A lost reply can follow a durable tapd publication. The next + // invocation finds the logged anchor before retrying the log. + return nil, err + } + if raw, _, err := export(); err == nil { + return raw, nil + } + return nil, swapkit.ErrPending +} + +// anchorLogged reports whether tapd's transfer log already contains the anchor +// transaction. Every returned record is matched again, so a backend that +// ignores the filter cannot suppress a first publication. +func anchorLogged(ctx context.Context, wallet *sdk.Wallet, + anchor sdk.Outpoint) (bool, error) { + + txid := SDKPoint(anchor).Hash + transfers, err := wallet.Client().ListTransfers(ctx, + &sdk.ListTransfersRequest{AnchorTxid: txid.String()}) + if err != nil { + return false, err + } + for _, transfer := range transfers { + if transfer == nil { + continue + } + logged, err := chainhash.NewHashFromStr(transfer.AnchorTxid) + if err == nil && *logged == txid { + return true, nil + } + } + return false, nil +} + +// declareOutput retains the native packet's complete key descriptor. A control +// block alone loses the original key parity and locator, which can replace a +// wallet-owned key's first stored derivation with the unrelated zero locator. +func declareOutput(ctx context.Context, wallet *sdk.Wallet, + pkg *sdk.CustomAnchorTransferPackage, + out sdk.CustomAnchorAssetOutputSummary) error { + + if out.PacketIndex >= uint32(len(pkg.ActiveVirtualPsbts)) { + return swapkit.ErrInvalid + } + packet, err := tappsbt.NewFromRawBytes(bytes.NewReader( + pkg.ActiveVirtualPsbts[out.PacketIndex]), false) + if err != nil { + return err + } + if out.VirtualOutputIndex >= uint32(len(packet.Outputs)) { + return swapkit.ErrInvalid + } + key := packet.Outputs[out.VirtualOutputIndex].ScriptKey + if key.TweakedScriptKey == nil || key.RawKey.PubKey == nil || + !bytes.Equal( + key.PubKey.SerializeCompressed(), + out.ScriptKey[:], + ) { + + return swapkit.ErrInvalid + } + raw, err := sdk.ParsePubKey(key.RawKey.PubKey.SerializeCompressed()) + if err != nil { + return err + } + _, err = wallet.Client(). + DeclareScriptKey(ctx, &sdk.DeclareScriptKeyRequest{ + ScriptKey: sdk.ScriptKey{ + PubKey: out.ScriptKey, + TapTweak: key.Tweak, + KeyDesc: sdk.KeyDescriptor{RawKeyBytes: raw, + KeyLocator: sdk.KeyLocator{ + Family: uint32( + key.RawKey.Family, + ), + Index: key.RawKey.Index, + }}, + }, + }) + return err +} + // importProof restores lineage missing from tapd's exact-output proof archive. func importProof(ctx context.Context, wallet *sdk.Wallet, raw []byte) error { p, err := LastProof(raw) @@ -305,3 +521,24 @@ func inspectAsset(raw []byte, group [33]byte, amount uint64) (*proof.Proof, } return p, nil } + +// confirmedInputProof preserves native proof sources without treating a compact +// path encoding as a proof-file encoding. Unconfirmed steps need the explicit +// ConfirmPackageInputs handoff before publication. +func confirmedInputProof(source sdk.CustomAnchorProofSourceSummary) ([]byte, error) { + switch source.Kind { + case sdk.CustomAnchorProofSourceConfirmedFile: + return bytes.Clone(source.Blob), nil + case sdk.CustomAnchorProofSourceCompactPath: + var path sdk.AssetProofPath + if err := path.UnmarshalBinary(source.Blob); err != nil { + return nil, err + } + if len(path.Steps) != 0 { + return nil, swapkit.ErrPending + } + return bytes.Clone(path.ConfirmedBaseProof), nil + default: + return nil, swapkit.ErrInvalid + } +} diff --git a/swapkit/tapsdk/publish_retry_test.go b/swapkit/tapsdk/publish_retry_test.go new file mode 100644 index 000000000..d8003c37b --- /dev/null +++ b/swapkit/tapsdk/publish_retry_test.go @@ -0,0 +1,189 @@ +package tapsdk + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/stretchr/testify/require" +) + +// publishRetryFixture returns a finalized package and a wallet stub whose +// proof archive holds only the package's input. +func publishRetryFixture(t *testing.T) (*sdk.CustomAnchorTransferPackage, + *observedRecoverySDK) { + + t.Helper() + pkg := signingPackage(t) + pkg.Publish.SkipAnchorTxBroadcast = false + pkg.Publish.ExternalBroadcast = false + pkg, err := pkg.Seal() + require.NoError(t, err) + signed, err := signTestPackage( + t.Context(), signingWallet(t, 2), signingWallet(t, 3), pkg, + ) + require.NoError(t, err) + final, err := pkg.WithFinalAnchorPSBT(finishSigningFixture(t, signed)) + require.NoError(t, err) + + inputRaw := final.Inputs[0].ProofSource.Blob + input, err := LastProof(inputRaw) + require.NoError(t, err) + return final, &observedRecoverySDK{ + partialRecoverySDK: &partialRecoverySDK{ + bindingSDK: &bindingSDK{valid: true}, + }, + input: &sdk.ProofFile{RawProofFile: inputRaw}, + point: sdk.Outpoint{ + Txid: sdk.Hash(input.OutPoint().Hash), + Index: input.OutPoint().Index, + }, + stopErr: errors.New("reached transfer log"), + } +} + +// TestLogAndExportDoesNotRelogAnchor checks that an anchor tapd already logged +// is never logged again while its exact output proof is not archived yet. +func TestLogAndExportDoesNotRelogAnchor(t *testing.T) { + pkg, client := publishRetryFixture(t) + wallet := sdk.NewWallet(client, sdk.NetworkRegtest) + chain := &bindingChain{tip: 200} + txid := SDKPoint(pkg.Outputs[0].AnchorOutpoint).Hash.String() + + // The first invocation finds no logged anchor and publishes. + _, err := LogAndExport(t.Context(), wallet, chain, 1, pkg, false) + require.ErrorIs(t, err, client.stopErr) + require.NotNil(t, client.logged) + require.Equal(t, []string{txid}, client.lookups) + + // A backend that ignores the anchor filter cannot suppress it. + client.logged = nil + client.transfers = []*sdk.AssetTransfer{ + nil, {AnchorTxid: chainhash.Hash{1}.String()}, + } + _, err = LogAndExport(t.Context(), wallet, chain, 1, pkg, false) + require.ErrorIs(t, err, client.stopErr) + require.NotNil(t, client.logged) + + // Once tapd lists the anchor, a publisher or observer retry waits for + // the archive instead of logging the transfer again. + client.logged = nil + client.transfers = append(client.transfers, &sdk.AssetTransfer{ + AnchorTxid: txid, + }) + for _, observed := range []bool{false, true} { + _, err = LogAndExport( + t.Context(), wallet, chain, 1, pkg, observed, + ) + require.ErrorIs(t, err, swapkit.ErrPending) + require.Nil(t, client.logged) + } + + // A failed lookup is reported and never read as an unlogged anchor. + client.transfersErr = errors.New("transfer log unavailable") + _, err = LogAndExport(t.Context(), wallet, chain, 1, pkg, false) + require.ErrorIs(t, err, client.transfersErr) + require.Nil(t, client.logged) +} + +// archivedOutputSDK also archives the exact output proof of a package. +type archivedOutputSDK struct { + *observedRecoverySDK + + output sdk.Outpoint + file *sdk.ProofFile +} + +// ExportProof returns the archived output proof for its exact locator. +func (s *archivedOutputSDK) ExportProof(ctx context.Context, ref sdk.AssetRef, + key sdk.PubKey, point *sdk.Outpoint) (*sdk.ProofFile, error) { + + if point != nil && *point == s.output { + return s.file, nil + } + return s.observedRecoverySDK.ExportProof(ctx, ref, key, point) +} + +// failingHeightChain reports a chain backend failure for the best height. +type failingHeightChain struct { + *bindingChain + + err error +} + +// Height returns the injected backend failure. +func (c *failingHeightChain) Height(context.Context) (uint32, error) { + return 0, c.err +} + +// archivedOutputProof derives the package's exact output proof at a height. +func archivedOutputProof(t *testing.T, pkg *sdk.CustomAnchorTransferPackage, + height uint32) *proof.Proof { + + t.Helper() + out := pkg.Outputs[0] + p, err := LastProof(pkg.Inputs[0].ProofSource.Blob) + require.NoError(t, err) + packet, err := DecodePSBT(pkg.AnchorPsbt) + require.NoError(t, err) + tx, err := psbt.Extract(packet) + require.NoError(t, err) + scriptKey, err := btcec.ParsePubKey(out.ScriptKey[:]) + require.NoError(t, err) + + p.Asset.ScriptKey = asset.NewScriptKey(scriptKey) + p.AnchorTx = *tx + p.InclusionProof.OutputIndex = out.AnchorOutpoint.Index + p.BlockHeight = height + p.TxMerkleProof = proof.TxMerkleProof{} + p.BlockHeader.MerkleRoot = tx.TxHash() + require.Equal(t, SDKPoint(out.AnchorOutpoint), p.OutPoint()) + require.Equal(t, out.IssuanceID, sdk.AssetID(p.Asset.ID())) + return p +} + +// TestLogAndExportArchivedProofAwaitsConfirmation checks that an archived +// exact output proof reports its own confirmation result. Neither an +// outstanding confirmation nor a chain backend failure logs the transfer again. +func TestLogAndExportArchivedProofAwaitsConfirmation(t *testing.T) { + pkg, recovery := publishRetryFixture(t) + p := archivedOutputProof(t, pkg, 200) + raw := bindingRaw(t, p) + client := &archivedOutputSDK{ + observedRecoverySDK: recovery, + output: pkg.Outputs[0].AnchorOutpoint, + file: &sdk.ProofFile{RawProofFile: raw}, + } + wallet := sdk.NewWallet(client, sdk.NetworkRegtest) + chain := &bindingChain{tip: 200, blocks: map[uint32]*wire.MsgBlock{ + 200: bindingBlock(p), + }} + + // One of three required confirmations is outstanding. + _, err := LogAndExport(t.Context(), wallet, chain, 3, pkg, false) + require.ErrorIs(t, err, swapkit.ErrPending) + + // A chain backend failure is reported, not answered with a new log. + backend := errors.New("chain backend unavailable") + _, err = LogAndExport(t.Context(), wallet, &failingHeightChain{ + bindingChain: chain, err: backend, + }, 1, pkg, false) + require.ErrorIs(t, err, backend) + + // The same archived proof is returned once it is confirmed deeply. + chain.tip = 202 + exported, err := LogAndExport(t.Context(), wallet, chain, 3, pkg, false) + require.NoError(t, err) + require.Equal(t, raw, exported) + + require.Nil(t, recovery.logged) + require.Empty(t, recovery.lookups) +} diff --git a/swapkit/tapsdk/recovery.go b/swapkit/tapsdk/recovery.go new file mode 100644 index 000000000..6747d5966 --- /dev/null +++ b/swapkit/tapsdk/recovery.go @@ -0,0 +1,305 @@ +package tapsdk + +import ( + "context" + "crypto/sha256" + + "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/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" +) + +func walletOutput(public [33]byte, family int32, index uint32, + ref sdk.AssetRef, amount uint64, value int64) ( + sdk.CustomAssetOutput, error) { + + pub, err := btcec.ParsePubKey(public[:]) + if err != nil { + return sdk.CustomAssetOutput{}, err + } + tweaked := txscript.ComputeTaprootKeyNoScript(pub) + even, err := schnorr.ParsePubKey(schnorr.SerializePubKey(tweaked)) + if err != nil { + return sdk.CustomAssetOutput{}, err + } + scriptKey, err := sdk.ParsePubKey(even.SerializeCompressed()) + if err != nil { + return sdk.CustomAssetOutput{}, err + } + loc := sdk.KeyLocator{Family: uint32(family), Index: index} + return sdk.CustomAssetOutput{ + ID: "recipient", AssetRef: ref, Amount: amount, + AnchorValueSat: uint64(value), + Script: sdk.CustomAssetScriptPlan{ + Mode: sdk.CustomAssetScriptExternal, + External: &sdk.CustomAssetExternalScriptPlan{ + ScriptKey: sdk.ScriptKey{PubKey: scriptKey, + KeyDesc: sdk.KeyDescriptor{ + RawKeyBytes: public, + KeyLocator: loc, + }}, + }, + }, + Anchor: sdk.CustomAnchorOutputPlan{ + InternalKey: sdk.InternalKey{ + PubKey: public, KeyLocator: loc, + }, + }, + }, nil +} + +// DiscoverOutcome reconstructs an offline peer's proof from its Bitcoin spend. +// It verifies the agreed leaf and observed witness before observer logging. +func DiscoverOutcome(ctx context.Context, wallet *sdk.Wallet, + chain Chain, minConfs uint32, s *SwapContext, + refund bool) ([]byte, error) { + + if s.Fallback == nil { + return nil, swapkit.ErrPending + } + d := s.Deposit + c := d.Contract + out := s.Fallback.Outputs[0] + point := SDKPoint(out.AnchorOutpoint) + // The server may only have its public partial after a lost reply. + // The committed unsigned anchor still fixes the output to watch. + anchor, err := DecodePSBT(s.Fallback.AnchorPsbt) + if err != nil { + return nil, err + } + block, _, err := chain.Spend( + ctx, + point, + anchor.UnsignedTx.TxOut[point.Index].PkScript, + s.Deposit.Proof.BlockHeight, + ) + if err != nil { + return nil, err + } + found := spendingTransaction(block, point) + if len(found.TxIn) != 1 || len(found.TxOut) != 1 { + return nil, swapkit.ErrInvalid + } + policy, err := HTLC(s.Offer, c) + if err != nil { + return nil, err + } + public := c.ServerKey + spend, err := policy.SuccessSpend(out.TaprootAssetRoot) + if refund { + public = c.ClientKey + spend, err = policy.TimeoutSpend(out.TaprootAssetRoot) + } + if err != nil { + return nil, err + } + if !spendsLeaf(found, out, spend) { + return nil, swapkit.ErrPending + } + fallbackProof, err := FallbackProof( + ctx, + wallet, + chain, + minConfs, + s, + true, + ) + if err != nil { + return nil, err + } + raw, err := rebuildObservedSpend( + ctx, wallet, chain, minConfs, out, fallbackProof, public, + spend, found, + ) + if err != nil { + return nil, err + } + return raw, nil +} + +// VerifyOutcome binds a proof to the agreed claim or timeout Bitcoin path. +func VerifyOutcome(ctx context.Context, wallet *sdk.Wallet, + chain Chain, minConfs uint32, s *SwapContext, + raw []byte, refund, requireUnspent bool) error { + + d := s.Deposit + c := d.Contract + if s.Fallback == nil { + return swapkit.ErrPending + } + prior := s.Fallback.Outputs[0] + if !prior.AssetRef.Equivalent( + sdk.AssetRefFromGroupKey(s.Offer.Terms.GroupKey), + ) || + prior.Amount != s.Offer.Terms.DepositAmount { + + return swapkit.ErrInvalid + } + pub := c.ServerKey + if refund { + pub = c.ClientKey + } + policy, err := HTLC(s.Offer, c) + if err != nil { + return err + } + spend, err := policy.SuccessSpend(prior.TaprootAssetRoot) + if refund { + spend, err = policy.TimeoutSpend(prior.TaprootAssetRoot) + } + if err != nil { + return err + } + err = VerifyWalletSpend( + ctx, + wallet, + chain, + minConfs, + raw, + prior, + pub, + spend, + requireUnspent, + 0, + ) + return err +} + +// DiscoverPreimage reads only the fixed fallback's current on-chain spend. +func DiscoverPreimage(ctx context.Context, chain Chain, + s *SwapContext) (swapkit.PreimageResult, error) { + + if s.Fallback == nil { + return swapkit.PreimageResult{}, nil + } + point := SDKPoint(s.Fallback.Outputs[0].AnchorOutpoint) + anchor, err := DecodePSBT(s.Fallback.AnchorPsbt) + if err != nil { + return swapkit.PreimageResult{}, err + } + block, _, err := chain.Spend( + ctx, + point, + anchor.UnsignedTx.TxOut[point.Index].PkScript, + s.Deposit.Proof.BlockHeight, + ) + if err == swapkit.ErrPending { + return swapkit.PreimageResult{}, nil + } + if err != nil { + return swapkit.PreimageResult{}, err + } + for _, input := range spendingTransaction(block, point).TxIn { + if input.PreviousOutPoint != point { + continue + } + for _, element := range input.Witness { + if len(element) != 32 || + sha256.Sum256(element) != s.Offer.Hash { + + continue + } + var preimage [32]byte + copy(preimage[:], element) + return swapkit.PreimageResult{ + Preimage: preimage, + Found: true, + }, nil + } + } + return swapkit.PreimageResult{}, nil +} + +// FallbackProof locates the fixed fallback and logs its confirmed witness. +// SDK Build verifies the returned lineage when constructing its successor; +// validation-only callers verify the complete outcome proof instead. +func FallbackProof(ctx context.Context, wallet *sdk.Wallet, + chain Chain, minConfs uint32, + s *SwapContext, observed bool) ([]byte, error) { + + if err := CheckNodeVersion(ctx, wallet); err != nil { + return nil, err + } + if s.Fallback == nil { + return nil, swapkit.ErrPending + } + contract := s.Deposit.Contract + spendInfo, err := OPTrueSpendInfo(contract.Output) + if err != nil { + return nil, err + } + if err := declareOPTrue(ctx, wallet, contract.Output.ScriptKey, + spendInfo.WitnessStack()); err != nil { + return nil, err + } + pkg := s.Fallback + packet, err := DecodePSBT(pkg.AnchorPsbt) + if err != nil { + return nil, err + } + if !packet.IsComplete() { + observed = true + if len(packet.UnsignedTx.TxIn) != 1 || + len(packet.Inputs) != 1 || + packet.Inputs[0].WitnessUtxo == nil { + + return nil, swapkit.ErrInvalid + } + point := packet.UnsignedTx.TxIn[0].PreviousOutPoint + block, _, err := chain.Spend( + ctx, point, packet.Inputs[0].WitnessUtxo.PkScript, + s.Deposit.Proof.BlockHeight, + ) + if err != nil { + return nil, err + } + tx := spendingTransaction(block, point) + if tx == nil || tx.TxHash() != packet.UnsignedTx.TxHash() || + len(tx.TxIn[0].Witness) != 1 { + + return nil, swapkit.ErrPending + } + requests, err := pkg.SigningRequests() + if err != nil || len(requests.MuSig2) != 1 { + return nil, swapkit.ErrInvalid + } + signed, err := pkg.ApplyKeyPathSignature( + requests.MuSig2[0].ID, tx.TxIn[0].Witness[0], + ) + if err != nil { + return nil, err + } + packet, err = DecodePSBT(signed.AnchorPsbt) + if err != nil { + return nil, err + } + if err := psbt.MaybeFinalizeAll(packet); err != nil { + return nil, err + } + final, err := EncodePSBT(packet) + if err != nil { + return nil, err + } + s.Fallback, err = pkg.WithFinalAnchorPSBT(final) + if err != nil { + return nil, err + } + } + raw, err := LogAndExport( + ctx, + wallet, + chain, + minConfs, + s.Fallback, + observed, + ) + if err != nil { + return nil, err + } + // The next SDK Build verifies this input lineage once. A validation-only + // caller verifies its complete outcome proof, including this lineage. + return raw, nil +} diff --git a/swapkit/tapsdk/spend.go b/swapkit/tapsdk/spend.go new file mode 100644 index 000000000..1720f1fdb --- /dev/null +++ b/swapkit/tapsdk/spend.go @@ -0,0 +1,66 @@ +package tapsdk + +import ( + "context" + + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightningnetwork/lnd/keychain" +) + +// RecoveryRequest constructs an SDK refund or claim to the normal wallet key. +func RecoveryRequest( + ctx context.Context, + wallet *sdk.Wallet, + out sdk.CustomAnchorAssetOutputSummary, + inputProof []byte, + public [33]byte, + key keychain.KeyLocator, + spend *swapkit.SwapAnchorSpendInfo, + fee uint64, +) ( + *sdk.CustomAnchorRequest, error) { + + if err := CheckNodeVersion(ctx, wallet); err != nil { + return nil, err + } + if out.AnchorValueSat < 330 || + fee > uint64(out.AnchorValueSat-330) { + + return nil, swapkit.ErrInvalid + } + value := out.AnchorValueSat - int64(fee) + if fee == 0 { + return nil, swapkit.ErrInvalid + } + recipient, err := walletOutput(public, int32(key.Family), key.Index, + out.AssetRef, out.Amount, value) + if err != nil { + return nil, err + } + // Declare the locator so normal wallet operations can spend the + // recovered asset after proof import. + _, err = wallet.Client(). + DeclareScriptKey(ctx, &sdk.DeclareScriptKeyRequest{ + ScriptKey: recipient.Script.External.ScriptKey, + }) + if err != nil { + return nil, err + } + p, err := LastProof(inputProof) + if err != nil { + return nil, err + } + anchor, err := AnchorTemplate( + p, recipient.Anchor.InternalKey, value, spend, + ) + if err != nil { + return nil, err + } + input, err := AssetInput(out, inputProof) + if err != nil { + return nil, err + } + return CustomRequest(input, recipient, anchor, + ScriptSigningPlan(spend, 0)), nil +} diff --git a/swapkit/tapsdk/spend_test.go b/swapkit/tapsdk/spend_test.go index 6c2d679ad..3d0e98896 100644 --- a/swapkit/tapsdk/spend_test.go +++ b/swapkit/tapsdk/spend_test.go @@ -2,9 +2,19 @@ package tapsdk import ( "context" + "encoding/json" + "errors" + "testing" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/commitment" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" ) type scriptWallet struct { @@ -26,3 +36,134 @@ func (w *scriptWallet) SignOutputRawKeyLocator(context.Context, *wire.MsgTx, } return [][]byte{w.signature}, nil } + +// TestScriptSpendRecovery checks retry after a lost signer reply and reuse of +// the completed transaction after the owning FSM saves it. +func TestScriptSpendRecovery(t *testing.T) { + for _, lostReply := range []bool{false, true} { + name := "completed package" + if lostReply { + name = "lost signer reply" + } + t.Run(name, func(t *testing.T) { + tx := scriptPackage(t) + requests, err := tx.SigningRequests() + require.NoError(t, err) + require.Len(t, requests.ScriptPath, 1) + request := requests.ScriptPath[0] + signature, err := schnorr.Sign( + bindingKey(2), request.Sighash[:], + ) + require.NoError(t, err) + lost := errors.New("simulated lost signer reply") + wallet := &scriptWallet{ + signature: signature.Serialize(), + } + if lostReply { + wallet.failure = lost + } + signed, err := FinalizeScriptSpend( + t.Context(), + wallet, + keychain.KeyLocator{}, + tx, + nil, + ) + if lostReply { + require.ErrorIs(t, err, lost) + require.Nil(t, signed) + signed, err = FinalizeScriptSpend( + t.Context(), + wallet, + keychain.KeyLocator{}, + tx, + nil, + ) + } + require.NoError(t, err) + final := signed.AnchorPsbt + require.NotEmpty(t, final) + stored, err := signed.MarshalBinary() + require.NoError(t, err) + restored, err := DecodeFinalPackage(stored) + require.NoError(t, err) + require.Equal(t, final, restored.AnchorPsbt) + require.Equal(t, signed.CommittedPackageDigest, + restored.CommittedPackageDigest) + }) + } +} + +type declaringRecoveryWallet struct{ sdk.Client } + +func (declaringRecoveryWallet) DeclareScriptKey(context.Context, + *sdk.DeclareScriptKeyRequest) (*sdk.ScriptKey, error) { + + return nil, nil +} + +// TestRecoveryPlanIgnoresChangedFeeConfiguration verifies the transaction that +// is rebuilt after restart stays identical when operator fee limits change. +func TestRecoveryPlanIgnoresChangedFeeConfiguration(t *testing.T) { + node, swap, _, _ := bindingOutcome(t, true) + var contract DepositContract + // Preserve the existing SDK/contract JSON field names. + //nolint:musttag + require.NoError(t, json.Unmarshal(swap.Offer.Terms.Contract, &contract)) + output := swap.Fallback.Outputs[0] + output.OPTrueSpend = contract.Output.OPTrueSpend + policy, err := HTLC(swap.Offer, contract) + require.NoError(t, err) + anchor := AnchorPlan(policy.AnchorPlan()) + require.Len(t, anchor.Tapscript.TapLeaves, 2) + branch := txscript.NewTapBranch( + txscript.NewBaseTapLeaf(anchor.Tapscript.TapLeaves[0].Script), + txscript.NewBaseTapLeaf(anchor.Tapscript.TapLeaves[1].Script), + ) + sibling := commitment.NewPreimageFromBranch(branch) + internal, err := btcec.ParsePubKey(anchor.InternalKey.PubKey[:]) + require.NoError(t, err) + script, err := btcec.ParsePubKey(contract.Output.ScriptKey[:]) + require.NoError(t, err) + previous := bindingProof(t, bindingGenesis(), script, internal, + &sibling, SDKPoint(contract.Output.AnchorOutpoint), 140, false) + previous.AnchorTx.TxOut[0].Value = output.AnchorValueSat + previous.BlockHeader.MerkleRoot = previous.AnchorTx.TxHash() + output.TaprootAssetRoot, _, err = isolatedRoots(previous) + require.NoError(t, err) + spend, err := policy.TimeoutSpend(output.TaprootAssetRoot) + require.NoError(t, err) + output.ScriptKey = contract.Output.ScriptKey + output.IssuanceID = sdk.AssetID(previous.Asset.ID()) + output.AnchorOutpoint = sdk.Outpoint{ + Txid: sdk.Hash(previous.OutPoint().Hash), + Index: previous.OutPoint().Index, + } + output.Amount = previous.Asset.Amount + output.AssetRef = contract.Output.AssetRef + proof := bindingRaw(t, previous) + fee := uint64(contract.Output.AnchorValueSat - output.AnchorValueSat) + node.client = declaringRecoveryWallet{} + before, err := RecoveryRequest( + t.Context(), + node.wallet(), + output, + proof, + contract.ClientKey, + keychain.KeyLocator{}, + spend, + fee, + ) + require.NoError(t, err) + // Recovery only accepts the agreed fee. No daemon fee configuration + // crosses this API when the same unsigned transfer is rebuilt. + after, err := RecoveryRequest(t.Context(), node.wallet(), output, proof, + contract.ClientKey, keychain.KeyLocator{}, spend, fee) + require.NoError(t, err) + require.Equal(t, before, after) + require.EqualValues(t, 8000, after.Outputs[0].AnchorValueSat) +} + +func (declaringRecoveryWallet) GetInfo(context.Context) (*sdk.Info, error) { + return fixtureNodeInfo(), nil +} diff --git a/swapkit/tapsdk/wallet_spend.go b/swapkit/tapsdk/wallet_spend.go new file mode 100644 index 000000000..9f47dc634 --- /dev/null +++ b/swapkit/tapsdk/wallet_spend.go @@ -0,0 +1,173 @@ +package tapsdk + +import ( + "bytes" + "context" + + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" +) + +// rebuildObservedSpend reconstructs an SDK transfer from a mined witness. +// It neither signs a wallet transaction nor broadcasts the observed spend. +func rebuildObservedSpend( + ctx context.Context, + wallet *sdk.Wallet, + chain Chain, + minConfs uint32, + prior sdk.CustomAnchorAssetOutputSummary, + inputProof []byte, + public [33]byte, + spend *swapkit.SwapAnchorSpendInfo, + observed *wire.MsgTx, +) ( + []byte, error) { + + if len(observed.TxIn) != 1 || len(observed.TxOut) != 1 || + !spendsLeaf(observed, prior, spend) { + + return nil, swapkit.ErrInvalid + } + p, err := LastProof(inputProof) + if err != nil { + return nil, err + } + value := observed.TxOut[0].Value + recipient, err := walletOutput(public, 0, 0, + prior.AssetRef, prior.Amount, value) + if err != nil { + return nil, err + } + anchor, err := AnchorTemplate(p, recipient.Anchor.InternalKey, + value, spend) + if err != nil { + return nil, err + } + // Recovery reproduces the mined transaction, including older non-RBF + // refunds. New templates use the current default sequence instead. + packet, err := DecodePSBT(anchor) + if err != nil { + return nil, err + } + packet.UnsignedTx.Version = observed.Version + packet.UnsignedTx.LockTime = observed.LockTime + packet.UnsignedTx.TxIn[0].Sequence = observed.TxIn[0].Sequence + anchor, err = EncodePSBT(packet) + if err != nil { + return nil, err + } + input, err := AssetInput(prior, inputProof) + if err != nil { + return nil, err + } + request := CustomRequest(input, + recipient, anchor, ScriptSigningPlan(spend, 0)) + pkg, err := commit(ctx, wallet, chain, minConfs, request) + if err != nil { + return nil, err + } + packet, err = DecodePSBT(pkg.AnchorPsbt) + if err != nil { + return nil, err + } + if packet.UnsignedTx.TxHash() != observed.TxHash() { + return nil, swapkit.ErrInvalid + } + requests, err := pkg.SigningRequests() + if err != nil || len(requests.ScriptPath) != 1 { + return nil, swapkit.ErrInvalid + } + witness := observed.TxIn[0].Witness + final, err := pkg.ApplyScriptPathWitness(requests.ScriptPath[0].ID, + witness[:len(witness)-2]) + if err != nil { + return nil, err + } + final, err = pkg.WithFinalAnchorPSBT(final.AnchorPsbt) + if err != nil { + return nil, err + } + return LogAndExport(ctx, wallet, chain, minConfs, final, true) +} + +// VerifyWalletSpend checks issuance, wallet recipient and Bitcoin leaf. +func VerifyWalletSpend( + ctx context.Context, + wallet *sdk.Wallet, + chain Chain, + minConfs uint32, + raw []byte, + prior sdk.CustomAnchorAssetOutputSummary, + public [33]byte, + spend *swapkit.SwapAnchorSpendInfo, + unspent bool, + minHeight uint64, +) error { + + group, ok := prior.AssetRef.GroupKey() + if !ok { + return swapkit.ErrInvalid + } + p, err := verifyProof( + ctx, wallet, chain, minConfs, raw, group, prior.Amount, unspent, + ) + if err != nil { + return err + } + if sdk.AssetID(p.Asset.ID()) != prior.IssuanceID || + uint64(p.BlockHeight) < minHeight { + + return swapkit.ErrInvalid + } + expected, err := walletOutput(public, 0, 0, + prior.AssetRef, prior.Amount, 330) + if err != nil { + return err + } + if !bytes.Equal(p.Asset.ScriptKey.PubKey.SerializeCompressed(), + expected.Script.External.ScriptKey.PubKey[:]) || + !bytes.Equal(p.InclusionProof.InternalKey.SerializeCompressed(), + public[:]) { + + return swapkit.ErrInvalid + } + block, err := chain.Block(ctx, p.BlockHeight) + if err != nil { + return err + } + for _, tx := range block.Transactions { + if tx.TxHash() != p.AnchorTx.TxHash() { + continue + } + if !spendsLeaf(tx, prior, spend) { + return swapkit.ErrInvalid + } + fetch := txscript.NewCannedPrevOutputFetcher( + spend.PkScript, prior.AnchorValueSat, + ) + hashes := txscript.NewTxSigHashes(tx, fetch) + engine, err := txscript.NewEngine(spend.PkScript, tx, 0, + txscript.StandardVerifyFlags, nil, hashes, + prior.AnchorValueSat, fetch) + if err != nil { + return err + } + return engine.Execute() + } + return swapkit.ErrInvalid +} + +func spendsLeaf(tx *wire.MsgTx, prior sdk.CustomAnchorAssetOutputSummary, + spend *swapkit.SwapAnchorSpendInfo) bool { + + if len(tx.TxIn) != 1 || tx.TxIn[0].PreviousOutPoint != + SDKPoint(prior.AnchorOutpoint) { + + return false + } + w := tx.TxIn[0].Witness + return len(w) >= 3 && bytes.Equal(w[len(w)-2], spend.LeafScript) && + bytes.Equal(w[len(w)-1], spend.ControlBlock) +} From d620bd25aca7c640553f573e01fe1781e0444b22 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 11 Nov 2026 15:26:50 +0100 Subject: [PATCH 09/25] tapsdk: Harden recovery, fallback and witness declaration Bind a recovery fallback package to the authenticated deposit before it is co-signed. The MuSig2 sighash covers the committed PSBT's own previous output, so a peer that changed only the signed input value or script obtained a valid signature that spent nothing real. Compare that prevout with the deposit output, reject a committed asset that carries its own lock time, and require normal broadcast. Verify a caller-provided asset witness commits to its input script key before declaring it to tapd, so a peer cannot bind a wallet script key to an unrelated internal key. Recognize a BIP-341 annex when matching the agreed leaf, reject an annex spend from reconstruction rather than mis-slicing its witness, report a reorg observed during outcome verification as retryable, and guard the observed-spend discovery helpers against a chain that returns a block without the watched spend. --- docs/release-notes/release-notes-next.md | 5 + swapkit/tapsdk/README.md | 19 +- swapkit/tapsdk/fallback.go | 46 ++++ swapkit/tapsdk/hardening_annex_test.go | 85 +++++++ .../hardening_fallback_asset_lock_test.go | 88 +++++++ swapkit/tapsdk/hardening_fallback_test.go | 217 ++++++++++++++++++ .../tapsdk/hardening_prepare_inputs_test.go | 82 +++++++ swapkit/tapsdk/hardening_recovery_test.go | 71 ++++++ swapkit/tapsdk/hardening_reorg_test.go | 77 +++++++ swapkit/tapsdk/original_refund.go | 10 +- swapkit/tapsdk/proofs.go | 15 ++ swapkit/tapsdk/recovery.go | 11 +- swapkit/tapsdk/wallet_spend.go | 33 ++- 13 files changed, 753 insertions(+), 6 deletions(-) create mode 100644 swapkit/tapsdk/hardening_annex_test.go create mode 100644 swapkit/tapsdk/hardening_fallback_asset_lock_test.go create mode 100644 swapkit/tapsdk/hardening_fallback_test.go create mode 100644 swapkit/tapsdk/hardening_prepare_inputs_test.go create mode 100644 swapkit/tapsdk/hardening_recovery_test.go create mode 100644 swapkit/tapsdk/hardening_reorg_test.go diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index c95916444..20872ce41 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -21,6 +21,11 @@ already using, and a failed lock no longer resets deposits that a withdrawal or a channel open has taken. +* Harden the shared `swapkit` adapter against hostile peer input: bind a + recovery fallback to the authenticated deposit before co-signing, verify a + caller-provided asset witness before declaring it, recognize annex spends, + and report a reorg during outcome verification as retryable. + #### Maintenance #### Contributors (Alphabetical Order) diff --git a/swapkit/tapsdk/README.md b/swapkit/tapsdk/README.md index fbb7f4c8b..1a5ef8e87 100644 --- a/swapkit/tapsdk/README.md +++ b/swapkit/tapsdk/README.md @@ -66,9 +66,12 @@ Use the following order for a confirmed-input transaction: complete batch of active and passive bindings and their union commitment. Both enforce bounded genesis provenance and the live tapd v0.8.8 floor. 3. `PrepareInputs` restores exact confirmed proof lineage and declares public - asset witnesses. Call the native `wallet.NewCustomAnchorTxBuilder().Build`. - Native SDK Build verifies lineage and constructs allocations and commitments; - do not repeat generic proof verification before Build. + asset witnesses. A caller-provided witness is verified to commit to its input + script key before it is declared, so a peer cannot bind a wallet script key + to an unrelated internal key. Call the native + `wallet.NewCustomAnchorTxBuilder().Build`. Native SDK Build verifies lineage + and constructs allocations and commitments; do not repeat generic proof + verification before Build. 4. Call `ApprovePlan` with the independently retained Bitcoin template, concrete `OutputBinding` allocations and absolute fee cap. It checks every issuance, allocation, asset key derivation, Bitcoin contract and complete transaction @@ -168,6 +171,16 @@ metadata. Recovery of an observed refund preserves its mined version, locktime and sequence, including older non-RBF refunds. A digest does not itself cover final witnesses: those are independently validated by the native SDK. +`NewSwap` validates a peer's fallback package against the authenticated deposit +before it is ever co-signed. It binds the committed PSBT's signed previous +output to the deposit output, rejects a committed asset that carries its own +lock time, and requires normal publication (no skip-broadcast). The observed +refund and claim discovery helpers tolerate a `Chain` that returns a block +without the watched spend, recognize the agreed leaf when the spend carries a +BIP-341 annex, and reject an annex spend from reconstruction rather than +mis-slicing its witness. A reorg observed while verifying an outcome is +reported as retryable, not invalid. + ## Validation Run `go test ./swapkit/... ./internal/assetproof ./internal/assetpolicy` with the diff --git a/swapkit/tapsdk/fallback.go b/swapkit/tapsdk/fallback.go index b6b451d2c..8639f76d3 100644 --- a/swapkit/tapsdk/fallback.go +++ b/swapkit/tapsdk/fallback.go @@ -6,6 +6,7 @@ import ( "github.com/lightninglabs/loop/swapkit" sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/tappsbt" ) // HTLC constructs the existing V3 Bitcoin contract for the offered swap. @@ -53,6 +54,12 @@ func validateRecoveryPackage(offer swapkit.Offer, d *DepositContext, return nil, swapkit.ErrInvalid } + // The peer owns the package it hands us, including its publication + // metadata. A fallback must be broadcast normally, so reject a package + // that would be logged without reaching the network. + if pkg.Publish.SkipAnchorTxBroadcast || pkg.Publish.ExternalBroadcast { + return nil, swapkit.ErrInvalid + } packet, err := DecodePSBT(pkg.AnchorPsbt) if err != nil { return nil, err @@ -65,6 +72,45 @@ func validateRecoveryPackage(offer swapkit.Offer, d *DepositContext, return nil, swapkit.ErrInvalid } + // The MuSig2 sighash is taken over the committed PSBT's own previous + // output. A peer that only changes the signed input value or script + // obtains a complete signature that is invalid for the real deposit, so + // bind the signed prevout to the authenticated deposit output here. + committed, err := DecodePSBT(pkg.CommittedAnchorPsbt) + if err != nil { + return nil, err + } + prevout := d.Proof.AnchorTx.TxOut[d.Proof.OutPoint().Index] + if len(committed.UnsignedTx.TxIn) != 1 || len(committed.Inputs) != 1 || + committed.UnsignedTx.TxIn[0].PreviousOutPoint != + d.Proof.OutPoint() || + committed.Inputs[0].WitnessUtxo == nil || + committed.Inputs[0].WitnessUtxo.Value != prevout.Value || + !bytes.Equal(committed.Inputs[0].WitnessUtxo.PkScript, + prevout.PkScript) { + + return nil, swapkit.ErrInvalid + } + // The Bitcoin transaction can be valid while the committed asset carries + // its own timelock that tapd's VM rejects at every height below it. The + // kit never agreed to such an asset, so reject it before signing. + if len(pkg.ActiveVirtualPsbts) != 1 { + return nil, swapkit.ErrInvalid + } + vPkt, err := tappsbt.Decode(pkg.ActiveVirtualPsbts[0]) + if err != nil { + return nil, err + } + if len(vPkt.Outputs) != 1 { + return nil, swapkit.ErrInvalid + } + vOut := vPkt.Outputs[0] + if vOut.LockTime != 0 || vOut.RelativeLockTime != 0 || + vOut.Asset == nil || vOut.Asset.LockTime != 0 || + vOut.Asset.RelativeLockTime != 0 { + + return nil, swapkit.ErrInvalid + } policy, err := HTLC(offer, c) if err != nil { return nil, err diff --git a/swapkit/tapsdk/hardening_annex_test.go b/swapkit/tapsdk/hardening_annex_test.go new file mode 100644 index 000000000..ac7c72d32 --- /dev/null +++ b/swapkit/tapsdk/hardening_annex_test.go @@ -0,0 +1,85 @@ +package tapsdk + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/loop/swapkit" + "github.com/stretchr/testify/require" +) + +// Shared subtest names for the claim and refund directions. +const ( + hardeningClaimCase = "claim" + hardeningRefundCase = "refund" +) + +// TestVerifyOutcomeRecognizesAnnexSpend checks that a consensus-valid claim or +// refund through the agreed leaf is still recognized when its witness also +// carries a BIP-341 annex. spendsLeaf strips the annex before reading the leaf +// script and control block from the last two witness elements. +func TestVerifyOutcomeRecognizesAnnexSpend(t *testing.T) { + for _, refund := range []bool{false, true} { + name := hardeningClaimCase + if refund { + name = hardeningRefundCase + } + t.Run(name, func(t *testing.T) { + checkAnnexSpend(t, refund) + }) + } +} + +func checkAnnexSpend(t *testing.T, refund bool) { + t.Helper() + r, s, p, chain := bindingOutcome(t, refund) + prior := s.Fallback.Outputs[0] + policy, err := HTLC(s.Offer, s.Deposit.Contract) + require.NoError(t, err) + path, err := policy.SuccessSpend(prior.TaprootAssetRoot) + signer := bindingKey(3) + if refund { + path, err = policy.TimeoutSpend(prior.TaprootAssetRoot) + signer = bindingKey(2) + } + require.NoError(t, err) + + // Sign the same transaction again, committing to an annex. + tx := &p.AnchorTx + annex := []byte{txscript.TaprootAnnexTag, 0x01} + fetch := txscript.NewCannedPrevOutputFetcher( + path.PkScript, prior.AnchorValueSat, + ) + hashes := txscript.NewTxSigHashes(tx, fetch) + digest, err := txscript.CalcTapscriptSignaturehash( + hashes, txscript.SigHashDefault, tx, 0, fetch, + txscript.NewBaseTapLeaf(path.LeafScript), + txscript.WithAnnex(annex), + ) + require.NoError(t, err) + sig, err := schnorr.Sign(signer, digest) + require.NoError(t, err) + old := tx.TxIn[0].Witness + witness := [][]byte{sig.Serialize(), path.LeafScript, + path.ControlBlock, annex} + if !refund { + // The success witness starts with the preimage. + witness = append([][]byte{old[0]}, witness...) + } + tx.TxIn[0].Witness = witness + require.Equal(t, p.BlockHeader.MerkleRoot, tx.TxHash()) + + // The spend satisfies the agreed leaf under consensus rules. + engine, err := txscript.NewEngine(path.PkScript, tx, 0, + txscript.StandardVerifyFlags, nil, + txscript.NewTxSigHashes(tx, fetch), + prior.AnchorValueSat, fetch) + require.NoError(t, err) + require.NoError(t, engine.Execute()) + + err = VerifyOutcome(t.Context(), r.wallet(), chain, 1, s, + bindingRaw(t, p), refund, refund) + require.NoError(t, err, "refund=%v", refund) + require.NotErrorIs(t, err, swapkit.ErrInvalid) +} diff --git a/swapkit/tapsdk/hardening_fallback_asset_lock_test.go b/swapkit/tapsdk/hardening_fallback_asset_lock_test.go new file mode 100644 index 000000000..72c530d21 --- /dev/null +++ b/swapkit/tapsdk/hardening_fallback_asset_lock_test.go @@ -0,0 +1,88 @@ +package tapsdk + +import ( + "bytes" + "testing" + + "github.com/lightninglabs/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tappsbt" + "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/stretchr/testify/require" +) + +// TestFallbackRejectsAssetTimeLock checks that the fallback validator inspects +// the committed virtual output. A peer can give the HTLC asset an asset-level +// lock time far beyond the agreed refund height; the Bitcoin transaction and +// every summary still match the offer, but tapd would reject the asset +// transition at any confirmation height before the lock. The validator must +// reject the package rather than co-sign an unusable fallback. +func TestFallbackRejectsAssetTimeLock(t *testing.T) { + f := newFallbackFixture(t) + + const lock = 5_000_000 + require.Greater(t, uint32(lock), f.offer.Terms.RefundHeight) + + forged := f.pkg.Clone() + packet, err := tappsbt.Decode(forged.ActiveVirtualPsbts[0]) + require.NoError(t, err) + require.Len(t, packet.Outputs, 1) + output := packet.Outputs[0] + output.LockTime = lock + output.Asset.LockTime = lock + + // Recommit exactly as an author's backend would, without bubbling the + // asset lock into the Bitcoin transaction. + anchor, err := DecodePSBT(forged.CommittedAnchorPsbt) + require.NoError(t, err) + all := []*tappsbt.VPacket{packet} + commitments, err := tapsend.CreateOutputCommitments( + all, tapsend.WithNoSTXOProofs(), + ) + require.NoError(t, err) + require.NoError(t, tapsend.UpdateTaprootOutputKeys( + anchor, packet, commitments, + )) + anchor.UnsignedTx.LockTime = 0 + anchor.UnsignedTx.TxIn[0].Sequence = 0xfffffffc + output.ProofSuffix, err = tapsend.CreateProofSuffix( + anchor.UnsignedTx, anchor.Outputs, packet, commitments, 0, all, + proof.WithVersion(output.ProofSuffix.Version), + ) + require.NoError(t, err) + forged.ActiveVirtualPsbts[0], err = tappsbt.Encode(packet) + require.NoError(t, err) + raw, err := EncodePSBT(anchor) + require.NoError(t, err) + forged.CommittedAnchorPsbt = raw + forged.AnchorPsbt = bytes.Clone(raw) + + _, merkleRoot, assetRoot, err := tapsend.AnchorOutputScript( + output.AnchorOutputInternalKey, + output.AnchorOutputTapscriptSibling, commitments[0], + ) + require.NoError(t, err) + txid := anchor.UnsignedTx.TxHash() + forged.Outputs[0].TaprootAssetRoot = sdk.Hash(assetRoot) + forged.Outputs[0].TaprootMerkleRoot = sdk.Hash(merkleRoot) + forged.Outputs[0].AnchorOutpoint.Txid = sdk.Hash(txid) + forged.ProofUpdates[0].AnchorOutpoint.Txid = sdk.Hash(txid) + var blob bytes.Buffer + require.NoError(t, output.ProofSuffix.Encode(&blob)) + forged.ProofUpdates[0].ProofBlob = blob.Bytes() + forged, err = forged.Seal() + require.NoError(t, err) + encoded, err := forged.MarshalBinary() + require.NoError(t, err) + + // The committed HTLC asset carries a lock time the kit never agreed to. + locked, err := proof.Decode( + forged.ProofUpdates[0].ProofBlob, + ) + require.NoError(t, err) + require.EqualValues(t, lock, locked.Asset.LockTime) + + _, err = NewSwap(f.offer, f.deposit, encoded) + require.ErrorIs(t, err, swapkit.ErrInvalid) +} diff --git a/swapkit/tapsdk/hardening_fallback_test.go b/swapkit/tapsdk/hardening_fallback_test.go new file mode 100644 index 000000000..36cf1ab5e --- /dev/null +++ b/swapkit/tapsdk/hardening_fallback_test.go @@ -0,0 +1,217 @@ +package tapsdk + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "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/loop/swapkit" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/commitment" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// fallbackFixture is an honest Loop In deposit, offer and native fallback +// package built with the kit's own FallbackRequest and the real SDK builder. +type fallbackFixture struct { + offer swapkit.Offer + deposit *DepositContext + pkg *sdk.CustomAnchorTransferPackage + prevout *wire.TxOut +} + +// newFallbackFixture builds the deposit with client scalar two and server +// scalar three, matching the keys of the package's LND session fixtures. +func newFallbackFixture(t *testing.T) *fallbackFixture { + t.Helper() + clientKey, serverKey := bindingPub(2), bindingPub(3) + dk, err := swapkit.NewDepositKit(swapkit.DepositKitParams{ + ClientKey: clientKey, ServerKey: serverKey, CSVExpiry: 100, + }) + require.NoError(t, err) + key, _, err := LegacyOPTrueDescriptor() + require.NoError(t, err) + scriptKey, err := btcec.ParsePubKey(key.PubKey[:]) + require.NoError(t, err) + plan := dk.AnchorPlan() + internal, err := btcec.ParsePubKey(plan.InternalKey[:]) + require.NoError(t, err) + sibling, err := commitment.NewPreimageFromLeaf( + txscript.NewBaseTapLeaf(plan.Leaves[0]), + ) + require.NoError(t, err) + deposit := bindingProof(t, bindingGenesis(), scriptKey, internal, + sibling, bindingGenesis().FirstPrevOut, 100, false) + assets, merkle, err := isolatedRoots(deposit) + require.NoError(t, err) + + c := DepositContract{Version: 1, ClientKey: clientKey, + ServerKey: serverKey, CSV: 100, + Output: sdk.CustomAnchorAssetOutputSummary{ + LogicalOutputID: "deposit", + ScriptKey: key.PubKey, + ScriptMode: sdk.CustomAssetScriptExternal, + AssetRef: sdk.AssetRefFromGroupKey(bindingPub(11)), + IssuanceID: sdk.AssetID(deposit.Asset.ID()), + AssetType: sdk.AssetType( + deposit.Asset.Genesis.Type, + ), + Amount: 1000, AnchorValueSat: 10_000, + TaprootAssetRoot: assets, TaprootMerkleRoot: merkle, + AnchorOutpoint: sdk.Outpoint{Txid: sdk.Hash( + deposit.AnchorTx.TxHash())}, + }} + // Preserve the existing SDK/contract JSON field names. + //nolint:musttag + contract, err := json.Marshal(c) + require.NoError(t, err) + preimage := [32]byte{99} + offer := swapkit.Offer{Hash: sha256.Sum256(preimage[:]), + Terms: swapkit.Terms{Version: 1, GroupKey: bindingPub(11), + DepositOutpoints: []string{deposit.OutPoint().String()}, + DepositAmount: 1000, PayoutAmount: 900, AssetFee: 100, + RefundHeight: 150, RecoveryDelta: 10, Contract: contract}} + d, err := LoadDeposit(swapkit.Deposit{ + Outpoint: deposit.OutPoint().String(), + Proof: bindingRaw(t, deposit), Contract: contract, + GroupKey: bindingPub(11), Amount: 1000, + }, keychain.KeyLocator{}) + require.NoError(t, err) + chain := &bindingChain{tip: 161, unspent: true, + blocks: map[uint32]*wire.MsgBlock{100: bindingBlock(deposit)}} + require.NoError(t, InspectDeposit(t.Context(), chain, 1, d, true)) + + // The honest author builds and commits the fallback natively. + s, err := NewSwap(offer, d, nil) + require.NoError(t, err) + request, err := FallbackRequest(s, 1_000, 1_000) + require.NoError(t, err) + wallet := sdk.NewWallet(&pathClient{t: t}, sdk.NetworkRegtest) + built, err := wallet.NewCustomAnchorTxBuilder().Build( + t.Context(), request, + ) + require.NoError(t, err) + pkg, err := built.Commit(t.Context(), sdk.CustomAnchorCommitOptions{}) + require.NoError(t, err) + + // The honest package is accepted, so the fixture is representative. + raw, err := pkg.MarshalBinary() + require.NoError(t, err) + _, err = NewSwap(offer, d, raw) + require.NoError(t, err) + + point := deposit.OutPoint() + return &fallbackFixture{offer: offer, deposit: d, pkg: pkg, + prevout: deposit.AnchorTx.TxOut[point.Index]} +} + +// executeAgainst runs the finalized anchor against one previous output. +func executeAgainst(t *testing.T, pkg *sdk.CustomAnchorTransferPackage, + prevout *wire.TxOut) error { + + t.Helper() + packet, err := DecodePSBT(pkg.AnchorPsbt) + require.NoError(t, err) + tx, err := psbt.Extract(packet) + require.NoError(t, err) + fetch := txscript.NewCannedPrevOutputFetcher( + prevout.PkScript, prevout.Value, + ) + engine, err := txscript.NewEngine(prevout.PkScript, tx, 0, + txscript.StandardVerifyFlags, nil, + txscript.NewTxSigHashes(tx, fetch), prevout.Value, fetch) + if err != nil { + return err + } + return engine.Execute() +} + +// finishFallback signs with both real LND session managers and finalizes +// through the SDK, exactly as an honest validator would after approval. +func finishFallback(t *testing.T, + pkg *sdk.CustomAnchorTransferPackage) *sdk.CustomAnchorTransferPackage { + + t.Helper() + signed, err := signTestPackage(t.Context(), signingWallet(t, 2), + signingWallet(t, 3), pkg) + require.NoError(t, err) + packet, err := DecodePSBT(signed.AnchorPsbt) + require.NoError(t, err) + require.NoError(t, psbt.MaybeFinalizeAll(packet)) + raw, err := EncodePSBT(packet) + require.NoError(t, err) + final, err := pkg.WithFinalAnchorPSBT(raw) + require.NoError(t, err) + return final +} + +// TestFallbackHonestBaselineExecutes shows that the honest fallback is signed +// into a transaction that is valid for the authenticated deposit output. +func TestFallbackHonestBaselineExecutes(t *testing.T) { + f := newFallbackFixture(t) + raw, err := f.pkg.MarshalBinary() + require.NoError(t, err) + s, err := NewSwap(f.offer, f.deposit, raw) + require.NoError(t, err) + final := finishFallback(t, s.Fallback) + require.NoError(t, executeAgainst(t, final, f.prevout)) +} + +// TestFallbackRejectsForgedPrevout checks that the fallback validator binds +// the committed package's signed previous output to the authenticated deposit. +// A peer that changes only the PSBT's claimed input value keeps the unsigned +// transaction, every summary and the honest txid, so only this prevout +// comparison distinguishes it. The forged package must be rejected before it +// is ever co-signed. +func TestFallbackRejectsForgedPrevout(t *testing.T) { + f := newFallbackFixture(t) + + forged := f.pkg.Clone() + packet, err := DecodePSBT(forged.CommittedAnchorPsbt) + require.NoError(t, err) + packet.Inputs[0].WitnessUtxo = wire.NewTxOut( + f.prevout.Value+5_000, bytes.Clone(f.prevout.PkScript), + ) + anchor, err := EncodePSBT(packet) + require.NoError(t, err) + forged.CommittedAnchorPsbt = anchor + forged.AnchorPsbt = bytes.Clone(anchor) + forged.Funding.ActualFeeSat += 5_000 + forged, err = forged.Seal() + require.NoError(t, err) + raw, err := forged.MarshalBinary() + require.NoError(t, err) + + // The unsigned transaction and txid are exactly the honest ones, so the + // only tell is the committed previous output the signature covers. + honest, err := DecodePSBT(f.pkg.CommittedAnchorPsbt) + require.NoError(t, err) + require.Equal(t, honest.UnsignedTx.TxHash(), packet.UnsignedTx.TxHash()) + + _, err = NewSwap(f.offer, f.deposit, raw) + require.ErrorIs(t, err, swapkit.ErrInvalid) +} + +// TestFallbackRejectsSkipBroadcast checks that the validator rejects a package +// whose sealed publication metadata would log the fallback without broadcasting +// it. The honest package never sets this flag. +func TestFallbackRejectsSkipBroadcast(t *testing.T) { + f := newFallbackFixture(t) + require.False(t, f.pkg.Publish.SkipAnchorTxBroadcast) + + forged := f.pkg.Clone() + forged.Publish.SkipAnchorTxBroadcast = true + forged, err := forged.Seal() + require.NoError(t, err) + raw, err := forged.MarshalBinary() + require.NoError(t, err) + + _, err = NewSwap(f.offer, f.deposit, raw) + require.ErrorIs(t, err, swapkit.ErrInvalid) +} diff --git a/swapkit/tapsdk/hardening_prepare_inputs_test.go b/swapkit/tapsdk/hardening_prepare_inputs_test.go new file mode 100644 index 000000000..2ad9ab94d --- /dev/null +++ b/swapkit/tapsdk/hardening_prepare_inputs_test.go @@ -0,0 +1,82 @@ +package tapsdk + +import ( + "bytes" + "context" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/txscript" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/stretchr/testify/require" +) + +// declaringClient records what PrepareInputs registers with tapd. The +// proof archive already holds the input proof, so no import is attempted. +type declaringClient struct { + sdk.Client + + raw []byte + declared []sdk.ScriptKey +} + +func (c *declaringClient) GetInfo(context.Context) (*sdk.Info, error) { + return fixtureNodeInfo(), nil +} + +func (c *declaringClient) DeclareScriptKey(_ context.Context, + request *sdk.DeclareScriptKeyRequest) (*sdk.ScriptKey, error) { + + c.declared = append(c.declared, request.ScriptKey) + return &request.ScriptKey, nil +} + +func (c *declaringClient) ExportProof(context.Context, sdk.AssetRef, + sdk.PubKey, *sdk.Outpoint) (*sdk.ProofFile, error) { + + return &sdk.ProofFile{RawProofFile: bytes.Clone(c.raw)}, nil +} + +// TestPrepareInputsRejectsUnboundDescriptor checks that PrepareInputs verifies +// a caller-provided asset witness before registering it in tapd's script key +// registry. A control block that names an unrelated internal key does not +// commit to the input's script key, so declaring it would bind the script key +// to a wallet key the owner never approved. The honest witness still registers. +func TestPrepareInputsRejectsUnboundDescriptor(t *testing.T) { + f := newPathFixture(t) + + // The honest stack registers the exact key derivation. + honest := &declaringClient{raw: f.request.Inputs[0].ProofFile} + require.NoError(t, PrepareInputs(t.Context(), + sdk.NewWallet(honest, sdk.NetworkRegtest), f.request)) + require.Len(t, honest.declared, 1) + require.True(t, descriptorDerives(t, honest.declared[0])) + + // The peer keeps the agreed proof and only replaces the witness with a + // control block for its own internal key and no Merkle path. + attacker := bindingKey(7).PubKey() + control := append([]byte{byte(txscript.BaseLeafVersion)}, + schnorr.SerializePubKey(attacker)...) + request := f.request.Clone() + request.Inputs[0].Witness.Stack = [][]byte{ + {txscript.OP_TRUE}, control, + } + + // PrepareInputs rejects the witness before registering anything. + client := &declaringClient{raw: request.Inputs[0].ProofFile} + err := PrepareInputs(t.Context(), + sdk.NewWallet(client, sdk.NetworkRegtest), request) + require.Error(t, err) + require.Empty(t, client.declared) +} + +// descriptorDerives reports whether the declared raw key and tweak +// produce the declared asset script key. +func descriptorDerives(t *testing.T, key sdk.ScriptKey) bool { + t.Helper() + raw, err := btcec.ParsePubKey(key.KeyDesc.RawKeyBytes[:]) + require.NoError(t, err) + output := txscript.ComputeTaprootOutputKey(raw, key.TapTweak) + return bytes.Equal(schnorr.SerializePubKey(output), key.PubKey[1:]) +} diff --git a/swapkit/tapsdk/hardening_recovery_test.go b/swapkit/tapsdk/hardening_recovery_test.go new file mode 100644 index 000000000..600724e03 --- /dev/null +++ b/swapkit/tapsdk/hardening_recovery_test.go @@ -0,0 +1,71 @@ +package tapsdk + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" + "github.com/stretchr/testify/require" +) + +// TestDiscoverOriginalRefundSurvivesStaleBlock checks that a Chain which +// returns a block without the watched spend yields a retryable error instead +// of dereferencing a nil transaction. +func TestDiscoverOriginalRefundSurvivesStaleBlock(t *testing.T) { + node, swap, _, chain := originalRefundFixture(t) + foreign := wire.NewMsgTx(2) + foreign.AddTxIn(wire.NewTxIn( + &wire.OutPoint{Hash: chainhash.Hash{0xaa}}, nil, nil, + )) + foreign.AddTxOut(wire.NewTxOut(1, []byte{txscript.OP_TRUE})) + pc := &partialRecoveryChain{ + bindingChain: chain, + spend: func(wire.OutPoint) (*wire.MsgBlock, uint32, error) { + return &wire.MsgBlock{ + Transactions: []*wire.MsgTx{foreign}, + }, 201, nil + }, + } + _, err := DiscoverOriginalRefund( + t.Context(), node.wallet(), pc, node.minConfs, swap, + ) + require.ErrorIs(t, err, swapkit.ErrPending) +} + +// TestDiscoverOriginalRefundRejectsAnnexSpend checks that an observed refund +// carrying a BIP-341 annex is identified as the agreed leaf but not +// reconstructed through a mis-sliced witness. +func TestDiscoverOriginalRefundRejectsAnnexSpend(t *testing.T) { + node, swap, refund, chain := originalRefundFixture(t) + annexed := refund.AnchorTx.Copy() + annexed.TxIn[0].Witness = append(annexed.TxIn[0].Witness, + []byte{txscript.TaprootAnnexTag}) + pc := &partialRecoveryChain{ + bindingChain: chain, + spend: func(wire.OutPoint) (*wire.MsgBlock, uint32, error) { + return &wire.MsgBlock{ + Header: refund.BlockHeader, + Transactions: []*wire.MsgTx{annexed}, + }, refund.BlockHeight, nil + }, + } + // spendsLeaf strips the annex and matches the agreed leaf, so the refund + // reaches reconstruction, which rejects the annex rather than mis-slice. + require.True(t, spendsLeaf(annexed, swap.Deposit.Contract.Output, + mustOriginalSpend(t, swap))) + _, err := DiscoverOriginalRefund( + t.Context(), node.wallet(), pc, node.minConfs, swap, + ) + require.ErrorIs(t, err, swapkit.ErrInvalid) +} + +func mustOriginalSpend(t *testing.T, + swap *SwapContext) *swapkit.SwapAnchorSpendInfo { + + t.Helper() + _, spend, err := OriginalRefundPolicy(swap.Deposit) + require.NoError(t, err) + return spend +} diff --git a/swapkit/tapsdk/hardening_reorg_test.go b/swapkit/tapsdk/hardening_reorg_test.go new file mode 100644 index 000000000..c8436f18b --- /dev/null +++ b/swapkit/tapsdk/hardening_reorg_test.go @@ -0,0 +1,77 @@ +package tapsdk + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapkit" + "github.com/stretchr/testify/require" +) + +// reorgRaceChain keeps reporting the confirmed block hash, and serves the +// block that replaced it at the same height only to full block lookups. This +// models a reorg that lands after the last confirmation check. +type reorgRaceChain struct { + *bindingChain + + height uint32 + replaced *wire.MsgBlock +} + +func (c *reorgRaceChain) Block(ctx context.Context, + height uint32) (*wire.MsgBlock, error) { + + if height == c.height { + return c.replaced, nil + } + return c.bindingChain.Block(ctx, height) +} + +// TestVerifyOutcomeReorgIsRetryable checks that a reorg between the last +// confirmation check and the full block lookup of VerifyWalletSpend is +// reported as pending, like a reorg seen before any check, and not as a +// permanently invalid outcome. +func TestVerifyOutcomeReorgIsRetryable(t *testing.T) { + for _, refund := range []bool{false, true} { + name := hardeningClaimCase + if refund { + name = hardeningRefundCase + } + t.Run(name, func(t *testing.T) { + checkReorgRace(t, refund) + }) + } +} + +func checkReorgRace(t *testing.T, refund bool) { + t.Helper() + r, s, p, chain := bindingOutcome(t, refund) + raw := bindingRaw(t, p) + + // The untouched chain accepts the outcome. + require.NoError(t, VerifyOutcome(t.Context(), r.wallet(), chain, + 1, s, raw, refund, refund)) + + // A reorg observed before any check is classified as pending. + original := chain.blocks[160] + chain.blocks[160] = &wire.MsgBlock{ + Header: wire.BlockHeader{Nonce: 7}, + } + err := VerifyOutcome(t.Context(), r.wallet(), chain, 1, s, raw, + refund, refund) + require.ErrorIs(t, err, swapkit.ErrPending) + chain.blocks[160] = original + + // The same reorg observed only by the full block lookup, after + // both confirmation checks passed, must stay pending as well. + raced := &reorgRaceChain{bindingChain: chain, height: 160, + replaced: &wire.MsgBlock{ + Header: wire.BlockHeader{Nonce: 7}, + }} + err = VerifyOutcome(t.Context(), r.wallet(), raced, 1, s, raw, + refund, refund) + require.Error(t, err) + require.ErrorIs(t, err, swapkit.ErrPending, + "reorg race classified as: %v", err) +} diff --git a/swapkit/tapsdk/original_refund.go b/swapkit/tapsdk/original_refund.go index 3ed2e141e..09925638d 100644 --- a/swapkit/tapsdk/original_refund.go +++ b/swapkit/tapsdk/original_refund.go @@ -67,9 +67,17 @@ func DiscoverOriginalRefund(ctx context.Context, wallet *sdk.Wallet, return nil, err } tx := spendingTransaction(block, base.OutPoint()) - if len(tx.TxIn) != 1 || len(tx.TxOut) != 1 { + if tx == nil { + // A faithful Chain returns a block that spends the point, but a + // stale or inconsistent response must not panic the recovery. return nil, swapkit.ErrPending } + // A confirmed spend with extra inputs or outputs cannot be rebuilt into + // a single-input recovery proof and never will be, so it is terminal. + // DiscoverOutcome classifies the same shape identically. + if len(tx.TxIn) != 1 || len(tx.TxOut) != 1 { + return nil, swapkit.ErrInvalid + } if !spendsLeaf(tx, c.Output, spend) { return nil, swapkit.ErrPending } diff --git a/swapkit/tapsdk/proofs.go b/swapkit/tapsdk/proofs.go index cf0008931..97430345f 100644 --- a/swapkit/tapsdk/proofs.go +++ b/swapkit/tapsdk/proofs.go @@ -459,6 +459,21 @@ func declareOPTrue(ctx context.Context, wallet *sdk.Wallet, if err != nil { return err } + // A caller-provided witness is peer input. Declaring it binds the asset + // script key to this internal key and tweak in tapd's wallet, so verify + // that the leaf and control block actually commit to the script key + // before registering it. This accepts both the legacy NUMS tree and an + // SDK supplied-key tree; it rejects a witness whose control block names + // an unrelated internal key. + spend := &sdk.CustomAssetOPTrueSpendInfo{ + LeafScript: bytes.Clone(witness[0]), + LeafVersion: uint8(control.LeafVersion), + ControlBlock: bytes.Clone(witness[1]), + InternalKey: internal, + } + if err := spend.Validate(scriptKey); err != nil { + return err + } _, err = wallet.Client(). DeclareScriptKey(ctx, &sdk.DeclareScriptKeyRequest{ ScriptKey: sdk.ScriptKey{PubKey: scriptKey, diff --git a/swapkit/tapsdk/recovery.go b/swapkit/tapsdk/recovery.go index 6747d5966..a132bc8d9 100644 --- a/swapkit/tapsdk/recovery.go +++ b/swapkit/tapsdk/recovery.go @@ -80,6 +80,11 @@ func DiscoverOutcome(ctx context.Context, wallet *sdk.Wallet, return nil, err } found := spendingTransaction(block, point) + if found == nil { + // A faithful Chain returns a block that spends the point, but a + // stale or inconsistent response must not panic the recovery. + return nil, swapkit.ErrPending + } if len(found.TxIn) != 1 || len(found.TxOut) != 1 { return nil, swapkit.ErrInvalid } @@ -192,7 +197,11 @@ func DiscoverPreimage(ctx context.Context, chain Chain, if err != nil { return swapkit.PreimageResult{}, err } - for _, input := range spendingTransaction(block, point).TxIn { + found := spendingTransaction(block, point) + if found == nil { + return swapkit.PreimageResult{}, nil + } + for _, input := range found.TxIn { if input.PreviousOutPoint != point { continue } diff --git a/swapkit/tapsdk/wallet_spend.go b/swapkit/tapsdk/wallet_spend.go index 9f47dc634..93aff111f 100644 --- a/swapkit/tapsdk/wallet_spend.go +++ b/swapkit/tapsdk/wallet_spend.go @@ -30,6 +30,16 @@ func rebuildObservedSpend( return nil, swapkit.ErrInvalid } + // Reconstruction replays the mined script-path stack minus its leaf and + // control block. A BIP-341 annex would shift those two elements, so the + // reconstructed witness would carry the wrong stack. An observed annex + // spend is rare and not reconstructable here, so reject it up front + // rather than mis-slice it. + if len(observed.TxIn[0].Witness) != + len(witnessWithoutAnnex(observed.TxIn[0].Witness)) { + + return nil, swapkit.ErrInvalid + } p, err := LastProof(inputProof) if err != nil { return nil, err @@ -137,6 +147,14 @@ func VerifyWalletSpend( if err != nil { return err } + // verifyProof confirmed the proof against the chain, but a reorg between + // that check and this re-read would replace the block at this height. + // Without the agreed transaction the loop below falls through to + // ErrInvalid; report the reorg as retryable instead, matching every other + // stale-evidence path. + if block.BlockHash() != p.BlockHeader.BlockHash() { + return swapkit.ErrPending + } for _, tx := range block.Transactions { if tx.TxHash() != p.AnchorTx.TxHash() { continue @@ -167,7 +185,20 @@ func spendsLeaf(tx *wire.MsgTx, prior sdk.CustomAnchorAssetOutputSummary, return false } - w := tx.TxIn[0].Witness + w := witnessWithoutAnnex(tx.TxIn[0].Witness) return len(w) >= 3 && bytes.Equal(w[len(w)-2], spend.LeafScript) && bytes.Equal(w[len(w)-1], spend.ControlBlock) } + +// witnessWithoutAnnex drops a BIP-341 annex. A taproot witness carries an annex +// when its final element begins with the annex tag and at least one other +// element precedes it. The annex is not part of the script-path stack, so the +// agreed leaf and control block are the last two elements once it is removed. +func witnessWithoutAnnex(w [][]byte) [][]byte { + if len(w) >= 2 && len(w[len(w)-1]) > 0 && + w[len(w)-1][0] == txscript.TaprootAnnexTag { + + return w[:len(w)-1] + } + return w +} From c351bbc30e5fd68693e7108a5637a7db19c1c281 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 13:52:24 +0200 Subject: [PATCH 10/25] swapserverrpc: Define the asset reservation purchase API Define quote, status, list, proof, and unpaid-cancellation calls for asset reservations. A client-generated ID identifies the same purchase across retries, including before its funding outpoint exists. Specify authenticated ownership, immutable quote terms, payment details, and public status fields. Generate the protobuf bindings and document the purchase API.  Conflicts:  docs/asset-reservations.md Use a dedicated cancellation request to distinguish probe-only release from cancellation of an unpaid purchase, keeping options separate from reservation identity. Carry receiving RFQs for the prepay and full-amount hold probe so the client can validate exact asset amounts, invoice terms, and route hints. Report probe receipt and invoice cancellation separately. Include the generated protobuf bindings. Define exact-issuance and group selectors from the start. Group quotes bind the on-chain and Lightning issuances and carry their membership evidence for verification before payment. --- docs/asset-reservation-rpc.md | 84 ++ docs/release-notes/release-notes-next.md | 8 + swapserverrpc/asset_reservation.pb.go | 1239 ++++++++++++++++++++ swapserverrpc/asset_reservation.proto | 352 ++++++ swapserverrpc/asset_reservation_grpc.pb.go | 274 +++++ 5 files changed, 1957 insertions(+) create mode 100644 docs/asset-reservation-rpc.md create mode 100644 swapserverrpc/asset_reservation.pb.go create mode 100644 swapserverrpc/asset_reservation.proto create mode 100644 swapserverrpc/asset_reservation_grpc.pb.go diff --git a/docs/asset-reservation-rpc.md b/docs/asset-reservation-rpc.md new file mode 100644 index 000000000..002aaa8d6 --- /dev/null +++ b/docs/asset-reservation-rpc.md @@ -0,0 +1,84 @@ +# Asset reservation protocol + +The wire contract lives in `swapserverrpc/asset_reservation.proto`. It is +separate from Bitcoin Instant Out reservations. These definitions do not +enable a service; handlers and authentication wiring remain separate. + +## Purchase and recovery + +The client saves a random request ID, requested asset and amount, and its key +before `QuoteAssetReservation`. The server binds that ID to the authenticated +owner and request. An exact retry returns the same purchase and invoice, +including after expiry. It must never create a fresh bill or reprice it. +Changing the asset, amount, or key under the same ID is rejected. A fresh quote +requires a new request after cancellation of the old unpaid purchase. + +The response may still be `QUOTING`. `GetAssetReservation` retrieves progress +and the immutable quote once available. The quote supplies both invoices, +asset terms, keys, Bitcoin equivalents, edge, expiry, and `prepay_rfq`. That +field carries the native receiving RFQ, without secrets, so the client can +bind the prepay invoice to its exact asset fee and route hint. The client +validates both invoices, probes only the estimated main amount, then waits +for approval. Paying the hold prepay is the only signal that permits funding. +The main Bitcoin equivalent is an estimate, not an exchange-rate guarantee. + +The quote identifies two Lightning nodes on the receiving side: + +- `receiving_node_key` is the identity key of the server's receiving LND node. + That node issues the prepay and probe invoices. It receives the asset prepay + through its asset channel with the conversion peer. +- `edge_key` is the identity key of that conversion peer. It accepts the + incoming BTC payment and forwards the corresponding asset payment to the + receiving node at the rate agreed in the receiving RFQ. + +The prepay follows this path: + +```text +Client's LND -- BTC through Lightning --> conversion peer (edge_key) + -- asset channel --> server's LND (receiving_node_key) +``` + +Both keys are 33-byte compressed Lightning node identity keys. The client +checks both invoice destinations against `receiving_node_key`, both route +hints against `edge_key`, and the prepay RFQ's peer against `edge_key`. The +probe invoice estimates a later payment through the same edge; it is never +paid. The reservation's separate `client_key` and `server_key` control the +funded reservation output. + +`AssetReservationStatus` summarizes progress reported by the server. It groups +internal server states into public statuses and does not define a shared state +machine. The client tracks its own progress and independently verifies delivery +before entering `Ready`, even when the server already reports `READY`. + +`GetAssetReservation` and `ListAssetReservations` return public facts only. +They never expose private preimages, wallet locks, native funding requests, +or internal errors. Before funding, recovery uses the saved ID. After funding, +the CLI uses canonical `txid:vout`. IDs remain stable in storage. Binary IDs +must have exactly 32 bytes; keys must be valid compressed public keys. + +`GetAssetReservationProof` returns the full exported proof for the exact +outpoint. The client verifies the asset, keys, script, amount, chain history, +required confirmations, unspent status, and remaining lifetime independently. +Server `READY` is not client `Ready`. `prepay_credit` is the exact asset fee +after verified settlement, never a conversion inferred from a BTC total. + +`CancelAssetReservation` requests cancellation; its reply may still say +`CANCELING`. Retry or poll after a lost reply. Only an authoritative canceled +invoice and resolved client payment establish an unpaid outcome. If settlement +won the race, continue delivery. No refund RPC is provided. + +## Boundaries + +All methods require L402 ownership checks. Unknown IDs, another owner's IDs, +and internal lookup failures return the same opaque status and message. +Detailed causes stay in server logs. Validate canonical outpoints before +lookup or dispatch. Page size is bounded; list results contain only owned +records. Notifications, when added, are hints to read this API again. + +The local client API will expose quote, approve, cancel, get, and list. +The approval request includes the displayed quote hash and prepay routing +cap. `skip_probe` bypasses only the requirement for a successful main probe. +Approval never reaches the server as a "paid" message. The CLI buy command runs +quote, probe, approval, payment, and verification in sequence; the daemon keeps +recovery running after the CLI disconnects. Local RPC handlers and CLI wiring +remain a separate step. diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 20872ce41..c1a309047 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -2,6 +2,14 @@ #### New Features +* Experimental clients can quote and purchase an on-chain asset reservation, + verify its funding proof against the agreed CSV contract, and resume a + pending purchase after restarting. Buyers can request an exact issuance or + a fungible asset group and verify both quoted issuances before payment. + A reorg that invalidates a delivered proof withdraws its readiness and + advertised deadlines. Delivery uses the execution cutoff without an + additional minimum usable-window term. + * Share asset deposit, reservation and CSV/CLTV HTLC contracts through `swapkit`, with transfer, signing, proof and recovery helpers backed by `github.com/lightninglabs/tap-sdk` v0.1.1 and Taproot Assets v0.8.8. New on-chain diff --git a/swapserverrpc/asset_reservation.pb.go b/swapserverrpc/asset_reservation.pb.go new file mode 100644 index 000000000..0411f96b3 --- /dev/null +++ b/swapserverrpc/asset_reservation.pb.go @@ -0,0 +1,1239 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v3.21.12 +// source: asset_reservation.proto + +package swapserverrpc + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// AssetReservationStatus summarizes server progress for RPC callers. Several +// internal server states may map to one status. The client tracks its own +// progress separately and must verify delivery before entering its Ready state. +type AssetReservationStatus int32 + +const ( + // Unspecified status; does not establish valid purchase progress. + AssetReservationStatus_ASSET_RESERVATION_UNKNOWN AssetReservationStatus = 0 + // Purchase recorded; the server is preparing its invoices and quote. + AssetReservationStatus_ASSET_RESERVATION_QUOTING AssetReservationStatus = 1 + // Quote available; waiting for the prepay to reach the receiving node. + AssetReservationStatus_ASSET_RESERVATION_AWAITING_PAYMENT AssetReservationStatus = 2 + // Prepay accepted; the server is preparing the reservation transfer. + AssetReservationStatus_ASSET_RESERVATION_PREPARING_FUNDING AssetReservationStatus = 3 + // Funding is being published or awaiting settlement, confirmations, or + // proof export. This status alone does not establish publication. + AssetReservationStatus_ASSET_RESERVATION_FUNDING AssetReservationStatus = 4 + // Server verified delivery and retains its proof. The client must still + // verify the output and active chain before marking its own state Ready. + AssetReservationStatus_ASSET_RESERVATION_READY AssetReservationStatus = 5 + // Cancellation requested but not yet authoritative. If payment settled + // instead, the server continues delivery rather than refunding it. + AssetReservationStatus_ASSET_RESERVATION_CANCELING AssetReservationStatus = 6 + // Server confirmed unpaid cancellation. The client must also resolve + // its own payment outcome before treating the purchase as canceled. + AssetReservationStatus_ASSET_RESERVATION_CANCELED AssetReservationStatus = 7 + // The reservation reached CSV expiry; the server's refund may still be + // preparing, publishing, or awaiting confirmation. + AssetReservationStatus_ASSET_RESERVATION_EXPIRED AssetReservationStatus = 8 + // A server-side inconsistency requires operator attention. This does + // not establish cancellation, a refund, or a safe client spend. + AssetReservationStatus_ASSET_RESERVATION_NEED_ADMIN AssetReservationStatus = 9 +) + +// Enum value maps for AssetReservationStatus. +var ( + AssetReservationStatus_name = map[int32]string{ + 0: "ASSET_RESERVATION_UNKNOWN", + 1: "ASSET_RESERVATION_QUOTING", + 2: "ASSET_RESERVATION_AWAITING_PAYMENT", + 3: "ASSET_RESERVATION_PREPARING_FUNDING", + 4: "ASSET_RESERVATION_FUNDING", + 5: "ASSET_RESERVATION_READY", + 6: "ASSET_RESERVATION_CANCELING", + 7: "ASSET_RESERVATION_CANCELED", + 8: "ASSET_RESERVATION_EXPIRED", + 9: "ASSET_RESERVATION_NEED_ADMIN", + } + AssetReservationStatus_value = map[string]int32{ + "ASSET_RESERVATION_UNKNOWN": 0, + "ASSET_RESERVATION_QUOTING": 1, + "ASSET_RESERVATION_AWAITING_PAYMENT": 2, + "ASSET_RESERVATION_PREPARING_FUNDING": 3, + "ASSET_RESERVATION_FUNDING": 4, + "ASSET_RESERVATION_READY": 5, + "ASSET_RESERVATION_CANCELING": 6, + "ASSET_RESERVATION_CANCELED": 7, + "ASSET_RESERVATION_EXPIRED": 8, + "ASSET_RESERVATION_NEED_ADMIN": 9, + } +) + +func (x AssetReservationStatus) Enum() *AssetReservationStatus { + p := new(AssetReservationStatus) + *p = x + return p +} + +func (x AssetReservationStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AssetReservationStatus) Descriptor() protoreflect.EnumDescriptor { + return file_asset_reservation_proto_enumTypes[0].Descriptor() +} + +func (AssetReservationStatus) Type() protoreflect.EnumType { + return &file_asset_reservation_proto_enumTypes[0] +} + +func (x AssetReservationStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AssetReservationStatus.Descriptor instead. +func (AssetReservationStatus) EnumDescriptor() ([]byte, []int) { + return file_asset_reservation_proto_rawDescGZIP(), []int{0} +} + +type QuoteAssetReservationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Reservation protocol version: 0 for an exact asset ID, 1 for a group + // key. The server rejects versions that do not match the selector. + ProtocolVersion uint32 `protobuf:"varint,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` + // Client-generated, nonzero 32-byte purchase ID, saved before the first + // request. Reuse it with the same selector, amount, and key after a lost + // response or restart. The server binds it to the authenticated owner + // and rejects reuse with different request fields. + ReservationId []byte `protobuf:"bytes,2,opt,name=reservation_id,json=reservationId,proto3" json:"reservation_id,omitempty"` + // Exact, nonzero 32-byte asset ID to reserve. The funding output and the + // receiving asset channel must use this issuance. Set exactly one of + // asset_id or group_key; leave the other empty. + AssetId []byte `protobuf:"bytes,3,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` + // Compressed 33-byte tweaked group public key returned by tapd. The + // server may choose different member issuances for on-chain funding + // and Lightning payments. The quote identifies both; delivery must + // match its funding asset ID. Mutually exclusive with asset_id. + GroupKey []byte `protobuf:"bytes,4,opt,name=group_key,json=groupKey,proto3" json:"group_key,omitempty"` + // Positive reservation principal in indivisible asset units, excluding + // the quoted service fee. The funding output must contain this exact + // amount. Values must fit signed 64-bit storage and server policy. + Amount uint64 `protobuf:"varint,5,opt,name=amount,proto3" json:"amount,omitempty"` + // Compressed 33-byte public key for the client's side of the reservation + // contract's cooperative spend. The client must control its secret and + // retain the key across restarts. This is not a Lightning node identity. + ClientKey []byte `protobuf:"bytes,6,opt,name=client_key,json=clientKey,proto3" json:"client_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QuoteAssetReservationRequest) Reset() { + *x = QuoteAssetReservationRequest{} + mi := &file_asset_reservation_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QuoteAssetReservationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QuoteAssetReservationRequest) ProtoMessage() {} + +func (x *QuoteAssetReservationRequest) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservation_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QuoteAssetReservationRequest.ProtoReflect.Descriptor instead. +func (*QuoteAssetReservationRequest) Descriptor() ([]byte, []int) { + return file_asset_reservation_proto_rawDescGZIP(), []int{0} +} + +func (x *QuoteAssetReservationRequest) GetProtocolVersion() uint32 { + if x != nil { + return x.ProtocolVersion + } + return 0 +} + +func (x *QuoteAssetReservationRequest) GetReservationId() []byte { + if x != nil { + return x.ReservationId + } + return nil +} + +func (x *QuoteAssetReservationRequest) GetAssetId() []byte { + if x != nil { + return x.AssetId + } + return nil +} + +func (x *QuoteAssetReservationRequest) GetGroupKey() []byte { + if x != nil { + return x.GroupKey + } + return nil +} + +func (x *QuoteAssetReservationRequest) GetAmount() uint64 { + if x != nil { + return x.Amount + } + return 0 +} + +func (x *QuoteAssetReservationRequest) GetClientKey() []byte { + if x != nil { + return x.ClientKey + } + return nil +} + +type AssetReservationSelector struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Identify one purchase owned by the authenticated caller. Exactly one + // selector is required; neither selector bypasses ownership checks. + // + // Types that are valid to be assigned to Selector: + // + // *AssetReservationSelector_ReservationId + // *AssetReservationSelector_Outpoint + Selector isAssetReservationSelector_Selector `protobuf_oneof:"selector"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AssetReservationSelector) Reset() { + *x = AssetReservationSelector{} + mi := &file_asset_reservation_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AssetReservationSelector) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AssetReservationSelector) ProtoMessage() {} + +func (x *AssetReservationSelector) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservation_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AssetReservationSelector.ProtoReflect.Descriptor instead. +func (*AssetReservationSelector) Descriptor() ([]byte, []int) { + return file_asset_reservation_proto_rawDescGZIP(), []int{1} +} + +func (x *AssetReservationSelector) GetSelector() isAssetReservationSelector_Selector { + if x != nil { + return x.Selector + } + return nil +} + +func (x *AssetReservationSelector) GetReservationId() []byte { + if x != nil { + if x, ok := x.Selector.(*AssetReservationSelector_ReservationId); ok { + return x.ReservationId + } + } + return nil +} + +func (x *AssetReservationSelector) GetOutpoint() string { + if x != nil { + if x, ok := x.Selector.(*AssetReservationSelector_Outpoint); ok { + return x.Outpoint + } + } + return "" +} + +type isAssetReservationSelector_Selector interface { + isAssetReservationSelector_Selector() +} + +type AssetReservationSelector_ReservationId struct { + // Nonzero 32-byte purchase ID from the original quote request. + // Works before funding and remains valid after an outpoint is known. + ReservationId []byte `protobuf:"bytes,1,opt,name=reservation_id,json=reservationId,proto3,oneof"` +} + +type AssetReservationSelector_Outpoint struct { + // Reservation funding output in canonical txid:vout form. Available + // only after funding is assigned; alternate encodings are rejected. + Outpoint string `protobuf:"bytes,2,opt,name=outpoint,proto3,oneof"` +} + +func (*AssetReservationSelector_ReservationId) isAssetReservationSelector_Selector() {} + +func (*AssetReservationSelector_Outpoint) isAssetReservationSelector_Selector() {} + +type CancelAssetReservationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required selector for the authenticated owner's existing purchase. + Reservation *AssetReservationSelector `protobuf:"bytes,1,opt,name=reservation,proto3" json:"reservation,omitempty"` + // If true, cancel only the probe invoice, preserving the purchase and + // prepay. If false, request cancellation of the unpaid purchase. Neither + // mode refunds a settled prepay. Probe cancellation is permanent; another + // probe attempt requires a new purchase ID. + ProbeOnly bool `protobuf:"varint,2,opt,name=probe_only,json=probeOnly,proto3" json:"probe_only,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CancelAssetReservationRequest) Reset() { + *x = CancelAssetReservationRequest{} + mi := &file_asset_reservation_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CancelAssetReservationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelAssetReservationRequest) ProtoMessage() {} + +func (x *CancelAssetReservationRequest) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservation_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelAssetReservationRequest.ProtoReflect.Descriptor instead. +func (*CancelAssetReservationRequest) Descriptor() ([]byte, []int) { + return file_asset_reservation_proto_rawDescGZIP(), []int{2} +} + +func (x *CancelAssetReservationRequest) GetReservation() *AssetReservationSelector { + if x != nil { + return x.Reservation + } + return nil +} + +func (x *CancelAssetReservationRequest) GetProbeOnly() bool { + if x != nil { + return x.ProbeOnly + } + return false +} + +type AssetReservationTerms struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Exact, nonzero 32-byte issuance ID for the on-chain funding output. + // Equals the requested asset ID for an exact purchase; for a group + // purchase, identifies the member issuance selected by the server. + AssetId []byte `protobuf:"bytes,1,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` + // Reserved principal in indivisible asset units, excluding fees. Must + // equal the requested amount and the amount proven in the funding output. + Amount uint64 `protobuf:"varint,2,opt,name=amount,proto3" json:"amount,omitempty"` + // Total quoted charge in indivisible asset units, including estimated + // server funding costs. Paid upfront and credited once against the later + // swap fee. An unused reservation does not receive an automatic refund. + Fee uint64 `protobuf:"varint,3,opt,name=fee,proto3" json:"fee,omitempty"` + // Block-based CSV delay for the server's refund path, from 1 to 65535. + // Measured from funding confirmation, not quote creation or delivery. + CsvDelay uint32 `protobuf:"varint,4,opt,name=csv_delay,json=csvDelay,proto3" json:"csv_delay,omitempty"` + // Positive funding confirmation depth required before delivery is ready. + // This is an agreed threshold, not the current confirmation count. + RequiredConfirmations uint32 `protobuf:"varint,5,opt,name=required_confirmations,json=requiredConfirmations,proto3" json:"required_confirmations,omitempty"` + // Positive block margin between the exclusive swap execution cutoff + // and CSV refund maturity. The later swap must also enforce its own + // claim-time requirements. + ExecutionDelta uint32 `protobuf:"varint,6,opt,name=execution_delta,json=executionDelta,proto3" json:"execution_delta,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AssetReservationTerms) Reset() { + *x = AssetReservationTerms{} + mi := &file_asset_reservation_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AssetReservationTerms) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AssetReservationTerms) ProtoMessage() {} + +func (x *AssetReservationTerms) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservation_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AssetReservationTerms.ProtoReflect.Descriptor instead. +func (*AssetReservationTerms) Descriptor() ([]byte, []int) { + return file_asset_reservation_proto_rawDescGZIP(), []int{3} +} + +func (x *AssetReservationTerms) GetAssetId() []byte { + if x != nil { + return x.AssetId + } + return nil +} + +func (x *AssetReservationTerms) GetAmount() uint64 { + if x != nil { + return x.Amount + } + return 0 +} + +func (x *AssetReservationTerms) GetFee() uint64 { + if x != nil { + return x.Fee + } + return 0 +} + +func (x *AssetReservationTerms) GetCsvDelay() uint32 { + if x != nil { + return x.CsvDelay + } + return 0 +} + +func (x *AssetReservationTerms) GetRequiredConfirmations() uint32 { + if x != nil { + return x.RequiredConfirmations + } + return 0 +} + +func (x *AssetReservationTerms) GetExecutionDelta() uint32 { + if x != nil { + return x.ExecutionDelta + } + return 0 +} + +type AssetReservationQuote struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Client-generated 32-byte purchase ID. Must match the request and the + // enclosing AssetReservation response. + ReservationId []byte `protobuf:"bytes,1,opt,name=reservation_id,json=reservationId,proto3" json:"reservation_id,omitempty"` + // Immutable asset principal, fee, and lifetime offered by the server. + // The client validates these terms before approving this exact quote. + Terms *AssetReservationTerms `protobuf:"bytes,2,opt,name=terms,proto3" json:"terms,omitempty"` + // Compressed 33-byte client contract key, echoed from the request. + // The client checks it against its locally saved signing key. + ClientKey []byte `protobuf:"bytes,3,opt,name=client_key,json=clientKey,proto3" json:"client_key,omitempty"` + // Compressed 33-byte server contract key. Used with client_key for + // cooperative spending and by the server for the CSV refund path. + ServerKey []byte `protobuf:"bytes,4,opt,name=server_key,json=serverKey,proto3" json:"server_key,omitempty"` + // The server's receiving LND node, as a 33-byte compressed identity key. + // This node issues both invoices and receives the asset prepay. The + // client checks that both invoice destinations match this key. + ReceivingNodeKey []byte `protobuf:"bytes,5,opt,name=receiving_node_key,json=receivingNodeKey,proto3" json:"receiving_node_key,omitempty"` + // Compressed 33-byte identity of the receiving node's conversion peer. + // Converts incoming BTC into assets on the receiving channel. Both + // invoice route hints and RFQs must identify this peer. + EdgeKey []byte `protobuf:"bytes,6,opt,name=edge_key,json=edgeKey,proto3" json:"edge_key,omitempty"` + // BOLT11 hold invoice for the quoted fee. The client pays BTC through + // the conversion peer; the server receives assets. Accepted HTLCs allow + // funding preparation; settlement follows observed funding publication. + PrepayInvoice string `protobuf:"bytes,7,opt,name=prepay_invoice,json=prepayInvoice,proto3" json:"prepay_invoice,omitempty"` + // BOLT11 hold invoice for one probe of the full reserved asset amount. + // Its hash must equal ProbeHash(reservation_id). The receiver verifies + // the asset receipt and cancels the invoice without settling it. + ProbeInvoice string `protobuf:"bytes,8,opt,name=probe_invoice,json=probeInvoice,proto3" json:"probe_invoice,omitempty"` + // Exact BTC prepay amount in millisatoshis, excluding routing fees. + // Must match prepay_invoice and the fee conversion in prepay_rfq. + PrepayAmountMsat uint64 `protobuf:"varint,9,opt,name=prepay_amount_msat,json=prepayAmountMsat,proto3" json:"prepay_amount_msat,omitempty"` + // BTC equivalent of the reserved principal, in millisatoshis excluding + // routing fees. Matches the probe invoice and RFQ. Estimates the later + // main payment; it does not lock the later swap's conversion rate. + EstimatedMainAmountMsat uint64 `protobuf:"varint,10,opt,name=estimated_main_amount_msat,json=estimatedMainAmountMsat,proto3" json:"estimated_main_amount_msat,omitempty"` + // Quote expiry as Unix seconds. No new prepay may start at or after + // this time. Bounded by both invoices and both receiving RFQs; an + // already-started payment must still be reconciled after expiry. + ExpiresAt int64 `protobuf:"varint,11,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + // Serialized rfqrpc.PeerAcceptedBuyQuote for receiving terms.fee asset + // units. Binds the BTC prepay amount, conversion peer, and route hint. + // Uses the payment issuance for group purchases. Contains no secrets. + PrepayRfq []byte `protobuf:"bytes,12,opt,name=prepay_rfq,json=prepayRfq,proto3" json:"prepay_rfq,omitempty"` + // Serialized rfqrpc.PeerAcceptedBuyQuote for receiving terms.amount + // asset units in the probe. Binds its BTC amount, peer, and route hint. + // Uses the payment issuance for group purchases. Contains no secrets. + ProbeRfq []byte `protobuf:"bytes,13,opt,name=probe_rfq,json=probeRfq,proto3" json:"probe_rfq,omitempty"` + // Group key, payment issuance, and membership evidence for a group + // request; absent for an exact-ID request. Included in the approval hash + // and verified before the client probes or pays. + Group *AssetReservationGroup `protobuf:"bytes,14,opt,name=group,proto3" json:"group,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AssetReservationQuote) Reset() { + *x = AssetReservationQuote{} + mi := &file_asset_reservation_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AssetReservationQuote) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AssetReservationQuote) ProtoMessage() {} + +func (x *AssetReservationQuote) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservation_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AssetReservationQuote.ProtoReflect.Descriptor instead. +func (*AssetReservationQuote) Descriptor() ([]byte, []int) { + return file_asset_reservation_proto_rawDescGZIP(), []int{4} +} + +func (x *AssetReservationQuote) GetReservationId() []byte { + if x != nil { + return x.ReservationId + } + return nil +} + +func (x *AssetReservationQuote) GetTerms() *AssetReservationTerms { + if x != nil { + return x.Terms + } + return nil +} + +func (x *AssetReservationQuote) GetClientKey() []byte { + if x != nil { + return x.ClientKey + } + return nil +} + +func (x *AssetReservationQuote) GetServerKey() []byte { + if x != nil { + return x.ServerKey + } + return nil +} + +func (x *AssetReservationQuote) GetReceivingNodeKey() []byte { + if x != nil { + return x.ReceivingNodeKey + } + return nil +} + +func (x *AssetReservationQuote) GetEdgeKey() []byte { + if x != nil { + return x.EdgeKey + } + return nil +} + +func (x *AssetReservationQuote) GetPrepayInvoice() string { + if x != nil { + return x.PrepayInvoice + } + return "" +} + +func (x *AssetReservationQuote) GetProbeInvoice() string { + if x != nil { + return x.ProbeInvoice + } + return "" +} + +func (x *AssetReservationQuote) GetPrepayAmountMsat() uint64 { + if x != nil { + return x.PrepayAmountMsat + } + return 0 +} + +func (x *AssetReservationQuote) GetEstimatedMainAmountMsat() uint64 { + if x != nil { + return x.EstimatedMainAmountMsat + } + return 0 +} + +func (x *AssetReservationQuote) GetExpiresAt() int64 { + if x != nil { + return x.ExpiresAt + } + return 0 +} + +func (x *AssetReservationQuote) GetPrepayRfq() []byte { + if x != nil { + return x.PrepayRfq + } + return nil +} + +func (x *AssetReservationQuote) GetProbeRfq() []byte { + if x != nil { + return x.ProbeRfq + } + return nil +} + +func (x *AssetReservationQuote) GetGroup() *AssetReservationGroup { + if x != nil { + return x.Group + } + return nil +} + +type AssetReservation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Client-generated 32-byte purchase ID from the quote request. Remains + // stable through retries, funding, and recovery, including before an + // on-chain outpoint exists. + ReservationId []byte `protobuf:"bytes,1,opt,name=reservation_id,json=reservationId,proto3" json:"reservation_id,omitempty"` + // Saved purchase terms, keys, invoices, and RFQs. Absent while the server + // is still preparing the quote. The client validates and saves the quote + // before approval; polling does not create a replacement quote. + Quote *AssetReservationQuote `protobuf:"bytes,2,opt,name=quote,proto3" json:"quote,omitempty"` + // Public summary of the server's progress, not the client's local FSM + // state. READY does not establish client readiness: the client must + // independently verify the delivery proof and current chain state. + State AssetReservationStatus `protobuf:"varint,3,opt,name=state,proto3,enum=looprpc.AssetReservationStatus" json:"state,omitempty"` + // Reservation funding output as canonical txid:vout. Empty until the + // funding output is assigned, then immutable. Its presence alone does + // not establish that the transaction was published or confirmed. + Outpoint string `protobuf:"bytes,4,opt,name=outpoint,proto3" json:"outpoint,omitempty"` + // Recorded block height containing the funding transaction, not its + // confirmation count. Zero until known. The client must check this + // height against the active chain and the delivered proof. + ConfirmationHeight uint32 `protobuf:"varint,5,opt,name=confirmation_height,json=confirmationHeight,proto3" json:"confirmation_height,omitempty"` + // First block height eligible for the server's CSV refund spend: + // confirmation_height + quote.terms.csv_delay. Zero until the funding + // height is known. Additional confirmations do not extend this deadline. + TimeoutHeight uint32 `protobuf:"varint,6,opt,name=timeout_height,json=timeoutHeight,proto3" json:"timeout_height,omitempty"` + // Exclusive cutoff for starting a later swap: timeout_height minus + // quote.terms.execution_delta. Zero until the funding height is known. + // A later swap must enforce this cutoff and its own claim-time checks. + ExecutionCutoff uint32 `protobuf:"varint,7,opt,name=execution_cutoff,json=executionCutoff,proto3" json:"execution_cutoff,omitempty"` + // Settled prepay credited toward the later swap fee, in indivisible + // asset units. Zero until the server verifies the settled asset receipt; + // otherwise equals quote.terms.fee. This is neither BTC nor a refund. + PrepayCredit uint64 `protobuf:"varint,8,opt,name=prepay_credit,json=prepayCredit,proto3" json:"prepay_credit,omitempty"` + // The server has a stored full asset proof retrievable through + // GetAssetReservationProof. Availability alone does not establish that + // the proof is valid on the current chain or the output is unspent. + ProofAvailable bool `protobuf:"varint,9,opt,name=proof_available,json=proofAvailable,proto3" json:"proof_available,omitempty"` + // The server saved a verified receipt for the full-amount asset probe. + // The client must also verify probe cancellation and its local payment + // outcome. The probe is never settled and reserves no future liquidity. + ProbeSucceeded bool `protobuf:"varint,10,opt,name=probe_succeeded,json=probeSucceeded,proto3" json:"probe_succeeded,omitempty"` + // The purchase's sole probe invoice is permanently canceled. This alone + // does not imply probe success or cancel the prepay invoice. A new probe + // attempt requires a new purchase ID. + ProbeCanceled bool `protobuf:"varint,11,opt,name=probe_canceled,json=probeCanceled,proto3" json:"probe_canceled,omitempty"` + // Funding was refused and the unpaid purchase authoritatively canceled. + // The client must also resolve its local payment before reporting a + // terminal failure; this flag alone cannot establish the payment outcome. + FundingUnavailable bool `protobuf:"varint,12,opt,name=funding_unavailable,json=fundingUnavailable,proto3" json:"funding_unavailable,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AssetReservation) Reset() { + *x = AssetReservation{} + mi := &file_asset_reservation_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AssetReservation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AssetReservation) ProtoMessage() {} + +func (x *AssetReservation) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservation_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AssetReservation.ProtoReflect.Descriptor instead. +func (*AssetReservation) Descriptor() ([]byte, []int) { + return file_asset_reservation_proto_rawDescGZIP(), []int{5} +} + +func (x *AssetReservation) GetReservationId() []byte { + if x != nil { + return x.ReservationId + } + return nil +} + +func (x *AssetReservation) GetQuote() *AssetReservationQuote { + if x != nil { + return x.Quote + } + return nil +} + +func (x *AssetReservation) GetState() AssetReservationStatus { + if x != nil { + return x.State + } + return AssetReservationStatus_ASSET_RESERVATION_UNKNOWN +} + +func (x *AssetReservation) GetOutpoint() string { + if x != nil { + return x.Outpoint + } + return "" +} + +func (x *AssetReservation) GetConfirmationHeight() uint32 { + if x != nil { + return x.ConfirmationHeight + } + return 0 +} + +func (x *AssetReservation) GetTimeoutHeight() uint32 { + if x != nil { + return x.TimeoutHeight + } + return 0 +} + +func (x *AssetReservation) GetExecutionCutoff() uint32 { + if x != nil { + return x.ExecutionCutoff + } + return 0 +} + +func (x *AssetReservation) GetPrepayCredit() uint64 { + if x != nil { + return x.PrepayCredit + } + return 0 +} + +func (x *AssetReservation) GetProofAvailable() bool { + if x != nil { + return x.ProofAvailable + } + return false +} + +func (x *AssetReservation) GetProbeSucceeded() bool { + if x != nil { + return x.ProbeSucceeded + } + return false +} + +func (x *AssetReservation) GetProbeCanceled() bool { + if x != nil { + return x.ProbeCanceled + } + return false +} + +func (x *AssetReservation) GetFundingUnavailable() bool { + if x != nil { + return x.FundingUnavailable + } + return false +} + +type ListAssetReservationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Maximum number of the authenticated owner's purchases to return. + // Zero selects the default of 100; values above 1000 are rejected. + Limit uint32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` + // Exclusive 32-byte purchase-ID cursor, compared in bytewise order. + // Leave empty for the first page; otherwise use the previous response's + // next_after_id. Pagination does not freeze records between requests. + AfterId []byte `protobuf:"bytes,2,opt,name=after_id,json=afterId,proto3" json:"after_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAssetReservationsRequest) Reset() { + *x = ListAssetReservationsRequest{} + mi := &file_asset_reservation_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAssetReservationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAssetReservationsRequest) ProtoMessage() {} + +func (x *ListAssetReservationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservation_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAssetReservationsRequest.ProtoReflect.Descriptor instead. +func (*ListAssetReservationsRequest) Descriptor() ([]byte, []int) { + return file_asset_reservation_proto_rawDescGZIP(), []int{6} +} + +func (x *ListAssetReservationsRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListAssetReservationsRequest) GetAfterId() []byte { + if x != nil { + return x.AfterId + } + return nil +} + +type ListAssetReservationsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // This page of the authenticated owner's purchases in ascending + // bytewise purchase-ID order. May be empty; includes terminal purchases. + Reservations []*AssetReservation `protobuf:"bytes,1,rep,name=reservations,proto3" json:"reservations,omitempty"` + // Pass this 32-byte ID as after_id to continue listing. Empty when no + // further page was found. Records may change between page requests. + NextAfterId []byte `protobuf:"bytes,2,opt,name=next_after_id,json=nextAfterId,proto3" json:"next_after_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAssetReservationsResponse) Reset() { + *x = ListAssetReservationsResponse{} + mi := &file_asset_reservation_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAssetReservationsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAssetReservationsResponse) ProtoMessage() {} + +func (x *ListAssetReservationsResponse) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservation_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAssetReservationsResponse.ProtoReflect.Descriptor instead. +func (*ListAssetReservationsResponse) Descriptor() ([]byte, []int) { + return file_asset_reservation_proto_rawDescGZIP(), []int{7} +} + +func (x *ListAssetReservationsResponse) GetReservations() []*AssetReservation { + if x != nil { + return x.Reservations + } + return nil +} + +func (x *ListAssetReservationsResponse) GetNextAfterId() []byte { + if x != nil { + return x.NextAfterId + } + return nil +} + +type AssetReservationProof struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Original 32-byte purchase ID. The client checks it against the saved + // purchase to prevent accepting another reservation's delivery. + ReservationId []byte `protobuf:"bytes,1,opt,name=reservation_id,json=reservationId,proto3" json:"reservation_id,omitempty"` + // Canonical txid:vout of the funding output proven by proof. Must match + // the saved delivery outpoint and the output established by verification. + Outpoint string `protobuf:"bytes,2,opt,name=outpoint,proto3" json:"outpoint,omitempty"` + // Full serialized Taproot Assets proof file, including transfer history, + // bounded to 16 MiB. The client independently verifies its provenance, + // contract, asset, amount, and current chain state before accepting it. + Proof []byte `protobuf:"bytes,3,opt,name=proof,proto3" json:"proof,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AssetReservationProof) Reset() { + *x = AssetReservationProof{} + mi := &file_asset_reservation_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AssetReservationProof) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AssetReservationProof) ProtoMessage() {} + +func (x *AssetReservationProof) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservation_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AssetReservationProof.ProtoReflect.Descriptor instead. +func (*AssetReservationProof) Descriptor() ([]byte, []int) { + return file_asset_reservation_proto_rawDescGZIP(), []int{8} +} + +func (x *AssetReservationProof) GetReservationId() []byte { + if x != nil { + return x.ReservationId + } + return nil +} + +func (x *AssetReservationProof) GetOutpoint() string { + if x != nil { + return x.Outpoint + } + return "" +} + +func (x *AssetReservationProof) GetProof() []byte { + if x != nil { + return x.Proof + } + return nil +} + +// Group membership evidence for the on-chain issuance in terms.asset_id and +// the issuance carried by the configured Lightning channel. First proof is +// the group anchor, followed by any distinct member issuance proofs needed. +type AssetReservationGroup struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Compressed 33-byte tweaked group public key. Must identify the group + // requested by the client and proven by the supplied issuance proofs. + GroupKey []byte `protobuf:"bytes,1,opt,name=group_key,json=groupKey,proto3" json:"group_key,omitempty"` + // Exact 32-byte issuance ID used on the receiving Lightning channel for + // the prepay and probe. May differ from terms.asset_id; both issuances + // must be fungible members of group_key. + PaymentAssetId []byte `protobuf:"bytes,2,opt,name=payment_asset_id,json=paymentAssetId,proto3" json:"payment_asset_id,omitempty"` + // Serialized individual Taproot Assets genesis proofs, not full proof + // files. The group anchor comes first, followed by distinct member + // proofs needed for the funding and payment issuances. One to three + // proofs, at most 3 MiB in total; verified before probing or paying. + IssuanceProofs [][]byte `protobuf:"bytes,3,rep,name=issuance_proofs,json=issuanceProofs,proto3" json:"issuance_proofs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AssetReservationGroup) Reset() { + *x = AssetReservationGroup{} + mi := &file_asset_reservation_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AssetReservationGroup) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AssetReservationGroup) ProtoMessage() {} + +func (x *AssetReservationGroup) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservation_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AssetReservationGroup.ProtoReflect.Descriptor instead. +func (*AssetReservationGroup) Descriptor() ([]byte, []int) { + return file_asset_reservation_proto_rawDescGZIP(), []int{9} +} + +func (x *AssetReservationGroup) GetGroupKey() []byte { + if x != nil { + return x.GroupKey + } + return nil +} + +func (x *AssetReservationGroup) GetPaymentAssetId() []byte { + if x != nil { + return x.PaymentAssetId + } + return nil +} + +func (x *AssetReservationGroup) GetIssuanceProofs() [][]byte { + if x != nil { + return x.IssuanceProofs + } + return nil +} + +var File_asset_reservation_proto protoreflect.FileDescriptor + +const file_asset_reservation_proto_rawDesc = "" + + "\n" + + "\x17asset_reservation.proto\x12\alooprpc\"\xdf\x01\n" + + "\x1cQuoteAssetReservationRequest\x12)\n" + + "\x10protocol_version\x18\x01 \x01(\rR\x0fprotocolVersion\x12%\n" + + "\x0ereservation_id\x18\x02 \x01(\fR\rreservationId\x12\x19\n" + + "\basset_id\x18\x03 \x01(\fR\aassetId\x12\x1b\n" + + "\tgroup_key\x18\x04 \x01(\fR\bgroupKey\x12\x16\n" + + "\x06amount\x18\x05 \x01(\x04R\x06amount\x12\x1d\n" + + "\n" + + "client_key\x18\x06 \x01(\fR\tclientKey\"m\n" + + "\x18AssetReservationSelector\x12'\n" + + "\x0ereservation_id\x18\x01 \x01(\fH\x00R\rreservationId\x12\x1c\n" + + "\boutpoint\x18\x02 \x01(\tH\x00R\boutpointB\n" + + "\n" + + "\bselector\"\x83\x01\n" + + "\x1dCancelAssetReservationRequest\x12C\n" + + "\vreservation\x18\x01 \x01(\v2!.looprpc.AssetReservationSelectorR\vreservation\x12\x1d\n" + + "\n" + + "probe_only\x18\x02 \x01(\bR\tprobeOnly\"\xd9\x01\n" + + "\x15AssetReservationTerms\x12\x19\n" + + "\basset_id\x18\x01 \x01(\fR\aassetId\x12\x16\n" + + "\x06amount\x18\x02 \x01(\x04R\x06amount\x12\x10\n" + + "\x03fee\x18\x03 \x01(\x04R\x03fee\x12\x1b\n" + + "\tcsv_delay\x18\x04 \x01(\rR\bcsvDelay\x125\n" + + "\x16required_confirmations\x18\x05 \x01(\rR\x15requiredConfirmations\x12'\n" + + "\x0fexecution_delta\x18\x06 \x01(\rR\x0eexecutionDelta\"\xc3\x04\n" + + "\x15AssetReservationQuote\x12%\n" + + "\x0ereservation_id\x18\x01 \x01(\fR\rreservationId\x124\n" + + "\x05terms\x18\x02 \x01(\v2\x1e.looprpc.AssetReservationTermsR\x05terms\x12\x1d\n" + + "\n" + + "client_key\x18\x03 \x01(\fR\tclientKey\x12\x1d\n" + + "\n" + + "server_key\x18\x04 \x01(\fR\tserverKey\x12,\n" + + "\x12receiving_node_key\x18\x05 \x01(\fR\x10receivingNodeKey\x12\x19\n" + + "\bedge_key\x18\x06 \x01(\fR\aedgeKey\x12%\n" + + "\x0eprepay_invoice\x18\a \x01(\tR\rprepayInvoice\x12#\n" + + "\rprobe_invoice\x18\b \x01(\tR\fprobeInvoice\x12,\n" + + "\x12prepay_amount_msat\x18\t \x01(\x04R\x10prepayAmountMsat\x12;\n" + + "\x1aestimated_main_amount_msat\x18\n" + + " \x01(\x04R\x17estimatedMainAmountMsat\x12\x1d\n" + + "\n" + + "expires_at\x18\v \x01(\x03R\texpiresAt\x12\x1d\n" + + "\n" + + "prepay_rfq\x18\f \x01(\fR\tprepayRfq\x12\x1b\n" + + "\tprobe_rfq\x18\r \x01(\fR\bprobeRfq\x124\n" + + "\x05group\x18\x0e \x01(\v2\x1e.looprpc.AssetReservationGroupR\x05group\"\x94\x04\n" + + "\x10AssetReservation\x12%\n" + + "\x0ereservation_id\x18\x01 \x01(\fR\rreservationId\x124\n" + + "\x05quote\x18\x02 \x01(\v2\x1e.looprpc.AssetReservationQuoteR\x05quote\x125\n" + + "\x05state\x18\x03 \x01(\x0e2\x1f.looprpc.AssetReservationStatusR\x05state\x12\x1a\n" + + "\boutpoint\x18\x04 \x01(\tR\boutpoint\x12/\n" + + "\x13confirmation_height\x18\x05 \x01(\rR\x12confirmationHeight\x12%\n" + + "\x0etimeout_height\x18\x06 \x01(\rR\rtimeoutHeight\x12)\n" + + "\x10execution_cutoff\x18\a \x01(\rR\x0fexecutionCutoff\x12#\n" + + "\rprepay_credit\x18\b \x01(\x04R\fprepayCredit\x12'\n" + + "\x0fproof_available\x18\t \x01(\bR\x0eproofAvailable\x12'\n" + + "\x0fprobe_succeeded\x18\n" + + " \x01(\bR\x0eprobeSucceeded\x12%\n" + + "\x0eprobe_canceled\x18\v \x01(\bR\rprobeCanceled\x12/\n" + + "\x13funding_unavailable\x18\f \x01(\bR\x12fundingUnavailable\"O\n" + + "\x1cListAssetReservationsRequest\x12\x14\n" + + "\x05limit\x18\x01 \x01(\rR\x05limit\x12\x19\n" + + "\bafter_id\x18\x02 \x01(\fR\aafterId\"\x82\x01\n" + + "\x1dListAssetReservationsResponse\x12=\n" + + "\freservations\x18\x01 \x03(\v2\x19.looprpc.AssetReservationR\freservations\x12\"\n" + + "\rnext_after_id\x18\x02 \x01(\fR\vnextAfterId\"p\n" + + "\x15AssetReservationProof\x12%\n" + + "\x0ereservation_id\x18\x01 \x01(\fR\rreservationId\x12\x1a\n" + + "\boutpoint\x18\x02 \x01(\tR\boutpoint\x12\x14\n" + + "\x05proof\x18\x03 \x01(\fR\x05proof\"\x87\x01\n" + + "\x15AssetReservationGroup\x12\x1b\n" + + "\tgroup_key\x18\x01 \x01(\fR\bgroupKey\x12(\n" + + "\x10payment_asset_id\x18\x02 \x01(\fR\x0epaymentAssetId\x12'\n" + + "\x0fissuance_proofs\x18\x03 \x03(\fR\x0eissuanceProofs*\xe5\x02\n" + + "\x16AssetReservationStatus\x12\x1d\n" + + "\x19ASSET_RESERVATION_UNKNOWN\x10\x00\x12\x1d\n" + + "\x19ASSET_RESERVATION_QUOTING\x10\x01\x12&\n" + + "\"ASSET_RESERVATION_AWAITING_PAYMENT\x10\x02\x12'\n" + + "#ASSET_RESERVATION_PREPARING_FUNDING\x10\x03\x12\x1d\n" + + "\x19ASSET_RESERVATION_FUNDING\x10\x04\x12\x1b\n" + + "\x17ASSET_RESERVATION_READY\x10\x05\x12\x1f\n" + + "\x1bASSET_RESERVATION_CANCELING\x10\x06\x12\x1e\n" + + "\x1aASSET_RESERVATION_CANCELED\x10\a\x12\x1d\n" + + "\x19ASSET_RESERVATION_EXPIRED\x10\b\x12 \n" + + "\x1cASSET_RESERVATION_NEED_ADMIN\x10\t2\xed\x03\n" + + "\x17AssetReservationService\x12Y\n" + + "\x15QuoteAssetReservation\x12%.looprpc.QuoteAssetReservationRequest\x1a\x19.looprpc.AssetReservation\x12S\n" + + "\x13GetAssetReservation\x12!.looprpc.AssetReservationSelector\x1a\x19.looprpc.AssetReservation\x12f\n" + + "\x15ListAssetReservations\x12%.looprpc.ListAssetReservationsRequest\x1a&.looprpc.ListAssetReservationsResponse\x12]\n" + + "\x18GetAssetReservationProof\x12!.looprpc.AssetReservationSelector\x1a\x1e.looprpc.AssetReservationProof\x12[\n" + + "\x16CancelAssetReservation\x12&.looprpc.CancelAssetReservationRequest\x1a\x19.looprpc.AssetReservationB-Z+github.com/lightninglabs/loop/swapserverrpcb\x06proto3" + +var ( + file_asset_reservation_proto_rawDescOnce sync.Once + file_asset_reservation_proto_rawDescData []byte +) + +func file_asset_reservation_proto_rawDescGZIP() []byte { + file_asset_reservation_proto_rawDescOnce.Do(func() { + file_asset_reservation_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_asset_reservation_proto_rawDesc), len(file_asset_reservation_proto_rawDesc))) + }) + return file_asset_reservation_proto_rawDescData +} + +var file_asset_reservation_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_asset_reservation_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_asset_reservation_proto_goTypes = []any{ + (AssetReservationStatus)(0), // 0: looprpc.AssetReservationStatus + (*QuoteAssetReservationRequest)(nil), // 1: looprpc.QuoteAssetReservationRequest + (*AssetReservationSelector)(nil), // 2: looprpc.AssetReservationSelector + (*CancelAssetReservationRequest)(nil), // 3: looprpc.CancelAssetReservationRequest + (*AssetReservationTerms)(nil), // 4: looprpc.AssetReservationTerms + (*AssetReservationQuote)(nil), // 5: looprpc.AssetReservationQuote + (*AssetReservation)(nil), // 6: looprpc.AssetReservation + (*ListAssetReservationsRequest)(nil), // 7: looprpc.ListAssetReservationsRequest + (*ListAssetReservationsResponse)(nil), // 8: looprpc.ListAssetReservationsResponse + (*AssetReservationProof)(nil), // 9: looprpc.AssetReservationProof + (*AssetReservationGroup)(nil), // 10: looprpc.AssetReservationGroup +} +var file_asset_reservation_proto_depIdxs = []int32{ + 2, // 0: looprpc.CancelAssetReservationRequest.reservation:type_name -> looprpc.AssetReservationSelector + 4, // 1: looprpc.AssetReservationQuote.terms:type_name -> looprpc.AssetReservationTerms + 10, // 2: looprpc.AssetReservationQuote.group:type_name -> looprpc.AssetReservationGroup + 5, // 3: looprpc.AssetReservation.quote:type_name -> looprpc.AssetReservationQuote + 0, // 4: looprpc.AssetReservation.state:type_name -> looprpc.AssetReservationStatus + 6, // 5: looprpc.ListAssetReservationsResponse.reservations:type_name -> looprpc.AssetReservation + 1, // 6: looprpc.AssetReservationService.QuoteAssetReservation:input_type -> looprpc.QuoteAssetReservationRequest + 2, // 7: looprpc.AssetReservationService.GetAssetReservation:input_type -> looprpc.AssetReservationSelector + 7, // 8: looprpc.AssetReservationService.ListAssetReservations:input_type -> looprpc.ListAssetReservationsRequest + 2, // 9: looprpc.AssetReservationService.GetAssetReservationProof:input_type -> looprpc.AssetReservationSelector + 3, // 10: looprpc.AssetReservationService.CancelAssetReservation:input_type -> looprpc.CancelAssetReservationRequest + 6, // 11: looprpc.AssetReservationService.QuoteAssetReservation:output_type -> looprpc.AssetReservation + 6, // 12: looprpc.AssetReservationService.GetAssetReservation:output_type -> looprpc.AssetReservation + 8, // 13: looprpc.AssetReservationService.ListAssetReservations:output_type -> looprpc.ListAssetReservationsResponse + 9, // 14: looprpc.AssetReservationService.GetAssetReservationProof:output_type -> looprpc.AssetReservationProof + 6, // 15: looprpc.AssetReservationService.CancelAssetReservation:output_type -> looprpc.AssetReservation + 11, // [11:16] is the sub-list for method output_type + 6, // [6:11] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_asset_reservation_proto_init() } +func file_asset_reservation_proto_init() { + if File_asset_reservation_proto != nil { + return + } + file_asset_reservation_proto_msgTypes[1].OneofWrappers = []any{ + (*AssetReservationSelector_ReservationId)(nil), + (*AssetReservationSelector_Outpoint)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_asset_reservation_proto_rawDesc), len(file_asset_reservation_proto_rawDesc)), + NumEnums: 1, + NumMessages: 10, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_asset_reservation_proto_goTypes, + DependencyIndexes: file_asset_reservation_proto_depIdxs, + EnumInfos: file_asset_reservation_proto_enumTypes, + MessageInfos: file_asset_reservation_proto_msgTypes, + }.Build() + File_asset_reservation_proto = out.File + file_asset_reservation_proto_goTypes = nil + file_asset_reservation_proto_depIdxs = nil +} diff --git a/swapserverrpc/asset_reservation.proto b/swapserverrpc/asset_reservation.proto new file mode 100644 index 000000000..1a1ca2d13 --- /dev/null +++ b/swapserverrpc/asset_reservation.proto @@ -0,0 +1,352 @@ +syntax = "proto3"; + +package looprpc; + +option go_package = "github.com/lightninglabs/loop/swapserverrpc"; + +// AssetReservationService buys asset reservations for later asset Loop Out +// swaps. Every call requires authentication. Paying the prepay starts funding; +// no client message can establish payment, publication, or proof validity. +service AssetReservationService { + // QuoteAssetReservation creates one purchase and its two invoices. Retry + // with the same ID, asset, amount, and key after an uncertain response. + // A response may still be preparing its quote; poll GetAssetReservation. + rpc QuoteAssetReservation (QuoteAssetReservationRequest) + returns (AssetReservation); + + // GetAssetReservation returns the authenticated owner's purchase by ID + // or funding outpoint, including its quote and current server progress. + // Poll this method after an uncertain reply or while awaiting delivery. + rpc GetAssetReservation (AssetReservationSelector) + returns (AssetReservation); + + // ListAssetReservations returns a page of the authenticated owner's + // purchases in reservation ID order. Use next_after_id to request the + // next page; purchases may change between pages. + rpc ListAssetReservations (ListAssetReservationsRequest) + returns (ListAssetReservationsResponse); + + // GetAssetReservationProof returns the full exported proof, not merely + // an inclusion proof. The client must verify it independently. + rpc GetAssetReservationProof (AssetReservationSelector) + returns (AssetReservationProof); + + // CancelAssetReservation requests cancellation of an unpaid purchase. + // A lost reply is not proof of cancellation. Settlement wins the race: + // an already-paid purchase continues delivery instead of being refunded. + rpc CancelAssetReservation (CancelAssetReservationRequest) + returns (AssetReservation); +} + +message QuoteAssetReservationRequest { + // Reservation protocol version: 0 for an exact asset ID, 1 for a group + // key. The server rejects versions that do not match the selector. + uint32 protocol_version = 1; + + // Client-generated, nonzero 32-byte purchase ID, saved before the first + // request. Reuse it with the same selector, amount, and key after a lost + // response or restart. The server binds it to the authenticated owner + // and rejects reuse with different request fields. + bytes reservation_id = 2; + + // Exact, nonzero 32-byte asset ID to reserve. The funding output and the + // receiving asset channel must use this issuance. Set exactly one of + // asset_id or group_key; leave the other empty. + bytes asset_id = 3; + + // Compressed 33-byte tweaked group public key returned by tapd. The + // server may choose different member issuances for on-chain funding + // and Lightning payments. The quote identifies both; delivery must + // match its funding asset ID. Mutually exclusive with asset_id. + bytes group_key = 4; + + // Positive reservation principal in indivisible asset units, excluding + // the quoted service fee. The funding output must contain this exact + // amount. Values must fit signed 64-bit storage and server policy. + uint64 amount = 5; + + // Compressed 33-byte public key for the client's side of the reservation + // contract's cooperative spend. The client must control its secret and + // retain the key across restarts. This is not a Lightning node identity. + bytes client_key = 6; +} + +message AssetReservationSelector { + // Identify one purchase owned by the authenticated caller. Exactly one + // selector is required; neither selector bypasses ownership checks. + oneof selector { + // Nonzero 32-byte purchase ID from the original quote request. + // Works before funding and remains valid after an outpoint is known. + bytes reservation_id = 1; + + // Reservation funding output in canonical txid:vout form. Available + // only after funding is assigned; alternate encodings are rejected. + string outpoint = 2; + } +} + +message CancelAssetReservationRequest { + // Required selector for the authenticated owner's existing purchase. + AssetReservationSelector reservation = 1; + + // If true, cancel only the probe invoice, preserving the purchase and + // prepay. If false, request cancellation of the unpaid purchase. Neither + // mode refunds a settled prepay. Probe cancellation is permanent; another + // probe attempt requires a new purchase ID. + bool probe_only = 2; +} + +message AssetReservationTerms { + // Exact, nonzero 32-byte issuance ID for the on-chain funding output. + // Equals the requested asset ID for an exact purchase; for a group + // purchase, identifies the member issuance selected by the server. + bytes asset_id = 1; + + // Reserved principal in indivisible asset units, excluding fees. Must + // equal the requested amount and the amount proven in the funding output. + uint64 amount = 2; + + // Total quoted charge in indivisible asset units, including estimated + // server funding costs. Paid upfront and credited once against the later + // swap fee. An unused reservation does not receive an automatic refund. + uint64 fee = 3; + + // Block-based CSV delay for the server's refund path, from 1 to 65535. + // Measured from funding confirmation, not quote creation or delivery. + uint32 csv_delay = 4; + + // Positive funding confirmation depth required before delivery is ready. + // This is an agreed threshold, not the current confirmation count. + uint32 required_confirmations = 5; + + // Positive block margin between the exclusive swap execution cutoff + // and CSV refund maturity. The later swap must also enforce its own + // claim-time requirements. + uint32 execution_delta = 6; +} + +message AssetReservationQuote { + // Client-generated 32-byte purchase ID. Must match the request and the + // enclosing AssetReservation response. + bytes reservation_id = 1; + + // Immutable asset principal, fee, and lifetime offered by the server. + // The client validates these terms before approving this exact quote. + AssetReservationTerms terms = 2; + + // Compressed 33-byte client contract key, echoed from the request. + // The client checks it against its locally saved signing key. + bytes client_key = 3; + + // Compressed 33-byte server contract key. Used with client_key for + // cooperative spending and by the server for the CSV refund path. + bytes server_key = 4; + + // The server's receiving LND node, as a 33-byte compressed identity key. + // This node issues both invoices and receives the asset prepay. The + // client checks that both invoice destinations match this key. + bytes receiving_node_key = 5; + + // Compressed 33-byte identity of the receiving node's conversion peer. + // Converts incoming BTC into assets on the receiving channel. Both + // invoice route hints and RFQs must identify this peer. + bytes edge_key = 6; + + // BOLT11 hold invoice for the quoted fee. The client pays BTC through + // the conversion peer; the server receives assets. Accepted HTLCs allow + // funding preparation; settlement follows observed funding publication. + string prepay_invoice = 7; + + // BOLT11 hold invoice for one probe of the full reserved asset amount. + // Its hash must equal ProbeHash(reservation_id). The receiver verifies + // the asset receipt and cancels the invoice without settling it. + string probe_invoice = 8; + + // Exact BTC prepay amount in millisatoshis, excluding routing fees. + // Must match prepay_invoice and the fee conversion in prepay_rfq. + uint64 prepay_amount_msat = 9; + + // BTC equivalent of the reserved principal, in millisatoshis excluding + // routing fees. Matches the probe invoice and RFQ. Estimates the later + // main payment; it does not lock the later swap's conversion rate. + uint64 estimated_main_amount_msat = 10; + + // Quote expiry as Unix seconds. No new prepay may start at or after + // this time. Bounded by both invoices and both receiving RFQs; an + // already-started payment must still be reconciled after expiry. + int64 expires_at = 11; + + // Serialized rfqrpc.PeerAcceptedBuyQuote for receiving terms.fee asset + // units. Binds the BTC prepay amount, conversion peer, and route hint. + // Uses the payment issuance for group purchases. Contains no secrets. + bytes prepay_rfq = 12; + + // Serialized rfqrpc.PeerAcceptedBuyQuote for receiving terms.amount + // asset units in the probe. Binds its BTC amount, peer, and route hint. + // Uses the payment issuance for group purchases. Contains no secrets. + bytes probe_rfq = 13; + + // Group key, payment issuance, and membership evidence for a group + // request; absent for an exact-ID request. Included in the approval hash + // and verified before the client probes or pays. + AssetReservationGroup group = 14; +} + +// AssetReservationStatus summarizes server progress for RPC callers. Several +// internal server states may map to one status. The client tracks its own +// progress separately and must verify delivery before entering its Ready state. +enum AssetReservationStatus { + // Unspecified status; does not establish valid purchase progress. + ASSET_RESERVATION_UNKNOWN = 0; + + // Purchase recorded; the server is preparing its invoices and quote. + ASSET_RESERVATION_QUOTING = 1; + + // Quote available; waiting for the prepay to reach the receiving node. + ASSET_RESERVATION_AWAITING_PAYMENT = 2; + + // Prepay accepted; the server is preparing the reservation transfer. + ASSET_RESERVATION_PREPARING_FUNDING = 3; + + // Funding is being published or awaiting settlement, confirmations, or + // proof export. This status alone does not establish publication. + ASSET_RESERVATION_FUNDING = 4; + + // Server verified delivery and retains its proof. The client must still + // verify the output and active chain before marking its own state Ready. + ASSET_RESERVATION_READY = 5; + + // Cancellation requested but not yet authoritative. If payment settled + // instead, the server continues delivery rather than refunding it. + ASSET_RESERVATION_CANCELING = 6; + + // Server confirmed unpaid cancellation. The client must also resolve + // its own payment outcome before treating the purchase as canceled. + ASSET_RESERVATION_CANCELED = 7; + + // The reservation reached CSV expiry; the server's refund may still be + // preparing, publishing, or awaiting confirmation. + ASSET_RESERVATION_EXPIRED = 8; + + // A server-side inconsistency requires operator attention. This does + // not establish cancellation, a refund, or a safe client spend. + ASSET_RESERVATION_NEED_ADMIN = 9; +} + +message AssetReservation { + // Client-generated 32-byte purchase ID from the quote request. Remains + // stable through retries, funding, and recovery, including before an + // on-chain outpoint exists. + bytes reservation_id = 1; + + // Saved purchase terms, keys, invoices, and RFQs. Absent while the server + // is still preparing the quote. The client validates and saves the quote + // before approval; polling does not create a replacement quote. + AssetReservationQuote quote = 2; + + // Public summary of the server's progress, not the client's local FSM + // state. READY does not establish client readiness: the client must + // independently verify the delivery proof and current chain state. + AssetReservationStatus state = 3; + + // Reservation funding output as canonical txid:vout. Empty until the + // funding output is assigned, then immutable. Its presence alone does + // not establish that the transaction was published or confirmed. + string outpoint = 4; + + // Recorded block height containing the funding transaction, not its + // confirmation count. Zero until known. The client must check this + // height against the active chain and the delivered proof. + uint32 confirmation_height = 5; + + // First block height eligible for the server's CSV refund spend: + // confirmation_height + quote.terms.csv_delay. Zero until the funding + // height is known. Additional confirmations do not extend this deadline. + uint32 timeout_height = 6; + + // Exclusive cutoff for starting a later swap: timeout_height minus + // quote.terms.execution_delta. Zero until the funding height is known. + // A later swap must enforce this cutoff and its own claim-time checks. + uint32 execution_cutoff = 7; + + // Settled prepay credited toward the later swap fee, in indivisible + // asset units. Zero until the server verifies the settled asset receipt; + // otherwise equals quote.terms.fee. This is neither BTC nor a refund. + uint64 prepay_credit = 8; + + // The server has a stored full asset proof retrievable through + // GetAssetReservationProof. Availability alone does not establish that + // the proof is valid on the current chain or the output is unspent. + bool proof_available = 9; + + // The server saved a verified receipt for the full-amount asset probe. + // The client must also verify probe cancellation and its local payment + // outcome. The probe is never settled and reserves no future liquidity. + bool probe_succeeded = 10; + + // The purchase's sole probe invoice is permanently canceled. This alone + // does not imply probe success or cancel the prepay invoice. A new probe + // attempt requires a new purchase ID. + bool probe_canceled = 11; + + // Funding was refused and the unpaid purchase authoritatively canceled. + // The client must also resolve its local payment before reporting a + // terminal failure; this flag alone cannot establish the payment outcome. + bool funding_unavailable = 12; +} + +message ListAssetReservationsRequest { + // Maximum number of the authenticated owner's purchases to return. + // Zero selects the default of 100; values above 1000 are rejected. + uint32 limit = 1; + + // Exclusive 32-byte purchase-ID cursor, compared in bytewise order. + // Leave empty for the first page; otherwise use the previous response's + // next_after_id. Pagination does not freeze records between requests. + bytes after_id = 2; +} + +message ListAssetReservationsResponse { + // This page of the authenticated owner's purchases in ascending + // bytewise purchase-ID order. May be empty; includes terminal purchases. + repeated AssetReservation reservations = 1; + + // Pass this 32-byte ID as after_id to continue listing. Empty when no + // further page was found. Records may change between page requests. + bytes next_after_id = 2; +} + +message AssetReservationProof { + // Original 32-byte purchase ID. The client checks it against the saved + // purchase to prevent accepting another reservation's delivery. + bytes reservation_id = 1; + + // Canonical txid:vout of the funding output proven by proof. Must match + // the saved delivery outpoint and the output established by verification. + string outpoint = 2; + + // Full serialized Taproot Assets proof file, including transfer history, + // bounded to 16 MiB. The client independently verifies its provenance, + // contract, asset, amount, and current chain state before accepting it. + bytes proof = 3; +} + +// Group membership evidence for the on-chain issuance in terms.asset_id and +// the issuance carried by the configured Lightning channel. First proof is +// the group anchor, followed by any distinct member issuance proofs needed. +message AssetReservationGroup { + // Compressed 33-byte tweaked group public key. Must identify the group + // requested by the client and proven by the supplied issuance proofs. + bytes group_key = 1; + + // Exact 32-byte issuance ID used on the receiving Lightning channel for + // the prepay and probe. May differ from terms.asset_id; both issuances + // must be fungible members of group_key. + bytes payment_asset_id = 2; + + // Serialized individual Taproot Assets genesis proofs, not full proof + // files. The group anchor comes first, followed by distinct member + // proofs needed for the funding and payment issuances. One to three + // proofs, at most 3 MiB in total; verified before probing or paying. + repeated bytes issuance_proofs = 3; +} diff --git a/swapserverrpc/asset_reservation_grpc.pb.go b/swapserverrpc/asset_reservation_grpc.pb.go new file mode 100644 index 000000000..ec7d7883f --- /dev/null +++ b/swapserverrpc/asset_reservation_grpc.pb.go @@ -0,0 +1,274 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. + +package swapserverrpc + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +// AssetReservationServiceClient is the client API for AssetReservationService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type AssetReservationServiceClient interface { + // QuoteAssetReservation creates one purchase and its two invoices. Retry + // with the same ID, asset, amount, and key after an uncertain response. + // A response may still be preparing its quote; poll GetAssetReservation. + QuoteAssetReservation(ctx context.Context, in *QuoteAssetReservationRequest, opts ...grpc.CallOption) (*AssetReservation, error) + // GetAssetReservation returns the authenticated owner's purchase by ID + // or funding outpoint, including its quote and current server progress. + // Poll this method after an uncertain reply or while awaiting delivery. + GetAssetReservation(ctx context.Context, in *AssetReservationSelector, opts ...grpc.CallOption) (*AssetReservation, error) + // ListAssetReservations returns a page of the authenticated owner's + // purchases in reservation ID order. Use next_after_id to request the + // next page; purchases may change between pages. + ListAssetReservations(ctx context.Context, in *ListAssetReservationsRequest, opts ...grpc.CallOption) (*ListAssetReservationsResponse, error) + // GetAssetReservationProof returns the full exported proof, not merely + // an inclusion proof. The client must verify it independently. + GetAssetReservationProof(ctx context.Context, in *AssetReservationSelector, opts ...grpc.CallOption) (*AssetReservationProof, error) + // CancelAssetReservation requests cancellation of an unpaid purchase. + // A lost reply is not proof of cancellation. Settlement wins the race: + // an already-paid purchase continues delivery instead of being refunded. + CancelAssetReservation(ctx context.Context, in *CancelAssetReservationRequest, opts ...grpc.CallOption) (*AssetReservation, error) +} + +type assetReservationServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewAssetReservationServiceClient(cc grpc.ClientConnInterface) AssetReservationServiceClient { + return &assetReservationServiceClient{cc} +} + +func (c *assetReservationServiceClient) QuoteAssetReservation(ctx context.Context, in *QuoteAssetReservationRequest, opts ...grpc.CallOption) (*AssetReservation, error) { + out := new(AssetReservation) + err := c.cc.Invoke(ctx, "/looprpc.AssetReservationService/QuoteAssetReservation", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *assetReservationServiceClient) GetAssetReservation(ctx context.Context, in *AssetReservationSelector, opts ...grpc.CallOption) (*AssetReservation, error) { + out := new(AssetReservation) + err := c.cc.Invoke(ctx, "/looprpc.AssetReservationService/GetAssetReservation", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *assetReservationServiceClient) ListAssetReservations(ctx context.Context, in *ListAssetReservationsRequest, opts ...grpc.CallOption) (*ListAssetReservationsResponse, error) { + out := new(ListAssetReservationsResponse) + err := c.cc.Invoke(ctx, "/looprpc.AssetReservationService/ListAssetReservations", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *assetReservationServiceClient) GetAssetReservationProof(ctx context.Context, in *AssetReservationSelector, opts ...grpc.CallOption) (*AssetReservationProof, error) { + out := new(AssetReservationProof) + err := c.cc.Invoke(ctx, "/looprpc.AssetReservationService/GetAssetReservationProof", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *assetReservationServiceClient) CancelAssetReservation(ctx context.Context, in *CancelAssetReservationRequest, opts ...grpc.CallOption) (*AssetReservation, error) { + out := new(AssetReservation) + err := c.cc.Invoke(ctx, "/looprpc.AssetReservationService/CancelAssetReservation", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AssetReservationServiceServer is the server API for AssetReservationService service. +// All implementations must embed UnimplementedAssetReservationServiceServer +// for forward compatibility +type AssetReservationServiceServer interface { + // QuoteAssetReservation creates one purchase and its two invoices. Retry + // with the same ID, asset, amount, and key after an uncertain response. + // A response may still be preparing its quote; poll GetAssetReservation. + QuoteAssetReservation(context.Context, *QuoteAssetReservationRequest) (*AssetReservation, error) + // GetAssetReservation returns the authenticated owner's purchase by ID + // or funding outpoint, including its quote and current server progress. + // Poll this method after an uncertain reply or while awaiting delivery. + GetAssetReservation(context.Context, *AssetReservationSelector) (*AssetReservation, error) + // ListAssetReservations returns a page of the authenticated owner's + // purchases in reservation ID order. Use next_after_id to request the + // next page; purchases may change between pages. + ListAssetReservations(context.Context, *ListAssetReservationsRequest) (*ListAssetReservationsResponse, error) + // GetAssetReservationProof returns the full exported proof, not merely + // an inclusion proof. The client must verify it independently. + GetAssetReservationProof(context.Context, *AssetReservationSelector) (*AssetReservationProof, error) + // CancelAssetReservation requests cancellation of an unpaid purchase. + // A lost reply is not proof of cancellation. Settlement wins the race: + // an already-paid purchase continues delivery instead of being refunded. + CancelAssetReservation(context.Context, *CancelAssetReservationRequest) (*AssetReservation, error) + mustEmbedUnimplementedAssetReservationServiceServer() +} + +// UnimplementedAssetReservationServiceServer must be embedded to have forward compatible implementations. +type UnimplementedAssetReservationServiceServer struct { +} + +func (UnimplementedAssetReservationServiceServer) QuoteAssetReservation(context.Context, *QuoteAssetReservationRequest) (*AssetReservation, error) { + return nil, status.Errorf(codes.Unimplemented, "method QuoteAssetReservation not implemented") +} +func (UnimplementedAssetReservationServiceServer) GetAssetReservation(context.Context, *AssetReservationSelector) (*AssetReservation, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetAssetReservation not implemented") +} +func (UnimplementedAssetReservationServiceServer) ListAssetReservations(context.Context, *ListAssetReservationsRequest) (*ListAssetReservationsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListAssetReservations not implemented") +} +func (UnimplementedAssetReservationServiceServer) GetAssetReservationProof(context.Context, *AssetReservationSelector) (*AssetReservationProof, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetAssetReservationProof not implemented") +} +func (UnimplementedAssetReservationServiceServer) CancelAssetReservation(context.Context, *CancelAssetReservationRequest) (*AssetReservation, error) { + return nil, status.Errorf(codes.Unimplemented, "method CancelAssetReservation not implemented") +} +func (UnimplementedAssetReservationServiceServer) mustEmbedUnimplementedAssetReservationServiceServer() { +} + +// UnsafeAssetReservationServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AssetReservationServiceServer will +// result in compilation errors. +type UnsafeAssetReservationServiceServer interface { + mustEmbedUnimplementedAssetReservationServiceServer() +} + +func RegisterAssetReservationServiceServer(s grpc.ServiceRegistrar, srv AssetReservationServiceServer) { + s.RegisterService(&AssetReservationService_ServiceDesc, srv) +} + +func _AssetReservationService_QuoteAssetReservation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QuoteAssetReservationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AssetReservationServiceServer).QuoteAssetReservation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/looprpc.AssetReservationService/QuoteAssetReservation", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AssetReservationServiceServer).QuoteAssetReservation(ctx, req.(*QuoteAssetReservationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AssetReservationService_GetAssetReservation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AssetReservationSelector) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AssetReservationServiceServer).GetAssetReservation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/looprpc.AssetReservationService/GetAssetReservation", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AssetReservationServiceServer).GetAssetReservation(ctx, req.(*AssetReservationSelector)) + } + return interceptor(ctx, in, info, handler) +} + +func _AssetReservationService_ListAssetReservations_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListAssetReservationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AssetReservationServiceServer).ListAssetReservations(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/looprpc.AssetReservationService/ListAssetReservations", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AssetReservationServiceServer).ListAssetReservations(ctx, req.(*ListAssetReservationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AssetReservationService_GetAssetReservationProof_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AssetReservationSelector) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AssetReservationServiceServer).GetAssetReservationProof(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/looprpc.AssetReservationService/GetAssetReservationProof", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AssetReservationServiceServer).GetAssetReservationProof(ctx, req.(*AssetReservationSelector)) + } + return interceptor(ctx, in, info, handler) +} + +func _AssetReservationService_CancelAssetReservation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CancelAssetReservationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AssetReservationServiceServer).CancelAssetReservation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/looprpc.AssetReservationService/CancelAssetReservation", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AssetReservationServiceServer).CancelAssetReservation(ctx, req.(*CancelAssetReservationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// AssetReservationService_ServiceDesc is the grpc.ServiceDesc for AssetReservationService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AssetReservationService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "looprpc.AssetReservationService", + HandlerType: (*AssetReservationServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "QuoteAssetReservation", + Handler: _AssetReservationService_QuoteAssetReservation_Handler, + }, + { + MethodName: "GetAssetReservation", + Handler: _AssetReservationService_GetAssetReservation_Handler, + }, + { + MethodName: "ListAssetReservations", + Handler: _AssetReservationService_ListAssetReservations_Handler, + }, + { + MethodName: "GetAssetReservationProof", + Handler: _AssetReservationService_GetAssetReservationProof_Handler, + }, + { + MethodName: "CancelAssetReservation", + Handler: _AssetReservationService_CancelAssetReservation_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "asset_reservation.proto", +} From 1f96ad9c1f84a070ed36785a8b1bd3bdf278bb19 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 10:40:14 +0200 Subject: [PATCH 11/25] assets/reservation: Define quoted amounts and lifetime limits Define a reservation's quoted asset amount, fee, confirmation depth, and CSV lifetime. Validate positive amounts, storage bounds, overflow, and block-based CSV limits. Check that the quoted lifetime values fit together without imposing a fixed fee or minimum lifetime policy. Test structural boundaries; approval and persistence follow in later commits. Describe the selected funding issuance independently of the original group selector. --- assets/reservation/terms.go | 90 ++++++++++++++++++++++++++ assets/reservation/terms_test.go | 108 +++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 assets/reservation/terms.go create mode 100644 assets/reservation/terms_test.go diff --git a/assets/reservation/terms.go b/assets/reservation/terms.go new file mode 100644 index 000000000..95c324cb8 --- /dev/null +++ b/assets/reservation/terms.go @@ -0,0 +1,90 @@ +package reservation + +import ( + "errors" + "math" +) + +// Terms records the asset amounts and lifetime offered for a reservation. +// For a group request the server chooses an on-chain issuance, otherwise +// the client supplies it. Fee and lifetime come from the server quote. +// +// The client validates and saves the returned terms before requesting approval. +// Approving the quote's hash accepts its asset fee and BTC prepay amount. The +// client saves the prepay routing cap and SkipProbe choice with the transition +// to payment. Recovery uses the saved terms, even if server policy has changed. +// The field comments describe the full flow; Validate alone checks only amounts +// and lifetime consistency. +type Terms struct { + // AssetID is the exact on-chain issuance, zero while a group request + // awaits its first quote. + AssetID [32]byte + + // Amount is the requested reservation principal, in indivisible asset + // units. The client checks the quote against its request and requires + // the reservation proof to contain this exact amount. + Amount uint64 + + // Fee is the server's total charge in indivisible asset units, including + // its estimated funding cost. The client pays it in full when buying + // the reservation. Execution owes Amount + Fee - prepaid credit, so + // crediting the prepay prevents double charging; it is not a refund of + // the funding charge. The server also keeps the prepay if the reservation + // is unused. + // + // For Amount = 10,000 and Fee = 10, the client pays 10 units upfront, + // then 10,000 at swap execution to receive 10,000 on-chain. Routing and + // client-side sweep fees are separate. The server pays its funding + // transaction's miner fee from its own BTC wallet. + // + // The client accepts Fee by approving the exact quote and uses the + // receiving RFQ to check the BTC prepay invoice. The server verifies that + // it received exactly Fee asset units; the client requires the reported + // prepay credit to match. The later swap applies that credit once. + Fee uint64 + + // CSVDelay is the server's quoted timeout delay, measured in blocks from + // the first funding confirmation. The client checks the reservation proof + // against a script built with this delay. Both parties use it to derive + // the original timeout height; recovery does not restart the clock. + CSVDelay uint32 + + // RequiredConfirmations is the funding depth quoted by the server. + // Each party checks the observed depth against this value before Ready. + // The client uses the quoted depth; Validate imposes no minimum beyond + // requiring a positive value that fits the reservation lifetime. + RequiredConfirmations uint32 + + // ExecutionDelta is the server's quoted margin between the execution + // cutoff and CSV maturity. Both parties derive the cutoff from it. + // The later swap must enforce that cutoff and its own claim-time checks. + ExecutionDelta uint32 +} + +// Validate rejects zero asset IDs, nonpositive amounts, signed-storage +// overflow, and CSV values outside the block-based BIP68 range. It also checks +// that confirmation depth and execution margin fit the delay. +// It does not compare terms with the client's request or fee policy, +// enforce a fee percentage or client lifetime policy, or inspect a funded output. +func (t Terms) Validate() error { + if t.AssetID == ([32]byte{}) || t.Amount == 0 || t.Fee == 0 || + t.Amount > math.MaxInt64 || t.Fee > math.MaxInt64 { + + return errors.New("invalid asset reservation amounts") + } + if t.Amount > math.MaxInt64-t.Fee { + return errors.New("asset amount plus fee is out of range") + } + + // A plain 16-bit sequence selects block-based CSV without disable or + // time-based flags. Use wider arithmetic for untrusted policy values. + required := uint64(t.RequiredConfirmations) + uint64(t.ExecutionDelta) + if t.CSVDelay == 0 || t.CSVDelay > math.MaxUint16 || + t.RequiredConfirmations == 0 || t.ExecutionDelta == 0 || + required > uint64(t.CSVDelay) { + + return errors.New("invalid asset reservation lifetime") + } + + return nil +} diff --git a/assets/reservation/terms_test.go b/assets/reservation/terms_test.go new file mode 100644 index 000000000..0f4dac60b --- /dev/null +++ b/assets/reservation/terms_test.go @@ -0,0 +1,108 @@ +package reservation + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTerms(t *testing.T) { + terms := Terms{ + AssetID: [32]byte{1}, + Amount: 10000, + Fee: 10, + CSVDelay: 1440, + RequiredConfirmations: 3, + ExecutionDelta: 90, + } + require.NoError(t, terms.Validate()) + + tests := []struct { + name string + change func(*Terms) + }{ + { + "missing asset", + func(t *Terms) { t.AssetID = [32]byte{} }, + }, + { + "zero amount", + func(t *Terms) { t.Amount = 0 }, + }, + { + "zero fee", + func(t *Terms) { t.Fee = 0 }, + }, + { + "amount out of range", + func(t *Terms) { t.Amount = math.MaxUint64 }, + }, + { + "fee out of range", + func(t *Terms) { t.Fee = math.MaxUint64 }, + }, + { + "gross amount overflow", + func(t *Terms) { t.Amount = math.MaxInt64 }, + }, + { + "zero CSV", + func(t *Terms) { t.CSVDelay = 0 }, + }, + { + "time CSV", + func(t *Terms) { t.CSVDelay |= 1 << 22 }, + }, + { + "disabled CSV", + func(t *Terms) { t.CSVDelay |= 1 << 31 }, + }, + { + "no confirmations", + func(t *Terms) { t.RequiredConfirmations = 0 }, + }, + { + "no cutoff", + func(t *Terms) { t.ExecutionDelta = 0 }, + }, + { + "cutoff before confirmation depth", + func(t *Terms) { t.ExecutionDelta = 1438 }, + }, + { + "overflow", + func(t *Terms) { t.ExecutionDelta = math.MaxUint32 }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + invalid := terms + test.change(&invalid) + require.Error(t, invalid.Validate()) + }) + } + + // At three confirmations, equality leaves one block before the cutoff. + // A larger execution margin would close execution before delivery. + terms.ExecutionDelta = terms.CSVDelay - terms.RequiredConfirmations + require.NoError(t, terms.Validate()) + terms.ExecutionDelta++ + require.Error(t, terms.Validate()) + terms.ExecutionDelta = 90 + + // Structural validation accepts quoted fees above and below the + // initial server price. The client must still approve those fees. + for _, fee := range []uint64{1, 9, 10, 11, 10000} { + quoted := terms + quoted.Fee = fee + require.NoError(t, quoted.Validate()) + require.Equal(t, fee, quoted.Fee) + } + + // Check the signed-storage boundary without percentage arithmetic. + terms.Fee = math.MaxInt64 - terms.Amount + require.NoError(t, terms.Validate()) + terms.Fee++ + require.Error(t, terms.Validate()) +} From 78f61d30f76d495d0417811eaf2ebbfce447dcf3 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 10:44:07 +0200 Subject: [PATCH 12/25] loopdb: Add reservation terms and state history tables Store a reservation's agreed terms separately from its ordered state history. Constrain identifiers, amounts, fees, and lifetime values, and make repeated inserts preserve the existing purchase. Allow a purchase row to exist before the server supplies its quote, with the fee and lifetime unset. Require those terms to form a complete set once supplied. Add schema tests for rollback, duplicate IDs, quoted fees, and invalid records. The shared database test helper selects SQLite or PostgreSQL through the existing build tag. Store the original optional group selector alongside the reservation identity in the initial unreleased schema. --- loopdb/asset_reservations_test.go | 172 ++++++++++++++++ loopdb/sqlc/asset_reservations.sql.go | 192 ++++++++++++++++++ .../000024_asset_reservations.down.sql | 2 + .../000024_asset_reservations.up.sql | 38 ++++ loopdb/sqlc/models.go | 23 +++ loopdb/sqlc/querier.go | 5 + loopdb/sqlc/queries/asset_reservations.sql | 42 ++++ 7 files changed, 474 insertions(+) create mode 100644 loopdb/asset_reservations_test.go create mode 100644 loopdb/sqlc/asset_reservations.sql.go create mode 100644 loopdb/sqlc/migrations/000024_asset_reservations.down.sql create mode 100644 loopdb/sqlc/migrations/000024_asset_reservations.up.sql create mode 100644 loopdb/sqlc/queries/asset_reservations.sql diff --git a/loopdb/asset_reservations_test.go b/loopdb/asset_reservations_test.go new file mode 100644 index 000000000..d9787e7ff --- /dev/null +++ b/loopdb/asset_reservations_test.go @@ -0,0 +1,172 @@ +package loopdb_test + +import ( + "bytes" + "database/sql" + "testing" + "time" + + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/stretchr/testify/require" +) + +func TestAssetReservationSchema(t *testing.T) { + db := loopdb.NewTestDB(t) + ctx := t.Context() + now := time.Unix(1700000000, 0).UTC() + args := sqlc.CreateAssetReservationParams{ + ReservationID: bytes.Repeat([]byte{1}, 32), + AssetID: bytes.Repeat([]byte{2}, 32), + Amount: 10000, + Fee: 10, + CsvDelay: 1440, + RequiredConfirmations: 3, + ExecutionDelta: 90, + ClientPubkey: bytes.Repeat([]byte{3}, 33), + ClientKeyFamily: 99, + ClientKeyIndex: 1, + CreatedAt: now, + } + + // A failed history write must not leave a partially created purchase. + err := db.ExecTx(ctx, &loopdb.SqliteTxOptions{}, func(q *sqlc.Queries) error { + _, err := q.CreateAssetReservation(ctx, args) + if err != nil { + return err + } + return q.InsertAssetReservationUpdate(ctx, + sqlc.InsertAssetReservationUpdateParams{ + ReservationID: args.ReservationID, + UpdateTimestamp: now, + }, + ) + }) + require.Error(t, err) + _, err = db.GetAssetReservation(ctx, args.ReservationID) + require.ErrorIs(t, err, sql.ErrNoRows) + + count, err := db.CreateAssetReservation(ctx, args) + require.NoError(t, err) + require.EqualValues(t, 1, count) + + // Reusing an ID cannot replace the accepted terms. + changed := args + changed.Amount = 20000 + changed.Fee = 20 + count, err = db.CreateAssetReservation(ctx, changed) + require.NoError(t, err) + require.Zero(t, count) + stored, err := db.GetAssetReservation(ctx, args.ReservationID) + require.NoError(t, err) + require.Equal(t, args.Amount, stored.Amount) + require.Equal(t, args.Fee, stored.Fee) + require.Equal(t, args.RequiredConfirmations, stored.RequiredConfirmations) + + // Store quoted fees on either side of the initial server price. + // A database read must not replace them with the current policy. + for i, fee := range []int64{9, 11} { + quoted := args + quoted.ReservationID = bytes.Repeat([]byte{byte(i + 5)}, 32) + quoted.Fee = fee + count, err := db.CreateAssetReservation(ctx, quoted) + require.NoError(t, err) + require.EqualValues(t, 1, count) + row, err := db.GetAssetReservation(ctx, quoted.ReservationID) + require.NoError(t, err) + require.Equal(t, fee, row.Fee) + } + + // History order comes from its sequence, not wall-clock differences. + for _, state := range []string{"Created", "Waiting"} { + err := db.InsertAssetReservationUpdate(ctx, + sqlc.InsertAssetReservationUpdateParams{ + ReservationID: args.ReservationID, + UpdateState: state, + UpdateTimestamp: now, + }, + ) + require.NoError(t, err) + } + updates, err := db.GetAssetReservationUpdates(ctx, args.ReservationID) + require.NoError(t, err) + require.Len(t, updates, 2) + require.Equal(t, "Created", updates[0].UpdateState) + require.Equal(t, "Waiting", updates[1].UpdateState) + + tests := []struct { + name string + change func(*sqlc.CreateAssetReservationParams) + }{ + { + "short ID", + func(p *sqlc.CreateAssetReservationParams) { + p.ReservationID = []byte{1} + }, + }, + { + "short asset", + func(p *sqlc.CreateAssetReservationParams) { + p.AssetID = []byte{1} + }, + }, + { + "short key", + func(p *sqlc.CreateAssetReservationParams) { + p.ClientPubkey = []byte{1} + }, + }, + { + "zero amount", + func(p *sqlc.CreateAssetReservationParams) { + p.Amount = 0 + }, + }, + { + "zero fee", + func(p *sqlc.CreateAssetReservationParams) { + p.Fee = 0 + }, + }, + { + "negative fee", + func(p *sqlc.CreateAssetReservationParams) { + p.Fee = -1 + }, + }, + { + "fee overflow", + func(p *sqlc.CreateAssetReservationParams) { + p.Amount = 9223372036854775807 + p.Fee = 9223372036854776 + }, + }, + { + "CSV flags", + func(p *sqlc.CreateAssetReservationParams) { + p.CsvDelay |= 1 << 22 + }, + }, + { + "zero depth", + func(p *sqlc.CreateAssetReservationParams) { + p.RequiredConfirmations = 0 + }, + }, + { + "cutoff before confirmation depth", + func(p *sqlc.CreateAssetReservationParams) { + p.ExecutionDelta = 1438 + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + invalid := args + invalid.ReservationID = bytes.Repeat([]byte{9}, 32) + test.change(&invalid) + _, err := db.CreateAssetReservation(ctx, invalid) + require.Error(t, err) + }) + } +} diff --git a/loopdb/sqlc/asset_reservations.sql.go b/loopdb/sqlc/asset_reservations.sql.go new file mode 100644 index 000000000..7a0a09590 --- /dev/null +++ b/loopdb/sqlc/asset_reservations.sql.go @@ -0,0 +1,192 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.25.0 +// source: asset_reservations.sql + +package sqlc + +import ( + "context" + "time" +) + +const createAssetReservation = `-- name: CreateAssetReservation :execrows +INSERT INTO asset_reservations ( + reservation_id, + asset_id, + amount, + fee, + csv_delay, + required_confirmations, + execution_delta, + client_pubkey, + client_key_family, + client_key_index, + created_at, group_key +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, $12 +) ON CONFLICT (reservation_id) DO NOTHING +` + +type CreateAssetReservationParams struct { + ReservationID []byte + AssetID []byte + Amount int64 + Fee int64 + CsvDelay int32 + RequiredConfirmations int32 + ExecutionDelta int32 + ClientPubkey []byte + ClientKeyFamily int32 + ClientKeyIndex int64 + CreatedAt time.Time + GroupKey []byte +} + +func (q *Queries) CreateAssetReservation(ctx context.Context, arg CreateAssetReservationParams) (int64, error) { + result, err := q.db.ExecContext(ctx, createAssetReservation, + arg.ReservationID, + arg.AssetID, + arg.Amount, + arg.Fee, + arg.CsvDelay, + arg.RequiredConfirmations, + arg.ExecutionDelta, + arg.ClientPubkey, + arg.ClientKeyFamily, + arg.ClientKeyIndex, + arg.CreatedAt, + arg.GroupKey, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getAssetReservation = `-- name: GetAssetReservation :one +SELECT id, reservation_id, asset_id, amount, fee, csv_delay, required_confirmations, execution_delta, client_pubkey, client_key_family, client_key_index, created_at, group_key FROM asset_reservations WHERE reservation_id = $1 +` + +func (q *Queries) GetAssetReservation(ctx context.Context, reservationID []byte) (AssetReservation, error) { + row := q.db.QueryRowContext(ctx, getAssetReservation, reservationID) + var i AssetReservation + err := row.Scan( + &i.ID, + &i.ReservationID, + &i.AssetID, + &i.Amount, + &i.Fee, + &i.CsvDelay, + &i.RequiredConfirmations, + &i.ExecutionDelta, + &i.ClientPubkey, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.CreatedAt, + &i.GroupKey, + ) + return i, err +} + +const getAssetReservationUpdates = `-- name: GetAssetReservationUpdates :many +SELECT id, reservation_id, update_state, update_timestamp FROM asset_reservation_updates +WHERE reservation_id = $1 +ORDER BY id +` + +func (q *Queries) GetAssetReservationUpdates(ctx context.Context, reservationID []byte) ([]AssetReservationUpdate, error) { + rows, err := q.db.QueryContext(ctx, getAssetReservationUpdates, reservationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AssetReservationUpdate + for rows.Next() { + var i AssetReservationUpdate + if err := rows.Scan( + &i.ID, + &i.ReservationID, + &i.UpdateState, + &i.UpdateTimestamp, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getAssetReservations = `-- name: GetAssetReservations :many +SELECT id, reservation_id, asset_id, amount, fee, csv_delay, required_confirmations, execution_delta, client_pubkey, client_key_family, client_key_index, created_at, group_key FROM asset_reservations ORDER BY id +` + +func (q *Queries) GetAssetReservations(ctx context.Context) ([]AssetReservation, error) { + rows, err := q.db.QueryContext(ctx, getAssetReservations) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AssetReservation + for rows.Next() { + var i AssetReservation + if err := rows.Scan( + &i.ID, + &i.ReservationID, + &i.AssetID, + &i.Amount, + &i.Fee, + &i.CsvDelay, + &i.RequiredConfirmations, + &i.ExecutionDelta, + &i.ClientPubkey, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.CreatedAt, + &i.GroupKey, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertAssetReservationUpdate = `-- name: InsertAssetReservationUpdate :exec +INSERT INTO asset_reservation_updates ( + reservation_id, update_state, update_timestamp +) VALUES ($1, $2, $3) +` + +type InsertAssetReservationUpdateParams struct { + ReservationID []byte + UpdateState string + UpdateTimestamp time.Time +} + +func (q *Queries) InsertAssetReservationUpdate(ctx context.Context, arg InsertAssetReservationUpdateParams) error { + _, err := q.db.ExecContext(ctx, insertAssetReservationUpdate, arg.ReservationID, arg.UpdateState, arg.UpdateTimestamp) + return err +} diff --git a/loopdb/sqlc/migrations/000024_asset_reservations.down.sql b/loopdb/sqlc/migrations/000024_asset_reservations.down.sql new file mode 100644 index 000000000..2b0492750 --- /dev/null +++ b/loopdb/sqlc/migrations/000024_asset_reservations.down.sql @@ -0,0 +1,2 @@ +DROP TABLE asset_reservation_updates; +DROP TABLE asset_reservations; diff --git a/loopdb/sqlc/migrations/000024_asset_reservations.up.sql b/loopdb/sqlc/migrations/000024_asset_reservations.up.sql new file mode 100644 index 000000000..4f6f78f2b --- /dev/null +++ b/loopdb/sqlc/migrations/000024_asset_reservations.up.sql @@ -0,0 +1,38 @@ +-- Store agreed terms separately from the append-only state history. +CREATE TABLE asset_reservations ( + id INTEGER PRIMARY KEY, + reservation_id BYTEA NOT NULL UNIQUE CHECK (length(reservation_id) = 32), + asset_id BYTEA NOT NULL CHECK (length(asset_id) = 32), + amount BIGINT NOT NULL CHECK (amount > 0), + -- Preserve the accepted quote; pricing policy belongs to the server. + fee BIGINT NOT NULL CHECK (fee >= 0), + csv_delay INTEGER NOT NULL CHECK (csv_delay BETWEEN 0 AND 65535), + required_confirmations INTEGER NOT NULL CHECK (required_confirmations >= 0), + execution_delta INTEGER NOT NULL CHECK (execution_delta >= 0), + client_pubkey BYTEA NOT NULL CHECK (length(client_pubkey) = 33), + client_key_family INTEGER NOT NULL CHECK (client_key_family >= 0), + client_key_index BIGINT NOT NULL + CHECK (client_key_index BETWEEN 0 AND 4294967295), + created_at TIMESTAMP NOT NULL, + group_key BYTEA CHECK (group_key IS NULL OR length(group_key) = 33), + -- 9223372036854775807 is the maximum signed 64-bit integer. + -- Subtract fee so checking that amount + fee fits cannot itself overflow. + CHECK (amount <= 9223372036854775807 - fee), + -- Before the quote, only the requested asset and amount are known. + CHECK ((fee = 0 AND csv_delay = 0 AND required_confirmations = 0 + AND execution_delta = 0) + OR (fee > 0 AND csv_delay > 0 AND required_confirmations > 0 + AND execution_delta > 0)), + CHECK (CAST(required_confirmations AS BIGINT) + + execution_delta <= csv_delay) +); + +CREATE TABLE asset_reservation_updates ( + id INTEGER PRIMARY KEY, + reservation_id BYTEA NOT NULL REFERENCES asset_reservations(reservation_id), + update_state TEXT NOT NULL CHECK (length(update_state) > 0), + update_timestamp TIMESTAMP NOT NULL +); + +CREATE INDEX asset_reservation_updates_id_idx + ON asset_reservation_updates(reservation_id, id); diff --git a/loopdb/sqlc/models.go b/loopdb/sqlc/models.go index b6385ebb7..94fddae12 100644 --- a/loopdb/sqlc/models.go +++ b/loopdb/sqlc/models.go @@ -9,6 +9,29 @@ import ( "time" ) +type AssetReservation struct { + ID int32 + ReservationID []byte + AssetID []byte + Amount int64 + Fee int64 + CsvDelay int32 + RequiredConfirmations int32 + ExecutionDelta int32 + ClientPubkey []byte + ClientKeyFamily int32 + ClientKeyIndex int64 + CreatedAt time.Time + GroupKey []byte +} + +type AssetReservationUpdate struct { + ID int32 + ReservationID []byte + UpdateState string + UpdateTimestamp time.Time +} + type Deposit struct { ID int32 DepositID []byte diff --git a/loopdb/sqlc/querier.go b/loopdb/sqlc/querier.go index d173eda84..981ce7832 100644 --- a/loopdb/sqlc/querier.go +++ b/loopdb/sqlc/querier.go @@ -13,6 +13,7 @@ type Querier interface { AllDeposits(ctx context.Context) ([]Deposit, error) AllStaticAddresses(ctx context.Context) ([]StaticAddress, error) CancelBatch(ctx context.Context, id int32) error + CreateAssetReservation(ctx context.Context, arg CreateAssetReservationParams) (int64, error) CreateDeposit(ctx context.Context, arg CreateDepositParams) error CreateReservation(ctx context.Context, arg CreateReservationParams) error CreateStaticAddress(ctx context.Context, arg CreateStaticAddressParams) error @@ -23,6 +24,9 @@ type Querier interface { DepositsForSwapHash(ctx context.Context, swapHash []byte) ([]DepositsForSwapHashRow, error) FetchLiquidityParams(ctx context.Context) ([]byte, error) GetAllWithdrawals(ctx context.Context) ([]Withdrawal, error) + GetAssetReservation(ctx context.Context, reservationID []byte) (AssetReservation, error) + GetAssetReservationUpdates(ctx context.Context, reservationID []byte) ([]AssetReservationUpdate, error) + GetAssetReservations(ctx context.Context) ([]AssetReservation, error) GetBatchSweeps(ctx context.Context, batchID int32) ([]Sweep, error) GetBatchSweptAmount(ctx context.Context, batchID int32) (int64, error) GetDeposit(ctx context.Context, depositID []byte) (Deposit, error) @@ -50,6 +54,7 @@ type Querier interface { GetUnconfirmedBatches(ctx context.Context) ([]SweepBatch, error) GetWithdrawalDeposits(ctx context.Context, withdrawalID []byte) ([][]byte, error) GetWithdrawalIDByDepositID(ctx context.Context, depositID []byte) ([]byte, error) + InsertAssetReservationUpdate(ctx context.Context, arg InsertAssetReservationUpdateParams) error InsertBatch(ctx context.Context, arg InsertBatchParams) (int32, error) InsertDepositUpdate(ctx context.Context, arg InsertDepositUpdateParams) error InsertHtlcKeys(ctx context.Context, arg InsertHtlcKeysParams) error diff --git a/loopdb/sqlc/queries/asset_reservations.sql b/loopdb/sqlc/queries/asset_reservations.sql new file mode 100644 index 000000000..5e354fad5 --- /dev/null +++ b/loopdb/sqlc/queries/asset_reservations.sql @@ -0,0 +1,42 @@ +-- name: CreateAssetReservation :execrows +INSERT INTO asset_reservations ( + reservation_id, + asset_id, + amount, + fee, + csv_delay, + required_confirmations, + execution_delta, + client_pubkey, + client_key_family, + client_key_index, + created_at, group_key +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, $12 +) ON CONFLICT (reservation_id) DO NOTHING; + +-- name: GetAssetReservation :one +SELECT * FROM asset_reservations WHERE reservation_id = $1; + +-- name: GetAssetReservations :many +SELECT * FROM asset_reservations ORDER BY id; + +-- name: InsertAssetReservationUpdate :exec +INSERT INTO asset_reservation_updates ( + reservation_id, update_state, update_timestamp +) VALUES ($1, $2, $3); + +-- name: GetAssetReservationUpdates :many +SELECT * FROM asset_reservation_updates +WHERE reservation_id = $1 +ORDER BY id; From 644b4a427775ae617b3b80a301b1913f2ac7e4c9 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 13:54:29 +0200 Subject: [PATCH 13/25] loopdb: Add fields for reservation purchase recovery Store the quote, main probe, SkipProbe preference, prepay routing cap, payment identity and result, outpoint, credit, and proof. Read the agreed amounts from the saved quote. Enforce unique funding outpoints and test the transition from an unquoted row. Persist the sole hold probe request, paying node, deadline, and terminal result alongside the reservation purchase. Recovery resumes that payment without replacing the quote or registering another probe. Persist whether a live probe route supplied the fee estimate, so recovery distinguishes missing route history from a zero-fee route. --- loopdb/asset_reservations_test.go | 39 +++++ loopdb/sqlc/asset_reservations.sql.go | 136 +++++++++++++++++- ...000025_asset_reservation_purchase.down.sql | 16 +++ .../000025_asset_reservation_purchase.up.sql | 28 ++++ .../000026_reservation_hold_probe.down.sql | 4 + .../000026_reservation_hold_probe.up.sql | 4 + .../000027_reservation_probe_fee.down.sql | 1 + .../000027_reservation_probe_fee.up.sql | 1 + loopdb/sqlc/models.go | 20 +++ loopdb/sqlc/querier.go | 1 + loopdb/sqlc/queries/asset_reservations.sql | 28 ++++ 11 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 loopdb/sqlc/migrations/000025_asset_reservation_purchase.down.sql create mode 100644 loopdb/sqlc/migrations/000025_asset_reservation_purchase.up.sql create mode 100644 loopdb/sqlc/migrations/000026_reservation_hold_probe.down.sql create mode 100644 loopdb/sqlc/migrations/000026_reservation_hold_probe.up.sql create mode 100644 loopdb/sqlc/migrations/000027_reservation_probe_fee.down.sql create mode 100644 loopdb/sqlc/migrations/000027_reservation_probe_fee.up.sql diff --git a/loopdb/asset_reservations_test.go b/loopdb/asset_reservations_test.go index d9787e7ff..301080312 100644 --- a/loopdb/asset_reservations_test.go +++ b/loopdb/asset_reservations_test.go @@ -170,3 +170,42 @@ func TestAssetReservationSchema(t *testing.T) { }) } } + +func TestAssetReservationPendingQuote(t *testing.T) { + db := loopdb.NewTestDB(t) + ctx := t.Context() + id := bytes.Repeat([]byte{7}, 32) + _, err := db.CreateAssetReservation(ctx, + sqlc.CreateAssetReservationParams{ + ReservationID: id, + AssetID: bytes.Repeat([]byte{2}, 32), + Amount: 10000, + ClientPubkey: bytes.Repeat([]byte{3}, 33), + CreatedAt: time.Now().UTC(), + }, + ) + require.NoError(t, err) + row, err := db.GetAssetReservation(ctx, id) + require.NoError(t, err) + require.Zero(t, row.Fee) + require.Empty(t, row.Quote) + + // Quote terms arrive as one complete set, never as partial defaults. + count, err := db.UpdateAssetReservationPurchase(ctx, + sqlc.UpdateAssetReservationPurchaseParams{ + ReservationID: id, + Fee: 11, + CsvDelay: 1440, + RequiredConfirmations: 3, + ExecutionDelta: 90, + Quote: []byte{1, 2, 3}, + }, + ) + require.NoError(t, err) + require.EqualValues(t, 1, count) + row, err = db.GetAssetReservation(ctx, id) + require.NoError(t, err) + require.EqualValues(t, 11, row.Fee) + require.EqualValues(t, 3, row.RequiredConfirmations) + require.Equal(t, []byte{1, 2, 3}, row.Quote) +} diff --git a/loopdb/sqlc/asset_reservations.sql.go b/loopdb/sqlc/asset_reservations.sql.go index 7a0a09590..0968e2854 100644 --- a/loopdb/sqlc/asset_reservations.sql.go +++ b/loopdb/sqlc/asset_reservations.sql.go @@ -7,6 +7,7 @@ package sqlc import ( "context" + "database/sql" "time" ) @@ -75,7 +76,7 @@ func (q *Queries) CreateAssetReservation(ctx context.Context, arg CreateAssetRes } const getAssetReservation = `-- name: GetAssetReservation :one -SELECT id, reservation_id, asset_id, amount, fee, csv_delay, required_confirmations, execution_delta, client_pubkey, client_key_family, client_key_index, created_at, group_key FROM asset_reservations WHERE reservation_id = $1 +SELECT id, reservation_id, asset_id, amount, fee, csv_delay, required_confirmations, execution_delta, client_pubkey, client_key_family, client_key_index, created_at, group_key, quote, max_route_fee_msat, skip_probe, main_probe, probes_checked_at, prepay_route_fee_msat, main_route_fee_msat, payment_hash, paying_node_key, payment_request, payment_result, funding_outpoint, confirmation_height, prepay_credit, deposit_proof, probe_node_key, probe_request, probe_result, probe_deadline, probe_fee_known FROM asset_reservations WHERE reservation_id = $1 ` func (q *Queries) GetAssetReservation(ctx context.Context, reservationID []byte) (AssetReservation, error) { @@ -95,6 +96,26 @@ func (q *Queries) GetAssetReservation(ctx context.Context, reservationID []byte) &i.ClientKeyIndex, &i.CreatedAt, &i.GroupKey, + &i.Quote, + &i.MaxRouteFeeMsat, + &i.SkipProbe, + &i.MainProbe, + &i.ProbesCheckedAt, + &i.PrepayRouteFeeMsat, + &i.MainRouteFeeMsat, + &i.PaymentHash, + &i.PayingNodeKey, + &i.PaymentRequest, + &i.PaymentResult, + &i.FundingOutpoint, + &i.ConfirmationHeight, + &i.PrepayCredit, + &i.DepositProof, + &i.ProbeNodeKey, + &i.ProbeRequest, + &i.ProbeResult, + &i.ProbeDeadline, + &i.ProbeFeeKnown, ) return i, err } @@ -134,7 +155,7 @@ func (q *Queries) GetAssetReservationUpdates(ctx context.Context, reservationID } const getAssetReservations = `-- name: GetAssetReservations :many -SELECT id, reservation_id, asset_id, amount, fee, csv_delay, required_confirmations, execution_delta, client_pubkey, client_key_family, client_key_index, created_at, group_key FROM asset_reservations ORDER BY id +SELECT id, reservation_id, asset_id, amount, fee, csv_delay, required_confirmations, execution_delta, client_pubkey, client_key_family, client_key_index, created_at, group_key, quote, max_route_fee_msat, skip_probe, main_probe, probes_checked_at, prepay_route_fee_msat, main_route_fee_msat, payment_hash, paying_node_key, payment_request, payment_result, funding_outpoint, confirmation_height, prepay_credit, deposit_proof, probe_node_key, probe_request, probe_result, probe_deadline, probe_fee_known FROM asset_reservations ORDER BY id ` func (q *Queries) GetAssetReservations(ctx context.Context) ([]AssetReservation, error) { @@ -160,6 +181,26 @@ func (q *Queries) GetAssetReservations(ctx context.Context) ([]AssetReservation, &i.ClientKeyIndex, &i.CreatedAt, &i.GroupKey, + &i.Quote, + &i.MaxRouteFeeMsat, + &i.SkipProbe, + &i.MainProbe, + &i.ProbesCheckedAt, + &i.PrepayRouteFeeMsat, + &i.MainRouteFeeMsat, + &i.PaymentHash, + &i.PayingNodeKey, + &i.PaymentRequest, + &i.PaymentResult, + &i.FundingOutpoint, + &i.ConfirmationHeight, + &i.PrepayCredit, + &i.DepositProof, + &i.ProbeNodeKey, + &i.ProbeRequest, + &i.ProbeResult, + &i.ProbeDeadline, + &i.ProbeFeeKnown, ); err != nil { return nil, err } @@ -190,3 +231,94 @@ func (q *Queries) InsertAssetReservationUpdate(ctx context.Context, arg InsertAs _, err := q.db.ExecContext(ctx, insertAssetReservationUpdate, arg.ReservationID, arg.UpdateState, arg.UpdateTimestamp) return err } + +const updateAssetReservationPurchase = `-- name: UpdateAssetReservationPurchase :execrows +UPDATE asset_reservations SET + fee = $2, + csv_delay = $3, + required_confirmations = $4, + execution_delta = $5, + quote = $6, + max_route_fee_msat = $7, + main_probe = $8, + probes_checked_at = $9, + prepay_route_fee_msat = $10, + main_route_fee_msat = $11, + payment_hash = $12, + paying_node_key = $13, + payment_request = $14, + payment_result = $15, + funding_outpoint = $16, + confirmation_height = $17, + prepay_credit = $18, + deposit_proof = $19, + skip_probe = $20, + probe_node_key = $21, + probe_request = $22, + probe_result = $23, + probe_deadline = $24, + probe_fee_known = $25 +WHERE reservation_id = $1 +` + +type UpdateAssetReservationPurchaseParams struct { + ReservationID []byte + Fee int64 + CsvDelay int32 + RequiredConfirmations int32 + ExecutionDelta int32 + Quote []byte + MaxRouteFeeMsat int64 + MainProbe int32 + ProbesCheckedAt sql.NullTime + PrepayRouteFeeMsat int64 + MainRouteFeeMsat int64 + PaymentHash []byte + PayingNodeKey []byte + PaymentRequest []byte + PaymentResult []byte + FundingOutpoint sql.NullString + ConfirmationHeight int64 + PrepayCredit int64 + DepositProof []byte + SkipProbe bool + ProbeNodeKey []byte + ProbeRequest []byte + ProbeResult []byte + ProbeDeadline sql.NullTime + ProbeFeeKnown bool +} + +func (q *Queries) UpdateAssetReservationPurchase(ctx context.Context, arg UpdateAssetReservationPurchaseParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateAssetReservationPurchase, + arg.ReservationID, + arg.Fee, + arg.CsvDelay, + arg.RequiredConfirmations, + arg.ExecutionDelta, + arg.Quote, + arg.MaxRouteFeeMsat, + arg.MainProbe, + arg.ProbesCheckedAt, + arg.PrepayRouteFeeMsat, + arg.MainRouteFeeMsat, + arg.PaymentHash, + arg.PayingNodeKey, + arg.PaymentRequest, + arg.PaymentResult, + arg.FundingOutpoint, + arg.ConfirmationHeight, + arg.PrepayCredit, + arg.DepositProof, + arg.SkipProbe, + arg.ProbeNodeKey, + arg.ProbeRequest, + arg.ProbeResult, + arg.ProbeDeadline, + arg.ProbeFeeKnown, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/loopdb/sqlc/migrations/000025_asset_reservation_purchase.down.sql b/loopdb/sqlc/migrations/000025_asset_reservation_purchase.down.sql new file mode 100644 index 000000000..66194ad61 --- /dev/null +++ b/loopdb/sqlc/migrations/000025_asset_reservation_purchase.down.sql @@ -0,0 +1,16 @@ +DROP INDEX asset_reservations_outpoint_idx; +ALTER TABLE asset_reservations DROP COLUMN deposit_proof; +ALTER TABLE asset_reservations DROP COLUMN prepay_credit; +ALTER TABLE asset_reservations DROP COLUMN confirmation_height; +ALTER TABLE asset_reservations DROP COLUMN funding_outpoint; +ALTER TABLE asset_reservations DROP COLUMN payment_result; +ALTER TABLE asset_reservations DROP COLUMN payment_request; +ALTER TABLE asset_reservations DROP COLUMN paying_node_key; +ALTER TABLE asset_reservations DROP COLUMN payment_hash; +ALTER TABLE asset_reservations DROP COLUMN main_route_fee_msat; +ALTER TABLE asset_reservations DROP COLUMN prepay_route_fee_msat; +ALTER TABLE asset_reservations DROP COLUMN probes_checked_at; +ALTER TABLE asset_reservations DROP COLUMN main_probe; +ALTER TABLE asset_reservations DROP COLUMN skip_probe; +ALTER TABLE asset_reservations DROP COLUMN max_route_fee_msat; +ALTER TABLE asset_reservations DROP COLUMN quote; diff --git a/loopdb/sqlc/migrations/000025_asset_reservation_purchase.up.sql b/loopdb/sqlc/migrations/000025_asset_reservation_purchase.up.sql new file mode 100644 index 000000000..7718db54c --- /dev/null +++ b/loopdb/sqlc/migrations/000025_asset_reservation_purchase.up.sql @@ -0,0 +1,28 @@ +-- Each field records one purchase fact, not an opaque FSM checkpoint. +ALTER TABLE asset_reservations ADD COLUMN quote BYTEA; +ALTER TABLE asset_reservations ADD COLUMN max_route_fee_msat BIGINT NOT NULL DEFAULT 0 + CHECK (max_route_fee_msat >= 0); +ALTER TABLE asset_reservations ADD COLUMN skip_probe BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE asset_reservations ADD COLUMN main_probe INTEGER NOT NULL DEFAULT 0 + CHECK (main_probe BETWEEN 0 AND 4); +ALTER TABLE asset_reservations ADD COLUMN probes_checked_at TIMESTAMP; +-- Approximate fee scaled from the main probe; ignores fixed hop fees. +ALTER TABLE asset_reservations ADD COLUMN prepay_route_fee_msat BIGINT NOT NULL DEFAULT 0 + CHECK (prepay_route_fee_msat >= 0); +ALTER TABLE asset_reservations ADD COLUMN main_route_fee_msat BIGINT NOT NULL DEFAULT 0 + CHECK (main_route_fee_msat >= 0); +ALTER TABLE asset_reservations ADD COLUMN payment_hash BYTEA + CHECK (payment_hash IS NULL OR length(payment_hash) = 32); +ALTER TABLE asset_reservations ADD COLUMN paying_node_key BYTEA + CHECK (paying_node_key IS NULL OR length(paying_node_key) = 33); +ALTER TABLE asset_reservations ADD COLUMN payment_request BYTEA; +ALTER TABLE asset_reservations ADD COLUMN payment_result BYTEA; +ALTER TABLE asset_reservations ADD COLUMN funding_outpoint TEXT; +ALTER TABLE asset_reservations ADD COLUMN confirmation_height BIGINT NOT NULL DEFAULT 0 + CHECK (confirmation_height BETWEEN 0 AND 2147483647); +ALTER TABLE asset_reservations ADD COLUMN prepay_credit BIGINT NOT NULL DEFAULT 0 + CHECK (prepay_credit >= 0); +ALTER TABLE asset_reservations ADD COLUMN deposit_proof BYTEA; + +CREATE UNIQUE INDEX asset_reservations_outpoint_idx + ON asset_reservations(funding_outpoint); diff --git a/loopdb/sqlc/migrations/000026_reservation_hold_probe.down.sql b/loopdb/sqlc/migrations/000026_reservation_hold_probe.down.sql new file mode 100644 index 000000000..39db09dab --- /dev/null +++ b/loopdb/sqlc/migrations/000026_reservation_hold_probe.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE asset_reservations DROP COLUMN probe_deadline; +ALTER TABLE asset_reservations DROP COLUMN probe_result; +ALTER TABLE asset_reservations DROP COLUMN probe_request; +ALTER TABLE asset_reservations DROP COLUMN probe_node_key; diff --git a/loopdb/sqlc/migrations/000026_reservation_hold_probe.up.sql b/loopdb/sqlc/migrations/000026_reservation_hold_probe.up.sql new file mode 100644 index 000000000..5ba1d202f --- /dev/null +++ b/loopdb/sqlc/migrations/000026_reservation_hold_probe.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE asset_reservations ADD COLUMN probe_node_key BLOB; +ALTER TABLE asset_reservations ADD COLUMN probe_request BLOB; +ALTER TABLE asset_reservations ADD COLUMN probe_result BLOB; +ALTER TABLE asset_reservations ADD COLUMN probe_deadline TIMESTAMP; diff --git a/loopdb/sqlc/migrations/000027_reservation_probe_fee.down.sql b/loopdb/sqlc/migrations/000027_reservation_probe_fee.down.sql new file mode 100644 index 000000000..068b9227c --- /dev/null +++ b/loopdb/sqlc/migrations/000027_reservation_probe_fee.down.sql @@ -0,0 +1 @@ +ALTER TABLE asset_reservations DROP COLUMN probe_fee_known; diff --git a/loopdb/sqlc/migrations/000027_reservation_probe_fee.up.sql b/loopdb/sqlc/migrations/000027_reservation_probe_fee.up.sql new file mode 100644 index 000000000..f6565a00a --- /dev/null +++ b/loopdb/sqlc/migrations/000027_reservation_probe_fee.up.sql @@ -0,0 +1 @@ +ALTER TABLE asset_reservations ADD COLUMN probe_fee_known BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/loopdb/sqlc/models.go b/loopdb/sqlc/models.go index 94fddae12..9e805460c 100644 --- a/loopdb/sqlc/models.go +++ b/loopdb/sqlc/models.go @@ -23,6 +23,26 @@ type AssetReservation struct { ClientKeyIndex int64 CreatedAt time.Time GroupKey []byte + Quote []byte + MaxRouteFeeMsat int64 + SkipProbe bool + MainProbe int32 + ProbesCheckedAt sql.NullTime + PrepayRouteFeeMsat int64 + MainRouteFeeMsat int64 + PaymentHash []byte + PayingNodeKey []byte + PaymentRequest []byte + PaymentResult []byte + FundingOutpoint sql.NullString + ConfirmationHeight int64 + PrepayCredit int64 + DepositProof []byte + ProbeNodeKey []byte + ProbeRequest []byte + ProbeResult []byte + ProbeDeadline sql.NullTime + ProbeFeeKnown bool } type AssetReservationUpdate struct { diff --git a/loopdb/sqlc/querier.go b/loopdb/sqlc/querier.go index 981ce7832..c506df619 100644 --- a/loopdb/sqlc/querier.go +++ b/loopdb/sqlc/querier.go @@ -76,6 +76,7 @@ type Querier interface { RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error SetDepositOnchainCost(ctx context.Context, arg SetDepositOnchainCostParams) error SwapHashForDepositID(ctx context.Context, depositID []byte) ([]byte, error) + UpdateAssetReservationPurchase(ctx context.Context, arg UpdateAssetReservationPurchaseParams) (int64, error) UpdateBatch(ctx context.Context, arg UpdateBatchParams) error UpdateDeposit(ctx context.Context, arg UpdateDepositParams) error UpdateInstantOut(ctx context.Context, arg UpdateInstantOutParams) error diff --git a/loopdb/sqlc/queries/asset_reservations.sql b/loopdb/sqlc/queries/asset_reservations.sql index 5e354fad5..6dfc69e9e 100644 --- a/loopdb/sqlc/queries/asset_reservations.sql +++ b/loopdb/sqlc/queries/asset_reservations.sql @@ -40,3 +40,31 @@ INSERT INTO asset_reservation_updates ( SELECT * FROM asset_reservation_updates WHERE reservation_id = $1 ORDER BY id; + +-- name: UpdateAssetReservationPurchase :execrows +UPDATE asset_reservations SET + fee = $2, + csv_delay = $3, + required_confirmations = $4, + execution_delta = $5, + quote = $6, + max_route_fee_msat = $7, + main_probe = $8, + probes_checked_at = $9, + prepay_route_fee_msat = $10, + main_route_fee_msat = $11, + payment_hash = $12, + paying_node_key = $13, + payment_request = $14, + payment_result = $15, + funding_outpoint = $16, + confirmation_height = $17, + prepay_credit = $18, + deposit_proof = $19, + skip_probe = $20, + probe_node_key = $21, + probe_request = $22, + probe_result = $23, + probe_deadline = $24, + probe_fee_known = $25 +WHERE reservation_id = $1; From c0b2a58ff546999175b3259b0511342765cd1cf5 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 11:14:34 +0200 Subject: [PATCH 14/25] assets/reservation: Save terms and state atomically Add a typed SQL store that saves a purchase and its first state in one transaction. An identical create request returns the saved progress; a reused ID with different terms or keys fails. Updates preserve the agreed terms. Use the SQLite and PostgreSQL database abstraction and test concurrent creation, failed writes, invalid records, and reopening a SQLite database. Persist and compare the original exact-issuance or group selector. --- assets/reservation/interface.go | 42 +++++ assets/reservation/reservation.go | 62 +++++++ assets/reservation/selector.go | 64 +++++++ assets/reservation/sql_store.go | 236 ++++++++++++++++++++++++ assets/reservation/sql_store_test.go | 155 ++++++++++++++++ assets/reservation/sqlite_store_test.go | 29 +++ 6 files changed, 588 insertions(+) create mode 100644 assets/reservation/interface.go create mode 100644 assets/reservation/reservation.go create mode 100644 assets/reservation/selector.go create mode 100644 assets/reservation/sql_store.go create mode 100644 assets/reservation/sql_store_test.go create mode 100644 assets/reservation/sqlite_store_test.go diff --git a/assets/reservation/interface.go b/assets/reservation/interface.go new file mode 100644 index 000000000..7caabf742 --- /dev/null +++ b/assets/reservation/interface.go @@ -0,0 +1,42 @@ +package reservation + +import ( + "context" + "errors" +) + +var ( + // ErrNotFound means the requested reservation is unavailable. + ErrNotFound = errors.New("asset reservation not found") + + // ErrRequestConflict means an ID was reused with different terms or keys. + ErrRequestConflict = errors.New("asset reservation request conflict") +) + +// Store persists reservations and their state history. The manager serializes +// updates to each reservation; the store never changes agreed terms. +type Store interface { + // CreateReservation saves a reservation and its initial state history + // atomically. Repeating a matching request loads its saved progress into + // the supplied reservation; a conflicting request returns ErrRequestConflict. + // On success, the supplied reservation includes its stored timestamps. + CreateReservation(context.Context, *Reservation) error + + // UpdateReservation saves progress and a state history entry atomically. + // It preserves agreed terms and client keys, returning ErrRequestConflict + // if they change, or ErrNotFound if the ID is absent. On success, the + // supplied reservation includes its stored timestamps. + UpdateReservation(context.Context, *Reservation) error + + // GetReservation loads a validated snapshot of the reservation's terms + // and latest state in one transaction. It returns + // ErrNotFound if the ID is absent. Changing the returned value does not + // change storage until UpdateReservation succeeds. + GetReservation(context.Context, ID) (*Reservation, error) + + // GetReservations loads validated snapshots of all saved reservations in + // one transaction, including canceled and expired purchases. The manager + // selects which ones need recovery. Invalid stored data fails the whole + // read so recovery cannot silently omit a reservation. + GetReservations(context.Context) ([]*Reservation, error) +} diff --git a/assets/reservation/reservation.go b/assets/reservation/reservation.go new file mode 100644 index 000000000..4a203e718 --- /dev/null +++ b/assets/reservation/reservation.go @@ -0,0 +1,62 @@ +package reservation + +import ( + "crypto/rand" + "errors" + "math" + "time" + + "github.com/lightninglabs/loop/fsm" + "github.com/lightningnetwork/lnd/keychain" +) + +// ID identifies a purchase before and after its funding outpoint exists. +type ID [32]byte + +// NewID returns a random reservation ID, also used to deduplicate requests. +func NewID() (ID, error) { + var id ID + _, err := rand.Read(id[:]) + return id, err +} + +// Reservation holds the client's agreed terms and progress. The FSM owns the +// live record; status readers use copies loaded from the store. +type Reservation struct { + // Terms holds the requested asset and amount, then the quoted fee and + // lifetime once a quote is validated. + Terms + + // GroupKey preserves the original selector after choosing an issuance. + GroupKey [33]byte + + // ID identifies this purchase across retries and restarts. + ID ID + + // ClientKey identifies the local signing key and how to derive it again. + ClientKey keychain.KeyDescriptor + + // State records the client's progress, independently of server status. + State fsm.StateType + + // CreatedAt records when the purchase was first stored. + CreatedAt time.Time + + // UpdatedAt records when the stored purchase last changed. + UpdatedAt time.Time +} + +// Validate checks the record before storage. State transitions belong to the +// FSM; the store only requires a named state. +func (r *Reservation) Validate() error { + if r == nil || r.ID == (ID{}) || r.State == fsm.EmptyState || + r.ClientKey.PubKey == nil || r.ClientKey.Family > math.MaxInt32 { + + return errors.New("invalid asset reservation") + } + + if _, err := ParseAssetSelector(r.selector().Bytes()); err != nil { + return err + } + return r.Terms.Validate() +} diff --git a/assets/reservation/selector.go b/assets/reservation/selector.go new file mode 100644 index 000000000..ba476eb2f --- /dev/null +++ b/assets/reservation/selector.go @@ -0,0 +1,64 @@ +package reservation + +import ( + "bytes" + "errors" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/loop/swapkit/tapsdk" +) + +// AssetSelector is the immutable client choice. A group permits a fungible +// member to be chosen independently for the funding and Lightning payments. +type AssetSelector struct { + AssetID [32]byte + GroupKey [33]byte +} + +// ParseAssetSelector requires exclusively an exact issuance or a canonical +// compressed, tweaked group key (the key returned by tapd). +func ParseAssetSelector(id, group []byte) (AssetSelector, error) { + switch { + case len(id) == 32 && len(group) == 0 && [32]byte(id) != ([32]byte{}): + return AssetSelector{AssetID: [32]byte(id)}, nil + case len(id) == 0 && len(group) == 33: + key, err := btcec.ParsePubKey(group) + if err == nil && bytes.Equal(key.SerializeCompressed(), group) { + return AssetSelector{GroupKey: [33]byte(group)}, nil + } + } + return AssetSelector{}, errors.New("specify one asset ID or compressed group key") +} + +// ProtocolVersion preserves the original wire protocol for exact issuances. +func (s AssetSelector) ProtocolVersion() uint32 { + if s.GroupKey != ([33]byte{}) { + return 1 + } + return 0 +} + +// Bytes returns the mutually exclusive wire representation of the selector. +func (s AssetSelector) Bytes() ([]byte, []byte) { + var id, group []byte + if s.AssetID != ([32]byte{}) { + id = s.AssetID[:] + } + if s.GroupKey != ([33]byte{}) { + group = s.GroupKey[:] + } + return id, group +} + +func (r *Reservation) selector() AssetSelector { + if r.GroupKey != ([33]byte{}) { + return AssetSelector{GroupKey: r.GroupKey} + } + return AssetSelector{AssetID: r.AssetID} +} + +// SameGroupKey compares canonical compressed keys using the x-only group +// identity used by tapd's universe and Schnorr verification. +func SameGroupKey(a, b []byte) bool { + return tapsdk.SameGroupKey(a, b) +} diff --git a/assets/reservation/sql_store.go b/assets/reservation/sql_store.go new file mode 100644 index 000000000..b9d58fb00 --- /dev/null +++ b/assets/reservation/sql_store.go @@ -0,0 +1,236 @@ +package reservation + +import ( + "context" + "database/sql" + "errors" + "math" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/keychain" +) + +// SqlStore stores client reservations in SQLite or PostgreSQL. +type SqlStore struct { + db *loopdb.BaseDB + clock clock.Clock +} + +var _ Store = (*SqlStore)(nil) + +// NewSqlStore returns a reservation store using the client's existing database. +func NewSqlStore(db *loopdb.BaseDB) *SqlStore { + return &SqlStore{ + db: db, + clock: clock.NewDefaultClock(), + } +} + +// CreateReservation saves agreed terms and the first state together. An +// identical request returns the saved record, including its current progress. +func (s *SqlStore) CreateReservation(ctx context.Context, r *Reservation) error { + if err := r.Validate(); err != nil { + return err + } + + next := *r + next.CreatedAt = s.clock.Now().UTC().Truncate(time.Microsecond) + next.UpdatedAt = next.CreatedAt + err := s.db.ExecTx(ctx, &loopdb.SqliteTxOptions{}, + func(q *sqlc.Queries) error { + _, group := r.selector().Bytes() + count, err := q.CreateAssetReservation(ctx, + sqlc.CreateAssetReservationParams{ + ReservationID: r.ID[:], + GroupKey: group, + AssetID: r.AssetID[:], + Amount: int64(r.Amount), + Fee: int64(r.Fee), + CsvDelay: int32(r.CSVDelay), + RequiredConfirmations: int32(r.RequiredConfirmations), + ExecutionDelta: int32(r.ExecutionDelta), + ClientPubkey: r.ClientKey.PubKey.SerializeCompressed(), + ClientKeyFamily: int32(r.ClientKey.Family), + ClientKeyIndex: int64(r.ClientKey.Index), + CreatedAt: next.CreatedAt, + }, + ) + if err != nil { + return err + } + if count == 0 { + stored, err := loadReservation(ctx, q, r.ID) + if err != nil { + return err + } + if !sameRequest(r, stored) { + return ErrRequestConflict + } + next = *stored + return nil + } + + return insertUpdate(ctx, q, &next) + }) + if err == nil { + *r = next + } + return err +} + +// UpdateReservation saves progress without changing the agreed terms. A +// failed transaction leaves the caller's timestamps unchanged. +func (s *SqlStore) UpdateReservation(ctx context.Context, r *Reservation) error { + if err := r.Validate(); err != nil { + return err + } + next := *r + next.UpdatedAt = s.clock.Now().UTC().Truncate(time.Microsecond) + err := s.db.ExecTx(ctx, &loopdb.SqliteTxOptions{}, + func(q *sqlc.Queries) error { + stored, err := loadReservation(ctx, q, r.ID) + if err != nil { + return err + } + if !sameRequest(r, stored) { + return ErrRequestConflict + } + next.CreatedAt = stored.CreatedAt + return insertUpdate(ctx, q, &next) + }) + if err == nil { + *r = next + } + return err +} + +// GetReservation loads the agreed terms and latest state in one transaction. +func (s *SqlStore) GetReservation(ctx context.Context, id ID) (*Reservation, + error) { + + var result *Reservation + err := s.db.ExecTx(ctx, loopdb.NewSqlReadOpts(), + func(q *sqlc.Queries) error { + var err error + result, err = loadReservation(ctx, q, id) + return err + }) + return result, err +} + +// GetReservations loads saved records for status and manager recovery. +func (s *SqlStore) GetReservations(ctx context.Context) ([]*Reservation, error) { + var result []*Reservation + err := s.db.ExecTx(ctx, loopdb.NewSqlReadOpts(), + func(q *sqlc.Queries) error { + rows, err := q.GetAssetReservations(ctx) + if err != nil { + return err + } + for _, row := range rows { + updates, err := q.GetAssetReservationUpdates( + ctx, row.ReservationID, + ) + if err != nil { + return err + } + r, err := toReservation(row, updates) + if err != nil { + return err + } + result = append(result, r) + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func insertUpdate(ctx context.Context, q *sqlc.Queries, r *Reservation) error { + return q.InsertAssetReservationUpdate(ctx, + sqlc.InsertAssetReservationUpdateParams{ + ReservationID: r.ID[:], + UpdateState: string(r.State), + UpdateTimestamp: r.UpdatedAt, + }, + ) +} + +func loadReservation(ctx context.Context, q *sqlc.Queries, id ID) (*Reservation, + error) { + + row, err := q.GetAssetReservation(ctx, id[:]) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + updates, err := q.GetAssetReservationUpdates(ctx, id[:]) + if err != nil { + return nil, err + } + return toReservation(row, updates) +} + +func toReservation(row sqlc.AssetReservation, + updates []sqlc.AssetReservationUpdate) (*Reservation, error) { + + if len(row.ReservationID) != 32 || len(row.AssetID) != 32 || + len(row.ClientPubkey) != btcec.PubKeyBytesLenCompressed || + len(updates) == 0 || row.ClientKeyIndex < 0 || + row.ClientKeyIndex > math.MaxUint32 { + + return nil, errors.New("invalid stored asset reservation") + } + pubkey, err := btcec.ParsePubKey(row.ClientPubkey) + if err != nil { + return nil, err + } + latest := updates[len(updates)-1] + r := &Reservation{ + ID: ID(row.ReservationID), + Terms: Terms{ + AssetID: [32]byte(row.AssetID), + Amount: uint64(row.Amount), + Fee: uint64(row.Fee), + CSVDelay: uint32(row.CsvDelay), + RequiredConfirmations: uint32(row.RequiredConfirmations), + ExecutionDelta: uint32(row.ExecutionDelta), + }, + ClientKey: keychain.KeyDescriptor{ + PubKey: pubkey, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily(row.ClientKeyFamily), + Index: uint32(row.ClientKeyIndex), + }, + }, + State: fsm.StateType(latest.UpdateState), + CreatedAt: row.CreatedAt.UTC(), + UpdatedAt: latest.UpdateTimestamp.UTC(), + } + if len(row.GroupKey) != 0 { + selector, err := ParseAssetSelector(nil, row.GroupKey) + if err != nil { + return nil, err + } + r.GroupKey = selector.GroupKey + } + if err := r.Validate(); err != nil { + return nil, err + } + return r, nil +} + +func sameRequest(a, b *Reservation) bool { + return a.ID == b.ID && a.Terms == b.Terms && a.GroupKey == b.GroupKey && + a.ClientKey.KeyLocator == b.ClientKey.KeyLocator && + a.ClientKey.PubKey.IsEqual(b.ClientKey.PubKey) +} diff --git a/assets/reservation/sql_store_test.go b/assets/reservation/sql_store_test.go new file mode 100644 index 000000000..ba5e0ac6c --- /dev/null +++ b/assets/reservation/sql_store_test.go @@ -0,0 +1,155 @@ +package reservation + +import ( + "context" + "math" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +func testReservation() *Reservation { + _, pubkey := btcec.PrivKeyFromBytes([]byte{1}) + return &Reservation{ + ID: ID{1}, + Terms: Terms{ + AssetID: [32]byte{2}, + Amount: 10000, + Fee: 11, + CSVDelay: 1440, + RequiredConfirmations: 3, + ExecutionDelta: 90, + }, + ClientKey: keychain.KeyDescriptor{ + PubKey: pubkey, + KeyLocator: keychain.KeyLocator{ + Family: 99, + Index: math.MaxUint32, + }, + }, + State: "AwaitApproval", + } +} + +func TestSqlStore(t *testing.T) { + db := loopdb.NewTestDB(t) + store := NewSqlStore(db.BaseDB) + now := time.Unix(1700000000, 0).UTC() + store.clock = clock.NewTestClock(now) + ctx := t.Context() + r := testReservation() + require.NoError(t, store.CreateReservation(ctx, r)) + require.Equal(t, now, r.CreatedAt) + require.Equal(t, now, r.UpdatedAt) + + loaded, err := store.GetReservation(ctx, r.ID) + require.NoError(t, err) + require.Equal(t, r, loaded) + + // Recovery returns the latest state and the original accepted fee. + r.State = "Canceled" + require.NoError(t, store.UpdateReservation(ctx, r)) + restarted := NewSqlStore(db.BaseDB) + loaded, err = restarted.GetReservation(ctx, r.ID) + require.NoError(t, err) + require.Equal(t, r, loaded) + require.EqualValues(t, 11, loaded.Fee) + + // A repeated create restores progress instead of resetting it. + retry := testReservation() + require.NoError(t, restarted.CreateReservation(ctx, retry)) + require.Equal(t, r, retry) + updates, err := db.GetAssetReservationUpdates(ctx, r.ID[:]) + require.NoError(t, err) + require.Len(t, updates, 2) + + changed := *r + changed.Fee++ + require.ErrorIs(t, store.CreateReservation(ctx, &changed), + ErrRequestConflict) + require.ErrorIs(t, store.UpdateReservation(ctx, &changed), + ErrRequestConflict) + changed = *r + changed.ClientKey.Index-- + require.ErrorIs(t, store.CreateReservation(ctx, &changed), + ErrRequestConflict) + + // Cancellation of a database call cannot advance persisted progress. + canceled, cancel := context.WithCancel(ctx) + cancel() + changed = *r + changed.State = "WaitForDelivery" + store.clock = clock.NewTestClock(now.Add(time.Hour)) + require.Error(t, store.UpdateReservation(canceled, &changed)) + require.Equal(t, r.UpdatedAt, changed.UpdatedAt) + loaded, err = store.GetReservation(ctx, r.ID) + require.NoError(t, err) + require.Equal(t, r, loaded) + + all, err := store.GetReservations(ctx) + require.NoError(t, err) + require.Equal(t, []*Reservation{r}, all) + _, err = store.GetReservation(ctx, ID{99}) + require.ErrorIs(t, err, ErrNotFound) + missing := *r + missing.ID = ID{99} + require.ErrorIs(t, store.UpdateReservation(ctx, &missing), ErrNotFound) + + // Damaged records fail closed instead of becoming recoverable purchases. + row, err := db.GetAssetReservation(ctx, r.ID[:]) + require.NoError(t, err) + _, err = toReservation(row, nil) + require.Error(t, err) + row.ClientPubkey = make([]byte, 33) + _, err = toReservation(row, updates) + require.Error(t, err) +} + +func TestSqlStoreConcurrentCreate(t *testing.T) { + db := loopdb.NewTestDB(t) + store := NewSqlStore(db.BaseDB) + ctx := t.Context() + results := make(chan *Reservation, 4) + errors := make(chan error, 4) + var wg sync.WaitGroup + for range 4 { + wg.Go(func() { + r := testReservation() + errors <- store.CreateReservation(ctx, r) + results <- r + }) + } + wg.Wait() + for range 4 { + require.NoError(t, <-errors) + } + first := <-results + for range 3 { + require.Equal(t, first, <-results) + } + updates, err := db.GetAssetReservationUpdates(ctx, first.ID[:]) + require.NoError(t, err) + require.Len(t, updates, 1) +} + +func TestReservationValidation(t *testing.T) { + id, err := NewID() + require.NoError(t, err) + require.NotEqual(t, ID{}, id) + r := testReservation() + require.NoError(t, r.Validate()) + r.ID = ID{} + require.Error(t, r.Validate()) + r = testReservation() + r.ClientKey.PubKey = nil + require.Error(t, r.Validate()) + r = testReservation() + r.ClientKey.Family = math.MaxUint32 + require.Error(t, r.Validate()) +} diff --git a/assets/reservation/sqlite_store_test.go b/assets/reservation/sqlite_store_test.go new file mode 100644 index 000000000..664c9f7a4 --- /dev/null +++ b/assets/reservation/sqlite_store_test.go @@ -0,0 +1,29 @@ +package reservation + +import ( + "path/filepath" + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/loop/loopdb" + "github.com/stretchr/testify/require" +) + +func TestSqliteStoreReopen(t *testing.T) { + cfg := &loopdb.SqliteConfig{ + DatabaseFileName: filepath.Join(t.TempDir(), "reservations.db"), + } + db, err := loopdb.NewSqliteStore(cfg, &chaincfg.RegressionNetParams) + require.NoError(t, err) + r := testReservation() + err = NewSqlStore(db.BaseDB).CreateReservation(t.Context(), r) + require.NoError(t, err) + require.NoError(t, db.Close()) + + db, err = loopdb.NewSqliteStore(cfg, &chaincfg.RegressionNetParams) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + loaded, err := NewSqlStore(db.BaseDB).GetReservation(t.Context(), r.ID) + require.NoError(t, err) + require.Equal(t, r, loaded) +} From 74d4b1aa675a041af823d7176fd6badcc46e1b3b Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 11:20:21 +0200 Subject: [PATCH 15/25] assets/reservation: Derive deadlines from funding confirmation Calculate the timeout and execution cutoff from the first funding confirmation so extra confirmations and restarts cannot extend a reservation's life. Test deadline calculations and signed-height boundaries. Keep lifetime arithmetic separate from proof, confirmation, and spend checks. --- assets/reservation/lifetime.go | 33 ++++++++++++++++++++++++ assets/reservation/lifetime_test.go | 39 +++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 assets/reservation/lifetime.go create mode 100644 assets/reservation/lifetime_test.go diff --git a/assets/reservation/lifetime.go b/assets/reservation/lifetime.go new file mode 100644 index 000000000..686d8bd4d --- /dev/null +++ b/assets/reservation/lifetime.go @@ -0,0 +1,33 @@ +package reservation + +import ( + "errors" + "math" +) + +// Lifetime contains absolute block heights derived from the first funding +// confirmation. Restart and extra confirmations never reset these heights. +type Lifetime struct { + // TimeoutHeight is the first block that may contain a CSV timeout spend. + TimeoutHeight uint32 + + // ExecutionCutoff is exclusive: no new swap may start at this height. + ExecutionCutoff uint32 +} + +// Lifetime derives the reservation's deadlines using block-based CSV rules. +// Heights must fit the signed block-height fields used by LND. +func (t Terms) Lifetime(confirmationHeight uint32) (Lifetime, error) { + if err := t.Validate(); err != nil { + return Lifetime{}, err + } + expiry := uint64(confirmationHeight) + uint64(t.CSVDelay) + if confirmationHeight == 0 || expiry > math.MaxInt32 { + return Lifetime{}, errors.New("invalid funding confirmation height") + } + + return Lifetime{ + TimeoutHeight: uint32(expiry), + ExecutionCutoff: uint32(expiry) - t.ExecutionDelta, + }, nil +} diff --git a/assets/reservation/lifetime_test.go b/assets/reservation/lifetime_test.go new file mode 100644 index 000000000..d875e0187 --- /dev/null +++ b/assets/reservation/lifetime_test.go @@ -0,0 +1,39 @@ +package reservation + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLifetime(t *testing.T) { + terms := testReservation().Terms + lifetime, err := terms.Lifetime(100) + require.NoError(t, err) + require.EqualValues(t, 1540, lifetime.TimeoutHeight) + require.EqualValues(t, 1450, lifetime.ExecutionCutoff) + + // CSV counts from the first confirmation, not from the delivery block. + afterRestart, err := terms.Lifetime(100) + require.NoError(t, err) + require.Equal(t, lifetime, afterRestart) + + for _, height := range []uint32{0, math.MaxInt32, math.MaxUint32} { + _, err := terms.Lifetime(height) + require.Error(t, err) + } + + // The highest representable expiry is valid; the next one is not. + lastConfirmation := uint32(math.MaxInt32) - terms.CSVDelay + lifetime, err = terms.Lifetime(lastConfirmation) + require.NoError(t, err) + require.EqualValues(t, math.MaxInt32, lifetime.TimeoutHeight) + _, err = terms.Lifetime(lastConfirmation + 1) + require.Error(t, err) + + // Even caller-supplied policy values go through structural validation. + terms.ExecutionDelta = math.MaxUint32 + _, err = terms.Lifetime(100) + require.Error(t, err) +} From adf0cba9a6e462ebb215c5d1637f3f74d41032e0 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 16:48:11 +0200 Subject: [PATCH 16/25] looprpc: Separate reservation quotes from payment approval Define separate Buy and Approve calls so callers can inspect the quote and main probe before authorizing payment. Buy can request skipped probing; Approve separately permits buying without a successful probe. Approving the exact quote hash accepts its asset fee and BTC prepay amount. The caller supplies the prepay routing cap and SkipProbe choice, which are saved with the transition that authorizes payment. Add get and list calls with purchase-ID and outpoint selectors. Report the probe outcome and estimated routing fees, assign swap read and execute permissions, and generate the bindings. Cancellation stays internal; unapproved quotes expire without a public cancellation RPC. Allow list requests to select one client state or active purchases. The filters are mutually exclusive; omitting both includes all states. Expose probe fee availability separately from probe success. Recovery can verify delivery even if LND has already pruned the route history. Expose mutually exclusive exact-issuance and group selection in the local purchase API. --- docs/asset-reservation-rpc.md | 32 + looprpc/asset_reservations.pb.go | 856 ++++++++++++++++++++++++++ looprpc/asset_reservations.proto | 127 ++++ looprpc/asset_reservations_grpc.pb.go | 209 +++++++ looprpc/perms.go | 16 + 5 files changed, 1240 insertions(+) create mode 100644 looprpc/asset_reservations.pb.go create mode 100644 looprpc/asset_reservations.proto create mode 100644 looprpc/asset_reservations_grpc.pb.go diff --git a/docs/asset-reservation-rpc.md b/docs/asset-reservation-rpc.md index 002aaa8d6..f9485e402 100644 --- a/docs/asset-reservation-rpc.md +++ b/docs/asset-reservation-rpc.md @@ -1,5 +1,12 @@ # Asset reservation protocol +The local `looprpc.AssetReservations` service separates `Buy` (request a +quote) from `Approve` (permit payment under saved limits). `Get`, `List`, +and `Cancel` support inspection and recovery without buying +again. Funded records accept canonical outpoints; pre-funding retries use a +stable ID. Local calls require the existing swap read or execute macaroon +permissions. Registration is part of the client runtime wiring. + The wire contract lives in `swapserverrpc/asset_reservation.proto`. It is separate from Bitcoin Instant Out reservations. These definitions do not enable a service; handlers and authentication wiring remain separate. @@ -22,6 +29,31 @@ validates both invoices, probes only the estimated main amount, then waits for approval. Paying the hold prepay is the only signal that permits funding. The main Bitcoin equivalent is an estimate, not an exchange-rate guarantee. +The local response reports `main_probe` and `main_probe_fee_msat`. +`estimated_prepay_route_fee_msat` scales that fee by the prepay/main BTC amount +ratio, rounded up to a millisatoshi, without accounting for fixed hop fees. +Both fee estimates are unavailable unless the main probe succeeds. The prepay +invoice is validated and paid under the approved limits, but is not probed. + +Normal approval requires a successful main probe. `Buy.skip_probe` skips the +automatic probe for a new purchase; it is saved before the worker starts and +survives a restart. Repeating `Buy` preserves the existing purchase's progress. +`Approve.skip_probe` separately records consent to buy without a successful +probe, including after a failed attempt. Skipping the probe never authorizes +payment. Missing or unsuccessful probes retry in the background while awaiting +approval, including after restart. A successful probe is reused for the saved +quote. There is no public probe command or RPC. + +The approval request accepts the displayed quote by hash, including its +asset fee and BTC prepay amount. The client checks the hash against the +immutable quote. It saves the prepay routing cap and `skip_probe` on the +reservation, in the same transaction as the transition to `PayPrepay`. That +state records consent. A retryable payment error after that write returns the +saved progress. If the approval reply is lost, the CLI queries the same ID; +if it cannot establish progress, it prints commands to inspect or resume that +purchase without creating another one. Recovery from `AwaitApproval` still waits +for the caller; recovery from `PayPrepay` resumes with the saved choices. + The quote identifies two Lightning nodes on the receiving side: - `receiving_node_key` is the identity key of the server's receiving LND node. diff --git a/looprpc/asset_reservations.pb.go b/looprpc/asset_reservations.pb.go new file mode 100644 index 000000000..86b0597e6 --- /dev/null +++ b/looprpc/asset_reservations.pb.go @@ -0,0 +1,856 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v3.21.12 +// source: asset_reservations.proto + +package looprpc + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ReservationProbeResult int32 + +const ( + ReservationProbeResult_RESERVATION_PROBE_NOT_RUN ReservationProbeResult = 0 + ReservationProbeResult_RESERVATION_PROBE_SUCCEEDED ReservationProbeResult = 1 + ReservationProbeResult_RESERVATION_PROBE_FAILED ReservationProbeResult = 2 + ReservationProbeResult_RESERVATION_PROBE_TIMED_OUT ReservationProbeResult = 3 + ReservationProbeResult_RESERVATION_PROBE_UNSUPPORTED ReservationProbeResult = 4 +) + +// Enum value maps for ReservationProbeResult. +var ( + ReservationProbeResult_name = map[int32]string{ + 0: "RESERVATION_PROBE_NOT_RUN", + 1: "RESERVATION_PROBE_SUCCEEDED", + 2: "RESERVATION_PROBE_FAILED", + 3: "RESERVATION_PROBE_TIMED_OUT", + 4: "RESERVATION_PROBE_UNSUPPORTED", + } + ReservationProbeResult_value = map[string]int32{ + "RESERVATION_PROBE_NOT_RUN": 0, + "RESERVATION_PROBE_SUCCEEDED": 1, + "RESERVATION_PROBE_FAILED": 2, + "RESERVATION_PROBE_TIMED_OUT": 3, + "RESERVATION_PROBE_UNSUPPORTED": 4, + } +) + +func (x ReservationProbeResult) Enum() *ReservationProbeResult { + p := new(ReservationProbeResult) + *p = x + return p +} + +func (x ReservationProbeResult) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ReservationProbeResult) Descriptor() protoreflect.EnumDescriptor { + return file_asset_reservations_proto_enumTypes[0].Descriptor() +} + +func (ReservationProbeResult) Type() protoreflect.EnumType { + return &file_asset_reservations_proto_enumTypes[0] +} + +func (x ReservationProbeResult) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ReservationProbeResult.Descriptor instead. +func (ReservationProbeResult) EnumDescriptor() ([]byte, []int) { + return file_asset_reservations_proto_rawDescGZIP(), []int{0} +} + +type BuyAssetReservationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // A fresh, nonzero 32-byte ID makes lost-response retries idempotent. + ReservationId []byte `protobuf:"bytes,1,opt,name=reservation_id,json=reservationId,proto3" json:"reservation_id,omitempty"` + // Set exactly one of asset_id or group_key. + AssetId []byte `protobuf:"bytes,2,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` + // Compressed, tweaked Taproot Assets group key. + GroupKey []byte `protobuf:"bytes,5,opt,name=group_key,json=groupKey,proto3" json:"group_key,omitempty"` + Amount uint64 `protobuf:"varint,3,opt,name=amount,proto3" json:"amount,omitempty"` + // Skip automatic probing for a new purchase. Reusing an ID preserves its + // saved progress. Approve must still set skip_probe if no probe succeeded. + SkipProbe bool `protobuf:"varint,4,opt,name=skip_probe,json=skipProbe,proto3" json:"skip_probe,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BuyAssetReservationRequest) Reset() { + *x = BuyAssetReservationRequest{} + mi := &file_asset_reservations_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BuyAssetReservationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BuyAssetReservationRequest) ProtoMessage() {} + +func (x *BuyAssetReservationRequest) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservations_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BuyAssetReservationRequest.ProtoReflect.Descriptor instead. +func (*BuyAssetReservationRequest) Descriptor() ([]byte, []int) { + return file_asset_reservations_proto_rawDescGZIP(), []int{0} +} + +func (x *BuyAssetReservationRequest) GetReservationId() []byte { + if x != nil { + return x.ReservationId + } + return nil +} + +func (x *BuyAssetReservationRequest) GetAssetId() []byte { + if x != nil { + return x.AssetId + } + return nil +} + +func (x *BuyAssetReservationRequest) GetGroupKey() []byte { + if x != nil { + return x.GroupKey + } + return nil +} + +func (x *BuyAssetReservationRequest) GetAmount() uint64 { + if x != nil { + return x.Amount + } + return 0 +} + +func (x *BuyAssetReservationRequest) GetSkipProbe() bool { + if x != nil { + return x.SkipProbe + } + return false +} + +type ClientAssetReservationSelector struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Selector: + // + // *ClientAssetReservationSelector_ReservationId + // *ClientAssetReservationSelector_Outpoint + Selector isClientAssetReservationSelector_Selector `protobuf_oneof:"selector"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientAssetReservationSelector) Reset() { + *x = ClientAssetReservationSelector{} + mi := &file_asset_reservations_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientAssetReservationSelector) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientAssetReservationSelector) ProtoMessage() {} + +func (x *ClientAssetReservationSelector) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservations_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientAssetReservationSelector.ProtoReflect.Descriptor instead. +func (*ClientAssetReservationSelector) Descriptor() ([]byte, []int) { + return file_asset_reservations_proto_rawDescGZIP(), []int{1} +} + +func (x *ClientAssetReservationSelector) GetSelector() isClientAssetReservationSelector_Selector { + if x != nil { + return x.Selector + } + return nil +} + +func (x *ClientAssetReservationSelector) GetReservationId() []byte { + if x != nil { + if x, ok := x.Selector.(*ClientAssetReservationSelector_ReservationId); ok { + return x.ReservationId + } + } + return nil +} + +func (x *ClientAssetReservationSelector) GetOutpoint() string { + if x != nil { + if x, ok := x.Selector.(*ClientAssetReservationSelector_Outpoint); ok { + return x.Outpoint + } + } + return "" +} + +type isClientAssetReservationSelector_Selector interface { + isClientAssetReservationSelector_Selector() +} + +type ClientAssetReservationSelector_ReservationId struct { + // Used before funding; funded purchases should use their outpoint. + ReservationId []byte `protobuf:"bytes,1,opt,name=reservation_id,json=reservationId,proto3,oneof"` +} + +type ClientAssetReservationSelector_Outpoint struct { + Outpoint string `protobuf:"bytes,2,opt,name=outpoint,proto3,oneof"` +} + +func (*ClientAssetReservationSelector_ReservationId) isClientAssetReservationSelector_Selector() {} + +func (*ClientAssetReservationSelector_Outpoint) isClientAssetReservationSelector_Selector() {} + +type ApproveAssetReservationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reservation *ClientAssetReservationSelector `protobuf:"bytes,1,opt,name=reservation,proto3" json:"reservation,omitempty"` + // Accept the exact displayed quote, including its asset fee and BTC + // prepay amount. Checked against the saved immutable quote. + QuoteHash []byte `protobuf:"bytes,2,opt,name=quote_hash,json=quoteHash,proto3" json:"quote_hash,omitempty"` + // Cap the prepay routing fee in millisatoshis. Saved on the reservation + // with the transition that authorizes payment. + MaxRouteFeeMsat uint64 `protobuf:"varint,3,opt,name=max_route_fee_msat,json=maxRouteFeeMsat,proto3" json:"max_route_fee_msat,omitempty"` + // Accept the quote without a successful main probe. Saved with the same + // transition. Quote validation and payment limits still apply. + SkipProbe bool `protobuf:"varint,4,opt,name=skip_probe,json=skipProbe,proto3" json:"skip_probe,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApproveAssetReservationRequest) Reset() { + *x = ApproveAssetReservationRequest{} + mi := &file_asset_reservations_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApproveAssetReservationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApproveAssetReservationRequest) ProtoMessage() {} + +func (x *ApproveAssetReservationRequest) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservations_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApproveAssetReservationRequest.ProtoReflect.Descriptor instead. +func (*ApproveAssetReservationRequest) Descriptor() ([]byte, []int) { + return file_asset_reservations_proto_rawDescGZIP(), []int{2} +} + +func (x *ApproveAssetReservationRequest) GetReservation() *ClientAssetReservationSelector { + if x != nil { + return x.Reservation + } + return nil +} + +func (x *ApproveAssetReservationRequest) GetQuoteHash() []byte { + if x != nil { + return x.QuoteHash + } + return nil +} + +func (x *ApproveAssetReservationRequest) GetMaxRouteFeeMsat() uint64 { + if x != nil { + return x.MaxRouteFeeMsat + } + return 0 +} + +func (x *ApproveAssetReservationRequest) GetSkipProbe() bool { + if x != nil { + return x.SkipProbe + } + return false +} + +type ClientAssetReservation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Reservation identity and principal. + ReservationId []byte `protobuf:"bytes,1,opt,name=reservation_id,json=reservationId,proto3" json:"reservation_id,omitempty"` + State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` + // Exact issuance reserved on chain, empty until selected for a group. + AssetId []byte `protobuf:"bytes,3,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` + GroupKey []byte `protobuf:"bytes,27,opt,name=group_key,json=groupKey,proto3" json:"group_key,omitempty"` + PaymentAssetId []byte `protobuf:"bytes,28,opt,name=payment_asset_id,json=paymentAssetId,proto3" json:"payment_asset_id,omitempty"` + Amount uint64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"` + // Quote identity, validity, and receiving edge. + QuoteHash []byte `protobuf:"bytes,16,opt,name=quote_hash,json=quoteHash,proto3" json:"quote_hash,omitempty"` + QuoteExpiresAt int64 `protobuf:"varint,20,opt,name=quote_expires_at,json=quoteExpiresAt,proto3" json:"quote_expires_at,omitempty"` + EdgeKey []byte `protobuf:"bytes,17,opt,name=edge_key,json=edgeKey,proto3" json:"edge_key,omitempty"` + // Asset charges and their Bitcoin payment amounts. + AssetFee uint64 `protobuf:"varint,5,opt,name=asset_fee,json=assetFee,proto3" json:"asset_fee,omitempty"` + PrepayCredit uint64 `protobuf:"varint,14,opt,name=prepay_credit,json=prepayCredit,proto3" json:"prepay_credit,omitempty"` + PrepayAmountMsat uint64 `protobuf:"varint,18,opt,name=prepay_amount_msat,json=prepayAmountMsat,proto3" json:"prepay_amount_msat,omitempty"` + // Indicative only; the later swap obtains a fresh execution quote. + EstimatedMainAmountMsat uint64 `protobuf:"varint,19,opt,name=estimated_main_amount_msat,json=estimatedMainAmountMsat,proto3" json:"estimated_main_amount_msat,omitempty"` + // Probe choice, outcome, and observed routing fees. + // Whether probing is waived for this purchase. Before approval this is + // a preference only; background recovery preserves it. + SkipProbe bool `protobuf:"varint,21,opt,name=skip_probe,json=skipProbe,proto3" json:"skip_probe,omitempty"` + // Result of probing the estimated main payment. Approval requires success + // unless skip_probe is set; success does not reserve future liquidity. + MainProbe ReservationProbeResult `protobuf:"varint,22,opt,name=main_probe,json=mainProbe,proto3,enum=looprpc.ReservationProbeResult" json:"main_probe,omitempty"` + ProbesCheckedAt int64 `protobuf:"varint,25,opt,name=probes_checked_at,json=probesCheckedAt,proto3" json:"probes_checked_at,omitempty"` + // A live route was observed and the probe succeeded. False means the fee + // estimates are unavailable, rather than a zero-fee route. + ProbeFeeKnown bool `protobuf:"varint,26,opt,name=probe_fee_known,json=probeFeeKnown,proto3" json:"probe_fee_known,omitempty"` + MainProbeFeeMsat uint64 `protobuf:"varint,24,opt,name=main_probe_fee_msat,json=mainProbeFeeMsat,proto3" json:"main_probe_fee_msat,omitempty"` + // Main routing fee scaled by prepay/main BTC amounts, rounded up to msat. + // Ignores fixed hop fees; available only when probe_fee_known is true. + EstimatedPrepayRouteFeeMsat uint64 `protobuf:"varint,23,opt,name=estimated_prepay_route_fee_msat,json=estimatedPrepayRouteFeeMsat,proto3" json:"estimated_prepay_route_fee_msat,omitempty"` + // Funding output and verification. + Outpoint string `protobuf:"bytes,10,opt,name=outpoint,proto3" json:"outpoint,omitempty"` + ProofVerified bool `protobuf:"varint,15,opt,name=proof_verified,json=proofVerified,proto3" json:"proof_verified,omitempty"` + RequiredConfirmations uint32 `protobuf:"varint,7,opt,name=required_confirmations,json=requiredConfirmations,proto3" json:"required_confirmations,omitempty"` + ConfirmationHeight uint32 `protobuf:"varint,11,opt,name=confirmation_height,json=confirmationHeight,proto3" json:"confirmation_height,omitempty"` + // Lifetime terms paired with their resulting block heights. + CsvDelay uint32 `protobuf:"varint,6,opt,name=csv_delay,json=csvDelay,proto3" json:"csv_delay,omitempty"` + TimeoutHeight uint32 `protobuf:"varint,12,opt,name=timeout_height,json=timeoutHeight,proto3" json:"timeout_height,omitempty"` + ExecutionDelta uint32 `protobuf:"varint,8,opt,name=execution_delta,json=executionDelta,proto3" json:"execution_delta,omitempty"` + ExecutionCutoff uint32 `protobuf:"varint,13,opt,name=execution_cutoff,json=executionCutoff,proto3" json:"execution_cutoff,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientAssetReservation) Reset() { + *x = ClientAssetReservation{} + mi := &file_asset_reservations_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientAssetReservation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientAssetReservation) ProtoMessage() {} + +func (x *ClientAssetReservation) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservations_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientAssetReservation.ProtoReflect.Descriptor instead. +func (*ClientAssetReservation) Descriptor() ([]byte, []int) { + return file_asset_reservations_proto_rawDescGZIP(), []int{3} +} + +func (x *ClientAssetReservation) GetReservationId() []byte { + if x != nil { + return x.ReservationId + } + return nil +} + +func (x *ClientAssetReservation) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *ClientAssetReservation) GetAssetId() []byte { + if x != nil { + return x.AssetId + } + return nil +} + +func (x *ClientAssetReservation) GetGroupKey() []byte { + if x != nil { + return x.GroupKey + } + return nil +} + +func (x *ClientAssetReservation) GetPaymentAssetId() []byte { + if x != nil { + return x.PaymentAssetId + } + return nil +} + +func (x *ClientAssetReservation) GetAmount() uint64 { + if x != nil { + return x.Amount + } + return 0 +} + +func (x *ClientAssetReservation) GetQuoteHash() []byte { + if x != nil { + return x.QuoteHash + } + return nil +} + +func (x *ClientAssetReservation) GetQuoteExpiresAt() int64 { + if x != nil { + return x.QuoteExpiresAt + } + return 0 +} + +func (x *ClientAssetReservation) GetEdgeKey() []byte { + if x != nil { + return x.EdgeKey + } + return nil +} + +func (x *ClientAssetReservation) GetAssetFee() uint64 { + if x != nil { + return x.AssetFee + } + return 0 +} + +func (x *ClientAssetReservation) GetPrepayCredit() uint64 { + if x != nil { + return x.PrepayCredit + } + return 0 +} + +func (x *ClientAssetReservation) GetPrepayAmountMsat() uint64 { + if x != nil { + return x.PrepayAmountMsat + } + return 0 +} + +func (x *ClientAssetReservation) GetEstimatedMainAmountMsat() uint64 { + if x != nil { + return x.EstimatedMainAmountMsat + } + return 0 +} + +func (x *ClientAssetReservation) GetSkipProbe() bool { + if x != nil { + return x.SkipProbe + } + return false +} + +func (x *ClientAssetReservation) GetMainProbe() ReservationProbeResult { + if x != nil { + return x.MainProbe + } + return ReservationProbeResult_RESERVATION_PROBE_NOT_RUN +} + +func (x *ClientAssetReservation) GetProbesCheckedAt() int64 { + if x != nil { + return x.ProbesCheckedAt + } + return 0 +} + +func (x *ClientAssetReservation) GetProbeFeeKnown() bool { + if x != nil { + return x.ProbeFeeKnown + } + return false +} + +func (x *ClientAssetReservation) GetMainProbeFeeMsat() uint64 { + if x != nil { + return x.MainProbeFeeMsat + } + return 0 +} + +func (x *ClientAssetReservation) GetEstimatedPrepayRouteFeeMsat() uint64 { + if x != nil { + return x.EstimatedPrepayRouteFeeMsat + } + return 0 +} + +func (x *ClientAssetReservation) GetOutpoint() string { + if x != nil { + return x.Outpoint + } + return "" +} + +func (x *ClientAssetReservation) GetProofVerified() bool { + if x != nil { + return x.ProofVerified + } + return false +} + +func (x *ClientAssetReservation) GetRequiredConfirmations() uint32 { + if x != nil { + return x.RequiredConfirmations + } + return 0 +} + +func (x *ClientAssetReservation) GetConfirmationHeight() uint32 { + if x != nil { + return x.ConfirmationHeight + } + return 0 +} + +func (x *ClientAssetReservation) GetCsvDelay() uint32 { + if x != nil { + return x.CsvDelay + } + return 0 +} + +func (x *ClientAssetReservation) GetTimeoutHeight() uint32 { + if x != nil { + return x.TimeoutHeight + } + return 0 +} + +func (x *ClientAssetReservation) GetExecutionDelta() uint32 { + if x != nil { + return x.ExecutionDelta + } + return 0 +} + +func (x *ClientAssetReservation) GetExecutionCutoff() uint32 { + if x != nil { + return x.ExecutionCutoff + } + return 0 +} + +type ListClientAssetReservationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Limit uint32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` + AfterId []byte `protobuf:"bytes,2,opt,name=after_id,json=afterId,proto3" json:"after_id,omitempty"` + // Match this client FSM state, using the exact name returned + // in ClientAssetReservation.state. Cannot be combined with active_only. + // Keep the filter unchanged while following next_after_id through pages. + State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` + // Exclude terminal states; include Ready and NeedAdminAttention. + // When false and state is empty, return all reservations. + ActiveOnly bool `protobuf:"varint,4,opt,name=active_only,json=activeOnly,proto3" json:"active_only,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListClientAssetReservationsRequest) Reset() { + *x = ListClientAssetReservationsRequest{} + mi := &file_asset_reservations_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListClientAssetReservationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListClientAssetReservationsRequest) ProtoMessage() {} + +func (x *ListClientAssetReservationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservations_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListClientAssetReservationsRequest.ProtoReflect.Descriptor instead. +func (*ListClientAssetReservationsRequest) Descriptor() ([]byte, []int) { + return file_asset_reservations_proto_rawDescGZIP(), []int{4} +} + +func (x *ListClientAssetReservationsRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListClientAssetReservationsRequest) GetAfterId() []byte { + if x != nil { + return x.AfterId + } + return nil +} + +func (x *ListClientAssetReservationsRequest) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *ListClientAssetReservationsRequest) GetActiveOnly() bool { + if x != nil { + return x.ActiveOnly + } + return false +} + +type ListClientAssetReservationsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reservations []*ClientAssetReservation `protobuf:"bytes,1,rep,name=reservations,proto3" json:"reservations,omitempty"` + NextAfterId []byte `protobuf:"bytes,2,opt,name=next_after_id,json=nextAfterId,proto3" json:"next_after_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListClientAssetReservationsResponse) Reset() { + *x = ListClientAssetReservationsResponse{} + mi := &file_asset_reservations_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListClientAssetReservationsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListClientAssetReservationsResponse) ProtoMessage() {} + +func (x *ListClientAssetReservationsResponse) ProtoReflect() protoreflect.Message { + mi := &file_asset_reservations_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListClientAssetReservationsResponse.ProtoReflect.Descriptor instead. +func (*ListClientAssetReservationsResponse) Descriptor() ([]byte, []int) { + return file_asset_reservations_proto_rawDescGZIP(), []int{5} +} + +func (x *ListClientAssetReservationsResponse) GetReservations() []*ClientAssetReservation { + if x != nil { + return x.Reservations + } + return nil +} + +func (x *ListClientAssetReservationsResponse) GetNextAfterId() []byte { + if x != nil { + return x.NextAfterId + } + return nil +} + +var File_asset_reservations_proto protoreflect.FileDescriptor + +const file_asset_reservations_proto_rawDesc = "" + + "\n" + + "\x18asset_reservations.proto\x12\alooprpc\"\xb2\x01\n" + + "\x1aBuyAssetReservationRequest\x12%\n" + + "\x0ereservation_id\x18\x01 \x01(\fR\rreservationId\x12\x19\n" + + "\basset_id\x18\x02 \x01(\fR\aassetId\x12\x1b\n" + + "\tgroup_key\x18\x05 \x01(\fR\bgroupKey\x12\x16\n" + + "\x06amount\x18\x03 \x01(\x04R\x06amount\x12\x1d\n" + + "\n" + + "skip_probe\x18\x04 \x01(\bR\tskipProbe\"s\n" + + "\x1eClientAssetReservationSelector\x12'\n" + + "\x0ereservation_id\x18\x01 \x01(\fH\x00R\rreservationId\x12\x1c\n" + + "\boutpoint\x18\x02 \x01(\tH\x00R\boutpointB\n" + + "\n" + + "\bselector\"\xd6\x01\n" + + "\x1eApproveAssetReservationRequest\x12I\n" + + "\vreservation\x18\x01 \x01(\v2'.looprpc.ClientAssetReservationSelectorR\vreservation\x12\x1d\n" + + "\n" + + "quote_hash\x18\x02 \x01(\fR\tquoteHash\x12+\n" + + "\x12max_route_fee_msat\x18\x03 \x01(\x04R\x0fmaxRouteFeeMsat\x12\x1d\n" + + "\n" + + "skip_probe\x18\x04 \x01(\bR\tskipProbe\"\xcb\b\n" + + "\x16ClientAssetReservation\x12%\n" + + "\x0ereservation_id\x18\x01 \x01(\fR\rreservationId\x12\x14\n" + + "\x05state\x18\x02 \x01(\tR\x05state\x12\x19\n" + + "\basset_id\x18\x03 \x01(\fR\aassetId\x12\x1b\n" + + "\tgroup_key\x18\x1b \x01(\fR\bgroupKey\x12(\n" + + "\x10payment_asset_id\x18\x1c \x01(\fR\x0epaymentAssetId\x12\x16\n" + + "\x06amount\x18\x04 \x01(\x04R\x06amount\x12\x1d\n" + + "\n" + + "quote_hash\x18\x10 \x01(\fR\tquoteHash\x12(\n" + + "\x10quote_expires_at\x18\x14 \x01(\x03R\x0equoteExpiresAt\x12\x19\n" + + "\bedge_key\x18\x11 \x01(\fR\aedgeKey\x12\x1b\n" + + "\tasset_fee\x18\x05 \x01(\x04R\bassetFee\x12#\n" + + "\rprepay_credit\x18\x0e \x01(\x04R\fprepayCredit\x12,\n" + + "\x12prepay_amount_msat\x18\x12 \x01(\x04R\x10prepayAmountMsat\x12;\n" + + "\x1aestimated_main_amount_msat\x18\x13 \x01(\x04R\x17estimatedMainAmountMsat\x12\x1d\n" + + "\n" + + "skip_probe\x18\x15 \x01(\bR\tskipProbe\x12>\n" + + "\n" + + "main_probe\x18\x16 \x01(\x0e2\x1f.looprpc.ReservationProbeResultR\tmainProbe\x12*\n" + + "\x11probes_checked_at\x18\x19 \x01(\x03R\x0fprobesCheckedAt\x12&\n" + + "\x0fprobe_fee_known\x18\x1a \x01(\bR\rprobeFeeKnown\x12-\n" + + "\x13main_probe_fee_msat\x18\x18 \x01(\x04R\x10mainProbeFeeMsat\x12D\n" + + "\x1festimated_prepay_route_fee_msat\x18\x17 \x01(\x04R\x1bestimatedPrepayRouteFeeMsat\x12\x1a\n" + + "\boutpoint\x18\n" + + " \x01(\tR\boutpoint\x12%\n" + + "\x0eproof_verified\x18\x0f \x01(\bR\rproofVerified\x125\n" + + "\x16required_confirmations\x18\a \x01(\rR\x15requiredConfirmations\x12/\n" + + "\x13confirmation_height\x18\v \x01(\rR\x12confirmationHeight\x12\x1b\n" + + "\tcsv_delay\x18\x06 \x01(\rR\bcsvDelay\x12%\n" + + "\x0etimeout_height\x18\f \x01(\rR\rtimeoutHeight\x12'\n" + + "\x0fexecution_delta\x18\b \x01(\rR\x0eexecutionDelta\x12)\n" + + "\x10execution_cutoff\x18\r \x01(\rR\x0fexecutionCutoff\"\x8c\x01\n" + + "\"ListClientAssetReservationsRequest\x12\x14\n" + + "\x05limit\x18\x01 \x01(\rR\x05limit\x12\x19\n" + + "\bafter_id\x18\x02 \x01(\fR\aafterId\x12\x14\n" + + "\x05state\x18\x03 \x01(\tR\x05state\x12\x1f\n" + + "\vactive_only\x18\x04 \x01(\bR\n" + + "activeOnly\"\x8e\x01\n" + + "#ListClientAssetReservationsResponse\x12C\n" + + "\freservations\x18\x01 \x03(\v2\x1f.looprpc.ClientAssetReservationR\freservations\x12\"\n" + + "\rnext_after_id\x18\x02 \x01(\fR\vnextAfterId*\xba\x01\n" + + "\x16ReservationProbeResult\x12\x1d\n" + + "\x19RESERVATION_PROBE_NOT_RUN\x10\x00\x12\x1f\n" + + "\x1bRESERVATION_PROBE_SUCCEEDED\x10\x01\x12\x1c\n" + + "\x18RESERVATION_PROBE_FAILED\x10\x02\x12\x1f\n" + + "\x1bRESERVATION_PROBE_TIMED_OUT\x10\x03\x12!\n" + + "\x1dRESERVATION_PROBE_UNSUPPORTED\x10\x042\xe9\x02\n" + + "\x11AssetReservations\x12K\n" + + "\x03Buy\x12#.looprpc.BuyAssetReservationRequest\x1a\x1f.looprpc.ClientAssetReservation\x12O\n" + + "\x03Get\x12'.looprpc.ClientAssetReservationSelector\x1a\x1f.looprpc.ClientAssetReservation\x12a\n" + + "\x04List\x12+.looprpc.ListClientAssetReservationsRequest\x1a,.looprpc.ListClientAssetReservationsResponse\x12S\n" + + "\aApprove\x12'.looprpc.ApproveAssetReservationRequest\x1a\x1f.looprpc.ClientAssetReservationB'Z%github.com/lightninglabs/loop/looprpcb\x06proto3" + +var ( + file_asset_reservations_proto_rawDescOnce sync.Once + file_asset_reservations_proto_rawDescData []byte +) + +func file_asset_reservations_proto_rawDescGZIP() []byte { + file_asset_reservations_proto_rawDescOnce.Do(func() { + file_asset_reservations_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_asset_reservations_proto_rawDesc), len(file_asset_reservations_proto_rawDesc))) + }) + return file_asset_reservations_proto_rawDescData +} + +var file_asset_reservations_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_asset_reservations_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_asset_reservations_proto_goTypes = []any{ + (ReservationProbeResult)(0), // 0: looprpc.ReservationProbeResult + (*BuyAssetReservationRequest)(nil), // 1: looprpc.BuyAssetReservationRequest + (*ClientAssetReservationSelector)(nil), // 2: looprpc.ClientAssetReservationSelector + (*ApproveAssetReservationRequest)(nil), // 3: looprpc.ApproveAssetReservationRequest + (*ClientAssetReservation)(nil), // 4: looprpc.ClientAssetReservation + (*ListClientAssetReservationsRequest)(nil), // 5: looprpc.ListClientAssetReservationsRequest + (*ListClientAssetReservationsResponse)(nil), // 6: looprpc.ListClientAssetReservationsResponse +} +var file_asset_reservations_proto_depIdxs = []int32{ + 2, // 0: looprpc.ApproveAssetReservationRequest.reservation:type_name -> looprpc.ClientAssetReservationSelector + 0, // 1: looprpc.ClientAssetReservation.main_probe:type_name -> looprpc.ReservationProbeResult + 4, // 2: looprpc.ListClientAssetReservationsResponse.reservations:type_name -> looprpc.ClientAssetReservation + 1, // 3: looprpc.AssetReservations.Buy:input_type -> looprpc.BuyAssetReservationRequest + 2, // 4: looprpc.AssetReservations.Get:input_type -> looprpc.ClientAssetReservationSelector + 5, // 5: looprpc.AssetReservations.List:input_type -> looprpc.ListClientAssetReservationsRequest + 3, // 6: looprpc.AssetReservations.Approve:input_type -> looprpc.ApproveAssetReservationRequest + 4, // 7: looprpc.AssetReservations.Buy:output_type -> looprpc.ClientAssetReservation + 4, // 8: looprpc.AssetReservations.Get:output_type -> looprpc.ClientAssetReservation + 6, // 9: looprpc.AssetReservations.List:output_type -> looprpc.ListClientAssetReservationsResponse + 4, // 10: looprpc.AssetReservations.Approve:output_type -> looprpc.ClientAssetReservation + 7, // [7:11] is the sub-list for method output_type + 3, // [3:7] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_asset_reservations_proto_init() } +func file_asset_reservations_proto_init() { + if File_asset_reservations_proto != nil { + return + } + file_asset_reservations_proto_msgTypes[1].OneofWrappers = []any{ + (*ClientAssetReservationSelector_ReservationId)(nil), + (*ClientAssetReservationSelector_Outpoint)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_asset_reservations_proto_rawDesc), len(file_asset_reservations_proto_rawDesc)), + NumEnums: 1, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_asset_reservations_proto_goTypes, + DependencyIndexes: file_asset_reservations_proto_depIdxs, + EnumInfos: file_asset_reservations_proto_enumTypes, + MessageInfos: file_asset_reservations_proto_msgTypes, + }.Build() + File_asset_reservations_proto = out.File + file_asset_reservations_proto_goTypes = nil + file_asset_reservations_proto_depIdxs = nil +} diff --git a/looprpc/asset_reservations.proto b/looprpc/asset_reservations.proto new file mode 100644 index 000000000..fb38fb18f --- /dev/null +++ b/looprpc/asset_reservations.proto @@ -0,0 +1,127 @@ +syntax = "proto3"; + +package looprpc; + +option go_package = "github.com/lightninglabs/loop/looprpc"; + +// AssetReservations controls local purchases. Buy requests a quote but does +// not pay. Approve saves payment choices and consent before payment begins. +// Unapproved quotes expire automatically; cancellation is internal recovery. +service AssetReservations { + rpc Buy (BuyAssetReservationRequest) returns (ClientAssetReservation); + rpc Get (ClientAssetReservationSelector) returns (ClientAssetReservation); + rpc List (ListClientAssetReservationsRequest) + returns (ListClientAssetReservationsResponse); + rpc Approve (ApproveAssetReservationRequest) + returns (ClientAssetReservation); +} + +message BuyAssetReservationRequest { + // A fresh, nonzero 32-byte ID makes lost-response retries idempotent. + bytes reservation_id = 1; + // Set exactly one of asset_id or group_key. + bytes asset_id = 2; + // Compressed, tweaked Taproot Assets group key. + bytes group_key = 5; + uint64 amount = 3; + // Skip automatic probing for a new purchase. Reusing an ID preserves its + // saved progress. Approve must still set skip_probe if no probe succeeded. + bool skip_probe = 4; +} + +message ClientAssetReservationSelector { + oneof selector { + // Used before funding; funded purchases should use their outpoint. + bytes reservation_id = 1; + string outpoint = 2; + } +} + +message ApproveAssetReservationRequest { + ClientAssetReservationSelector reservation = 1; + // Accept the exact displayed quote, including its asset fee and BTC + // prepay amount. Checked against the saved immutable quote. + bytes quote_hash = 2; + // Cap the prepay routing fee in millisatoshis. Saved on the reservation + // with the transition that authorizes payment. + uint64 max_route_fee_msat = 3; + // Accept the quote without a successful main probe. Saved with the same + // transition. Quote validation and payment limits still apply. + bool skip_probe = 4; +} + +enum ReservationProbeResult { + RESERVATION_PROBE_NOT_RUN = 0; + RESERVATION_PROBE_SUCCEEDED = 1; + RESERVATION_PROBE_FAILED = 2; + RESERVATION_PROBE_TIMED_OUT = 3; + RESERVATION_PROBE_UNSUPPORTED = 4; +} + +message ClientAssetReservation { + // Reservation identity and principal. + bytes reservation_id = 1; + string state = 2; + // Exact issuance reserved on chain, empty until selected for a group. + bytes asset_id = 3; + bytes group_key = 27; + bytes payment_asset_id = 28; + uint64 amount = 4; + + // Quote identity, validity, and receiving edge. + bytes quote_hash = 16; + int64 quote_expires_at = 20; + bytes edge_key = 17; + + // Asset charges and their Bitcoin payment amounts. + uint64 asset_fee = 5; + uint64 prepay_credit = 14; + uint64 prepay_amount_msat = 18; + // Indicative only; the later swap obtains a fresh execution quote. + uint64 estimated_main_amount_msat = 19; + + // Probe choice, outcome, and observed routing fees. + // Whether probing is waived for this purchase. Before approval this is + // a preference only; background recovery preserves it. + bool skip_probe = 21; + // Result of probing the estimated main payment. Approval requires success + // unless skip_probe is set; success does not reserve future liquidity. + ReservationProbeResult main_probe = 22; + int64 probes_checked_at = 25; + // A live route was observed and the probe succeeded. False means the fee + // estimates are unavailable, rather than a zero-fee route. + bool probe_fee_known = 26; + uint64 main_probe_fee_msat = 24; + // Main routing fee scaled by prepay/main BTC amounts, rounded up to msat. + // Ignores fixed hop fees; available only when probe_fee_known is true. + uint64 estimated_prepay_route_fee_msat = 23; + + // Funding output and verification. + string outpoint = 10; + bool proof_verified = 15; + uint32 required_confirmations = 7; + uint32 confirmation_height = 11; + + // Lifetime terms paired with their resulting block heights. + uint32 csv_delay = 6; + uint32 timeout_height = 12; + uint32 execution_delta = 8; + uint32 execution_cutoff = 13; +} + +message ListClientAssetReservationsRequest { + uint32 limit = 1; + bytes after_id = 2; + // Match this client FSM state, using the exact name returned + // in ClientAssetReservation.state. Cannot be combined with active_only. + // Keep the filter unchanged while following next_after_id through pages. + string state = 3; + // Exclude terminal states; include Ready and NeedAdminAttention. + // When false and state is empty, return all reservations. + bool active_only = 4; +} + +message ListClientAssetReservationsResponse { + repeated ClientAssetReservation reservations = 1; + bytes next_after_id = 2; +} diff --git a/looprpc/asset_reservations_grpc.pb.go b/looprpc/asset_reservations_grpc.pb.go new file mode 100644 index 000000000..9ab54fa1e --- /dev/null +++ b/looprpc/asset_reservations_grpc.pb.go @@ -0,0 +1,209 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. + +package looprpc + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +// AssetReservationsClient is the client API for AssetReservations service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type AssetReservationsClient interface { + Buy(ctx context.Context, in *BuyAssetReservationRequest, opts ...grpc.CallOption) (*ClientAssetReservation, error) + Get(ctx context.Context, in *ClientAssetReservationSelector, opts ...grpc.CallOption) (*ClientAssetReservation, error) + List(ctx context.Context, in *ListClientAssetReservationsRequest, opts ...grpc.CallOption) (*ListClientAssetReservationsResponse, error) + Approve(ctx context.Context, in *ApproveAssetReservationRequest, opts ...grpc.CallOption) (*ClientAssetReservation, error) +} + +type assetReservationsClient struct { + cc grpc.ClientConnInterface +} + +func NewAssetReservationsClient(cc grpc.ClientConnInterface) AssetReservationsClient { + return &assetReservationsClient{cc} +} + +func (c *assetReservationsClient) Buy(ctx context.Context, in *BuyAssetReservationRequest, opts ...grpc.CallOption) (*ClientAssetReservation, error) { + out := new(ClientAssetReservation) + err := c.cc.Invoke(ctx, "/looprpc.AssetReservations/Buy", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *assetReservationsClient) Get(ctx context.Context, in *ClientAssetReservationSelector, opts ...grpc.CallOption) (*ClientAssetReservation, error) { + out := new(ClientAssetReservation) + err := c.cc.Invoke(ctx, "/looprpc.AssetReservations/Get", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *assetReservationsClient) List(ctx context.Context, in *ListClientAssetReservationsRequest, opts ...grpc.CallOption) (*ListClientAssetReservationsResponse, error) { + out := new(ListClientAssetReservationsResponse) + err := c.cc.Invoke(ctx, "/looprpc.AssetReservations/List", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *assetReservationsClient) Approve(ctx context.Context, in *ApproveAssetReservationRequest, opts ...grpc.CallOption) (*ClientAssetReservation, error) { + out := new(ClientAssetReservation) + err := c.cc.Invoke(ctx, "/looprpc.AssetReservations/Approve", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AssetReservationsServer is the server API for AssetReservations service. +// All implementations must embed UnimplementedAssetReservationsServer +// for forward compatibility +type AssetReservationsServer interface { + Buy(context.Context, *BuyAssetReservationRequest) (*ClientAssetReservation, error) + Get(context.Context, *ClientAssetReservationSelector) (*ClientAssetReservation, error) + List(context.Context, *ListClientAssetReservationsRequest) (*ListClientAssetReservationsResponse, error) + Approve(context.Context, *ApproveAssetReservationRequest) (*ClientAssetReservation, error) + mustEmbedUnimplementedAssetReservationsServer() +} + +// UnimplementedAssetReservationsServer must be embedded to have forward compatible implementations. +type UnimplementedAssetReservationsServer struct { +} + +func (UnimplementedAssetReservationsServer) Buy(context.Context, *BuyAssetReservationRequest) (*ClientAssetReservation, error) { + return nil, status.Errorf(codes.Unimplemented, "method Buy not implemented") +} +func (UnimplementedAssetReservationsServer) Get(context.Context, *ClientAssetReservationSelector) (*ClientAssetReservation, error) { + return nil, status.Errorf(codes.Unimplemented, "method Get not implemented") +} +func (UnimplementedAssetReservationsServer) List(context.Context, *ListClientAssetReservationsRequest) (*ListClientAssetReservationsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method List not implemented") +} +func (UnimplementedAssetReservationsServer) Approve(context.Context, *ApproveAssetReservationRequest) (*ClientAssetReservation, error) { + return nil, status.Errorf(codes.Unimplemented, "method Approve not implemented") +} +func (UnimplementedAssetReservationsServer) mustEmbedUnimplementedAssetReservationsServer() {} + +// UnsafeAssetReservationsServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AssetReservationsServer will +// result in compilation errors. +type UnsafeAssetReservationsServer interface { + mustEmbedUnimplementedAssetReservationsServer() +} + +func RegisterAssetReservationsServer(s grpc.ServiceRegistrar, srv AssetReservationsServer) { + s.RegisterService(&AssetReservations_ServiceDesc, srv) +} + +func _AssetReservations_Buy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(BuyAssetReservationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AssetReservationsServer).Buy(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/looprpc.AssetReservations/Buy", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AssetReservationsServer).Buy(ctx, req.(*BuyAssetReservationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AssetReservations_Get_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ClientAssetReservationSelector) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AssetReservationsServer).Get(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/looprpc.AssetReservations/Get", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AssetReservationsServer).Get(ctx, req.(*ClientAssetReservationSelector)) + } + return interceptor(ctx, in, info, handler) +} + +func _AssetReservations_List_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListClientAssetReservationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AssetReservationsServer).List(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/looprpc.AssetReservations/List", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AssetReservationsServer).List(ctx, req.(*ListClientAssetReservationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AssetReservations_Approve_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ApproveAssetReservationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AssetReservationsServer).Approve(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/looprpc.AssetReservations/Approve", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AssetReservationsServer).Approve(ctx, req.(*ApproveAssetReservationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// AssetReservations_ServiceDesc is the grpc.ServiceDesc for AssetReservations service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AssetReservations_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "looprpc.AssetReservations", + HandlerType: (*AssetReservationsServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Buy", + Handler: _AssetReservations_Buy_Handler, + }, + { + MethodName: "Get", + Handler: _AssetReservations_Get_Handler, + }, + { + MethodName: "List", + Handler: _AssetReservations_List_Handler, + }, + { + MethodName: "Approve", + Handler: _AssetReservations_Approve_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "asset_reservations.proto", +} diff --git a/looprpc/perms.go b/looprpc/perms.go index 9187920a7..ffb00f409 100644 --- a/looprpc/perms.go +++ b/looprpc/perms.go @@ -5,6 +5,22 @@ import "gopkg.in/macaroon-bakery.v2/bakery" // RequiredPermissions is a map of all loop RPC methods and their // required macaroon permissions to access loopd. var RequiredPermissions = map[string][]bakery.Op{ + "/looprpc.AssetReservations/Buy": {{ + Entity: "swap", + Action: "execute", + }}, + "/looprpc.AssetReservations/Approve": {{ + Entity: "swap", + Action: "execute", + }}, + "/looprpc.AssetReservations/Get": {{ + Entity: "swap", + Action: "read", + }}, + "/looprpc.AssetReservations/List": {{ + Entity: "swap", + Action: "read", + }}, "/looprpc.SwapClient/LoopOut": {{ Entity: "swap", Action: "execute", From 0958610937d92c536a71955261bc03137af30788 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 14:24:10 +0200 Subject: [PATCH 17/25] assets/reservation: Preserve purchase facts across restarts Retain the immutable quote, main probe, fee estimates, skip preference, prepay routing cap, payment identity, and delivery facts. Save progress and state history in one transaction, and prevent changes to payment choices once the purchase leaves approval. Check the quote hash, routing cap, and probe choice on approval. The hash binds consent to both quoted amounts. Read those amounts from the quote and payment choices from the reservation during recovery. Require a successful main probe unless the caller permits buying without one. Estimate prepay routing fees from the main fee and BTC amount ratio, rounding up and ignoring fixed hop fees. Test persistence, conflicting updates, quote consent, routing caps, outpoints, and fee arithmetic. Bind the probe hash to the client-generated reservation ID. Preserve the probe request, node, deadline, and terminal result across writes and restarts; the purchase keeps one quote and one probe. Save live route fee availability with purchase progress, including an observed zero fee. Missing attempt history does not imply a zero fee. Filter startup recovery in SQL to exclude canceled and expired purchases. Read matching reservations and their latest state and timestamp in one statement, avoiding full history reads and mixed PostgreSQL snapshots. Support either one named state or active purchases in manager list calls. Test filtering, single-statement reads, and recovery past completed records. Preserve the original group request independently of the quoted funding issuance. Reject changed quote or payment issuances and exclude failed quote acquisition from startup recovery. --- assets/reservation/interface.go | 51 +++- assets/reservation/probe.go | 16 + assets/reservation/purchase.go | 102 +++++++ assets/reservation/purchase_test.go | 79 +++++ assets/reservation/reservation.go | 326 ++++++++++++++++++++- assets/reservation/selector_test.go | 54 ++++ assets/reservation/sql_purchase.go | 177 +++++++++++ assets/reservation/sql_purchase_test.go | 223 ++++++++++++++ assets/reservation/sql_store.go | 153 ++++++---- assets/reservation/sql_store_test.go | 147 +++++++++- loopdb/sqlc/asset_reservations.sql.go | 175 ++++++++--- loopdb/sqlc/querier.go | 3 +- loopdb/sqlc/queries/asset_reservations.sql | 27 +- 13 files changed, 1408 insertions(+), 125 deletions(-) create mode 100644 assets/reservation/probe.go create mode 100644 assets/reservation/purchase.go create mode 100644 assets/reservation/purchase_test.go create mode 100644 assets/reservation/selector_test.go create mode 100644 assets/reservation/sql_purchase.go create mode 100644 assets/reservation/sql_purchase_test.go diff --git a/assets/reservation/interface.go b/assets/reservation/interface.go index 7caabf742..b4fb66010 100644 --- a/assets/reservation/interface.go +++ b/assets/reservation/interface.go @@ -3,8 +3,36 @@ package reservation import ( "context" "errors" + "fmt" + + "github.com/lightninglabs/loop/fsm" ) +// StateFilter selects reservations by their latest saved state. State and +// ActiveOnly are mutually exclusive; neither means all states. ActiveOnly +// excludes QuoteFailed, QuoteRejected, Canceled and Expired, retaining Ready +// and NeedAdminAttention. +type StateFilter struct { + State fsm.StateType + ActiveOnly bool +} + +// Validate rejects conflicting filters and unknown state names. +func (f StateFilter) Validate() error { + if f.ActiveOnly && f.State != "" { + return errors.New("state and active_only are mutually exclusive") + } + switch f.State { + case "", RequestQuote, ProbeRoutes, AwaitApproval, PayPrepay, + WaitForDelivery, VerifyReservation, Ready, CancelPrepay, + QuoteFailed, QuoteRejected, Canceled, Expired, NeedAdminAttention: + + default: + return fmt.Errorf("unknown reservation state %q", f.State) + } + return nil +} + var ( // ErrNotFound means the requested reservation is unavailable. ErrNotFound = errors.New("asset reservation not found") @@ -22,21 +50,22 @@ type Store interface { // On success, the supplied reservation includes its stored timestamps. CreateReservation(context.Context, *Reservation) error - // UpdateReservation saves progress and a state history entry atomically. - // It preserves agreed terms and client keys, returning ErrRequestConflict - // if they change, or ErrNotFound if the ID is absent. On success, the - // supplied reservation includes its stored timestamps. + // UpdateReservation saves changed purchase facts and a state history entry + // atomically. It accepts the first quote, then preserves agreed terms and + // established payment and delivery facts. It returns ErrNotFound for an + // unknown ID or ErrRequestConflict for changes to protected values. + // Unchanged progress adds no history entry. On success, the supplied + // reservation reflects the saved values, including its timestamps. UpdateReservation(context.Context, *Reservation) error - // GetReservation loads a validated snapshot of the reservation's terms - // and latest state in one transaction. It returns + // GetReservation loads a validated snapshot of the reservation's terms, + // purchase facts, and latest state in one transaction. It returns // ErrNotFound if the ID is absent. Changing the returned value does not // change storage until UpdateReservation succeeds. GetReservation(context.Context, ID) (*Reservation, error) - // GetReservations loads validated snapshots of all saved reservations in - // one transaction, including canceled and expired purchases. The manager - // selects which ones need recovery. Invalid stored data fails the whole - // read so recovery cannot silently omit a reservation. - GetReservations(context.Context) ([]*Reservation, error) + // GetReservations reads matching records and their latest state together + // in one statement. Invalid matching data fails the read rather than + // silently omitting a purchase. + GetReservations(context.Context, StateFilter) ([]*Reservation, error) } diff --git a/assets/reservation/probe.go b/assets/reservation/probe.go new file mode 100644 index 000000000..e6ab80ad3 --- /dev/null +++ b/assets/reservation/probe.go @@ -0,0 +1,16 @@ +package reservation + +import ( + "crypto/sha256" + + "github.com/lightningnetwork/lnd/lntypes" +) + +// ProbeHash binds the sole probe invoice to a client-generated purchase ID. +// As with conventional Loop In probes, flipping a bit of the digest ensures +// the known input is not its preimage. Neither party can settle this invoice. +func ProbeHash(id ID) lntypes.Hash { + hash := sha256.Sum256(id[:]) + hash[0] ^= 1 + return hash +} diff --git a/assets/reservation/purchase.go b/assets/reservation/purchase.go new file mode 100644 index 000000000..706a46d67 --- /dev/null +++ b/assets/reservation/purchase.go @@ -0,0 +1,102 @@ +package reservation + +import ( + "crypto/sha256" + "errors" + "math/big" + "time" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/swapserverrpc" + "google.golang.org/protobuf/proto" +) + +// ProbeOutcome describes reachability, not a promise of payment success. +type ProbeOutcome uint8 + +const ( + ProbeNotRun ProbeOutcome = iota + ProbeSucceeded + ProbeFailed + ProbeTimedOut + ProbeUnsupported +) + +// ProbeResults retains the main probe and fee estimates shown before approval. +type ProbeResults struct { + // Main reports whether the estimated swap payment reached its destination. + Main ProbeOutcome + + // PrepayFeeMsat scales MainFeeMsat by the BTC payment amounts, rounded up. + // It ignores fixed hop fees; availability requires success and FeeKnown. + PrepayFeeMsat uint64 + + // FeeKnown distinguishes a captured route fee from missing history. + FeeKnown bool + + // MainFeeMsat is the most recently observed in-flight route's fee. + // It is an estimate; LND may retry with a different route. + MainFeeMsat uint64 + + // CheckedAt records when the main probe completed. + CheckedAt time.Time +} + +// QuoteHash identifies the exact quote the user approved. +func QuoteHash(q *swapserverrpc.AssetReservationQuote) ([32]byte, error) { + if q == nil { + return [32]byte{}, errors.New("missing reservation quote") + } + data, err := (proto.MarshalOptions{ + Deterministic: true, + }).Marshal(q) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(data), nil +} + +// TermsFromRPC checks a server offer without imposing client pricing policy. +func TermsFromRPC(t *swapserverrpc.AssetReservationTerms) (Terms, error) { + if t == nil || len(t.AssetId) != 32 { + return Terms{}, errors.New("invalid reservation terms") + } + terms := Terms{ + AssetID: [32]byte(t.AssetId), + Amount: t.Amount, + Fee: t.Fee, + CSVDelay: t.CsvDelay, + RequiredConfirmations: t.RequiredConfirmations, + ExecutionDelta: t.ExecutionDelta, + } + return terms, terms.Validate() +} + +// ParseOutpoint accepts exactly the canonical txid:vout representation. +func ParseOutpoint(value string) (*wire.OutPoint, error) { + outpoint, err := wire.NewOutPointFromString(value) + if err != nil || outpoint.String() != value { + return nil, errors.New("invalid reservation outpoint") + } + return outpoint, nil +} + +// estimatePrepayRoutingFee scales the main fee by the BTC payment amounts. +// It rounds up to a millisatoshi and deliberately ignores fixed hop fees. +func estimatePrepayRoutingFee(mainFee, prepayAmount, mainAmount uint64) ( + uint64, error) { + + if prepayAmount == 0 || mainAmount == 0 { + return 0, errors.New("missing amount for prepay fee estimate") + } + // Multiply before dividing, using a wide integer so large valid quotes + // cannot overflow or lose precision through floating-point arithmetic. + fee := new(big.Int).SetUint64(mainFee) + fee.Mul(fee, new(big.Int).SetUint64(prepayAmount)) + fee.Add(fee, new(big.Int).SetUint64(mainAmount-1)) + fee.Quo(fee, new(big.Int).SetUint64(mainAmount)) + if !fee.IsInt64() { + return 0, errors.New("prepay routing fee estimate is out of range") + } + return fee.Uint64(), nil +} diff --git a/assets/reservation/purchase_test.go b/assets/reservation/purchase_test.go new file mode 100644 index 000000000..65784f161 --- /dev/null +++ b/assets/reservation/purchase_test.go @@ -0,0 +1,79 @@ +package reservation + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEstimatePrepayRoutingFee(t *testing.T) { + for _, tc := range []struct { + name string + fee, prepay, main, want uint64 + wantErr bool + }{ + { + name: "proportional", + fee: 10_000, + prepay: 11_000, + main: 10_000_000, + want: 11, + }, + { + name: "round up", + fee: 10, + prepay: 11_000, + main: 10_000_000, + want: 1, + }, + { + name: "free route", + prepay: 11_000, + main: 10_000_000, + }, + { + name: "larger prepay", + fee: 10, + prepay: 200, + main: 100, + want: 20, + }, + { + name: "wide product", + fee: math.MaxInt64, + prepay: math.MaxInt64, + main: math.MaxInt64, + want: math.MaxInt64, + }, + { + name: "result out of range", + fee: math.MaxInt64, + prepay: 2, + main: 1, + wantErr: true, + }, + { + name: "missing main", + fee: 1, + prepay: 1, + wantErr: true, + }, + { + name: "missing prepay", + fee: 1, + main: 1, + wantErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := estimatePrepayRoutingFee(tc.fee, tc.prepay, tc.main) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/assets/reservation/reservation.go b/assets/reservation/reservation.go index 4a203e718..948093519 100644 --- a/assets/reservation/reservation.go +++ b/assets/reservation/reservation.go @@ -1,13 +1,22 @@ package reservation import ( + "bytes" "crypto/rand" "errors" "math" "time" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lntypes" + "google.golang.org/protobuf/proto" ) // ID identifies a purchase before and after its funding outpoint exists. @@ -20,6 +29,23 @@ func NewID() (ID, error) { return id, err } +// States name the action resumed after a restart. +const ( + RequestQuote fsm.StateType = "RequestQuote" + QuoteFailed fsm.StateType = "QuoteFailed" + QuoteRejected fsm.StateType = "QuoteRejected" + ProbeRoutes fsm.StateType = "ProbeRoutes" + AwaitApproval fsm.StateType = "AwaitApproval" + PayPrepay fsm.StateType = "PayPrepay" + WaitForDelivery fsm.StateType = "WaitForDelivery" + VerifyReservation fsm.StateType = "VerifyReservation" + Ready fsm.StateType = "Ready" + CancelPrepay fsm.StateType = "CancelPrepay" + Canceled fsm.StateType = "Canceled" + Expired fsm.StateType = "Expired" + NeedAdminAttention fsm.StateType = "NeedAdminAttention" +) + // Reservation holds the client's agreed terms and progress. The FSM owns the // live record; status readers use copies loaded from the store. type Reservation struct { @@ -27,7 +53,7 @@ type Reservation struct { // lifetime once a quote is validated. Terms - // GroupKey preserves the original selector after choosing an issuance. + // GroupKey preserves the original selector even after choosing an issuance. GroupKey [33]byte // ID identifies this purchase across retries and restarts. @@ -44,6 +70,40 @@ type Reservation struct { // UpdatedAt records when the stored purchase last changed. UpdatedAt time.Time + + // Quote holds the validated offer; nil means no quote has been saved. + Quote *swapserverrpc.AssetReservationQuote + + // SkipProbe skips automatic probing before approval. Approval saves the + // caller's choice with PayPrepay, where it permits payment without a + // successful main probe. This flag alone never authorizes payment. + // Background recovery preserves this choice. + SkipProbe bool + + // Probes holds the main probe outcome and both routing fee estimates. + Probes ProbeResults + + // The single probe request is saved before sending and never replaced. + ProbeNodeKey *btcec.PublicKey + ProbeRequest *routerrpc.SendPaymentRequest + ProbeResult *lnrpc.Payment + ProbeDeadline time.Time + + // MaxRouteFeeMsat caps the prepay routing fee in millisatoshis. Save it + // with the transition to PayPrepay; that state records consent to pay. + MaxRouteFeeMsat uint64 + + // Payment identity and request are saved before the first send. A node + // change must not turn an unknown payment into permission to pay again. + PaymentHash lntypes.Hash + PayingNodeKey *btcec.PublicKey + PaymentRequest *routerrpc.SendPaymentRequest + PaymentResult *lnrpc.Payment + + FundingOutpoint *wire.OutPoint + ConfirmationHeight uint32 + PrepayCredit uint64 + ReservationProof []byte } // Validate checks the record before storage. State transitions belong to the @@ -58,5 +118,267 @@ func (r *Reservation) Validate() error { if _, err := ParseAssetSelector(r.selector().Bytes()); err != nil { return err } - return r.Terms.Validate() + if r.Fee == 0 { + if r.GroupKey != ([33]byte{}) && r.AssetID != ([32]byte{}) { + return errors.New("unquoted group reservation has an issuance") + } + requested := Terms{ + AssetID: r.AssetID, + Amount: r.Amount, + } + if r.Terms != requested || + r.Amount == 0 || r.Amount > math.MaxInt64 || r.Quote != nil { + + return errors.New("invalid unquoted reservation") + } + } else if err := r.Terms.Validate(); err != nil { + return err + } + return r.validatePurchase() +} + +// ValidateQuote checks the offer's binding and amounts. The payment adapter +// must also decode both invoices and validate network, hashes, destinations, +// hints, exact values, final CLTV, expiry, and the receiving RFQ. +func (r *Reservation) ValidateQuote( + q *swapserverrpc.AssetReservationQuote) error { + + if q == nil || !bytes.Equal(q.ReservationId, r.ID[:]) || + !bytes.Equal(q.ClientKey, r.ClientKey.PubKey.SerializeCompressed()) { + + return errors.New("quote does not match reservation request") + } + terms, err := TermsFromRPC(q.Terms) + if err != nil { + return err + } + if r.GroupKey == ([33]byte{}) { + if q.Group != nil || terms.AssetID != r.AssetID { + return errors.New("quote changes exact issuance") + } + } else if !bytes.Equal(q.GetGroup().GetGroupKey(), r.GroupKey[:]) || + len(q.Group.PaymentAssetId) != 32 || [32]byte(q.Group.PaymentAssetId) == ([32]byte{}) { + + return errors.New("quote changes requested group or lacks payment issuance") + } + if terms.Amount != r.Amount || + (r.Fee != 0 && terms != r.Terms) { + + return errors.New("quote changes reservation terms") + } + for _, key := range [][]byte{q.ServerKey, q.ReceivingNodeKey, q.EdgeKey} { + if len(key) != btcec.PubKeyBytesLenCompressed { + return errors.New("invalid quote public key") + } + if _, err := btcec.ParsePubKey(key); err != nil { + return errors.New("invalid quote public key") + } + } + if bytes.Equal(q.ServerKey, q.ClientKey) || q.PrepayInvoice == "" || + q.ProbeInvoice == "" || q.PrepayInvoice == q.ProbeInvoice || + q.PrepayAmountMsat == 0 || q.PrepayAmountMsat > math.MaxInt64 || + q.EstimatedMainAmountMsat == 0 || + q.EstimatedMainAmountMsat > math.MaxInt64 || q.ExpiresAt <= 0 { + + return errors.New("invalid reservation quote") + } + + return nil +} + +// CheckApproval validates a caller's consent against the immutable quote. +// Accepting its hash accepts the quoted asset fee and BTC prepay amount. +// Routing fees and permission to buy without a successful probe are separate. +func (r *Reservation) CheckApproval( + a *looprpc.ApproveAssetReservationRequest) error { + + if a == nil || r.Quote == nil || len(a.QuoteHash) != 32 { + return errors.New("missing reservation approval") + } + hash, err := QuoteHash(r.Quote) + if err != nil { + return err + } + if !bytes.Equal(hash[:], a.QuoteHash) { + return errors.New("approval does not match reservation quote") + } + if a.MaxRouteFeeMsat > math.MaxInt64 { + return errors.New("reservation routing fee limit is out of range") + } + return r.checkProbe(a.SkipProbe) +} + +// checkPrepay permits preparation only after the approval transition was saved. +func (r *Reservation) checkPrepay() error { + if r.State != PayPrepay { + return errors.New("reservation is not approved for prepay") + } + return r.checkPaymentLimits() +} + +// checkPaymentLimits requires a saved quote, a routing cap that fits storage, +// and either a successful probe or explicit permission to skip it. +func (r *Reservation) checkPaymentLimits() error { + if r.Quote == nil || r.MaxRouteFeeMsat > math.MaxInt64 { + return errors.New("invalid reservation payment limits") + } + return r.checkProbe(r.SkipProbe) +} + +// checkProbe requires a recorded successful probe unless skipping was allowed. +// Probe success describes past reachability, not guaranteed future liquidity. +func (r *Reservation) checkProbe(skipProbe bool) error { + if !skipProbe && (r.Probes.CheckedAt.IsZero() || + r.Probes.Main != ProbeSucceeded) { + + return errors.New("reservation requires a successful main probe " + + "or explicit approval to skip probing") + } + return nil +} + +// validatePurchase checks that saved quote, probe, payment, and delivery facts +// are internally consistent. It does not query LND or verify the on-chain proof; +// recovery must reconcile those external facts before continuing the purchase. +func (r *Reservation) validatePurchase() error { + if r.Quote != nil { + if err := r.ValidateQuote(r.Quote); err != nil { + return err + } + } + if r.ProbeRequest != nil { + if r.Quote == nil || r.ProbeNodeKey == nil || + r.ProbeDeadline.IsZero() || + r.ProbeRequest.PaymentRequest != r.Quote.ProbeInvoice || + r.ProbeRequest.MaxParts != 1 || + r.ProbeRequest.TimeoutSeconds <= 0 { + + return errors.New("invalid saved probe request") + } + } else if r.ProbeNodeKey != nil || !r.ProbeDeadline.IsZero() || + r.ProbeResult != nil { + + return errors.New("incomplete saved probe") + } + if r.ProbeResult != nil && + (r.ProbeResult.PaymentHash != ProbeHash(r.ID).String() || + r.ProbeResult.Status != lnrpc.Payment_FAILED) { + + return errors.New("invalid terminal probe result") + } + if r.Probes.Main > ProbeUnsupported || + r.Probes.PrepayFeeMsat > math.MaxInt64 || + r.Probes.MainFeeMsat > math.MaxInt64 { + + return errors.New("invalid reservation probe results") + } + if r.MaxRouteFeeMsat > math.MaxInt64 { + return errors.New("reservation routing fee limit is out of range") + } + if r.State == PayPrepay || r.PaymentRequest != nil { + if err := r.checkPaymentLimits(); err != nil { + return err + } + } + if r.PaymentRequest != nil { + if r.awaitingApproval() || r.PaymentHash == (lntypes.Hash{}) || + r.PayingNodeKey == nil || + r.PaymentRequest.PaymentRequest != r.Quote.PrepayInvoice || + r.PaymentRequest.FeeLimitSat != 0 || + r.PaymentRequest.FeeLimitMsat < 0 || + uint64(r.PaymentRequest.FeeLimitMsat) > r.MaxRouteFeeMsat { + + return errors.New("invalid saved prepay payment") + } + } else if r.PaymentHash != (lntypes.Hash{}) || r.PayingNodeKey != nil { + return errors.New("incomplete saved prepay payment") + } + if r.PaymentResult != nil { + if r.PaymentRequest == nil || + r.PaymentResult.PaymentHash != r.PaymentHash.String() { + + return errors.New("payment result does not match prepay") + } + if r.PaymentResult.Status == lnrpc.Payment_SUCCEEDED { + if r.PaymentResult.ValueMsat <= 0 || + uint64(r.PaymentResult.ValueMsat) != + r.Quote.PrepayAmountMsat || + r.PaymentResult.FeeMsat < 0 || + r.PaymentResult.FeeMsat > r.PaymentRequest.FeeLimitMsat { + + return errors.New("prepay receipt exceeds approved amounts") + } + preimage, err := lntypes.MakePreimageFromStr( + r.PaymentResult.PaymentPreimage, + ) + if err != nil || preimage.Hash() != r.PaymentHash { + return errors.New("invalid prepay settlement preimage") + } + } + } + if r.ConfirmationHeight > math.MaxInt32 || r.PrepayCredit > r.Fee || + (r.PrepayCredit != 0 && r.PrepayCredit != r.Fee) { + + return errors.New("invalid reservation delivery facts") + } + return nil +} + +// awaitingApproval identifies the states in which purchase choices may change. +// Once a purchase leaves these states, its choices stay fixed through payment, +// cancellation, delivery, and recovery. +func (r *Reservation) awaitingApproval() bool { + return r.State == RequestQuote || r.State == ProbeRoutes || + r.State == AwaitApproval +} + +// preservePurchase rejects updates that replace already-fixed purchase facts. +// Quotes, payment identities, terminal results, and recorded delivery facts stay +// unchanged; approval choices also become fixed once approval is left behind. +// New facts may be filled in as the purchase progresses. +func preservePurchase(next, saved *Reservation) error { + if saved.ProbeRequest != nil && + (!proto.Equal(next.ProbeRequest, saved.ProbeRequest) || + next.ProbeNodeKey == nil || + !next.ProbeNodeKey.IsEqual(saved.ProbeNodeKey) || + !next.ProbeDeadline.Equal(saved.ProbeDeadline)) { + + return ErrRequestConflict + } + if saved.ProbeResult != nil && + !proto.Equal(next.ProbeResult, saved.ProbeResult) { + + return ErrRequestConflict + } + if (saved.Fee != 0 && next.Terms != saved.Terms) || + (saved.Quote != nil && !proto.Equal(next.Quote, saved.Quote)) || + (!saved.awaitingApproval() && (next.awaitingApproval() || + next.MaxRouteFeeMsat != saved.MaxRouteFeeMsat || + next.Probes != saved.Probes || + next.SkipProbe != saved.SkipProbe)) || + (saved.PaymentRequest != nil && + (!proto.Equal(next.PaymentRequest, saved.PaymentRequest) || + next.PaymentHash != saved.PaymentHash || + next.PayingNodeKey == nil || + !next.PayingNodeKey.IsEqual(saved.PayingNodeKey))) || + (saved.FundingOutpoint != nil && (next.FundingOutpoint == nil || + *next.FundingOutpoint != *saved.FundingOutpoint)) || + (saved.ConfirmationHeight != 0 && + next.ConfirmationHeight != saved.ConfirmationHeight) || + (saved.PrepayCredit != 0 && next.PrepayCredit != saved.PrepayCredit) || + (len(saved.ReservationProof) != 0 && + !bytes.Equal( + next.ReservationProof, saved.ReservationProof, + )) { + + return ErrRequestConflict + } + if saved.PaymentResult != nil && + (saved.PaymentResult.Status == lnrpc.Payment_SUCCEEDED || + saved.PaymentResult.Status == lnrpc.Payment_FAILED) && + !proto.Equal(next.PaymentResult, saved.PaymentResult) { + + return ErrRequestConflict + } + return nil } diff --git a/assets/reservation/selector_test.go b/assets/reservation/selector_test.go new file mode 100644 index 000000000..3e1289b46 --- /dev/null +++ b/assets/reservation/selector_test.go @@ -0,0 +1,54 @@ +package reservation + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestGroupPurchaseIdentity(t *testing.T) { + _, pub := btcec.PrivKeyFromBytes([]byte{3}) + group := pub.SerializeCompressed() + fund, pay := [32]byte{1}, [32]byte{2} + _, err := ParseAssetSelector(fund[:], group) + require.Error(t, err) + _, err = ParseAssetSelector(nil, group[1:]) + require.Error(t, err) + selector, err := ParseAssetSelector(nil, group) + require.NoError(t, err) + r := testReservation() + r.GroupKey = selector.GroupKey + r.Terms = Terms{Amount: r.Amount} + r.State = RequestQuote + store := NewSqlStore(loopdb.NewTestDB(t).BaseDB) + ctx := t.Context() + require.NoError(t, store.CreateReservation(ctx, r)) + q := testPurchaseQuote(r) + q.Terms.AssetId = fund[:] + q.Group = &swapserverrpc.AssetReservationGroup{GroupKey: group, PaymentAssetId: pay[:]} + require.NoError(t, r.ValidateQuote(q)) + wrong := proto.Clone(q).(*swapserverrpc.AssetReservationQuote) + wrong.Group = nil + require.Error(t, r.ValidateQuote(wrong)) + r.Quote = q + r.Terms, err = TermsFromRPC(q.Terms) + require.NoError(t, err) + r.State = AwaitApproval + require.NoError(t, store.UpdateReservation(ctx, r)) + saved, err := store.GetReservation(ctx, r.ID) + require.NoError(t, err) + require.Equal(t, selector, saved.selector()) + require.Equal(t, fund, saved.AssetID) + require.Equal(t, pay[:], saved.Quote.Group.PaymentAssetId) + // Changing just the Lightning issuance must invalidate both the + // approval hash and a replay of the stored quote. + approval := testApproval(t, saved) + saved.Quote.Group.PaymentAssetId = fund[:] + require.ErrorContains(t, saved.CheckApproval(approval), + "approval does not match reservation quote") + require.Error(t, store.UpdateReservation(ctx, saved)) +} diff --git a/assets/reservation/sql_purchase.go b/assets/reservation/sql_purchase.go new file mode 100644 index 000000000..5b80377a2 --- /dev/null +++ b/assets/reservation/sql_purchase.go @@ -0,0 +1,177 @@ +package reservation + +import ( + "context" + "database/sql" + "errors" + "math" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lntypes" + "google.golang.org/protobuf/proto" +) + +func updatePurchase(ctx context.Context, q *sqlc.Queries, + r *Reservation) error { + + args := sqlc.UpdateAssetReservationPurchaseParams{ + ReservationID: r.ID[:], + Fee: int64(r.Fee), + CsvDelay: int32(r.CSVDelay), + RequiredConfirmations: int32(r.RequiredConfirmations), + ExecutionDelta: int32(r.ExecutionDelta), + MainProbe: int32(r.Probes.Main), + ProbeFeeKnown: r.Probes.FeeKnown, + SkipProbe: r.SkipProbe, + MaxRouteFeeMsat: int64(r.MaxRouteFeeMsat), + PrepayRouteFeeMsat: int64(r.Probes.PrepayFeeMsat), + MainRouteFeeMsat: int64(r.Probes.MainFeeMsat), + ConfirmationHeight: int64(r.ConfirmationHeight), + PrepayCredit: int64(r.PrepayCredit), + DepositProof: r.ReservationProof, + } + if !r.Probes.CheckedAt.IsZero() { + args.ProbesCheckedAt = sql.NullTime{ + Time: r.Probes.CheckedAt, + Valid: true, + } + } + if r.ProbeNodeKey != nil { + args.ProbeNodeKey = r.ProbeNodeKey.SerializeCompressed() + } + if !r.ProbeDeadline.IsZero() { + args.ProbeDeadline = sql.NullTime{Time: r.ProbeDeadline, Valid: true} + } + if r.PaymentRequest != nil { + args.PaymentHash = r.PaymentHash[:] + args.PayingNodeKey = r.PayingNodeKey.SerializeCompressed() + } + if r.FundingOutpoint != nil { + args.FundingOutpoint = sql.NullString{ + String: r.FundingOutpoint.String(), + Valid: true, + } + } + for _, field := range []struct { + message proto.Message + column *[]byte + }{ + {r.ProbeRequest, &args.ProbeRequest}, + {r.ProbeResult, &args.ProbeResult}, + { + r.Quote, + &args.Quote, + }, + { + r.PaymentRequest, + &args.PaymentRequest, + }, + { + r.PaymentResult, + &args.PaymentResult, + }, + } { + if !field.message.ProtoReflect().IsValid() { + continue + } + data, err := proto.Marshal(field.message) + if err != nil { + return err + } + *field.column = data + } + count, err := q.UpdateAssetReservationPurchase(ctx, args) + if err != nil { + return err + } + if count != 1 { + return ErrNotFound + } + return nil +} + +func readPurchase(row sqlc.AssetReservation, r *Reservation) error { + if len(row.ProbeRequest) != 0 { + var err error + r.ProbeNodeKey, err = btcec.ParsePubKey(row.ProbeNodeKey) + if err != nil { + return err + } + r.ProbeRequest = &routerrpc.SendPaymentRequest{} + if err := proto.Unmarshal(row.ProbeRequest, r.ProbeRequest); err != nil { + return err + } + } + if len(row.ProbeResult) != 0 { + r.ProbeResult = &lnrpc.Payment{} + if err := proto.Unmarshal(row.ProbeResult, r.ProbeResult); err != nil { + return err + } + } + if row.ProbeDeadline.Valid { + r.ProbeDeadline = row.ProbeDeadline.Time.UTC() + } + r.SkipProbe = row.SkipProbe + r.MaxRouteFeeMsat = uint64(row.MaxRouteFeeMsat) + r.Probes = ProbeResults{ + Main: ProbeOutcome(row.MainProbe), + FeeKnown: row.ProbeFeeKnown, + PrepayFeeMsat: uint64(row.PrepayRouteFeeMsat), + MainFeeMsat: uint64(row.MainRouteFeeMsat), + } + if row.ProbesCheckedAt.Valid { + r.Probes.CheckedAt = row.ProbesCheckedAt.Time.UTC() + } + if len(row.Quote) != 0 { + r.Quote = &swapserverrpc.AssetReservationQuote{} + if err := proto.Unmarshal(row.Quote, r.Quote); err != nil { + return err + } + if r.GroupKey != ([33]byte{}) { + t, err := TermsFromRPC(r.Quote.Terms) + if err != nil { + return err + } + r.AssetID = t.AssetID + } + } + if len(row.PaymentRequest) != 0 { + if len(row.PaymentHash) != 32 || len(row.PayingNodeKey) != 33 { + return errors.New("invalid saved payment identity") + } + var err error + r.PayingNodeKey, err = btcec.ParsePubKey(row.PayingNodeKey) + if err != nil { + return err + } + r.PaymentHash = lntypes.Hash(row.PaymentHash) + r.PaymentRequest = &routerrpc.SendPaymentRequest{} + if err := proto.Unmarshal(row.PaymentRequest, r.PaymentRequest); err != nil { + return err + } + } + if len(row.PaymentResult) != 0 { + r.PaymentResult = &lnrpc.Payment{} + if err := proto.Unmarshal(row.PaymentResult, r.PaymentResult); err != nil { + return err + } + } + if row.FundingOutpoint.Valid { + var err error + r.FundingOutpoint, err = ParseOutpoint(row.FundingOutpoint.String) + if err != nil { + return err + } + } + if row.ConfirmationHeight < 0 || row.ConfirmationHeight > math.MaxInt32 { + return errors.New("invalid saved confirmation height") + } + r.ConfirmationHeight = uint32(row.ConfirmationHeight) + r.PrepayCredit = uint64(row.PrepayCredit) + r.ReservationProof = row.DepositProof + return nil +} diff --git a/assets/reservation/sql_purchase_test.go b/assets/reservation/sql_purchase_test.go new file mode 100644 index 000000000..178556fdd --- /dev/null +++ b/assets/reservation/sql_purchase_test.go @@ -0,0 +1,223 @@ +package reservation + +import ( + "context" + "math" + "strings" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func testPurchaseQuote(r *Reservation) *swapserverrpc.AssetReservationQuote { + _, server := btcec.PrivKeyFromBytes([]byte{2}) + _, edge := btcec.PrivKeyFromBytes([]byte{3}) + return &swapserverrpc.AssetReservationQuote{ + ReservationId: r.ID[:], + Terms: &swapserverrpc.AssetReservationTerms{ + AssetId: r.AssetID[:], + Amount: r.Amount, + Fee: 11, + CsvDelay: 1440, + RequiredConfirmations: 3, + ExecutionDelta: 90, + }, + ClientKey: r.ClientKey.PubKey.SerializeCompressed(), + ServerKey: server.SerializeCompressed(), + ReceivingNodeKey: server.SerializeCompressed(), + EdgeKey: edge.SerializeCompressed(), + PrepayInvoice: "prepay", + ProbeInvoice: "probe only", + PrepayAmountMsat: 11000, + EstimatedMainAmountMsat: 10000000, + ExpiresAt: 1900000000, + } +} + +func testApproval(t *testing.T, r *Reservation) *looprpc.ApproveAssetReservationRequest { + t.Helper() + hash, err := QuoteHash(r.Quote) + require.NoError(t, err) + return &looprpc.ApproveAssetReservationRequest{ + QuoteHash: hash[:], + MaxRouteFeeMsat: 100, + } +} + +func TestPurchaseStore(t *testing.T) { + ctx := t.Context() + db := loopdb.NewTestDB(t) + store := NewSqlStore(db.BaseDB) + r := testReservation() + r.Terms = Terms{ + AssetID: r.AssetID, + Amount: r.Amount, + } + r.State = "RequestQuote" + require.NoError(t, store.CreateReservation(ctx, r)) + request := *r + r.Quote = testPurchaseQuote(r) + var err error + r.Terms, err = TermsFromRPC(r.Quote.Terms) + require.NoError(t, err) + r.Probes = ProbeResults{ + FeeKnown: true, + Main: ProbeSucceeded, + CheckedAt: time.Unix(1800000000, 0).UTC(), + } + r.ProbeNodeKey = r.ClientKey.PubKey + r.ProbeRequest = &routerrpc.SendPaymentRequest{ + PaymentRequest: r.Quote.ProbeInvoice, + TimeoutSeconds: 10, MaxParts: 1, + } + r.ProbeDeadline = time.Unix(1800000010, 0).UTC() + r.ProbeResult = &lnrpc.Payment{ + PaymentHash: ProbeHash(r.ID).String(), + Status: lnrpc.Payment_FAILED, + } + r.State = "AwaitApproval" + require.NoError(t, store.UpdateReservation(ctx, r)) + r.MaxRouteFeeMsat = 100 + r.State = PayPrepay + require.NoError(t, store.UpdateReservation(ctx, r)) + + preimage := lntypes.Preimage{9} + r.PaymentHash = preimage.Hash() + r.PayingNodeKey = r.ClientKey.PubKey + r.PaymentRequest = &routerrpc.SendPaymentRequest{ + PaymentRequest: r.Quote.PrepayInvoice, + FeeLimitMsat: 100, + } + r.State = "PayPrepay" + require.NoError(t, store.UpdateReservation(ctx, r)) + r.PaymentResult = &lnrpc.Payment{ + PaymentHash: r.PaymentHash.String(), + PaymentPreimage: preimage.String(), + Status: lnrpc.Payment_SUCCEEDED, + ValueMsat: int64(r.Quote.PrepayAmountMsat), + } + r.FundingOutpoint = &wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 2, + } + r.ConfirmationHeight = 100 + r.PrepayCredit = 11 + r.ReservationProof = []byte{1, 2, 3} + r.State = "Ready" + require.NoError(t, store.UpdateReservation(ctx, r)) + loaded, err := NewSqlStore(db.BaseDB).GetReservation(ctx, r.ID) + require.NoError(t, err) + require.True(t, proto.Equal(r.Quote, loaded.Quote)) + require.True(t, proto.Equal(r.PaymentRequest, loaded.PaymentRequest)) + require.True(t, proto.Equal(r.PaymentResult, loaded.PaymentResult)) + require.Equal(t, r.MaxRouteFeeMsat, loaded.MaxRouteFeeMsat) + require.Equal(t, r.SkipProbe, loaded.SkipProbe) + require.Equal(t, r.Probes, loaded.Probes) + require.True(t, proto.Equal(r.ProbeRequest, loaded.ProbeRequest)) + require.True(t, proto.Equal(r.ProbeResult, loaded.ProbeResult)) + require.Equal(t, r.ProbeDeadline, loaded.ProbeDeadline) + require.True(t, r.ProbeNodeKey.IsEqual(loaded.ProbeNodeKey)) + require.Equal(t, r.FundingOutpoint, loaded.FundingOutpoint) + require.Equal(t, r.ReservationProof, loaded.ReservationProof) + require.Equal(t, r.PrepayCredit, loaded.PrepayCredit) + + // A retry of the original unquoted request restores the same purchase. + require.NoError(t, store.CreateReservation(ctx, &request)) + require.Equal(t, r.State, request.State) + require.True(t, proto.Equal(r.Quote, request.Quote)) + updates, err := db.GetAssetReservationUpdates(ctx, r.ID[:]) + require.NoError(t, err) + require.NoError(t, store.UpdateReservation(ctx, loaded)) + after, err := db.GetAssetReservationUpdates(ctx, r.ID[:]) + require.NoError(t, err) + require.Len(t, after, len(updates)) + + for _, mutate := range []func(*Reservation){ + func(v *Reservation) { v.Quote.PrepayInvoice += "changed" }, + func(v *Reservation) { v.MaxRouteFeeMsat++ }, + func(v *Reservation) { v.SkipProbe = true }, + func(v *Reservation) { v.State = AwaitApproval }, + func(v *Reservation) { v.PaymentRequest.TimeoutSeconds++ }, + func(v *Reservation) { v.FundingOutpoint.Index++ }, + func(v *Reservation) { v.PrepayCredit = 0 }, + func(v *Reservation) { v.ReservationProof = nil }, + } { + changed, err := store.GetReservation(ctx, r.ID) + require.NoError(t, err) + mutate(changed) + require.Error(t, store.UpdateReservation(ctx, changed)) + } + canceled, cancel := context.WithCancel(ctx) + cancel() + loaded.State = "Expired" + require.Error(t, store.UpdateReservation(canceled, loaded)) + loaded, err = store.GetReservation(ctx, r.ID) + require.NoError(t, err) + require.EqualValues(t, "Ready", loaded.State) +} + +func TestApprovalBindsQuotedAmounts(t *testing.T) { + for _, field := range []string{"asset fee", "BTC prepay"} { + t.Run(field, func(t *testing.T) { + r := testReservation() + r.Quote = testPurchaseQuote(r) + a := testApproval(t, r) + a.SkipProbe = true + require.NoError(t, r.CheckApproval(a)) + + // Consent to the displayed quote cannot authorize a + // different amount, even when the caller skips probing. + switch field { + case "asset fee": + r.Quote.Terms.Fee++ + + case "BTC prepay": + r.Quote.PrepayAmountMsat++ + } + require.Error(t, r.CheckApproval(a)) + }) + } +} + +func TestApprovalAndOutpoint(t *testing.T) { + r := testReservation() + r.Quote = testPurchaseQuote(r) + r.Probes = ProbeResults{ + FeeKnown: true, + Main: ProbeFailed, + CheckedAt: time.Unix(1800000000, 0), + } + a := testApproval(t, r) + // A failed probe needs explicit consent. SkipProbe cannot override limits. + require.Error(t, r.CheckApproval(a)) + a.SkipProbe = true + require.NoError(t, r.CheckApproval(a)) + a.MaxRouteFeeMsat = math.MaxUint64 + require.Error(t, r.CheckApproval(a)) + a.MaxRouteFeeMsat = 0 + require.NoError(t, r.CheckApproval(a)) + a.QuoteHash[0]++ + require.Error(t, r.CheckApproval(a)) + + text := strings.Repeat("ab", 32) + ":2" + outpoint, err := ParseOutpoint(text) + require.NoError(t, err) + require.Equal(t, text, outpoint.String()) + for _, invalid := range []string{ + strings.ToUpper(text), text[:len(text)-1] + "02", "bad", + } { + _, err := ParseOutpoint(invalid) + require.Error(t, err) + } +} diff --git a/assets/reservation/sql_store.go b/assets/reservation/sql_store.go index b9d58fb00..2446a739b 100644 --- a/assets/reservation/sql_store.go +++ b/assets/reservation/sql_store.go @@ -13,6 +13,7 @@ import ( "github.com/lightninglabs/loop/loopdb/sqlc" "github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/keychain" + "google.golang.org/protobuf/proto" ) // SqlStore stores client reservations in SQLite or PostgreSQL. @@ -43,12 +44,13 @@ func (s *SqlStore) CreateReservation(ctx context.Context, r *Reservation) error next.UpdatedAt = next.CreatedAt err := s.db.ExecTx(ctx, &loopdb.SqliteTxOptions{}, func(q *sqlc.Queries) error { - _, group := r.selector().Bytes() + selector := r.selector() + _, group := selector.Bytes() count, err := q.CreateAssetReservation(ctx, sqlc.CreateAssetReservationParams{ ReservationID: r.ID[:], + AssetID: selector.AssetID[:], GroupKey: group, - AssetID: r.AssetID[:], Amount: int64(r.Amount), Fee: int64(r.Fee), CsvDelay: int32(r.CSVDelay), @@ -75,6 +77,9 @@ func (s *SqlStore) CreateReservation(ctx context.Context, r *Reservation) error return nil } + if err := updatePurchase(ctx, q, &next); err != nil { + return err + } return insertUpdate(ctx, q, &next) }) if err == nil { @@ -83,8 +88,9 @@ func (s *SqlStore) CreateReservation(ctx context.Context, r *Reservation) error return err } -// UpdateReservation saves progress without changing the agreed terms. A -// failed transaction leaves the caller's timestamps unchanged. +// UpdateReservation saves purchase facts and state history atomically while +// preserving the request and already-fixed facts. Identical progress is a no-op; +// a failed transaction leaves the caller's timestamps unchanged. func (s *SqlStore) UpdateReservation(ctx context.Context, r *Reservation) error { if err := r.Validate(); err != nil { return err @@ -93,14 +99,31 @@ func (s *SqlStore) UpdateReservation(ctx context.Context, r *Reservation) error next.UpdatedAt = s.clock.Now().UTC().Truncate(time.Microsecond) err := s.db.ExecTx(ctx, &loopdb.SqliteTxOptions{}, func(q *sqlc.Queries) error { + // Compare with the durable record inside this transaction. stored, err := loadReservation(ctx, q, r.ID) if err != nil { return err } + // Keep the original request identity, amount, and client key. if !sameRequest(r, stored) { return ErrRequestConflict } + // Reject changes to facts and choices already committed. + if err := preservePurchase(r, stored); err != nil { + return err + } + // Repeated saves retain timestamps and add no history entry. + if sameProgress(r, stored) { + next = *stored + return nil + } next.CreatedAt = stored.CreatedAt + // Persist the new purchase facts alongside the state update. + if err := updatePurchase(ctx, q, &next); err != nil { + return err + } + // Append history in the same transaction; either both writes + // commit or neither does. return insertUpdate(ctx, q, &next) }) if err == nil { @@ -109,47 +132,47 @@ func (s *SqlStore) UpdateReservation(ctx context.Context, r *Reservation) error return err } -// GetReservation loads the agreed terms and latest state in one transaction. +// GetReservation reads the purchase facts and latest state in one statement, +// so PostgreSQL cannot mix snapshots from concurrent worker updates. func (s *SqlStore) GetReservation(ctx context.Context, id ID) (*Reservation, error) { - var result *Reservation - err := s.db.ExecTx(ctx, loopdb.NewSqlReadOpts(), - func(q *sqlc.Queries) error { - var err error - result, err = loadReservation(ctx, q, id) - return err - }) - return result, err + return loadReservation(ctx, s.db.Queries, id) } -// GetReservations loads saved records for status and manager recovery. -func (s *SqlStore) GetReservations(ctx context.Context) ([]*Reservation, error) { - var result []*Reservation - err := s.db.ExecTx(ctx, loopdb.NewSqlReadOpts(), - func(q *sqlc.Queries) error { - rows, err := q.GetAssetReservations(ctx) - if err != nil { - return err - } - for _, row := range rows { - updates, err := q.GetAssetReservationUpdates( - ctx, row.ReservationID, - ) - if err != nil { - return err - } - r, err := toReservation(row, updates) - if err != nil { - return err - } - result = append(result, r) - } - return nil - }) +// GetReservations filters and reads the reservation and its latest state in +// one statement, giving both SQLite and PostgreSQL a consistent snapshot. +func (s *SqlStore) GetReservations(ctx context.Context, filter StateFilter) ( + []*Reservation, error) { + + if err := filter.Validate(); err != nil { + return nil, err + } + rows, err := s.db.GetAssetReservations(ctx, sqlc.GetAssetReservationsParams{ + State: sql.NullString{ + String: string(filter.State), Valid: filter.State != "", + }, + ActiveOnly: filter.ActiveOnly, + CanceledState: string(Canceled), + RejectedState: string(QuoteRejected), + FailedState: string(QuoteFailed), + ExpiredState: string(Expired), + }) if err != nil { return nil, err } + var result []*Reservation + for _, row := range rows { + if !row.UpdateState.Valid || !row.UpdateTimestamp.Valid { + return nil, errors.New("missing reservation state history") + } + r, err := toReservation(row.AssetReservation, + row.UpdateState.String, row.UpdateTimestamp.Time) + if err != nil { + return nil, err + } + result = append(result, r) + } return result, nil } @@ -166,26 +189,26 @@ func insertUpdate(ctx context.Context, q *sqlc.Queries, r *Reservation) error { func loadReservation(ctx context.Context, q *sqlc.Queries, id ID) (*Reservation, error) { - row, err := q.GetAssetReservation(ctx, id[:]) + row, err := q.GetAssetReservationSnapshot(ctx, id[:]) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } - updates, err := q.GetAssetReservationUpdates(ctx, id[:]) - if err != nil { - return nil, err + if !row.UpdateState.Valid || !row.UpdateTimestamp.Valid { + return nil, errors.New("missing reservation state history") } - return toReservation(row, updates) + return toReservation(row.AssetReservation, row.UpdateState.String, + row.UpdateTimestamp.Time) } -func toReservation(row sqlc.AssetReservation, - updates []sqlc.AssetReservationUpdate) (*Reservation, error) { +func toReservation(row sqlc.AssetReservation, state string, + updatedAt time.Time) (*Reservation, error) { if len(row.ReservationID) != 32 || len(row.AssetID) != 32 || len(row.ClientPubkey) != btcec.PubKeyBytesLenCompressed || - len(updates) == 0 || row.ClientKeyIndex < 0 || + state == "" || row.ClientKeyIndex < 0 || row.ClientKeyIndex > math.MaxUint32 { return nil, errors.New("invalid stored asset reservation") @@ -194,17 +217,14 @@ func toReservation(row sqlc.AssetReservation, if err != nil { return nil, err } - latest := updates[len(updates)-1] r := &Reservation{ - ID: ID(row.ReservationID), - Terms: Terms{ - AssetID: [32]byte(row.AssetID), - Amount: uint64(row.Amount), - Fee: uint64(row.Fee), - CSVDelay: uint32(row.CsvDelay), - RequiredConfirmations: uint32(row.RequiredConfirmations), - ExecutionDelta: uint32(row.ExecutionDelta), - }, + ID: ID(row.ReservationID), + AssetID: [32]byte(row.AssetID), + Amount: uint64(row.Amount), + Fee: uint64(row.Fee), + CSVDelay: uint32(row.CsvDelay), + RequiredConfirmations: uint32(row.RequiredConfirmations), + ExecutionDelta: uint32(row.ExecutionDelta), ClientKey: keychain.KeyDescriptor{ PubKey: pubkey, KeyLocator: keychain.KeyLocator{ @@ -212,16 +232,19 @@ func toReservation(row sqlc.AssetReservation, Index: uint32(row.ClientKeyIndex), }, }, - State: fsm.StateType(latest.UpdateState), + State: fsm.StateType(state), CreatedAt: row.CreatedAt.UTC(), - UpdatedAt: latest.UpdateTimestamp.UTC(), + UpdatedAt: updatedAt.UTC(), } if len(row.GroupKey) != 0 { - selector, err := ParseAssetSelector(nil, row.GroupKey) + s, err := ParseAssetSelector(nil, row.GroupKey) if err != nil { return nil, err } - r.GroupKey = selector.GroupKey + r.GroupKey = s.GroupKey + } + if err := readPurchase(row, r); err != nil { + return nil, err } if err := r.Validate(); err != nil { return nil, err @@ -230,7 +253,17 @@ func toReservation(row sqlc.AssetReservation, } func sameRequest(a, b *Reservation) bool { - return a.ID == b.ID && a.Terms == b.Terms && a.GroupKey == b.GroupKey && + return a.ID == b.ID && a.selector() == b.selector() && a.Amount == b.Amount && + (a.Fee == 0 || b.Fee == 0 || a.Terms == b.Terms) && a.ClientKey.KeyLocator == b.ClientKey.KeyLocator && a.ClientKey.PubKey.IsEqual(b.ClientKey.PubKey) } + +func sameProgress(a, b *Reservation) bool { + return a.State == b.State && a.Terms == b.Terms && a.Probes == b.Probes && + a.SkipProbe == b.SkipProbe && + proto.Equal(a.ProbeResult, b.ProbeResult) && + a.MaxRouteFeeMsat == b.MaxRouteFeeMsat && + proto.Equal(a.PaymentResult, b.PaymentResult) && + preservePurchase(a, b) == nil && preservePurchase(b, a) == nil +} diff --git a/assets/reservation/sql_store_test.go b/assets/reservation/sql_store_test.go index ba5e0ac6c..921e7c75f 100644 --- a/assets/reservation/sql_store_test.go +++ b/assets/reservation/sql_store_test.go @@ -2,30 +2,153 @@ package reservation import ( "context" + "database/sql" "math" "sync" "testing" "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/loopdb/sqlc" "github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/require" ) +// reservationQueryCounter checks that loading state and funding facts does +// not require separate statements, which could observe different snapshots. +type reservationQueryCounter struct { + sqlc.DBTX + + calls int +} + +func (c *reservationQueryCounter) QueryContext(ctx context.Context, + query string, args ...any) (*sql.Rows, error) { + + c.calls++ + return c.DBTX.QueryContext(ctx, query, args...) +} + +func (c *reservationQueryCounter) QueryRowContext(ctx context.Context, + query string, args ...any) *sql.Row { + + c.calls++ + return c.DBTX.QueryRowContext(ctx, query, args...) +} + +func TestSqlStoreReservationSnapshot(t *testing.T) { + db := loopdb.NewTestDB(t) + store := NewSqlStore(db.BaseDB) + r := testReservation() + require.NoError(t, store.CreateReservation(t.Context(), r)) + r.State = Ready + require.NoError(t, store.UpdateReservation(t.Context(), r)) + queries := &reservationQueryCounter{DBTX: db.DB} + readDB := *db.BaseDB + readDB.Queries = sqlc.New(queries) + got, err := NewSqlStore(&readDB).GetReservation(t.Context(), r.ID) + require.NoError(t, err) + require.Equal(t, r, got) + require.Equal(t, 1, queries.calls) + + _, err = NewSqlStore(&readDB).GetReservation(t.Context(), ID{99}) + require.ErrorIs(t, err, ErrNotFound) + _, err = db.ExecContext(t.Context(), + "DELETE FROM asset_reservation_updates WHERE reservation_id = $1", r.ID[:]) + require.NoError(t, err) + _, err = NewSqlStore(&readDB).GetReservation(t.Context(), r.ID) + require.ErrorContains(t, err, "missing reservation state history") +} + +func TestSqlStoreStateFilter(t *testing.T) { + db := loopdb.NewTestDB(t) + store := NewSqlStore(db.BaseDB) + ctx := t.Context() + saved := make(map[ID]*Reservation) + for i, state := range []fsm.StateType{ + Ready, AwaitApproval, Canceled, Expired, NeedAdminAttention, + QuoteRejected, + } { + r := testReservation() + r.ID = ID{byte(i + 1)} + require.NoError(t, store.CreateReservation(ctx, r)) + r.State = state + require.NoError(t, store.UpdateReservation(ctx, r)) + saved[r.ID] = r + } + // All requested facts, including latest timestamps, must come from a + // single statement on either database backend. + queries := &reservationQueryCounter{DBTX: db.DB} + readDB := *db.BaseDB + readDB.Queries = sqlc.New(queries) + store = NewSqlStore(&readDB) + for _, tc := range []struct { + name string + filter StateFilter + ids []ID + }{ + {"all", StateFilter{}, []ID{{1}, {2}, {3}, {4}, {5}, {6}}}, + {"active", StateFilter{ActiveOnly: true}, []ID{{1}, {2}, {5}}}, + {"latest state", StateFilter{State: AwaitApproval}, []ID{{2}}}, + {"ready", StateFilter{State: Ready}, []ID{{1}}}, + {"rejected", StateFilter{State: QuoteRejected}, []ID{{6}}}, + {"admin", StateFilter{State: NeedAdminAttention}, []ID{{5}}}, + {"no matches", StateFilter{State: ProbeRoutes}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + queries.calls = 0 + rows, err := store.GetReservations(ctx, tc.filter) + require.NoError(t, err) + require.Equal(t, 1, queries.calls) + var ids []ID + for _, r := range rows { + require.Equal(t, saved[r.ID], r) + ids = append(ids, r.ID) + } + require.Equal(t, tc.ids, ids) + }) + } + _, err := store.GetReservations(ctx, StateFilter{ + State: "ready", + }) + require.ErrorContains(t, err, "unknown reservation state") + _, err = store.GetReservations(ctx, StateFilter{ + State: Ready, ActiveOnly: true, + }) + require.ErrorContains(t, err, "mutually exclusive") + + // Excluded rows must not be decoded or validated. An invalid saved + // quote on a completed purchase must not block recovery of active ones. + completedID := ID{3} + _, err = db.ExecContext(ctx, `UPDATE asset_reservations SET quote = $1 + WHERE reservation_id = $2`, []byte{0xff}, completedID[:]) + require.NoError(t, err) + rows, err := store.GetReservations(ctx, StateFilter{ActiveOnly: true}) + require.NoError(t, err) + require.Len(t, rows, 3) + _, err = store.GetReservations(ctx, StateFilter{}) + require.Error(t, err) + activeID := ID{2} + _, err = db.ExecContext(ctx, `DELETE FROM asset_reservation_updates + WHERE reservation_id = $1`, activeID[:]) + require.NoError(t, err) + _, err = store.GetReservations(ctx, StateFilter{ActiveOnly: true}) + require.Error(t, err) +} + func testReservation() *Reservation { _, pubkey := btcec.PrivKeyFromBytes([]byte{1}) return &Reservation{ - ID: ID{1}, - Terms: Terms{ - AssetID: [32]byte{2}, - Amount: 10000, - Fee: 11, - CSVDelay: 1440, - RequiredConfirmations: 3, - ExecutionDelta: 90, - }, + ID: ID{1}, + AssetID: [32]byte{2}, + Amount: 10000, + Fee: 11, + CSVDelay: 1440, + RequiredConfirmations: 3, + ExecutionDelta: 90, ClientKey: keychain.KeyDescriptor{ PubKey: pubkey, KeyLocator: keychain.KeyLocator{ @@ -92,7 +215,7 @@ func TestSqlStore(t *testing.T) { require.NoError(t, err) require.Equal(t, r, loaded) - all, err := store.GetReservations(ctx) + all, err := store.GetReservations(ctx, StateFilter{}) require.NoError(t, err) require.Equal(t, []*Reservation{r}, all) _, err = store.GetReservation(ctx, ID{99}) @@ -104,10 +227,10 @@ func TestSqlStore(t *testing.T) { // Damaged records fail closed instead of becoming recoverable purchases. row, err := db.GetAssetReservation(ctx, r.ID[:]) require.NoError(t, err) - _, err = toReservation(row, nil) + _, err = toReservation(row, "", time.Time{}) require.Error(t, err) row.ClientPubkey = make([]byte, 33) - _, err = toReservation(row, updates) + _, err = toReservation(row, updates[1].UpdateState, updates[1].UpdateTimestamp) require.Error(t, err) } diff --git a/loopdb/sqlc/asset_reservations.sql.go b/loopdb/sqlc/asset_reservations.sql.go index 0968e2854..3d1ddb667 100644 --- a/loopdb/sqlc/asset_reservations.sql.go +++ b/loopdb/sqlc/asset_reservations.sql.go @@ -120,6 +120,66 @@ func (q *Queries) GetAssetReservation(ctx context.Context, reservationID []byte) return i, err } +const getAssetReservationSnapshot = `-- name: GetAssetReservationSnapshot :one +SELECT r.id, r.reservation_id, r.asset_id, r.amount, r.fee, r.csv_delay, r.required_confirmations, r.execution_delta, r.client_pubkey, r.client_key_family, r.client_key_index, r.created_at, r.group_key, r.quote, r.max_route_fee_msat, r.skip_probe, r.main_probe, r.probes_checked_at, r.prepay_route_fee_msat, r.main_route_fee_msat, r.payment_hash, r.paying_node_key, r.payment_request, r.payment_result, r.funding_outpoint, r.confirmation_height, r.prepay_credit, r.deposit_proof, r.probe_node_key, r.probe_request, r.probe_result, r.probe_deadline, r.probe_fee_known, u.update_state, u.update_timestamp +FROM asset_reservations r +LEFT JOIN asset_reservation_updates u ON u.id = ( + SELECT id FROM asset_reservation_updates + WHERE reservation_id = r.reservation_id + ORDER BY id DESC LIMIT 1 +) +WHERE r.reservation_id = $1 +` + +type GetAssetReservationSnapshotRow struct { + AssetReservation AssetReservation + UpdateState sql.NullString + UpdateTimestamp sql.NullTime +} + +func (q *Queries) GetAssetReservationSnapshot(ctx context.Context, reservationID []byte) (GetAssetReservationSnapshotRow, error) { + row := q.db.QueryRowContext(ctx, getAssetReservationSnapshot, reservationID) + var i GetAssetReservationSnapshotRow + err := row.Scan( + &i.AssetReservation.ID, + &i.AssetReservation.ReservationID, + &i.AssetReservation.AssetID, + &i.AssetReservation.Amount, + &i.AssetReservation.Fee, + &i.AssetReservation.CsvDelay, + &i.AssetReservation.RequiredConfirmations, + &i.AssetReservation.ExecutionDelta, + &i.AssetReservation.ClientPubkey, + &i.AssetReservation.ClientKeyFamily, + &i.AssetReservation.ClientKeyIndex, + &i.AssetReservation.CreatedAt, + &i.AssetReservation.GroupKey, + &i.AssetReservation.Quote, + &i.AssetReservation.MaxRouteFeeMsat, + &i.AssetReservation.SkipProbe, + &i.AssetReservation.MainProbe, + &i.AssetReservation.ProbesCheckedAt, + &i.AssetReservation.PrepayRouteFeeMsat, + &i.AssetReservation.MainRouteFeeMsat, + &i.AssetReservation.PaymentHash, + &i.AssetReservation.PayingNodeKey, + &i.AssetReservation.PaymentRequest, + &i.AssetReservation.PaymentResult, + &i.AssetReservation.FundingOutpoint, + &i.AssetReservation.ConfirmationHeight, + &i.AssetReservation.PrepayCredit, + &i.AssetReservation.DepositProof, + &i.AssetReservation.ProbeNodeKey, + &i.AssetReservation.ProbeRequest, + &i.AssetReservation.ProbeResult, + &i.AssetReservation.ProbeDeadline, + &i.AssetReservation.ProbeFeeKnown, + &i.UpdateState, + &i.UpdateTimestamp, + ) + return i, err +} + const getAssetReservationUpdates = `-- name: GetAssetReservationUpdates :many SELECT id, reservation_id, update_state, update_timestamp FROM asset_reservation_updates WHERE reservation_id = $1 @@ -155,52 +215,91 @@ func (q *Queries) GetAssetReservationUpdates(ctx context.Context, reservationID } const getAssetReservations = `-- name: GetAssetReservations :many -SELECT id, reservation_id, asset_id, amount, fee, csv_delay, required_confirmations, execution_delta, client_pubkey, client_key_family, client_key_index, created_at, group_key, quote, max_route_fee_msat, skip_probe, main_probe, probes_checked_at, prepay_route_fee_msat, main_route_fee_msat, payment_hash, paying_node_key, payment_request, payment_result, funding_outpoint, confirmation_height, prepay_credit, deposit_proof, probe_node_key, probe_request, probe_result, probe_deadline, probe_fee_known FROM asset_reservations ORDER BY id +SELECT r.id, r.reservation_id, r.asset_id, r.amount, r.fee, r.csv_delay, r.required_confirmations, r.execution_delta, r.client_pubkey, r.client_key_family, r.client_key_index, r.created_at, r.group_key, r.quote, r.max_route_fee_msat, r.skip_probe, r.main_probe, r.probes_checked_at, r.prepay_route_fee_msat, r.main_route_fee_msat, r.payment_hash, r.paying_node_key, r.payment_request, r.payment_result, r.funding_outpoint, r.confirmation_height, r.prepay_credit, r.deposit_proof, r.probe_node_key, r.probe_request, r.probe_result, r.probe_deadline, r.probe_fee_known, u.update_state, u.update_timestamp +FROM asset_reservations r +LEFT JOIN asset_reservation_updates u ON u.id = ( + SELECT id FROM asset_reservation_updates + WHERE reservation_id = r.reservation_id + ORDER BY id DESC LIMIT 1 +) +WHERE (CAST($1 AS TEXT) IS NULL + OR u.update_state = $1) + AND (NOT CAST($2 AS BOOLEAN) + OR u.update_state IS NULL + OR u.update_state NOT IN ( + $3, $4, + $5, $6 + )) +ORDER BY r.id ` -func (q *Queries) GetAssetReservations(ctx context.Context) ([]AssetReservation, error) { - rows, err := q.db.QueryContext(ctx, getAssetReservations) +type GetAssetReservationsParams struct { + State sql.NullString + ActiveOnly bool + CanceledState string + ExpiredState string + RejectedState string + FailedState string +} + +type GetAssetReservationsRow struct { + AssetReservation AssetReservation + UpdateState sql.NullString + UpdateTimestamp sql.NullTime +} + +func (q *Queries) GetAssetReservations(ctx context.Context, arg GetAssetReservationsParams) ([]GetAssetReservationsRow, error) { + rows, err := q.db.QueryContext(ctx, getAssetReservations, + arg.State, + arg.ActiveOnly, + arg.CanceledState, + arg.ExpiredState, + arg.RejectedState, + arg.FailedState, + ) if err != nil { return nil, err } defer rows.Close() - var items []AssetReservation + var items []GetAssetReservationsRow for rows.Next() { - var i AssetReservation + var i GetAssetReservationsRow if err := rows.Scan( - &i.ID, - &i.ReservationID, - &i.AssetID, - &i.Amount, - &i.Fee, - &i.CsvDelay, - &i.RequiredConfirmations, - &i.ExecutionDelta, - &i.ClientPubkey, - &i.ClientKeyFamily, - &i.ClientKeyIndex, - &i.CreatedAt, - &i.GroupKey, - &i.Quote, - &i.MaxRouteFeeMsat, - &i.SkipProbe, - &i.MainProbe, - &i.ProbesCheckedAt, - &i.PrepayRouteFeeMsat, - &i.MainRouteFeeMsat, - &i.PaymentHash, - &i.PayingNodeKey, - &i.PaymentRequest, - &i.PaymentResult, - &i.FundingOutpoint, - &i.ConfirmationHeight, - &i.PrepayCredit, - &i.DepositProof, - &i.ProbeNodeKey, - &i.ProbeRequest, - &i.ProbeResult, - &i.ProbeDeadline, - &i.ProbeFeeKnown, + &i.AssetReservation.ID, + &i.AssetReservation.ReservationID, + &i.AssetReservation.AssetID, + &i.AssetReservation.Amount, + &i.AssetReservation.Fee, + &i.AssetReservation.CsvDelay, + &i.AssetReservation.RequiredConfirmations, + &i.AssetReservation.ExecutionDelta, + &i.AssetReservation.ClientPubkey, + &i.AssetReservation.ClientKeyFamily, + &i.AssetReservation.ClientKeyIndex, + &i.AssetReservation.CreatedAt, + &i.AssetReservation.GroupKey, + &i.AssetReservation.Quote, + &i.AssetReservation.MaxRouteFeeMsat, + &i.AssetReservation.SkipProbe, + &i.AssetReservation.MainProbe, + &i.AssetReservation.ProbesCheckedAt, + &i.AssetReservation.PrepayRouteFeeMsat, + &i.AssetReservation.MainRouteFeeMsat, + &i.AssetReservation.PaymentHash, + &i.AssetReservation.PayingNodeKey, + &i.AssetReservation.PaymentRequest, + &i.AssetReservation.PaymentResult, + &i.AssetReservation.FundingOutpoint, + &i.AssetReservation.ConfirmationHeight, + &i.AssetReservation.PrepayCredit, + &i.AssetReservation.DepositProof, + &i.AssetReservation.ProbeNodeKey, + &i.AssetReservation.ProbeRequest, + &i.AssetReservation.ProbeResult, + &i.AssetReservation.ProbeDeadline, + &i.AssetReservation.ProbeFeeKnown, + &i.UpdateState, + &i.UpdateTimestamp, ); err != nil { return nil, err } diff --git a/loopdb/sqlc/querier.go b/loopdb/sqlc/querier.go index c506df619..e1d5b9c6d 100644 --- a/loopdb/sqlc/querier.go +++ b/loopdb/sqlc/querier.go @@ -25,8 +25,9 @@ type Querier interface { FetchLiquidityParams(ctx context.Context) ([]byte, error) GetAllWithdrawals(ctx context.Context) ([]Withdrawal, error) GetAssetReservation(ctx context.Context, reservationID []byte) (AssetReservation, error) + GetAssetReservationSnapshot(ctx context.Context, reservationID []byte) (GetAssetReservationSnapshotRow, error) GetAssetReservationUpdates(ctx context.Context, reservationID []byte) ([]AssetReservationUpdate, error) - GetAssetReservations(ctx context.Context) ([]AssetReservation, error) + GetAssetReservations(ctx context.Context, arg GetAssetReservationsParams) ([]GetAssetReservationsRow, error) GetBatchSweeps(ctx context.Context, batchID int32) ([]Sweep, error) GetBatchSweptAmount(ctx context.Context, batchID int32) (int64, error) GetDeposit(ctx context.Context, depositID []byte) (Deposit, error) diff --git a/loopdb/sqlc/queries/asset_reservations.sql b/loopdb/sqlc/queries/asset_reservations.sql index 6dfc69e9e..62de3cf57 100644 --- a/loopdb/sqlc/queries/asset_reservations.sql +++ b/loopdb/sqlc/queries/asset_reservations.sql @@ -28,8 +28,33 @@ INSERT INTO asset_reservations ( -- name: GetAssetReservation :one SELECT * FROM asset_reservations WHERE reservation_id = $1; +-- name: GetAssetReservationSnapshot :one +SELECT sqlc.embed(r), u.update_state, u.update_timestamp +FROM asset_reservations r +LEFT JOIN asset_reservation_updates u ON u.id = ( + SELECT id FROM asset_reservation_updates + WHERE reservation_id = r.reservation_id + ORDER BY id DESC LIMIT 1 +) +WHERE r.reservation_id = $1; + -- name: GetAssetReservations :many -SELECT * FROM asset_reservations ORDER BY id; +SELECT sqlc.embed(r), u.update_state, u.update_timestamp +FROM asset_reservations r +LEFT JOIN asset_reservation_updates u ON u.id = ( + SELECT id FROM asset_reservation_updates + WHERE reservation_id = r.reservation_id + ORDER BY id DESC LIMIT 1 +) +WHERE (CAST(sqlc.narg('state') AS TEXT) IS NULL + OR u.update_state = sqlc.narg('state')) + AND (NOT CAST(sqlc.arg('active_only') AS BOOLEAN) + OR u.update_state IS NULL + OR u.update_state NOT IN ( + sqlc.arg('canceled_state'), sqlc.arg('expired_state'), + sqlc.arg('rejected_state'), sqlc.arg('failed_state') + )) +ORDER BY r.id; -- name: InsertAssetReservationUpdate :exec INSERT INTO asset_reservation_updates ( From 2cb37be45d307ac570f550313644676a0af94835 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 14:24:42 +0200 Subject: [PATCH 18/25] assets/reservation: Add the client purchase state machine Drive quote requests, one full-amount hold probe, approval, prepay payment, and reservation verification through a persistent state machine. Save the probe request before dispatch. Require server receipt and cancellation plus a resolved local payment before approval. A failed probe cancels the unpaid purchase. Resolve any in-flight HTLC before final cancellation; a fresh attempt needs a new purchase ID. Recovery resumes saved work and recognizes completed probes even after their deadline, while still enforcing quote expiry. Save approval and routing limits before sending the prepay, and track the original payment after uncertain results. Settlement always resumes delivery. Verify the proof and funding before Ready, retain the original CSV clock, and stop node calls after a failed database write. Persist fees from live payment updates. Recognize probe success from the payment-level incorrect-details reason and server receipt and cancellation, without requiring terminal HTLC attempt records. A missed live route leaves the estimate unavailable but does not fail delivery. Bound quote acquisition to one minute across restarts and stop invalid quotes before payment. Verify group membership before probing or paying. --- assets/reservation/actions.go | 766 +++++++++++++++++++++++++++++++ assets/reservation/fsm.go | 279 +++++++++++ assets/reservation/fsm.md | 99 ++++ assets/reservation/fsm_test.go | 760 ++++++++++++++++++++++++++++++ assets/reservation/probe_test.go | 363 +++++++++++++++ assets/reservation/services.go | 119 +++++ 6 files changed, 2386 insertions(+) create mode 100644 assets/reservation/actions.go create mode 100644 assets/reservation/fsm.go create mode 100644 assets/reservation/fsm.md create mode 100644 assets/reservation/fsm_test.go create mode 100644 assets/reservation/probe_test.go create mode 100644 assets/reservation/services.go diff --git a/assets/reservation/actions.go b/assets/reservation/actions.go new file mode 100644 index 000000000..d62754afb --- /dev/null +++ b/assets/reservation/actions.go @@ -0,0 +1,766 @@ +package reservation + +import ( + "bytes" + "context" + "errors" + "math" + "time" + + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/lnrpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +func (f *FSM) selector() *swapserverrpc.AssetReservationSelector { + return &swapserverrpc.AssetReservationSelector{ + Selector: &swapserverrpc.AssetReservationSelector_ReservationId{ + ReservationId: f.reservation.ID[:], + }, + } +} + +// hasExpiredQuote reports whether a saved quote exists and has expired. +func (f *FSM) hasExpiredQuote() bool { + return f.reservation.Quote != nil && + f.cfg.Clock.Now().Unix() >= f.reservation.Quote.ExpiresAt +} + +// RequestQuoteAction repeats the saved request ID after a lost reply. +func (f *FSM) RequestQuoteAction(ctx context.Context, + _ fsm.EventContext) fsm.EventType { + + // Stop after a failed write. Recovery must reload the saved reservation + // before we make another server request. + if f.persistErr != nil { + return fsm.NoOp + } + + r := f.reservation + // Resume a saved quote, or cancel if it has expired. + if r.Quote != nil { + if f.hasExpiredQuote() { + return OnCancel + } + + return OnQuote + } + + if !r.CreatedAt.IsZero() && + !f.cfg.Clock.Now().Before(r.CreatedAt.Add(time.Minute)) { + + return OnQuoteFailed + } + // Reuse the saved ID and key so a lost reply finds the same purchase. + selector := r.selector() + id, group := selector.Bytes() + response, err := f.cfg.Server.QuoteAssetReservation(ctx, + &swapserverrpc.QuoteAssetReservationRequest{ + ReservationId: r.ID[:], + AssetId: id, + GroupKey: group, + ProtocolVersion: selector.ProtocolVersion(), + Amount: r.Amount, + ClientKey: r.ClientKey.PubKey.SerializeCompressed(), + }, + ) + if status.Code(err) == codes.OutOfRange { + // Admission was refused before an invoice existed. Persist this + // result so polling or restart cannot silently buy later. + return OnQuoteRejected + } + if err != nil { + return f.stayInState(err) + } + if response == nil || !bytes.Equal(response.ReservationId, r.ID[:]) { + return OnQuoteFailed + } + if response.State == + swapserverrpc.AssetReservationStatus_ASSET_RESERVATION_CANCELED { + + return OnCancel + } + if response.Quote == nil { + // The server has not supplied a quote yet. Stay here and retry. + return fsm.NoOp + } + // Bind the terms to our request, then check the invoices and RFQ + // before saving anything the user may later approve. + if err := r.ValidateQuote(response.Quote); err != nil { + return f.rejectQuote(err) + } + if err := f.cfg.Payments.ValidateQuote(ctx, response.Quote); err != nil { + return f.rejectQuote(err) + } + if response.Quote.Group != nil { + err := f.cfg.Wallet.VerifyGroup(ctx, response.Quote) + if errors.Is(err, ErrInvalidReservation) { + return f.rejectQuote(err) + } + if err != nil { + return f.stayInState(err) + } + } + r.Quote = proto.Clone(response.Quote).(*swapserverrpc.AssetReservationQuote) + r.Terms, _ = TermsFromRPC(r.Quote.Terms) + // Keep the exact quote across restarts before probing its routes. + if !f.save(ctx) { + return fsm.NoOp + } + + // The quote may have expired while we validated or saved it. + // CancelPrepay resolves any payment before marking the purchase canceled. + if f.hasExpiredQuote() { + return OnCancel + } + + return OnQuote +} + +// rejectQuote ends local quote work before any payment can be prepared. The +// server independently expires unpaid invoices; cleanup cannot keep this +// unapproved purchase retrying an invalid quote indefinitely. +func (f *FSM) rejectQuote(err error) fsm.EventType { + f.LastActionError = err + return OnQuoteFailed +} + +// ProbeRoutesAction resumes one payment, never starts another after failure. +// Success requires server asset acceptance and cancellation plus local failure. +func (f *FSM) ProbeRoutesAction(ctx context.Context, + _ fsm.EventContext) fsm.EventType { + + if f.persistErr != nil { + return fsm.NoOp + } + r := f.reservation + if r.Quote == nil { + return OnCancel + } + if !r.Probes.CheckedAt.IsZero() { + if r.Probes.Main != ProbeSucceeded { + return OnCancel + } + return OnProbed + } + if r.ProbeRequest == nil && r.Probes.Main == ProbeNotRun && + !r.SkipProbe && !f.hasExpiredQuote() { + + plan, err := f.cfg.Payments.PrepareProbe(ctx, r) + if err != nil { + // No request has been saved or sent. A temporary node + // failure can retry while the original quote is valid. + switch status.Code(err) { + case codes.Unavailable, codes.DeadlineExceeded, codes.Canceled: + return f.stayInState(err) + } + if errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled) { + + return f.stayInState(err) + } + + return f.finishProbe(ctx, ProbeFailed, 0) + } + if plan == nil || plan.Request == nil || plan.NodeKey == nil || + plan.Hash != ProbeHash(r.ID) || + plan.Request.TimeoutSeconds <= 0 { + + return f.finishProbe(ctx, ProbeFailed, 0) + } + r.ProbeNodeKey, r.ProbeRequest = plan.NodeKey, plan.Request + r.ProbeDeadline = f.cfg.Clock.Now().UTC().Add( + time.Duration(plan.Request.TimeoutSeconds) * time.Second, + ).Truncate(time.Microsecond) + if !f.save(ctx) { + return fsm.NoOp + } + } + + resolved := r.ProbeRequest == nil + notFound := false + if !resolved && r.ProbeResult == nil { + result, err := f.cfg.Payments.Lookup( + ctx, r.ProbeNodeKey, ProbeHash(r.ID), + ) + switch { + case errors.Is(err, ErrPaymentNotFound): + notFound, resolved = true, true + case err != nil: + return f.retryProbe(ctx, err) + case result == nil || result.PaymentHash != ProbeHash(r.ID).String(): + return f.finishProbe(ctx, ProbeFailed, 0) + case result.Status == lnrpc.Payment_SUCCEEDED: + return f.fail(errors.New("probe unexpectedly settled")) + case result.Status == lnrpc.Payment_FAILED, + result.Status == lnrpc.Payment_IN_FLIGHT: + + if err := f.observeProbe(ctx, result); err != nil { + if f.persistErr != nil { + return fsm.NoOp + } + return f.finishProbe(ctx, ProbeFailed, 0) + } + } + } + resolved = resolved || r.ProbeResult != nil + cancelProbe := r.SkipProbe || r.ProbeResult != nil + selector := f.selector() + var remote *swapserverrpc.AssetReservation + var err error + if cancelProbe { + remote, err = f.cfg.Server.CancelAssetReservation(ctx, + &swapserverrpc.CancelAssetReservationRequest{ + Reservation: selector, + ProbeOnly: true, + }) + } else { + remote, err = f.cfg.Server.GetAssetReservation(ctx, selector) + } + if err != nil { + return f.retryProbe(ctx, err) + } + if remote == nil || !bytes.Equal(remote.ReservationId, r.ID[:]) || + !proto.Equal(remote.Quote, r.Quote) { + + return f.finishProbe(ctx, ProbeFailed, 0) + } + if f.hasExpiredQuote() { + return f.finishProbe(ctx, ProbeTimedOut, 0) + } + if r.SkipProbe && r.ProbeRequest == nil && remote.ProbeCanceled { + return OnProbed + } + // LND may prune every failed HTLC attempt. Its payment-level reason, + // together with the server's exact asset receipt and cancellation, + // establishes success without depending on retained attempts. + // Check completion before applying the dispatch deadline on recovery. + if remote.ProbeSucceeded && remote.ProbeCanceled && + r.ProbeResult != nil && + r.ProbeResult.FailureReason == + lnrpc.PaymentFailureReason_FAILURE_REASON_INCORRECT_PAYMENT_DETAILS { + + return f.finishProbe(ctx, ProbeSucceeded, r.Probes.MainFeeMsat) + } + timedOut := r.ProbeResult != nil && r.ProbeResult.FailureReason == + lnrpc.PaymentFailureReason_FAILURE_REASON_TIMEOUT + if timedOut || (!r.ProbeDeadline.IsZero() && + !f.cfg.Clock.Now().Before(r.ProbeDeadline)) { + + return f.finishProbe(ctx, ProbeTimedOut, 0) + } + observe := func(update *lnrpc.Payment) error { + return f.observeProbe(ctx, update) + } + var streamErr error + switch { + case notFound && !cancelProbe && !remote.ProbeCanceled: + streamErr = f.cfg.Payments.SendProbe(ctx, r, observe) + case !resolved: + streamErr = f.cfg.Payments.TrackProbe(ctx, r, observe) + default: + if !remote.ProbeCanceled { + return fsm.NoOp + } + return f.finishProbe(ctx, ProbeFailed, 0) + } + if f.persistErr != nil { + return fsm.NoOp + } + if streamErr != nil { + if errors.Is(streamErr, context.DeadlineExceeded) { + return f.finishProbe(ctx, ProbeTimedOut, 0) + } + // A terminal payment failure was saved by observe and is + // classified from that record on the next pass. + return f.retryProbe(ctx, streamErr) + } + return fsm.NoOp +} + +// retryProbe keeps a temporary node or server failure retryable. Every pass +// looks up the single saved probe before dispatching, so another attempt can +// neither send it twice nor outlast its deadline or the quote. +func (f *FSM) retryProbe(ctx context.Context, err error) fsm.EventType { + r := f.reservation + if f.hasExpiredQuote() || (!r.ProbeDeadline.IsZero() && + !f.cfg.Clock.Now().Before(r.ProbeDeadline)) { + + return f.finishProbe(ctx, ProbeTimedOut, 0) + } + return f.stayInState(err) +} + +// observeProbe saves fees from live routes before failed attempts are pruned. +// Terminal success classification uses only the payment-level failure reason. +func (f *FSM) observeProbe(ctx context.Context, update *lnrpc.Payment) error { + r := f.reservation + if update == nil || update.PaymentHash != ProbeHash(r.ID).String() { + return errors.New("invalid probe payment update") + } + switch update.Status { + case lnrpc.Payment_FAILED: + r.ProbeResult = proto.Clone(update).(*lnrpc.Payment) + + case lnrpc.Payment_IN_FLIGHT: + for _, attempt := range update.Htlcs { + if attempt == nil || attempt.Route == nil || + attempt.Status != lnrpc.HTLCAttempt_IN_FLIGHT { + + continue + } + route := attempt.Route + if route.TotalFeesMsat < 0 || + route.TotalAmtMsat < route.TotalFeesMsat || + route.TotalAmtMsat-route.TotalFeesMsat != + int64(r.Quote.EstimatedMainAmountMsat) { + + return errors.New("invalid live probe route") + } + r.Probes.MainFeeMsat = uint64(route.TotalFeesMsat) + r.Probes.FeeKnown = true + } + + case lnrpc.Payment_SUCCEEDED: + return errors.New("probe unexpectedly settled") + + default: + return nil + } + if !f.save(ctx) { + return f.persistErr + } + return nil +} + +// finishProbe saves success for approval or failure for quote abandonment. +func (f *FSM) finishProbe(ctx context.Context, outcome ProbeOutcome, + fee uint64) fsm.EventType { + + if fee > math.MaxInt64 { + return f.fail(errors.New("invalid probe fee")) + } + r := f.reservation + var prepay uint64 + if outcome == ProbeSucceeded { + var err error + prepay, err = estimatePrepayRoutingFee( + fee, r.Quote.PrepayAmountMsat, + r.Quote.EstimatedMainAmountMsat, + ) + if err != nil { + return f.fail(err) + } + } + r.Probes = ProbeResults{ + Main: outcome, MainFeeMsat: fee, PrepayFeeMsat: prepay, + FeeKnown: outcome == ProbeSucceeded && r.Probes.FeeKnown, + CheckedAt: f.cfg.Clock.Now().UTC().Truncate(time.Microsecond), + } + if !f.save(ctx) { + return fsm.NoOp + } + if outcome != ProbeSucceeded { + return OnCancel + } + return OnProbed +} + +// AwaitApprovalAction requires explicit approval of the exact saved quote. +// Recovery waits here until consent and the PayPrepay transition are saved. +func (f *FSM) AwaitApprovalAction(_ context.Context, + data fsm.EventContext) fsm.EventType { + + if f.persistErr != nil { + return fsm.NoOp + } + // Approval cannot revive a missing or expired quote. Start prepay + // cancellation instead of accepting consent on stale terms. + if f.reservation.Quote == nil || f.hasExpiredQuote() { + return OnCancel + } + + // Recovery alone is not consent to pay. Wait for explicit approval. + if f.event != OnApprove { + return fsm.NoOp + } + + a, ok := data.(*looprpc.ApproveAssetReservationRequest) + if !ok { + return f.stayInState(errors.New("missing quote approval")) + } + if err := f.reservation.CheckApproval(a); err != nil { + return f.stayInState(err) + } + // The entry hook saves only the routing cap and SkipProbe choice, in + // the same transaction as PayPrepay. A failed write leaves us unapproved. + return OnApproved +} + +// observePayment records only a terminal result from the saved paying node. +func (f *FSM) observePayment(ctx context.Context) (bool, error) { + r := f.reservation + if r.PaymentResult != nil && + (r.PaymentResult.Status == lnrpc.Payment_SUCCEEDED || + r.PaymentResult.Status == lnrpc.Payment_FAILED) { + + return true, nil + } + result, err := f.cfg.Payments.Lookup(ctx, r.PayingNodeKey, r.PaymentHash) + if err != nil { + return false, err + } + if result == nil || result.PaymentHash != r.PaymentHash.String() { + return false, errors.New("invalid prepay payment observation") + } + if result.Status != lnrpc.Payment_SUCCEEDED && + result.Status != lnrpc.Payment_FAILED { + + // An in-flight payment is unresolved; it must not trigger a resend. + return false, nil + } + next := *r + next.PaymentResult = proto.Clone(result).(*lnrpc.Payment) + if err := next.Validate(); err != nil { + return false, err + } + r.PaymentResult = next.PaymentResult + if !f.save(ctx) { + return false, f.persistErr + } + return true, nil +} + +// PayPrepayAction looks up the saved hash before any send. A lost send reply +// or an in-flight payment is not permission for another payment attempt. +func (f *FSM) PayPrepayAction(ctx context.Context, + _ fsm.EventContext) fsm.EventType { + + if f.persistErr != nil { + return fsm.NoOp + } + r := f.reservation + if r.PaymentRequest == nil { + // With no saved payment plan, a missing or expired quote must + // go straight to prepay cancellation. + if f.reservation.Quote == nil || f.hasExpiredQuote() { + return OnCancel + } + if err := r.checkPrepay(); err != nil { + return f.fail(err) + } + + plan, err := f.cfg.Payments.Prepare(ctx, r) + if err != nil { + return f.stayInState(err) + } + if plan == nil || plan.Request == nil || plan.NodeKey == nil { + return f.fail(errors.New("missing prepay payment plan")) + } + r.PaymentHash, r.PayingNodeKey = plan.Hash, plan.NodeKey + r.PaymentRequest = plan.Request + // Save the exact request and node before sending, so a lost reply + // can be resolved by looking up this payment after a restart. + if !f.save(ctx) { + return fsm.NoOp + } + } + // Resolve an existing attempt before checking quote expiry: a settled + // prepay still entitles the client to delivery after the quote expires. + terminal, err := f.observePayment(ctx) + if terminal { + if r.PaymentResult.Status == lnrpc.Payment_SUCCEEDED { + return OnPaid + } + return OnCancel + } + // Only confirmed absence on the saved paying node permits a send. + // In-flight payments and lookup errors must wait for another lookup. + if !errors.Is(err, ErrPaymentNotFound) { + return f.stayInState(err) + } + // No payment was found. Cancel the prepay if its quote is now missing + // or expired, rather than starting a payment on stale terms. + if f.reservation.Quote == nil || f.hasExpiredQuote() { + return OnCancel + } + // Validate the invoice again after a potentially long offline period. + if err := f.cfg.Payments.ValidateQuote(ctx, r.Quote); err != nil { + return f.stayInState(err) + } + // Validation can take time; check expiry once more before sending. + if f.reservation.Quote == nil || f.hasExpiredQuote() { + return OnCancel + } + + payErr := f.cfg.Payments.Pay(ctx, r.PayingNodeKey, r.PaymentRequest) + + // A send reply does not establish settlement. Stay here and look up + // the payment on the next attempt, whether Pay returns an error or nil. + return f.stayInState(payErr) +} + +// WaitForDeliveryAction retrieves delivery facts but never trusts server Ready +// as proof that the client can use the reservation. +func (f *FSM) WaitForDeliveryAction(ctx context.Context, + _ fsm.EventContext) fsm.EventType { + + if f.persistErr != nil { + return fsm.NoOp + } + r := f.reservation + if r.PaymentResult == nil || + r.PaymentResult.Status != lnrpc.Payment_SUCCEEDED { + + return f.fail(errors.New("reservation has no settled prepay")) + } + status, err := f.cfg.Server.GetAssetReservation(ctx, f.selector()) + if err != nil { + return f.stayInState(err) + } + if status == nil || !bytes.Equal(status.ReservationId, r.ID[:]) || + !proto.Equal(status.Quote, r.Quote) { + + return f.fail(errors.New("server changed the reservation")) + } + if status.State == + swapserverrpc.AssetReservationStatus_ASSET_RESERVATION_CANCELED { + + // A settled prepay is still owed delivery. Server cancellation + // is an inconsistency that needs attention, not a normal exit. + return f.fail(errors.New("server canceled a paid reservation")) + } + // Wait until the server can identify the output and supply its proof. + // Its state alone cannot establish that delivery is valid. + if status.Outpoint == "" || !status.ProofAvailable { + return fsm.NoOp + } + // The entire service fee was prepaid and must be credited to this + // reservation for its later swap. + if status.PrepayCredit != r.Fee { + return f.fail(errors.New("incorrect settled reservation credit")) + } + outpoint, err := ParseOutpoint(status.Outpoint) + if err != nil { + return f.fail(err) + } + r.FundingOutpoint, r.PrepayCredit = outpoint, status.PrepayCredit + if !f.save(ctx) { + return fsm.NoOp + } + return OnDelivery +} + +// VerifyReservationAction independently checks the full exported proof. An +// offline client uses the original CSV clock, not a new delivery window. +func (f *FSM) VerifyReservationAction(ctx context.Context, + _ fsm.EventContext) fsm.EventType { + + if f.persistErr != nil { + return fsm.NoOp + } + r := f.reservation + if r.FundingOutpoint == nil { + return f.fail(errors.New("missing reservation funding outpoint")) + } + response, err := f.cfg.Server.GetAssetReservationProof(ctx, f.selector()) + if err != nil { + return f.stayInState(err) + } + if response == nil || !bytes.Equal(response.ReservationId, r.ID[:]) || + response.Outpoint != r.FundingOutpoint.String() || len(response.Proof) == 0 { + + return f.fail(errors.New("proof does not match reservation")) + } + status, err := f.cfg.Wallet.Verify(ctx, r, response.Proof) + // A proof mismatch needs attention; node or transport errors can be + // retried without accepting the delivered output. + if errors.Is(err, ErrInvalidReservation) { + return f.fail(err) + } + if err != nil { + return f.stayInState(err) + } + if err := f.checkChain(status); err != nil { + return f.stayInState(err) + } + f.readyVerified = true + r.ConfirmationHeight = status.ConfirmationHeight + r.ReservationProof = bytes.Clone(response.Proof) + // Preserve the verified proof and original confirmation height before + // declaring readiness or expiry. Recovery must use the same CSV clock. + if !f.save(ctx) { + return fsm.NoOp + } + lifetime, _ := r.Terms.Lifetime(r.ConfirmationHeight) + // An offline client may first verify delivery after its CSV lifetime + // has ended. Such a reservation goes directly to Expired. + if status.Height >= lifetime.TimeoutHeight { + return OnTimeout + } + if status.Spent { + return f.fail(errors.New("reservation spent before timeout")) + } + return OnProof +} + +// checkChain validates local funding heights when verifying delivery and +// monitoring a ready reservation. It requires the quoted confirmation depth, +// preserves any saved first-confirmation height, and checks that the derived +// deadlines fit LND's block-height range. Keeping the confirmation height fixed +// prevents recovery or a chain change from shifting the reservation's expiry. +// The caller then checks whether the reservation has expired or been spent. +func (f *FSM) checkChain(status FundingStatus) error { + r := f.reservation + // Require a valid local height and the quoted confirmation depth before + // treating the output as ready. + if status.Height > math.MaxInt32 || status.ConfirmationHeight == 0 || + status.Height < status.ConfirmationHeight || + (status.Height-status.ConfirmationHeight+1) < r.RequiredConfirmations { + + return errors.New("reservation needs confirmed chain data") + } + // Do not move an established confirmation height and thereby shift + // the reservation's expiry during recovery or a chain change. + if r.ConfirmationHeight != 0 && + r.ConfirmationHeight != status.ConfirmationHeight { + + return ErrInvalidReservation + } + _, err := r.Terms.Lifetime(status.ConfirmationHeight) + return err +} + +// ReadyAction watches the verified output through the original CSV expiry. +// A later swap must separately enforce its execution cutoff and claim margin. +func (f *FSM) ReadyAction(ctx context.Context, + _ fsm.EventContext) fsm.EventType { + + if f.persistErr != nil { + return fsm.NoOp + } + // Verify persisted evidence once after restart. Routine watches only + // refresh chain state; immutable proof history need not be reverified. + if !f.readyVerified { + _, err := f.cfg.Wallet.Verify(ctx, f.reservation, + f.reservation.ReservationProof) + if err != nil { + return f.readyError(err) + } + f.readyVerified = true + } + status, err := f.cfg.Wallet.Inspect(ctx, f.reservation) + if err != nil { + return f.readyError(err) + } + if err := f.checkChain(status); err != nil { + return f.readyError(err) + } + lifetime, _ := f.reservation.Terms.Lifetime( + f.reservation.ConfirmationHeight, + ) + // At CSV expiry, finish watching. Before then, an observed spend is + // unexpected in this purchase flow and needs attention. + if status.Height >= lifetime.TimeoutHeight { + return OnTimeout + } + if status.Spent { + return f.fail(errors.New("reservation spent before timeout")) + } + return fsm.NoOp +} + +// readyError keeps transient node failures retryable while withdrawing a +// reservation whose saved evidence no longer matches the active chain. +func (f *FSM) readyError(err error) fsm.EventType { + if errors.Is(err, ErrInvalidReservation) { + return f.fail(err) + } + return f.stayInState(err) +} + +// CancelPrepayAction waits for both server cancellation and a resolved local +// payment. A settled payment resumes delivery, even after a cancel request. +func (f *FSM) CancelPrepayAction(ctx context.Context, + _ fsm.EventContext) fsm.EventType { + + if !f.save(ctx) { + return fsm.NoOp + } + r := f.reservation + resolved := f.reservation.PaymentRequest == nil + if !resolved { + terminal, err := f.observePayment(ctx) + // Settlement wins over cancellation: continue toward delivery + // even if cancellation was requested or the quote has expired. + if terminal && f.reservation.PaymentResult.Status == lnrpc.Payment_SUCCEEDED { + return OnPaid + } + if err != nil && !errors.Is(err, ErrPaymentNotFound) { + return f.stayInState(err) + } + resolved = terminal || errors.Is(err, ErrPaymentNotFound) + } + // Ask the server to cancel even while a payment is in flight, so its + // hold invoice can be canceled and the local payment can resolve. + status, err := f.cfg.Server.CancelAssetReservation(ctx, + &swapserverrpc.CancelAssetReservationRequest{ + Reservation: f.selector(), + }) + if err != nil { + return f.stayInState(err) + } + if status == nil || !bytes.Equal(status.ReservationId, r.ID[:]) { + return f.fail(errors.New("invalid reservation cancellation")) + } + if f.reservation.ProbeRequest != nil && f.reservation.ProbeResult == nil { + result, err := f.cfg.Payments.Lookup( + ctx, f.reservation.ProbeNodeKey, + ProbeHash(f.reservation.ID), + ) + switch { + case errors.Is(err, ErrPaymentNotFound): + case err != nil: + return f.stayInState(err) + case result == nil: + return f.stayInState(errors.New("missing probe result")) + case result.Status == lnrpc.Payment_SUCCEEDED: + return f.fail(errors.New("probe unexpectedly settled")) + case result.Status != lnrpc.Payment_FAILED: + resolved = false + default: + f.reservation.ProbeResult = proto.Clone(result).(*lnrpc.Payment) + if !f.save(ctx) { + return fsm.NoOp + } + } + } + // Finish only when the local payment is resolved and the server has + // confirmed cancellation. Either side alone leaves an open obligation. + if resolved && status.State == + swapserverrpc.AssetReservationStatus_ASSET_RESERVATION_CANCELED { + + if status.FundingUnavailable { + return OnQuoteRejected + } + return OnCanceled + } + return fsm.NoOp +} + +// NeedAdminAttentionAction retains the purchase and reports the inconsistency. +func (f *FSM) NeedAdminAttentionAction(_ context.Context, + _ fsm.EventContext) fsm.EventType { + + // Notify only after the state was saved, once per live FSM. Keep the + // reservation available for investigation and recovery. + if f.persistErr == nil && !f.adminNotified { + f.cfg.NotifyAdmin(f.reservation.ID, f.LastActionError) + f.adminNotified = true + } + return fsm.NoOp +} diff --git a/assets/reservation/fsm.go b/assets/reservation/fsm.go new file mode 100644 index 000000000..be2ee7e9d --- /dev/null +++ b/assets/reservation/fsm.go @@ -0,0 +1,279 @@ +package reservation + +import ( + "context" + "errors" + "sync" + + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/looprpc" +) + +// Events cannot bypass the checks in their destination action. +const ( + OnRecover fsm.EventType = "OnRecover" + OnQuote fsm.EventType = "OnQuote" + OnQuoteFailed fsm.EventType = "OnQuoteFailed" + OnQuoteRejected fsm.EventType = "OnQuoteRejected" + OnProbed fsm.EventType = "OnProbed" + OnApprove fsm.EventType = "OnApprove" + OnApproved fsm.EventType = "OnApproved" + OnPaid fsm.EventType = "OnPaid" + OnDelivery fsm.EventType = "OnDelivery" + OnProof fsm.EventType = "OnProof" + OnCancel fsm.EventType = "OnCancel" + OnCanceled fsm.EventType = "OnCanceled" + OnTimeout fsm.EventType = "OnTimeout" +) + +// FSM saves state on entry and financial facts before node calls, following +// static-address Loop In. Its manager owns the live reservation. +type FSM struct { + machine *fsm.StateMachine + eventMu sync.Mutex + cfg *Config + reservation *Reservation + persistErr error + event fsm.EventType + readyVerified bool + adminNotified bool + LastActionError error +} + +// NewFSM reconstructs a saved state without making node calls. +func NewFSM(cfg *Config, reservation *Reservation) (*FSM, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + if err := reservation.Validate(); err != nil { + return nil, err + } + f := &FSM{ + cfg: cfg, + reservation: reservation, + } + if err := f.restore(reservation); err != nil { + return nil, err + } + return f, nil +} + +// restore rebuilds the in-memory state machine from the supplied reservation. +// The caller supplies saved facts; restore does not read or write the database. +// +// For a new purchase, the manager has already stored the reservation in +// RequestQuote. For an existing purchase, r.State selects the last saved step, +// preserving its quote, payment choices, and payment or delivery facts. The +// manager sends OnRecover afterward to start or resume that step's action. +// +// After a failed write, SendEvent reloads the reservation and calls restore to +// discard any uncommitted in-memory progress before handling the next event. +// A successful restore clears persistErr and reconnects the state-entry hook. +// It runs no action and makes no node calls itself. +func (f *FSM) restore(r *Reservation) error { + if err := r.Validate(); err != nil { + return err + } + states := f.states() + if _, ok := states[r.State]; !ok { + return errors.New("unknown client reservation state") + } + f.reservation = r + f.machine = fsm.NewStateMachineWithState(states, r.State, 0) + f.machine.ActionEntryFunc = f.enterState + f.persistErr = nil + return nil +} + +// SendEvent reloads durable facts after a failed write. The shared FSM's +// entry hook cannot veto its in-memory transition, so that state is discarded. +func (f *FSM) SendEvent(ctx context.Context, event fsm.EventType, + data fsm.EventContext) error { + + f.eventMu.Lock() + defer f.eventMu.Unlock() + if f.persistErr != nil { + r, err := f.cfg.Store.GetReservation(ctx, f.reservation.ID) + if err != nil { + return err + } + if err := f.restore(r); err != nil { + return err + } + } + f.LastActionError = nil + return f.machine.SendEvent(ctx, event, data) +} + +func (f *FSM) states() fsm.States { + return fsm.States{ + RequestQuote: { + Action: f.RequestQuoteAction, + Transitions: fsm.Transitions{ + OnRecover: RequestQuote, + OnQuote: ProbeRoutes, + OnQuoteFailed: QuoteFailed, + OnQuoteRejected: QuoteRejected, + OnCancel: CancelPrepay, + fsm.OnError: NeedAdminAttention, + }, + }, + ProbeRoutes: { + Action: f.ProbeRoutesAction, + Transitions: fsm.Transitions{ + OnRecover: ProbeRoutes, + OnProbed: AwaitApproval, + OnCancel: CancelPrepay, + fsm.OnError: NeedAdminAttention, + }, + }, + AwaitApproval: { + Action: f.AwaitApprovalAction, + Transitions: fsm.Transitions{ + OnRecover: AwaitApproval, + OnApprove: AwaitApproval, + OnApproved: PayPrepay, + OnCancel: CancelPrepay, + fsm.OnError: NeedAdminAttention, + }, + }, + PayPrepay: { + Action: f.PayPrepayAction, + Transitions: fsm.Transitions{ + OnRecover: PayPrepay, + OnPaid: WaitForDelivery, + OnCancel: CancelPrepay, + fsm.OnError: NeedAdminAttention, + }, + }, + WaitForDelivery: { + Action: f.WaitForDeliveryAction, + Transitions: fsm.Transitions{ + OnRecover: WaitForDelivery, + OnDelivery: VerifyReservation, + fsm.OnError: NeedAdminAttention, + }, + }, + VerifyReservation: { + Action: f.VerifyReservationAction, + Transitions: fsm.Transitions{ + OnRecover: VerifyReservation, + OnProof: Ready, + OnTimeout: Expired, + fsm.OnError: NeedAdminAttention, + }, + }, + Ready: { + Action: f.ReadyAction, + Transitions: fsm.Transitions{ + OnRecover: Ready, + OnTimeout: Expired, + fsm.OnError: NeedAdminAttention, + }, + }, + CancelPrepay: { + Action: f.CancelPrepayAction, + Transitions: fsm.Transitions{ + OnRecover: CancelPrepay, + // A repeated cancel is idempotent. The caller may + // cancel again, or its cancel may race the quote + // expiry that already moved us here. + OnCancel: CancelPrepay, + OnPaid: WaitForDelivery, + OnCanceled: Canceled, + OnQuoteRejected: QuoteRejected, + fsm.OnError: NeedAdminAttention, + }, + }, + NeedAdminAttention: { + Action: f.NeedAdminAttentionAction, + Transitions: fsm.Transitions{ + OnRecover: NeedAdminAttention, + }, + }, + QuoteFailed: {Action: fsm.NoOpAction, + Transitions: fsm.Transitions{OnRecover: QuoteFailed}}, + QuoteRejected: { + Action: fsm.NoOpAction, + Transitions: fsm.Transitions{OnRecover: QuoteRejected}, + }, + Canceled: { + Action: fsm.NoOpAction, + Transitions: fsm.Transitions{OnRecover: Canceled}, + }, + Expired: { + Action: fsm.NoOpAction, + Transitions: fsm.Transitions{OnRecover: Expired}, + }, + } +} + +// enterState runs after the shared FSM selects its next state and before that +// state's action. It records the triggering event so actions can distinguish +// approval from recovery. Re-entering the same state needs no transition write. +// +// A transition saves the new state together with any payment choices: approval +// stores the routing cap and SkipProbe. Background retries preserve that choice. +// The live reservation is updated only after the write succeeds. +// +// If approval validation or persistence fails, persistErr tells the next action +// to stop before making service calls. The shared FSM has already advanced in +// memory, so SendEvent reloads the saved reservation before handling another event. +func (f *FSM) enterState(ctx context.Context, n fsm.Notification) { + f.event = n.Event + if f.reservation.State == n.NextState { + return + } + + r := *f.reservation + if n.Event == OnApproved { + // The quote stays fixed. Persist the caller's remaining choices + // atomically with the state that authorizes prepay preparation. + req, _ := n.EventContext.(*looprpc.ApproveAssetReservationRequest) + if err := r.CheckApproval(req); err != nil { + f.persistErr, f.LastActionError = err, err + return + } + r.MaxRouteFeeMsat = req.MaxRouteFeeMsat + r.SkipProbe = req.SkipProbe + } + r.State = n.NextState + f.persistErr = f.cfg.Store.UpdateReservation(ctx, &r) + if f.persistErr != nil { + f.LastActionError = f.persistErr + return + } + *f.reservation = r +} + +func (f *FSM) save(ctx context.Context) bool { + if f.persistErr != nil { + return false + } + f.persistErr = f.cfg.Store.UpdateReservation(ctx, f.reservation) + if f.persistErr != nil { + f.LastActionError = f.persistErr + return false + } + return true +} + +// stayInState records the action's result and ends this event without another +// transition. The error may be nil, for example after sending a payment whose +// settlement is still unknown. The manager later sends OnRecover or handles +// the next caller command; this helper does not retry an operation itself. +func (f *FSM) stayInState(err error) fsm.EventType { + f.LastActionError = err + return fsm.NoOp +} + +func (f *FSM) fail(err error) fsm.EventType { + f.LastActionError = err + return fsm.OnError +} + +// IsFinal reports whether purchase recovery and expiry watching are complete. +func IsFinal(state fsm.StateType) bool { + return state == QuoteFailed || state == QuoteRejected || + state == Canceled || state == Expired +} diff --git a/assets/reservation/fsm.md b/assets/reservation/fsm.md new file mode 100644 index 000000000..5de78022e --- /dev/null +++ b/assets/reservation/fsm.md @@ -0,0 +1,99 @@ +# Client asset reservation purchase + +The client uses named actions and `OnRecover`, as in static-address Loop In. +Tests use the real SQLite store with node doubles, not real asset transfers. + +```mermaid +stateDiagram-v2 + RequestQuote --> ProbeRoutes: exact quote saved + RequestQuote --> QuoteFailed: invalid quote or one-minute deadline + RequestQuote --> QuoteRejected: server refuses funding admission + ProbeRoutes --> AwaitApproval: main probe succeeded or explicitly skipped + AwaitApproval --> PayPrepay: exact quote and limits approved + PayPrepay --> WaitForDelivery: local payment settled + WaitForDelivery --> VerifyReservation: outpoint and proof available + VerifyReservation --> Ready: local proof and chain checks pass + Ready --> Expired: original CSV matures + VerifyReservation --> Expired: recovered after original CSV maturity + RequestQuote --> CancelPrepay: canceled or expired quote + ProbeRoutes --> CancelPrepay: probe failure, timeout, or expired quote + AwaitApproval --> CancelPrepay: canceled or expired quote + PayPrepay --> CancelPrepay: canceled or terminal failed payment + CancelPrepay --> CancelPrepay: repeated cancellation + CancelPrepay --> QuoteRejected: funding refused and local payment resolved + CancelPrepay --> Canceled: server canceled and local payment resolved + CancelPrepay --> WaitForDelivery: payment settled instead +``` + +An `OutOfRange` quote refusal ends in `QuoteRejected` before any probe or +payment. This terminal state is saved, excluded from active lists and restart +recovery, and cannot resume when inventory returns. The CLI reports +`cannot initiate swap: rpc error: code = OutOfRange desc = amount above current maximum`. +A new attempt requires a fresh purchase ID. Transient quote RPC errors and missing quotes retry for at most one minute +from persisted purchase creation. Invalid quotes end immediately in +`QuoteFailed`, before any probe or payment. That state is terminal and excluded +from recovery; server invoices expire independently. A restart cannot reset +the quote deadline. +The same terminal state and CLI error represent a later funding refusal: the +server must confirm cancellation with `funding_unavailable`, and the client +must first resolve its own payment. An in-flight held prepay stays tracked and +is never resent or treated as canceled solely because of server status. +New servers hold the prepay while preparing funding and settle after Bitcoin +publication. The client requires local settlement before delivery verification. + +Every state accepts `OnRecover`. `AwaitApproval` waits for explicit consent. +The transition to `PayPrepay` saves the prepay routing cap and `SkipProbe` in +the same transaction; that state records consent. If the write fails, recovery +waits for approval again. If it succeeds, recovery resumes payment with those +saved choices. Before sending, save the exact payment hash, paying node, +and LND request. Look up that payment first on every recovery. A timeout or a +different paying node must never be reported as payment-not-found. A terminal +failed payment is canceled, not paid again. Settlement resumes delivery even +after quote expiry or a lost cancellation reply. + +Entry writes precede actions. If a write fails, make no further node call and +reload the durable record before the next event. No observer notification can +substitute for a committed state. Normal approval requires a successful main +probe. Before approval, `SkipProbe` preserves the preference to skip automatic +probing across restarts. Approval must explicitly set it again to permit payment +without a successful probe. Recovery tracks the single saved probe payment +and cancellation. A failed probe cancels the unpaid purchase. A new attempt +uses a fresh purchase ID; recovery never replaces or revives the old quote. +Quote validation and payment limits remain mandatory. Each invoice must match +its own RFQ; the prepay and probe may use different rates. Approval displays +the exact BTC prepay and implied prepay/probe prices. + +Server status is only a delivery hint. Verify the full exported proof with +tapd and the shared deposit kit, then check exact output, local keys, amount, +three required confirmations, known spends, and the original CSV clock. +Spend and expiry tracking follow Instant Out's LND notifications. Recovery +restores the watches and fully verifies the saved proof once. Routine Ready +checks refresh chain state without repeating tapd proof verification. A watch +interval ending without a block is normal, not an error. No wallet history or +synchronous unspent assertion is required. +Client `Ready` means verified and unexpired, including after late recovery. +Server delivery must precede the execution cutoff, without an additional +minimum window. A later swap must still enforce the saved execution cutoff +and its own claim margin. + +Genuine inconsistencies enter `NeedAdminAttention` and notify the operator. +A temporary paying-node RPC failure before probe dispatch retries the same +quote until expiry. Later temporary failures of the probe lookup, its +dispatch, or the server status read also keep the purchase and retry while +the probe deadline and the quote allow. Every pass looks up the saved probe +before dispatching, so a retry cannot send it twice. A definitive mismatch between the delivered proof and +the active chain removes readiness and its advertised deadlines. Real node adapters and +runtime registration remain separate steps; no RPC or CLI is enabled here. + +ProbeRoutes persists its node, request, and deadline before `SendPaymentV2`. +It validates the server receipt/cancellation report and the local payment-level +`FAILURE_REASON_INCORRECT_PAYMENT_DETAILS` before saving success. Live +`SendPaymentV2` and recovery `TrackPaymentV2` updates supply the route fee, +which is saved before failed attempts can be pruned. Missing route history +leaves the estimate unavailable and does not invalidate successful delivery. Failure or timeout enters `CancelPrepay`, which +requests cancellation before waiting for the local probe payment to resolve. +Explicit skip releases just the probe invoice before approval. Successful +probes also release their hold invoice; they never settle. +Recovery checks completed probe evidence before applying the probe deadline, +but an expired purchase quote still cancels. Check the deadline after status +lookups and immediately before dispatch so slow RPCs cannot start a late probe. diff --git a/assets/reservation/fsm_test.go b/assets/reservation/fsm_test.go new file mode 100644 index 000000000..69244e8e8 --- /dev/null +++ b/assets/reservation/fsm_test.go @@ -0,0 +1,760 @@ +package reservation + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +const invalidCase = "invalid" + +type failingStore struct { + Store + + fail func(*Reservation) bool +} + +func (s *failingStore) UpdateReservation(ctx context.Context, + r *Reservation) error { + + if s.fail != nil && s.fail(r) { + return errors.New("injected write failure") + } + return s.Store.UpdateReservation(ctx, r) +} + +type clientHarness struct { + swapserverrpc.AssetReservationServiceClient + + t *testing.T + store *failingStore + cfg *Config + machine *FSM + id ID + quote *swapserverrpc.AssetReservationQuote + preimage lntypes.Preimage + result *lnrpc.Payment + probeInvoices []string + probeResult ProbeOutcome + probePayment *lnrpc.Payment + probeCanceled bool + probeSucceeded bool + sends int + quotes int + cancels int + lookups int + notifications int + deriveCalls int + verifyCalls int + quoteErr error + quoteValidationErr error + quotePending bool + blockQuote bool + quoteStarted chan struct{} + fundingUnavailable bool + deliver bool + badProof bool + status FundingStatus + outpoint wire.OutPoint +} + +func newClientHarness(t *testing.T) *clientHarness { + t.Helper() + db := loopdb.NewTestDB(t) + r := testReservation() + r.Terms = Terms{ + AssetID: r.AssetID, + Amount: r.Amount, + } + r.State = RequestQuote + h := &clientHarness{ + t: t, + id: r.ID, + quote: testPurchaseQuote(r), + store: &failingStore{ + Store: NewSqlStore(db.BaseDB), + }, + preimage: lntypes.Preimage{9}, + probeResult: ProbeSucceeded, + outpoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 2, + }, + status: FundingStatus{ + Height: 101, + ConfirmationHeight: 100, + }, + } + h.cfg = &Config{ + Store: h.store, + Server: h, + Payments: h, + Wallet: h, + Clock: clock.NewTestClock(time.Unix(1800000000, 0)), + NotifyAdmin: func(ID, error) { h.notifications++ }, + } + h.store.Store.(*SqlStore).clock = h.cfg.Clock + require.NoError(t, h.store.CreateReservation(t.Context(), r)) + h.restart() + return h +} + +func (h *clientHarness) record() *Reservation { + h.t.Helper() + r, err := h.store.GetReservation(h.t.Context(), h.id) + require.NoError(h.t, err) + return r +} + +func (h *clientHarness) restart() { + h.t.Helper() + var err error + h.machine, err = NewFSM(h.cfg, h.record()) + require.NoError(h.t, err) +} + +func (h *clientHarness) event(event fsm.EventType, data fsm.EventContext) { + h.t.Helper() + require.NoError(h.t, h.machine.SendEvent(h.t.Context(), event, data)) + for range 3 { + if h.record().State != ProbeRoutes || h.machine.LastActionError != nil { + break + } + require.NoError(h.t, h.machine.SendEvent(h.t.Context(), OnRecover, nil)) + } +} + +func (h *clientHarness) state(state fsm.StateType) { + h.t.Helper() + require.Equal(h.t, state, h.record().State) +} + +func (h *clientHarness) QuoteAssetReservation(ctx context.Context, + r *swapserverrpc.QuoteAssetReservationRequest, _ ...grpc.CallOption) ( + *swapserverrpc.AssetReservation, error) { + + h.quotes++ + if h.quoteErr != nil { + return nil, h.quoteErr + } + if h.blockQuote { + select { + case h.quoteStarted <- struct{}{}: + + default: + } + <-ctx.Done() + return nil, ctx.Err() + } + require.Equal(h.t, h.id[:], r.ReservationId) + if h.quotePending { + return &swapserverrpc.AssetReservation{ReservationId: r.ReservationId}, nil + } + return &swapserverrpc.AssetReservation{ + ReservationId: h.quote.ReservationId, + Quote: proto.Clone(h.quote).(*swapserverrpc.AssetReservationQuote), + }, nil +} + +func (h *clientHarness) GetAssetReservation(context.Context, + *swapserverrpc.AssetReservationSelector, ...grpc.CallOption) ( + *swapserverrpc.AssetReservation, error) { + + response := &swapserverrpc.AssetReservation{ + ReservationId: h.quote.ReservationId, + Quote: h.quote, + State: swapserverrpc.AssetReservationStatus_ASSET_RESERVATION_FUNDING, + ProbeCanceled: h.probeCanceled, ProbeSucceeded: h.probeSucceeded, + } + if h.deliver { + response.State = swapserverrpc.AssetReservationStatus_ASSET_RESERVATION_READY + response.Outpoint = h.outpoint.String() + response.ProofAvailable = true + response.PrepayCredit = h.quote.Terms.Fee + } + return response, nil +} + +func (h *clientHarness) GetAssetReservationProof(context.Context, + *swapserverrpc.AssetReservationSelector, ...grpc.CallOption) ( + *swapserverrpc.AssetReservationProof, error) { + + return &swapserverrpc.AssetReservationProof{ + ReservationId: h.quote.ReservationId, + Outpoint: h.outpoint.String(), + Proof: []byte{1, 2, 3}, + }, nil +} + +func (h *clientHarness) CancelAssetReservation(ctx context.Context, + req *swapserverrpc.CancelAssetReservationRequest, _ ...grpc.CallOption) ( + *swapserverrpc.AssetReservation, error) { + + if req.ProbeOnly { + h.probeCanceled = true + return h.GetAssetReservation(ctx, req.Reservation) + } + h.cancels++ + return &swapserverrpc.AssetReservation{ + FundingUnavailable: h.fundingUnavailable, + ReservationId: h.quote.ReservationId, + State: swapserverrpc.AssetReservationStatus_ASSET_RESERVATION_CANCELED, + }, nil +} + +func (h *clientHarness) ValidateQuote(context.Context, + *swapserverrpc.AssetReservationQuote) error { + + return h.quoteValidationErr +} + +func (h *clientHarness) PrepareProbe(_ context.Context, + r *Reservation) (*PaymentPlan, error) { + + return &PaymentPlan{ + Hash: ProbeHash(r.ID), NodeKey: r.ClientKey.PubKey, + Request: &routerrpc.SendPaymentRequest{ + PaymentRequest: r.Quote.ProbeInvoice, + TimeoutSeconds: 10, MaxParts: 1, + CltvLimit: 160, FeeLimitMsat: 100, + }, + }, nil +} + +func (h *clientHarness) SendProbe(_ context.Context, r *Reservation, + observe func(*lnrpc.Payment) error) error { + + h.probeInvoices = append(h.probeInvoices, r.Quote.ProbeInvoice) + h.probeSucceeded = h.probeResult == ProbeSucceeded + h.probeCanceled = true + h.probePayment = &lnrpc.Payment{ + PaymentHash: ProbeHash(r.ID).String(), + Status: lnrpc.Payment_FAILED, + FailureReason: lnrpc.PaymentFailureReason_FAILURE_REASON_NO_ROUTE, + } + if h.probeResult == ProbeTimedOut { + h.probePayment.FailureReason = + lnrpc.PaymentFailureReason_FAILURE_REASON_TIMEOUT + } + if h.probeSucceeded { + // Default LND settings do not retain failed HTLC attempts. + h.probePayment.FailureReason = + lnrpc.PaymentFailureReason_FAILURE_REASON_INCORRECT_PAYMENT_DETAILS + } + return observe(&lnrpc.Payment{ + PaymentHash: ProbeHash(r.ID).String(), + Status: lnrpc.Payment_IN_FLIGHT, + Htlcs: []*lnrpc.HTLCAttempt{{ + Status: lnrpc.HTLCAttempt_IN_FLIGHT, + Route: &lnrpc.Route{ + TotalFeesMsat: 10, + TotalAmtMsat: int64(r.Quote.EstimatedMainAmountMsat) + 10, + }, + }}, + }) +} + +func (h *clientHarness) TrackProbe(_ context.Context, _ *Reservation, + observe func(*lnrpc.Payment) error) error { + + return observe(h.probePayment) +} + +func (h *clientHarness) Prepare(_ context.Context, + r *Reservation) (*PaymentPlan, error) { + + return &PaymentPlan{ + Hash: h.preimage.Hash(), + NodeKey: r.ClientKey.PubKey, + Request: &routerrpc.SendPaymentRequest{ + PaymentRequest: r.Quote.PrepayInvoice, + FeeLimitMsat: int64(r.MaxRouteFeeMsat), + }, + }, nil +} + +func (h *clientHarness) Lookup(_ context.Context, _ *btcec.PublicKey, + hash lntypes.Hash) (*lnrpc.Payment, error) { + + if hash == ProbeHash(ID(h.quote.ReservationId)) { + if h.probePayment == nil { + return nil, ErrPaymentNotFound + } + return proto.Clone(h.probePayment).(*lnrpc.Payment), nil + } + h.lookups++ + if h.result == nil { + return nil, ErrPaymentNotFound + } + return proto.Clone(h.result).(*lnrpc.Payment), nil +} + +func (h *clientHarness) Pay(context.Context, *btcec.PublicKey, + *routerrpc.SendPaymentRequest) error { + + h.sends++ + h.result = &lnrpc.Payment{ + PaymentHash: h.preimage.Hash().String(), + Status: lnrpc.Payment_IN_FLIGHT, + } + return errors.New("lost send reply") +} + +func (h *clientHarness) VerifyGroup(context.Context, *swapserverrpc.AssetReservationQuote) error { + return ErrInvalidReservation +} + +func (h *clientHarness) DeriveKey(context.Context) ( + *keychain.KeyDescriptor, error) { + + h.deriveCalls++ + key := testReservation().ClientKey + return &key, nil +} + +func (h *clientHarness) Verify(context.Context, *Reservation, []byte) ( + FundingStatus, error) { + + h.verifyCalls++ + if h.badProof { + return FundingStatus{}, ErrInvalidReservation + } + return h.status, nil +} + +func (h *clientHarness) Inspect(context.Context, *Reservation) ( + FundingStatus, error) { + + return h.status, nil +} + +func (h *clientHarness) approve() { + h.t.Helper() + h.event(OnApprove, testApproval(h.t, h.record())) +} + +func (h *clientHarness) settle() { + h.result.Status = lnrpc.Payment_SUCCEEDED + h.result.ValueMsat = int64(h.record().Quote.PrepayAmountMsat) + h.result.PaymentPreimage = h.preimage.String() +} + +func TestClientPurchaseFSM(t *testing.T) { + h := newClientHarness(t) + h.event(OnRecover, nil) + h.state(AwaitApproval) + h.restart() + h.event(OnRecover, nil) + h.state(AwaitApproval) + require.Zero(t, h.sends) + h.approve() + h.state(PayPrepay) + require.Equal(t, 1, h.sends) + h.restart() + h.event(OnRecover, nil) + require.Equal(t, 1, h.sends) + h.settle() + h.event(OnRecover, nil) + h.state(WaitForDelivery) + h.deliver = true + h.event(OnRecover, nil) + h.state(VerifyReservation) + require.Empty(t, h.record().ReservationProof) + h.status.Height++ + h.event(OnRecover, nil) + h.state(Ready) + require.EqualValues(t, 11, h.record().PrepayCredit) + require.EqualValues(t, 100, h.record().ConfirmationHeight) + h.restart() + h.status.Height = 1539 + h.event(OnRecover, nil) + h.state(Ready) + h.status.Height++ + h.event(OnRecover, nil) + h.state(Expired) + require.Equal(t, 1, h.sends) + require.Zero(t, h.notifications) +} + +func TestClientProbeApproval(t *testing.T) { + h := newClientHarness(t) + h.event(OnRecover, nil) + h.state(AwaitApproval) + require.EqualValues(t, 10, h.record().Probes.MainFeeMsat) + require.EqualValues(t, 1, h.record().Probes.PrepayFeeMsat) + h.restart() + h.event(OnRecover, nil) + require.Len(t, h.probeInvoices, 1) + a := testApproval(t, h.record()) + a.QuoteHash[0] ^= 1 + h.event(OnApprove, a) + h.state(AwaitApproval) + require.Zero(t, h.sends) + a.QuoteHash[0] ^= 1 + h.event(OnApprove, a) + require.Equal(t, 1, h.sends) +} + +func TestClientSkipProbeSurvivesRecovery(t *testing.T) { + h := newClientHarness(t) + r := h.record() + r.SkipProbe = true + require.NoError(t, h.store.UpdateReservation(t.Context(), r)) + h.restart() + h.event(OnRecover, nil) + h.state(AwaitApproval) + require.Empty(t, h.probeInvoices) + require.Equal(t, ProbeResults{}, h.record().Probes) + + // Recovery never overrides the saved skip preference. + h.restart() + h.event(OnRecover, nil) + h.state(AwaitApproval) + require.True(t, h.record().SkipProbe) + require.Empty(t, h.probeInvoices) + approval := testApproval(t, h.record()) + approval.SkipProbe = true + h.event(OnApprove, approval) + require.Equal(t, 1, h.sends) + require.Empty(t, h.probeInvoices) +} + +func TestClientFailedProbeCancelsPurchase(t *testing.T) { + for _, outcome := range []ProbeOutcome{ProbeFailed, ProbeTimedOut} { + t.Run(fmt.Sprint(outcome), func(t *testing.T) { + h := newClientHarness(t) + h.probeResult = outcome + h.event(OnRecover, nil) + h.state(Canceled) + require.Equal(t, 1, h.cancels) + require.Equal(t, outcome, h.record().Probes.Main) + quote := proto.Clone(h.record().Quote) + + // Recovery retains this purchase and its failed probe. It + // cannot create another quote or approve the canceled one. + h.restart() + h.event(OnRecover, nil) + h.state(Canceled) + require.Equal(t, 1, h.quotes) + require.Len(t, h.probeInvoices, 1) + require.True(t, proto.Equal(quote, h.record().Quote)) + approval := testApproval(t, h.record()) + approval.SkipProbe = true + require.ErrorIs(t, h.machine.SendEvent( + t.Context(), OnApprove, approval, + ), fsm.ErrEventRejected) + require.Zero(t, h.sends) + }) + } +} + +func TestClientPurchaseWriteBoundaries(t *testing.T) { + const approvalBoundary = "approval" + for _, boundary := range []string{approvalBoundary, "payment"} { + for _, skipProbe := range []bool{false, true} { + name := fmt.Sprintf("%s/skip_probe=%t", boundary, skipProbe) + t.Run(name, func(t *testing.T) { + h := newClientHarness(t) + h.event(OnRecover, nil) + h.store.fail = func(r *Reservation) bool { + if boundary == approvalBoundary { + return r.State == PayPrepay + } + return r.PaymentRequest != nil + } + a := testApproval(t, h.record()) + a.SkipProbe = skipProbe + if skipProbe { + // Zero is a valid cap, not an approval marker. + a.MaxRouteFeeMsat = 0 + } + h.event(OnApprove, a) + require.Zero(t, h.sends) + h.store.fail = nil + h.restart() + if boundary == approvalBoundary { + h.state(AwaitApproval) + require.Zero(t, h.record().MaxRouteFeeMsat) + require.False(t, h.record().SkipProbe) + h.event(OnRecover, nil) + require.Zero(t, h.sends) + h.event(OnApprove, a) + } else { + h.state(PayPrepay) + h.event(OnRecover, nil) + } + require.Equal(t, 1, h.sends) + require.Equal(t, a.MaxRouteFeeMsat, + h.record().MaxRouteFeeMsat) + require.Equal(t, a.SkipProbe, h.record().SkipProbe) + }) + } + } +} + +func TestClientSavedPaymentChoicesCannotChange(t *testing.T) { + h := newClientHarness(t) + h.event(OnRecover, nil) + // Commit approval but fail before saving the exact payment request. + h.store.fail = func(r *Reservation) bool { + return r.PaymentRequest != nil + } + h.approve() + h.state(PayPrepay) + require.Zero(t, h.sends) + h.store.fail = nil + h.restart() + changed := testApproval(t, h.record()) + changed.MaxRouteFeeMsat++ + require.ErrorIs(t, h.machine.SendEvent(t.Context(), OnApprove, changed), + fsm.ErrEventRejected) + require.EqualValues(t, 100, h.record().MaxRouteFeeMsat) + for _, mutate := range []func(*Reservation){ + func(r *Reservation) { r.MaxRouteFeeMsat++ }, + func(r *Reservation) { r.SkipProbe = true }, + func(r *Reservation) { r.State = AwaitApproval }, + } { + r := h.record() + mutate(r) + require.ErrorIs(t, h.store.UpdateReservation(t.Context(), r), + ErrRequestConflict) + } + h.event(OnRecover, nil) + require.Equal(t, 1, h.sends) +} + +func TestClientApprovalEventRequiresConsent(t *testing.T) { + h := newClientHarness(t) + h.event(OnRecover, nil) + + // Payment preferences alone do not authorize a purchase. Recovery must + // wait until explicit approval saves the transition to PayPrepay. + r := h.record() + r.MaxRouteFeeMsat = 100 + r.SkipProbe = true + require.NoError(t, h.store.UpdateReservation(t.Context(), r)) + h.restart() + h.event(OnRecover, nil) + h.state(AwaitApproval) + require.Zero(t, h.sends) + + h.event(OnApproved, nil) + require.Error(t, h.machine.LastActionError) + h.state(AwaitApproval) + require.Zero(t, h.sends) + h.event(OnRecover, nil) + h.state(AwaitApproval) + require.Zero(t, h.sends) +} + +func TestClientCanceledAndLateSettledPayment(t *testing.T) { + for _, name := range []string{"failed", "settled"} { + t.Run(name, func(t *testing.T) { + settled := name == "settled" + h := newClientHarness(t) + h.event(OnRecover, nil) + h.approve() + h.event(OnCancel, nil) + h.state(CancelPrepay) + if settled { + h.settle() + } else { + h.result.Status = lnrpc.Payment_FAILED + } + h.cfg.Clock.(*clock.TestClock).SetTime(time.Unix(2000000000, 0)) + h.restart() + h.event(OnRecover, nil) + if settled { + h.state(WaitForDelivery) + } else { + h.state(Canceled) + } + require.Equal(t, 1, h.sends) + }) + } +} + +// TestClientRepeatedCancel accepts cancellation while one is already pending. +// A caller may cancel twice, or its cancel may race the quote expiry that +// already moved the purchase into CancelPrepay. +func TestClientRepeatedCancel(t *testing.T) { + h := newClientHarness(t) + h.event(OnRecover, nil) + h.approve() + h.event(OnCancel, nil) + h.state(CancelPrepay) + h.event(OnCancel, nil) + h.state(CancelPrepay) +} + +func TestClientInvalidProof(t *testing.T) { + h := newClientHarness(t) + h.event(OnRecover, nil) + h.approve() + h.settle() + h.deliver, h.badProof = true, true + h.event(OnRecover, nil) + h.state(NeedAdminAttention) + require.Empty(t, h.record().ReservationProof) + require.Equal(t, 1, h.notifications) + for name, state := range h.machine.states() { + require.Equal(t, name, state.Transitions[OnRecover]) + } +} + +// TestClientQuoteRejected never retries a definitive funding refusal, including +// after restart. No quote, probe or payment exists for this purchase. +func TestClientQuoteRejected(t *testing.T) { + h := newClientHarness(t) + h.quoteErr = status.Error( + codes.OutOfRange, "amount above current maximum", + ) + h.event(OnRecover, nil) + h.state(QuoteRejected) + require.True(t, IsFinal(h.record().State)) + require.Nil(t, h.record().Quote) + require.Zero(t, h.sends) + require.Empty(t, h.probeInvoices) + require.Zero(t, h.cancels) + h.quoteErr = nil + h.restart() + h.event(OnRecover, nil) + h.state(QuoteRejected) + require.Equal(t, 1, h.quotes) +} + +// TestClientQuoteUnavailableStillRetries preserves recovery of uncertain RPCs. +func TestClientQuoteUnavailableStillRetries(t *testing.T) { + h := newClientHarness(t) + h.quoteErr = status.Error(codes.Unavailable, "reservation unavailable") + h.event(OnRecover, nil) + h.state(RequestQuote) + require.EqualError(t, h.machine.LastActionError, h.quoteErr.Error()) + h.quoteErr = nil + h.restart() + h.event(OnRecover, nil) + h.state(AwaitApproval) + require.Equal(t, 2, h.quotes) +} + +// TestClientHeldFundingRefusal waits for local payment resolution before +// persisting the public funding error. A held payment is never resent. +func TestClientHeldFundingRefusal(t *testing.T) { + h := newClientHarness(t) + h.event(OnRecover, nil) + h.approve() + h.fundingUnavailable = true + h.event(OnCancel, nil) + h.state(CancelPrepay) + h.restart() + h.event(OnRecover, nil) + h.state(CancelPrepay) + require.Equal(t, 1, h.sends) + h.result.Status = lnrpc.Payment_FAILED + h.result.FailureReason = + lnrpc.PaymentFailureReason_FAILURE_REASON_INCORRECT_PAYMENT_DETAILS + h.event(OnRecover, nil) + h.state(QuoteRejected) + require.True(t, IsFinal(h.record().State)) + h.restart() + h.event(OnRecover, nil) + h.state(QuoteRejected) + require.Equal(t, 1, h.sends) +} + +func TestClientFundingRefusalCannotOverrideSettlement(t *testing.T) { + h := newClientHarness(t) + h.event(OnRecover, nil) + h.approve() + h.fundingUnavailable = true + h.event(OnCancel, nil) + h.state(CancelPrepay) + h.settle() + h.event(OnRecover, nil) + h.state(WaitForDelivery) +} + +func TestReadyVerifiesOncePerRestart(t *testing.T) { + h := newClientHarness(t) + h.event(OnRecover, nil) + h.approve() + h.settle() + h.deliver = true + h.status.Height = 102 + h.event(OnRecover, nil) + h.state(Ready) + calls := h.verifyCalls + for range 10 { + h.event(OnRecover, nil) + } + require.Equal(t, calls, h.verifyCalls) + h.restart() + h.event(OnRecover, nil) + require.Equal(t, calls+1, h.verifyCalls) + for range 10 { + h.event(OnRecover, nil) + } + require.Equal(t, calls+1, h.verifyCalls) + // A restarted process must not trust persisted proof without verification. + h.restart() + h.badProof = true + h.status.Height = 1540 + h.event(OnRecover, nil) + h.state(NeedAdminAttention) + require.NotEmpty(t, h.record().ReservationProof) +} + +func TestQuoteFailuresStopBeforePayment(t *testing.T) { + for _, failure := range []string{invalidCase, "pending", "unavailable"} { + t.Run(failure, func(t *testing.T) { + h := newClientHarness(t) + switch failure { + case invalidCase: + h.quoteValidationErr = errors.New("invalid invoice") + case "pending": + h.quotePending = true + case "unavailable": + h.quoteErr = errors.New("server offline") + } + h.event(OnRecover, nil) + if failure != invalidCase { + h.state(RequestQuote) + h.cfg.Clock.(*clock.TestClock).SetTime( + h.record().CreatedAt.Add(time.Minute), + ) + h.restart() + h.event(OnRecover, nil) + } + h.state(QuoteFailed) + calls := h.quotes + h.restart() + h.event(OnRecover, nil) + require.Equal(t, calls, h.quotes) + require.Zero(t, h.sends) + require.Nil(t, h.record().ProbeRequest) + active, err := h.store.GetReservations(t.Context(), + StateFilter{ActiveOnly: true}) + require.NoError(t, err) + require.Empty(t, active) + }) + } +} diff --git a/assets/reservation/probe_test.go b/assets/reservation/probe_test.go new file mode 100644 index 000000000..89f4c5ee0 --- /dev/null +++ b/assets/reservation/probe_test.go @@ -0,0 +1,363 @@ +package reservation + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestProbeRecoveryRecognizesCompletedPayment(t *testing.T) { + for _, quoteExpired := range []bool{false, true} { + name := "valid quote" + if quoteExpired { + name = "expired quote" + } + t.Run(name, func(t *testing.T) { + h := newClientHarness(t) + // The receiver completed the probe, but the client stopped + // before recording the terminal observation. + require.NoError(t, h.machine.SendEvent( + t.Context(), OnRecover, nil, + )) + h.state(ProbeRoutes) + require.Nil(t, h.record().ProbeResult) + now := h.record().ProbeDeadline.Add(time.Second) + if quoteExpired { + now = time.Unix(h.quote.ExpiresAt, 0) + } + h.cfg.Clock.(*clock.TestClock).SetTime(now) + h.restart() + h.event(OnRecover, nil) + if quoteExpired { + h.state(Canceled) + require.Equal(t, 1, h.cancels) + } else { + h.state(AwaitApproval) + require.Equal(t, ProbeSucceeded, h.record().Probes.Main) + require.Zero(t, h.cancels) + } + require.Len(t, h.probeInvoices, 1) + require.Zero(t, h.sends) + }) + } +} + +type delayedProbeStatus struct{ *clientHarness } + +func (s delayedProbeStatus) GetAssetReservation(ctx context.Context, + req *swapserverrpc.AssetReservationSelector, opts ...grpc.CallOption) ( + *swapserverrpc.AssetReservation, error) { + + s.cfg.Clock.(*clock.TestClock).SetTime(s.record().ProbeDeadline) + return s.clientHarness.GetAssetReservation(ctx, req, opts...) +} + +func TestProbeDoesNotDispatchAfterSlowServerLookup(t *testing.T) { + h := newClientHarness(t) + h.cfg.Server = delayedProbeStatus{clientHarness: h} + h.restart() + h.event(OnRecover, nil) + h.state(Canceled) + require.Equal(t, ProbeTimedOut, h.record().Probes.Main) + require.Equal(t, 1, h.cancels) + require.Empty(t, h.probeInvoices) +} + +func TestProbeResumesPaymentWithoutResending(t *testing.T) { + h := newClientHarness(t) + // Stop after dispatch, before the result has been read or persisted. + require.NoError(t, h.machine.SendEvent(t.Context(), OnRecover, nil)) + h.state(ProbeRoutes) + require.Len(t, h.probeInvoices, 1) + h.probePayment.Status = lnrpc.Payment_IN_FLIGHT + h.probeCanceled = false + h.restart() + require.NoError(t, h.machine.SendEvent(t.Context(), OnRecover, nil)) + h.state(ProbeRoutes) + require.Len(t, h.probeInvoices, 1) + + // Expiration requests remote cancellation, but cannot finish while the + // local HTLC remains in flight. A restart must still only track it. + h.cfg.Clock.(*clock.TestClock).SetTime( + h.record().ProbeDeadline.Add(time.Second), + ) + h.event(OnRecover, nil) + require.Equal(t, 1, h.cancels) + h.state(CancelPrepay) + h.restart() + h.probePayment.Status = lnrpc.Payment_FAILED + h.probeSucceeded = false + h.event(OnRecover, nil) + h.state(Canceled) + require.Equal(t, ProbeTimedOut, h.record().Probes.Main) + require.Len(t, h.probeInvoices, 1) +} + +func TestProbeRequestMustBeDurableBeforeSend(t *testing.T) { + h := newClientHarness(t) + h.store.fail = func(r *Reservation) bool { return r.ProbeRequest != nil } + require.NoError(t, h.machine.SendEvent(t.Context(), OnRecover, nil)) + require.Empty(t, h.probeInvoices) + require.Nil(t, h.record().ProbeRequest) + h.store.fail = nil + h.restart() + h.event(OnRecover, nil) + h.state(AwaitApproval) + require.Len(t, h.probeInvoices, 1) +} + +func TestProbeFailureRequiresReceiverEvidence(t *testing.T) { + for _, mismatch := range []string{"receipt", "reason"} { + t.Run(mismatch, func(t *testing.T) { + h := newClientHarness(t) + require.NoError(t, h.machine.SendEvent(t.Context(), OnRecover, nil)) + switch mismatch { + case "receipt": + h.probeSucceeded = false + case "reason": + h.probePayment.FailureReason = + lnrpc.PaymentFailureReason_FAILURE_REASON_NO_ROUTE + } + h.event(OnRecover, nil) + h.state(Canceled) + require.Equal(t, 1, h.cancels) + h.restart() + h.event(OnRecover, nil) + require.Len(t, h.probeInvoices, 1) + }) + } +} + +// Success and its fee estimate survive restart without retained attempts. +func TestProbeSucceedsWithoutHTLCAttempts(t *testing.T) { + h := newClientHarness(t) + require.NoError(t, h.machine.SendEvent(t.Context(), OnRecover, nil)) + require.Empty(t, h.probePayment.Htlcs) + require.EqualValues(t, 10, h.record().Probes.MainFeeMsat) + require.True(t, h.record().Probes.FeeKnown) + + // Save the terminal payment, then stop before saving probe success. + h.store.fail = func(r *Reservation) bool { + return !r.Probes.CheckedAt.IsZero() + } + require.NoError(t, h.machine.SendEvent(t.Context(), OnRecover, nil)) + require.NotNil(t, h.record().ProbeResult) + require.Empty(t, h.record().ProbeResult.Htlcs) + h.store.fail = nil + h.restart() + h.event(OnRecover, nil) + h.state(AwaitApproval) + require.Equal(t, ProbeSucceeded, h.record().Probes.Main) + require.EqualValues(t, 10, h.record().Probes.MainFeeMsat) + require.True(t, h.record().Probes.FeeKnown) + require.EqualValues(t, 1, h.record().Probes.PrepayFeeMsat) + require.Len(t, h.probeInvoices, 1) + require.Zero(t, h.cancels) +} + +func TestProbeLiveFeeWriteFailureStopsObservation(t *testing.T) { + h := newClientHarness(t) + h.store.fail = func(r *Reservation) bool { return r.Probes.FeeKnown } + require.NoError(t, h.machine.SendEvent(t.Context(), OnRecover, nil)) + require.False(t, h.record().Probes.FeeKnown) + require.Nil(t, h.record().ProbeResult) + require.Len(t, h.probeInvoices, 1) + require.Zero(t, h.cancels) + + // The payment resolved while the route write was unavailable. Recovery + // can recognize delivery, but must not invent a fee from its result. + h.store.fail = nil + h.restart() + h.event(OnRecover, nil) + h.state(AwaitApproval) + require.False(t, h.record().Probes.FeeKnown) + require.Len(t, h.probeInvoices, 1) +} + +func TestProbeRecoveryWithoutRouteStillSucceeds(t *testing.T) { + h := newClientHarness(t) + require.NoError(t, h.machine.SendEvent(t.Context(), OnRecover, nil)) + // Model a restart that missed the live route before LND pruned it. + r := h.record() + r.Probes.MainFeeMsat, r.Probes.FeeKnown = 0, false + require.NoError(t, h.store.UpdateReservation(t.Context(), r)) + h.restart() + h.event(OnRecover, nil) + h.state(AwaitApproval) + require.False(t, h.record().Probes.FeeKnown) + require.Equal(t, ProbeSucceeded, h.record().Probes.Main) + require.Zero(t, h.cancels) +} + +type probePreparationFailure struct{ Payments } + +func (p probePreparationFailure) PrepareProbe(context.Context, + *Reservation) (*PaymentPlan, error) { + + return nil, errors.New("paying node unavailable") +} + +func TestProbePreparationFailureCancelsPurchase(t *testing.T) { + h := newClientHarness(t) + h.cfg.Payments = probePreparationFailure{Payments: h} + h.restart() + h.event(OnRecover, nil) + h.state(Canceled) + require.Equal(t, ProbeFailed, h.record().Probes.Main) + require.Equal(t, 1, h.cancels) + require.Empty(t, h.probeInvoices) + require.Zero(t, h.sends) +} + +type probeTrackingFailure struct{ Payments } + +func (p probeTrackingFailure) Lookup(context.Context, *btcec.PublicKey, + lntypes.Hash) (*lnrpc.Payment, error) { + + return nil, errors.New("paying node unavailable") +} + +func TestProbeTrackingFailureStillRequestsCancellation(t *testing.T) { + h := newClientHarness(t) + require.NoError(t, h.machine.SendEvent(t.Context(), OnRecover, nil)) + h.probePayment.Status = lnrpc.Payment_IN_FLIGHT + h.cfg.Payments = probeTrackingFailure{Payments: h} + h.restart() + // A failed lookup is not a probe result. The purchase keeps its saved + // probe and looks again while the probe deadline allows it. + h.event(OnRecover, nil) + h.state(ProbeRoutes) + require.Zero(t, h.cancels) + require.Equal(t, ProbeNotRun, h.record().Probes.Main) + + // Past the deadline the unresolved probe is given up and canceled. + h.cfg.Clock.(*clock.TestClock).SetTime( + h.record().ProbeDeadline.Add(time.Second), + ) + h.event(OnRecover, nil) + h.state(CancelPrepay) + require.Equal(t, 1, h.cancels) + require.Equal(t, ProbeTimedOut, h.record().Probes.Main) + require.Len(t, h.probeInvoices, 1) + + // Neither a tracking error nor a restart proves the HTLC resolved. + h.restart() + h.event(OnRecover, nil) + h.state(CancelPrepay) + h.cfg.Payments = h + h.restart() + h.event(OnRecover, nil) + h.state(CancelPrepay) + h.probePayment.Status = lnrpc.Payment_FAILED + h.event(OnRecover, nil) + h.state(Canceled) + require.Len(t, h.probeInvoices, 1) + require.Zero(t, h.sends) +} + +// flakyProbePayments fails a probe step once with a temporary error. +type flakyProbePayments struct { + Payments + + prepareErr, sendErr error +} + +func (p *flakyProbePayments) PrepareProbe(ctx context.Context, + r *Reservation) (*PaymentPlan, error) { + + if err := p.prepareErr; err != nil { + p.prepareErr = nil + return nil, err + } + return p.Payments.PrepareProbe(ctx, r) +} + +func (p *flakyProbePayments) SendProbe(ctx context.Context, r *Reservation, + observe func(*lnrpc.Payment) error) error { + + if err := p.sendErr; err != nil { + p.sendErr = nil + return err + } + return p.Payments.SendProbe(ctx, r, observe) +} + +// flakyProbeServer fails one probe status read with a temporary error. +type flakyProbeServer struct { + swapserverrpc.AssetReservationServiceClient + + statusErr error +} + +func (s *flakyProbeServer) GetAssetReservation(ctx context.Context, + in *swapserverrpc.AssetReservationSelector, + opts ...grpc.CallOption) (*swapserverrpc.AssetReservation, error) { + + if err := s.statusErr; err != nil { + s.statusErr = nil + return nil, err + } + return s.AssetReservationServiceClient.GetAssetReservation( + ctx, in, opts..., + ) +} + +// TestProbeRetriesTemporaryFailures checks that one temporary node or server +// failure does not end the purchase. Every pass looks up the saved probe +// before dispatching, so the retry sends it exactly once. +func TestProbeRetriesTemporaryFailures(t *testing.T) { + unavailable := status.Error(codes.Unavailable, "node restarting") + for _, tc := range []struct { + name string + arm func(*flakyProbePayments, *flakyProbeServer) + }{ + {"dispatch never reached the node", func(p *flakyProbePayments, + _ *flakyProbeServer) { + + p.sendErr = unavailable + }}, + {"server status read", func(_ *flakyProbePayments, + s *flakyProbeServer) { + + s.statusErr = unavailable + }}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newClientHarness(t) + payments := &flakyProbePayments{Payments: h} + server := &flakyProbeServer{ + AssetReservationServiceClient: h, + } + tc.arm(payments, server) + h.cfg.Payments, h.cfg.Server = payments, server + h.restart() + + // The failed pass keeps the purchase and sends nothing. + require.NoError(t, h.machine.SendEvent( + t.Context(), OnRecover, nil, + )) + h.state(ProbeRoutes) + require.Error(t, h.machine.LastActionError) + require.Zero(t, h.cancels) + require.Empty(t, h.probeInvoices) + require.Equal(t, ProbeNotRun, h.record().Probes.Main) + + // The next pass completes the single saved probe. + h.event(OnRecover, nil) + h.state(AwaitApproval) + require.Len(t, h.probeInvoices, 1) + require.Equal(t, ProbeSucceeded, h.record().Probes.Main) + }) + } +} diff --git a/assets/reservation/services.go b/assets/reservation/services.go new file mode 100644 index 000000000..8ad1c197b --- /dev/null +++ b/assets/reservation/services.go @@ -0,0 +1,119 @@ +package reservation + +import ( + "context" + "errors" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lntypes" +) + +var ( + // ErrPaymentNotFound must come from an authoritative lookup on the + // saved paying node. A timeout or a different node is not absence. + ErrPaymentNotFound = errors.New("prepay payment not found") + ErrInvalidReservation = errors.New("invalid funded reservation") +) + +// PaymentPlan contains the exact LND request and node identity saved before +// sending a probe or prepay. Its hash must match the corresponding invoice. +type PaymentPlan struct { + Hash lntypes.Hash + NodeKey *btcec.PublicKey + Request *routerrpc.SendPaymentRequest +} + +// Payments checks quotes and pays approved prepays through bounded LND calls. +type Payments interface { + // ValidateQuote checks that the invoices and receiving RFQ agree with + // the quote, including amounts, destinations, and validity periods. + // It sends no payment and must run again before paying after recovery. + ValidateQuote(context.Context, *swapserverrpc.AssetReservationQuote) error + + // PrepareProbe returns a request that must be saved before SendProbe. + PrepareProbe(context.Context, *Reservation) (*PaymentPlan, error) + SendProbe(context.Context, *Reservation, func(*lnrpc.Payment) error) error + + // TrackProbe streams the saved payment, including in-flight routes. + // Both methods deliver updates synchronously for persistence by the FSM. + TrackProbe(context.Context, *Reservation, func(*lnrpc.Payment) error) error + + // Prepare rechecks the approved quote, available balance, payment + // lifetime, and routing limits without sending a payment. It returns + // the exact request and paying node, which the caller must save before + // calling Pay so that a restart cannot lose track of the payment. + Prepare(context.Context, *Reservation) (*PaymentPlan, error) + + // Lookup retrieves the payment's current status by hash from the saved + // paying node. The payment may still be in flight. ErrPaymentNotFound + // means that node confirmed its absence; a timeout or node mismatch + // must return a different error. Lookup never sends a payment. + Lookup(context.Context, *btcec.PublicKey, lntypes.Hash) (*lnrpc.Payment, error) + + // Pay sends the saved request through the saved paying node. A nil + // error does not confirm settlement; an error does not prove that + // nothing was sent. The caller must use Lookup to resolve the outcome + // and must not pay again after a terminal failure. + Pay(context.Context, *btcec.PublicKey, *routerrpc.SendPaymentRequest) error +} + +// FundingStatus comes from a synchronized local LND-backed chain view, not +// server status. ConfirmationHeight is the original funding height. +type FundingStatus struct { + Height uint32 + ConfirmationHeight uint32 + Spent bool +} + +// ReservationVerifier uses tapd and the shared deposit kit. Verify checks full +// proof history, asset, amount, both keys, the local key locator, scripts, +// and exact output. Both reads report authoritative chain and spend status. +type ReservationVerifier interface { + // DeriveKey allocates the next local key for jointly controlling the + // reservation with the server. The caller must save its descriptor + // before requesting a quote and reuse it when retrying that purchase. + DeriveKey(context.Context) (*keychain.KeyDescriptor, error) + + // VerifyGroup authenticates the quoted funding and payment issuances + // against the requested group before probing or paying. + VerifyGroup(context.Context, *swapserverrpc.AssetReservationQuote) error + + // Verify checks the supplied asset proof against the reservation's + // terms, keys, and funding output, and checks its anchor against the + // local chain. It returns the observed funding status; the caller must + // still apply the required confirmation depth and lifetime limits. + Verify(context.Context, *Reservation, []byte) (FundingStatus, error) + + // Inspect rechecks the saved proof and observes funding confirmations + // and spends through the local chain backend. It may wait for a new + // block or spend until the context ends. The confirmation height stays + // anchored to the funding transaction so that recovery cannot extend + // the reservation's lifetime. + Inspect(context.Context, *Reservation) (FundingStatus, error) +} + +// Config provides the client's purchase services. Node adapters and daemon +// wiring remain separate from this FSM. +type Config struct { + Store Store + Server swapserverrpc.AssetReservationServiceClient + Payments Payments + Wallet ReservationVerifier + Clock clock.Clock + NotifyAdmin func(ID, error) +} + +// Validate rejects a client that cannot recover its purchases. +func (c *Config) Validate() error { + if c == nil || c.Store == nil || c.Server == nil || c.Payments == nil || + c.Wallet == nil || c.Clock == nil || c.NotifyAdmin == nil { + + return errors.New("incomplete reservation client configuration") + } + return nil +} From 80868f3ac7d6e7d5c8d2af51251b5805305b81cf Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 14:26:13 +0200 Subject: [PATCH 19/25] assets/reservation: Add a manager with one worker per purchase Restore unfinished reservations and start one FSM worker for each. Route approval requests through that worker so its actions run in sequence. Periodic OnRecover events resume purchases and monitor ready reservations until expiry. Before starting a new worker, save the reservation ID, client key, requested asset and amount, and SkipProbe choice. Repeated creation requests return the saved reservation; conflicting assets or amounts are rejected. Apply MaxActiveReservations only to new purchases, so lowering the limit cannot prevent recovery. Keep one pending command per worker and return ErrReservationBusy when its queue is full. Bound worker attempts with CallTimeout, return saved snapshots to callers, and cancel and join workers on shutdown. Creation and its snapshot read share one timeout and stop when either the caller or the manager cancels. Test concurrent duplicate requests, recovery above MaxActiveReservations, shutdown during quote retrieval, key derivation, and snapshot reads, automatic expiry of unapproved quotes, and SkipProbe across restarts. Carry the original exact-issuance or group selector through admission and idempotent creation. --- assets/reservation/log.go | 16 ++ assets/reservation/manager.go | 447 +++++++++++++++++++++++++++++ assets/reservation/manager_test.go | 349 ++++++++++++++++++++++ docs/asset-reservation-rpc.md | 3 +- 4 files changed, 814 insertions(+), 1 deletion(-) create mode 100644 assets/reservation/log.go create mode 100644 assets/reservation/manager.go create mode 100644 assets/reservation/manager_test.go diff --git a/assets/reservation/log.go b/assets/reservation/log.go new file mode 100644 index 000000000..b51b7bdf9 --- /dev/null +++ b/assets/reservation/log.go @@ -0,0 +1,16 @@ +package reservation + +import ( + "github.com/btcsuite/btclog/v2" + "github.com/lightningnetwork/lnd/build" +) + +// Subsystem identifies reservation lifecycle logs. +const Subsystem = "ARESV" + +var log btclog.Logger = build.NewSubLogger(Subsystem, nil) + +// UseLogger sets the logger before starting reservation workers. +func UseLogger(logger btclog.Logger) { + log = logger +} diff --git a/assets/reservation/manager.go b/assets/reservation/manager.go new file mode 100644 index 000000000..ac2593108 --- /dev/null +++ b/assets/reservation/manager.go @@ -0,0 +1,447 @@ +package reservation + +import ( + "context" + "errors" + "math" + "sync" + "sync/atomic" + "time" + + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/looprpc" + "google.golang.org/protobuf/proto" +) + +var ( + ErrManagerStopped = errors.New("reservation manager stopped") + ErrReservationBusy = errors.New("reservation is busy") + ErrReservationLimit = errors.New("active reservation limit reached") +) + +// ManagerConfig bounds node work and admission. Saved purchases resume even +// when they exceed MaxActiveReservations after a configuration change. +type ManagerConfig struct { + FSM *Config + PollInterval time.Duration + CallTimeout time.Duration + MaxActiveReservations int +} + +type managerRequest struct { + ctx context.Context + id ID + selector AssetSelector + amount uint64 + skipProbe bool + create bool + event fsm.EventType + approval *looprpc.ApproveAssetReservationRequest + response chan managerResponse +} + +type managerResponse struct { + reservation *Reservation + err error +} + +type reservationWorker struct { + fsm *FSM + commands chan *managerRequest +} + +// Manager restores purchases and owns one worker per live reservation. +// Its main loop owns the registry; workers serialize actions without holding +// a registry mutex across database, payment, or proof calls. +type Manager struct { + cfg ManagerConfig + requests chan *managerRequest + finished chan ID + ready chan struct{} + done chan struct{} + started atomic.Bool +} + +// NewManager validates the services and limits and prepares the request queues. +// Call Run to restore saved reservations and start processing requests. +func NewManager(cfg ManagerConfig) (*Manager, error) { + if err := cfg.FSM.Validate(); err != nil { + return nil, err + } + if cfg.PollInterval <= 0 || cfg.CallTimeout <= 0 || + cfg.MaxActiveReservations <= 0 { + + return nil, errors.New("invalid reservation manager limits") + } + return &Manager{ + cfg: cfg, + requests: make(chan *managerRequest), + finished: make(chan ID), + ready: make(chan struct{}), + done: make(chan struct{}), + }, nil +} + +// Run restores unfinished reservations and starts their workers before accepting +// requests. MaxActiveReservations limits only new purchases; saved reservations +// always resume. This loop owns the worker registry and routes each command to +// the worker for its reservation. +// +// Run may be called once. Canceling ctx stops the workers and waits for them to +// exit. Their saved progress remains available to the next manager. +func (m *Manager) Run(ctx context.Context) error { + if !m.started.CompareAndSwap(false, true) { + return errors.New("reservation manager already started") + } + + runCtx, cancel := context.WithCancel(ctx) + var workers sync.WaitGroup + defer func() { + cancel() + workers.Wait() + close(m.done) + }() + + readCtx, readCancel := context.WithTimeout(runCtx, m.cfg.CallTimeout) + reservations, err := m.cfg.FSM.Store.GetReservations(readCtx, StateFilter{ + ActiveOnly: true, + }) + readCancel() + if err != nil { + return err + } + active := make(map[ID]*reservationWorker) + for _, reservation := range reservations { + worker, err := m.newWorker(reservation) + if err != nil { + return err + } + active[reservation.ID] = worker + } + + start := func(id ID, worker *reservationWorker) { + workers.Go(func() { m.runWorker(runCtx, id, worker) }) + } + for id, worker := range active { + start(id, worker) + } + + // All existing non-final reservations restored. Signal ready to process + // new reservation requests. + close(m.ready) + + for { + select { + case <-runCtx.Done(): + return runCtx.Err() + + case id := <-m.finished: + delete(active, id) + + case request := <-m.requests: + if request.ctx.Err() != nil { + request.response <- managerResponse{ + err: request.ctx.Err(), + } + continue + } + + // NewPurchase takes this path, including retries of a saved ID. + // Create a missing reservation or reuse its saved progress, + // starting a worker only if needed. This request carries no FSM + // event; approval and recovery commands go below. + if request.create { + // Creation and its snapshot read share one timeout. + // Either the caller or manager shutdown can cancel + // this work without canceling the caller's context. + callCtx, callCancel := context.WithTimeout( + request.ctx, m.cfg.CallTimeout, + ) + stopCancel := context.AfterFunc(runCtx, callCancel) + r, err := m.create(callCtx, request, len(active)) + + if err == nil && !IsFinal(r.State) && + active[r.ID] == nil { + + worker, workerErr := m.newWorker(r) + if workerErr != nil { + err = workerErr + } else { + active[r.ID] = worker + start(r.ID, worker) + } + } + // Never return the worker's live reservation to a caller. + if err == nil { + r, err = m.Get(callCtx, request.id) + } + stopCancel() + callCancel() + request.response <- managerResponse{ + reservation: r, + err: err, + } + continue + } + + // Route this command to the reservation's existing worker. + // Missing or finalized reservations have no worker to receive it. + worker := active[request.id] + if worker == nil { + request.response <- managerResponse{ + err: ErrNotFound, + } + continue + } + select { + case worker.commands <- request: + + default: + request.response <- managerResponse{ + err: ErrReservationBusy, + } + } + } + } +} + +// newWorker restores a reservation's FSM and gives it room for one pending +// command. Run starts the worker separately and rejects further commands with +// ErrReservationBusy while that slot is occupied. +func (m *Manager) newWorker(r *Reservation) (*reservationWorker, error) { + machine, err := NewFSM(m.cfg.FSM, r) + if err != nil { + return nil, err + } + return &reservationWorker{ + fsm: machine, + commands: make(chan *managerRequest, 1), + }, nil +} + +// runWorker resumes one reservation on startup, on each polling tick, and in +// response to commands. It processes one event at a time, bounds each attempt +// with CallTimeout, and answers callers with the resulting saved progress. +// +// The worker keeps running through temporary errors and while a ready +// reservation awaits expiry. It stops when the saved state is final or ctx is +// canceled, then releases its registry slot and answers any pending command. +func (m *Manager) runWorker(ctx context.Context, id ID, w *reservationWorker) { + defer func() { + select { + case m.finished <- id: + + case <-ctx.Done(): + } + + // The registry no longer dispatches here. Answer anything queued + // just before the final state instead of leaving its caller waiting. + for { + select { + case pending := <-w.commands: + pending.response <- managerResponse{ + err: ErrNotFound, + } + + default: + return + } + } + }() + + ticker := time.NewTicker(m.cfg.PollInterval) + defer ticker.Stop() + var request *managerRequest + for { + event := OnRecover + var data fsm.EventContext + if request != nil { + event, data = request.event, request.approval + } + callCtx, cancel := context.WithTimeout(ctx, m.cfg.CallTimeout) + err := w.fsm.SendEvent(callCtx, event, data) + if err == nil { + err = w.fsm.LastActionError + } + cancel() + if err != nil && ctx.Err() == nil { + log.ErrorS(ctx, "Reservation action will resume", err, + "reservation_id", id) + } + readCtx, readCancel := context.WithTimeout(ctx, m.cfg.CallTimeout) + r, readErr := m.cfg.FSM.Store.GetReservation(readCtx, id) + readCancel() + if err == nil { + err = readErr + } + if request != nil { + request.response <- managerResponse{ + reservation: r, + err: err, + } + request = nil + } + if readErr == nil && IsFinal(r.State) { + return + } + select { + case <-ctx.Done(): + return + + case <-ticker.C: + + case request = <-w.commands: + } + } +} + +// create reuses a saved reservation when its asset and amount match the request. +// For a new ID, it checks the active limit, derives a client key, and saves the +// request and probe preference before a worker can ask for a quote. Run calls +// this method serially so concurrent requests cannot create duplicate purchases. +func (m *Manager) create(ctx context.Context, request *managerRequest, + active int) (*Reservation, error) { + + r, err := m.cfg.FSM.Store.GetReservation(ctx, request.id) + if err == nil { + if r.selector() != request.selector || r.Amount != request.amount { + return nil, ErrRequestConflict + } + return r, nil + } + if !errors.Is(err, ErrNotFound) { + return nil, err + } + if active >= m.cfg.MaxActiveReservations { + return nil, ErrReservationLimit + } + key, err := m.cfg.FSM.Wallet.DeriveKey(ctx) + if err != nil { + return nil, err + } + if key == nil { + return nil, errors.New("missing reservation client key") + } + r = &Reservation{ + ID: request.id, + ClientKey: *key, + State: RequestQuote, + AssetID: request.selector.AssetID, + GroupKey: request.selector.GroupKey, + Amount: request.amount, + SkipProbe: request.skipProbe, + } + if err := m.cfg.FSM.Store.CreateReservation(ctx, r); err != nil { + return nil, err + } + return r, nil +} + +// WaitInitComplete waits until saved reservations have workers and Run can +// accept requests. Individual workers may still be recovering their purchases. +// Waiting ends if ctx is canceled or the manager stops before becoming ready. +func (m *Manager) WaitInitComplete(ctx context.Context) error { + select { + case <-m.done: + return ErrManagerStopped + + case <-ctx.Done(): + return ctx.Err() + + case <-m.ready: + return nil + } +} + +// submit waits for initialization, hands the request to Run, and waits for its +// reply. A buffered reply lets the worker finish if the caller stops waiting. +// Once a command is queued, canceling the caller's context may end this wait +// while the worker still processes the command under the manager's context. +func (m *Manager) submit(request *managerRequest) (*Reservation, error) { + if err := m.WaitInitComplete(request.ctx); err != nil { + return nil, err + } + request.response = make(chan managerResponse, 1) + select { + case <-request.ctx.Done(): + return nil, request.ctx.Err() + + case <-m.done: + return nil, ErrManagerStopped + + case m.requests <- request: + } + select { + case <-request.ctx.Done(): + return nil, request.ctx.Err() + + case <-m.done: + return nil, ErrManagerStopped + + case result := <-request.response: + return result.reservation, result.err + } +} + +// NewPurchase validates and saves a purchase, then returns its saved progress +// while a worker requests the quote. Reuse id after a lost response to retrieve +// the same reservation and client key; changing its asset or amount is rejected. +// skipProbe applies only to a new purchase. Payment requires separate approval. +func (m *Manager) NewPurchase(ctx context.Context, id ID, selector AssetSelector, + amount uint64, skipProbe bool) (*Reservation, error) { + + if _, err := ParseAssetSelector(selector.Bytes()); err != nil { + return nil, err + } + if id == (ID{}) || amount == 0 || + amount > math.MaxInt64 { + + return nil, errors.New("invalid reservation request") + } + return m.submit(&managerRequest{ + ctx: ctx, + id: id, + selector: selector, + amount: amount, + create: true, + skipProbe: skipProbe, + }) +} + +// Approve copies the caller's consent and sends it to the reservation's worker. +// The FSM checks the quote hash and payment choices, then saves those choices +// with the state that permits payment. The reply reports progress after that +// attempt; the worker continues resolving payment and delivery afterward. +func (m *Manager) Approve(ctx context.Context, id ID, + approval *looprpc.ApproveAssetReservationRequest) (*Reservation, error) { + + if approval == nil { + return nil, errors.New("missing quote approval") + } + request := proto.Clone(approval).(*looprpc.ApproveAssetReservationRequest) + return m.submit(&managerRequest{ + ctx: ctx, + id: id, + event: OnApprove, + approval: request, + }) +} + +// Get loads one reservation from storage within CallTimeout. The returned +// snapshot is independent of the worker and may include a final state. +func (m *Manager) Get(ctx context.Context, id ID) (*Reservation, error) { + readCtx, cancel := context.WithTimeout(ctx, m.cfg.CallTimeout) + defer cancel() + return m.cfg.FSM.Store.GetReservation(readCtx, id) +} + +// List loads saved reservations matching the filter within CallTimeout. An +// empty filter includes final purchases. It reads storage directly, +// independently of the live worker registry. +func (m *Manager) List(ctx context.Context, filter StateFilter) ( + []*Reservation, error) { + + readCtx, cancel := context.WithTimeout(ctx, m.cfg.CallTimeout) + defer cancel() + return m.cfg.FSM.Store.GetReservations(readCtx, filter) +} diff --git a/assets/reservation/manager_test.go b/assets/reservation/manager_test.go new file mode 100644 index 000000000..5deb857a5 --- /dev/null +++ b/assets/reservation/manager_test.go @@ -0,0 +1,349 @@ +package reservation + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/looprpc" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +func startClientManager(t *testing.T, cfg *Config, + maxActiveReservations int) (*Manager, func()) { + + t.Helper() + m, err := NewManager(ManagerConfig{ + FSM: cfg, + PollInterval: 10 * time.Millisecond, + CallTimeout: 5 * time.Second, + MaxActiveReservations: maxActiveReservations, + }) + require.NoError(t, err) + ctx, cancel := context.WithCancel(t.Context()) + result := make(chan error, 1) + go func() { result <- m.Run(ctx) }() + require.NoError(t, m.WaitInitComplete(t.Context())) + stop := sync.OnceFunc(func() { + cancel() + select { + case err := <-result: + require.ErrorIs(t, err, context.Canceled) + + case <-time.After(2 * time.Second): + t.Fatal("reservation manager did not stop") + } + }) + t.Cleanup(stop) + return m, stop +} + +func TestClientManagerDuplicateRequests(t *testing.T) { + h := newClientHarness(t) + h.store.Store = NewSqlStore(loopdb.NewTestDB(t).BaseDB) + h.store.Store.(*SqlStore).clock = h.cfg.Clock + m, stop := startClientManager(t, h.cfg, 1) + ctx := t.Context() + results := make(chan error, 4) + var requests sync.WaitGroup + for range 4 { + requests.Go(func() { + _, err := m.NewPurchase(ctx, h.id, AssetSelector{AssetID: [32]byte{2}}, 10000, false) + results <- err + }) + } + requests.Wait() + for range 4 { + require.NoError(t, <-results) + } + require.Eventually(t, func() bool { + r, err := m.Get(ctx, h.id) + return err == nil && r.State == AwaitApproval + }, 2*time.Second, time.Millisecond) + _, err := m.NewPurchase(ctx, h.id, AssetSelector{AssetID: [32]byte{2}}, 10001, false) + require.ErrorIs(t, err, ErrRequestConflict) + _, err = m.NewPurchase(ctx, ID{9}, AssetSelector{AssetID: [32]byte{2}}, 10000, false) + require.ErrorIs(t, err, ErrReservationLimit) + reservations, err := m.List(ctx, StateFilter{}) + require.NoError(t, err) + require.Len(t, reservations, 1) + // An unapproved purchase expires without a caller canceling it. + h.cfg.Clock.(*clock.TestClock).SetTime(time.Unix(h.quote.ExpiresAt, 0)) + require.Eventually(t, func() bool { + r, err := m.Get(ctx, h.id) + return err == nil && r.State == Canceled + }, 2*time.Second, time.Millisecond) + stop() + require.Equal(t, 1, h.deriveCalls) + require.Equal(t, 1, h.quotes) + require.Zero(t, h.sends) +} + +func TestClientManagerRestoresBeyondAdmissionLimit(t *testing.T) { + h := newClientHarness(t) + h.store.Store = NewSqlStore(loopdb.NewTestDB(t).BaseDB) + h.store.Store.(*SqlStore).clock = h.cfg.Clock + for _, id := range []ID{{1}, {2}} { + r := testReservation() + r.ID, r.State = id, AwaitApproval + r.Quote = testPurchaseQuote(r) + // These purchases already passed their probes. Recovery should + // restore their workers without new node calls or state writes. + r.Probes = ProbeResults{ + Main: ProbeSucceeded, CheckedAt: h.cfg.Clock.Now(), + } + require.NoError(t, h.store.CreateReservation(t.Context(), r)) + } + m, stop := startClientManager(t, h.cfg, 1) + for _, id := range []ID{{1}, {2}} { + // An invalid approval must reach each restored worker and be + // rejected by its FSM, proving recovery started both workers. + r, err := m.Approve(t.Context(), id, + &looprpc.ApproveAssetReservationRequest{}) + require.ErrorContains(t, err, "missing reservation approval") + require.Equal(t, AwaitApproval, r.State) + } + _, err := m.NewPurchase(t.Context(), ID{3}, AssetSelector{AssetID: [32]byte{2}}, 10000, false) + require.ErrorIs(t, err, ErrReservationLimit) + stop() + require.Zero(t, h.sends) + require.Zero(t, h.quotes) +} + +func TestClientManagerRecoveryFiltersCompleted(t *testing.T) { + h := newClientHarness(t) + db := loopdb.NewTestDB(t) + h.store.Store = NewSqlStore(db.BaseDB) + active := testReservation() + active.Quote = testPurchaseQuote(active) + require.NoError(t, h.store.CreateReservation(t.Context(), active)) + for i, state := range []fsm.StateType{ + QuoteFailed, QuoteRejected, Canceled, Expired, + } { + r := testReservation() + r.ID, r.State = ID{byte(i + 2)}, state + require.NoError(t, h.store.CreateReservation(t.Context(), r)) + // Loading these snapshots would fail validation. Recovery must + // exclude them in the database, before decoding their quotes. + _, err := db.ExecContext(t.Context(), `UPDATE asset_reservations + SET quote = $1 WHERE reservation_id = $2`, []byte{0xff}, r.ID[:]) + require.NoError(t, err) + } + m, stop := startClientManager(t, h.cfg, 1) + require.Eventually(t, func() bool { + r, err := m.Get(t.Context(), active.ID) + return err == nil && r.State == AwaitApproval + }, 2*time.Second, time.Millisecond) + rows, err := m.List(t.Context(), StateFilter{ + State: AwaitApproval, + }) + require.NoError(t, err) + require.Len(t, rows, 1) + rows, err = m.List(t.Context(), StateFilter{ActiveOnly: true}) + require.NoError(t, err) + require.Len(t, rows, 1) + stop() +} + +func TestClientManagerCancelsPendingNodeCalls(t *testing.T) { + h := newClientHarness(t) + h.blockQuote = true + h.quoteStarted = make(chan struct{}, 1) + _, stop := startClientManager(t, h.cfg, 1) + select { + case <-h.quoteStarted: + + case <-time.After(2 * time.Second): + t.Fatal("quote call did not start") + } + stop() + require.Zero(t, h.sends) + r := h.record() + require.Equal(t, RequestQuote, r.State) +} + +func TestClientManagerSkipsProbeAcrossRestart(t *testing.T) { + h := newClientHarness(t) + h.store.Store = NewSqlStore(loopdb.NewTestDB(t).BaseDB) + h.store.Store.(*SqlStore).clock = h.cfg.Clock + h.blockQuote = true + h.quoteStarted = make(chan struct{}, 1) + m, stop := startClientManager(t, h.cfg, 1) + _, err := m.NewPurchase(t.Context(), h.id, AssetSelector{AssetID: [32]byte{2}}, 10000, true) + require.NoError(t, err) + select { + case <-h.quoteStarted: + + case <-time.After(2 * time.Second): + t.Fatal("quote call did not start") + } + stop() + require.True(t, h.record().SkipProbe) + require.Empty(t, h.probeInvoices) + + // A restart before the quote arrives must retain the skip preference. + h.blockQuote = false + m, stop = startClientManager(t, h.cfg, 1) + var r *Reservation + require.Eventually(t, func() bool { + r, err = m.Get(t.Context(), h.id) + return err == nil && r.State == AwaitApproval + }, 2*time.Second, time.Millisecond) + require.True(t, r.SkipProbe) + require.Equal(t, ProbeResults{}, r.Probes) + require.Equal(t, AwaitApproval, r.State) + require.Zero(t, r.MaxRouteFeeMsat) + require.True(t, r.SkipProbe) + + a := testApproval(t, r) + _, err = m.Approve(t.Context(), h.id, a) + require.Error(t, err) + r, err = m.Get(t.Context(), h.id) + require.NoError(t, err) + require.Nil(t, r.PaymentRequest) + require.Equal(t, AwaitApproval, r.State) + require.Zero(t, r.MaxRouteFeeMsat) + require.True(t, r.SkipProbe) + + // Skipping alone cannot pay. The approval must explicitly accept the + // missing probe, and storage must retain that consent for recovery. + a.SkipProbe = true + r, err = m.Approve(t.Context(), h.id, a) + require.ErrorContains(t, err, "lost send reply") + require.Equal(t, a.MaxRouteFeeMsat, r.MaxRouteFeeMsat) + require.Equal(t, a.SkipProbe, r.SkipProbe) + stop() + require.Empty(t, h.probeInvoices) + require.Equal(t, 1, h.sends) + h.restart() + h.event(OnRecover, nil) + require.Equal(t, 1, h.sends) + require.Empty(t, h.probeInvoices) +} + +// blockedCreationWallet holds key derivation until its context is canceled. +type blockedCreationWallet struct { + ReservationVerifier + + started chan struct{} +} + +func (w *blockedCreationWallet) DeriveKey(ctx context.Context) ( + *keychain.KeyDescriptor, error) { + + close(w.started) + <-ctx.Done() + return nil, ctx.Err() +} + +// blockedSnapshotStore lets creation load an existing reservation, then holds +// the response snapshot read until its context is canceled. +type blockedSnapshotStore struct { + Store + + started chan struct{} + reads int +} + +func (s *blockedSnapshotStore) GetReservation(ctx context.Context, + id ID) (*Reservation, error) { + + s.reads++ + if s.reads == 2 { + close(s.started) + <-ctx.Done() + return nil, ctx.Err() + } + return s.Store.GetReservation(ctx, id) +} + +// TestClientManagerCancelsCreation checks shutdown while a new-purchase request +// is waiting on key derivation or the saved snapshot returned to its caller. +func TestClientManagerCancelsCreation(t *testing.T) { + for _, phase := range []string{"key derivation", "snapshot read"} { + t.Run(phase, func(t *testing.T) { + h := newClientHarness(t) + started := make(chan struct{}) + if phase == "key derivation" { + h.store.Store = NewSqlStore(loopdb.NewTestDB(t).BaseDB) + h.store.Store.(*SqlStore).clock = h.cfg.Clock + h.cfg.Wallet = &blockedCreationWallet{ + ReservationVerifier: h.cfg.Wallet, + started: started, + } + } else { + // A final reservation has no worker, so only the + // creation request reads it during this test. + r := h.record() + r.State = Canceled + require.NoError(t, h.store.UpdateReservation(t.Context(), r)) + h.cfg.Store = &blockedSnapshotStore{ + Store: h.store, + started: started, + } + } + + m, err := NewManager(ManagerConfig{ + FSM: h.cfg, + PollInterval: time.Hour, + CallTimeout: 5 * time.Second, + MaxActiveReservations: 1, + }) + require.NoError(t, err) + runCtx, cancelRun := context.WithCancel(t.Context()) + requestCtx, cancelRequest := context.WithCancel(t.Context()) + done := make(chan struct{}) + var runErr error + go func() { + runErr = m.Run(runCtx) + close(done) + }() + t.Cleanup(func() { + cancelRequest() + cancelRun() + select { + case <-done: + + case <-time.After(2 * time.Second): + t.Error("reservation manager did not stop after cleanup") + } + }) + require.NoError(t, m.WaitInitComplete(t.Context())) + response := make(chan error, 1) + go func() { + _, err := m.NewPurchase( + requestCtx, h.id, AssetSelector{AssetID: [32]byte{2}}, 10000, false, + ) + response <- err + }() + select { + case <-started: + + case <-time.After(2 * time.Second): + t.Fatal("creation call did not start") + } + + cancelRun() + select { + case <-done: + require.ErrorIs(t, runErr, context.Canceled) + + case <-time.After(time.Second): + t.Fatal("shutdown did not cancel the creation call") + } + require.NoError(t, requestCtx.Err()) + select { + case err := <-response: + require.Error(t, err) + + case <-time.After(time.Second): + t.Fatal("purchase caller did not return") + } + }) + } +} diff --git a/docs/asset-reservation-rpc.md b/docs/asset-reservation-rpc.md index f9485e402..f0e192d6c 100644 --- a/docs/asset-reservation-rpc.md +++ b/docs/asset-reservation-rpc.md @@ -105,7 +105,8 @@ All methods require L402 ownership checks. Unknown IDs, another owner's IDs, and internal lookup failures return the same opaque status and message. Detailed causes stay in server logs. Validate canonical outpoints before lookup or dispatch. Page size is bounded; list results contain only owned -records. Notifications, when added, are hints to read this API again. +records. This version uses RPC calls and periodic status checks, without +notification-stream integration. Later notifications can prompt fresh reads. The local client API will expose quote, approve, cancel, get, and list. The approval request includes the displayed quote hash and prepay routing From c103187592cad8076abf45611d6acec78920350c Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 15:29:56 +0200 Subject: [PATCH 20/25] assets/reservation: Verify deposits and track them through LND Bind the deposit proof to its amount, scripts, outpoint, and local co-signing key before calling tapd. Record verified issuance metadata for assets new to the tapd v0.8.8 verifier, then verify the full history. This does not import wallet assets. Check the proof's block against LND and observe the exact confirmed output. Use LND confirmation, spend, and block notifications to watch deposits and restore watches after restart. As with Instant Out, Ready means no spend is known; it does not wait for a synchronous unspent-output check. Test proof binding, issuance insertion before history verification, rejected proofs, repeated verification, and watch recovery. Use the shared swapkit CSV contract and isolated asset binding from the start. Enforce tapd v0.8.8, bind verified summaries to the exact proof, and authenticate group issuance evidence without importing wallet assets. Allow proof RPC responses up to the 16 MiB raw-proof limit plus metadata. Exercise maximum-sized and oversized responses through real gRPC calls. --- assets/reservation/actions.go | 8 +- assets/reservation/confirmation.go | 213 +++++++++++ assets/reservation/confirmation_test.go | 187 ++++++++++ assets/reservation/fsm.md | 6 +- assets/reservation/group.go | 42 +++ assets/reservation/kit.go | 214 +++++++++++ assets/reservation/kit_test.go | 57 +++ assets/reservation/proof.go | 82 +++++ assets/reservation/proof_rpc_test.go | 93 +++++ assets/reservation/proof_test.go | 286 +++++++++++++++ assets/reservation/tap_proof.go | 53 +++ assets/reservation/verifier_test.go | 418 ++++++++++++++++++++++ assets/reservation/wallet.go | 195 ++++++++++ assets/reservation/wallet_version_test.go | 71 ++++ 14 files changed, 1923 insertions(+), 2 deletions(-) create mode 100644 assets/reservation/confirmation.go create mode 100644 assets/reservation/confirmation_test.go create mode 100644 assets/reservation/group.go create mode 100644 assets/reservation/kit.go create mode 100644 assets/reservation/kit_test.go create mode 100644 assets/reservation/proof.go create mode 100644 assets/reservation/proof_rpc_test.go create mode 100644 assets/reservation/proof_test.go create mode 100644 assets/reservation/tap_proof.go create mode 100644 assets/reservation/verifier_test.go create mode 100644 assets/reservation/wallet.go create mode 100644 assets/reservation/wallet_version_test.go diff --git a/assets/reservation/actions.go b/assets/reservation/actions.go index d62754afb..bca74c3d3 100644 --- a/assets/reservation/actions.go +++ b/assets/reservation/actions.go @@ -11,6 +11,7 @@ import ( "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/lnrpc" + "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" @@ -567,7 +568,12 @@ func (f *FSM) VerifyReservationAction(ctx context.Context, if r.FundingOutpoint == nil { return f.fail(errors.New("missing reservation funding outpoint")) } - response, err := f.cfg.Server.GetAssetReservationProof(ctx, f.selector()) + // Full histories can exceed gRPC's default 4 MiB receive limit. Keep + // the transport bound aligned with the verifier's proof limit. + response, err := f.cfg.Server.GetAssetReservationProof( + ctx, f.selector(), + grpc.MaxCallRecvMsgSize(maxReservationProofMessageSize), + ) if err != nil { return f.stayInState(err) } diff --git a/assets/reservation/confirmation.go b/assets/reservation/confirmation.go new file mode 100644 index 000000000..d9f0ef908 --- /dev/null +++ b/assets/reservation/confirmation.go @@ -0,0 +1,213 @@ +package reservation + +import ( + "bytes" + "context" + "errors" + "math" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" +) + +// ErrChainNotReady means the local node cannot yet verify the output. +var ErrChainNotReady = errors.New("reservation chain view is not ready") + +// ChainSource checks that a proof belongs to the local chain. +type ChainSource interface { + GetBlockHash(context.Context, int64) (chainhash.Hash, error) +} + +// ChainNode supplies the local node's synchronization status and height. +type ChainNode interface { + GetInfo(context.Context) (*lndclient.Info, error) +} + +// ChainReader follows Instant Out's notification-based reservation view. +// Recovery registers again with the saved height hint; no wallet import or +// synchronous unspent assertion is required on the client. +type ChainReader struct { + Chain ChainSource + Node ChainNode + ChainNotifier lndclient.ChainNotifierClient +} + +// Validate requires LND confirmation, spend, and height services. +func (c *ChainReader) Validate() error { + if c == nil || c.Chain == nil || c.Node == nil || + c.ChainNotifier == nil { + + return errors.New("incomplete reservation chain reader") + } + return nil +} + +// Observe verifies the funding output and checks for a known spend. +// The FSM enforces the agreed confirmation depth and original CSV lifetime. +func (c *ChainReader) Observe(ctx context.Context, outpoint wire.OutPoint, + output *wire.TxOut, firstHeight uint32) (FundingStatus, error) { + + var result FundingStatus + if err := c.Validate(); err != nil { + return result, err + } + if output == nil || output.Value <= 0 || len(output.PkScript) == 0 || + firstHeight == 0 || firstHeight > math.MaxInt32 { + + return result, ErrInvalidReservation + } + + actionCtx, cancel := context.WithCancel(ctx) + defer cancel() + spends, spendErrors, err := c.ChainNotifier.RegisterSpendNtfn( + actionCtx, &outpoint, output.PkScript, int32(firstHeight), + ) + if err != nil { + return result, err + } + confs, confErrors, err := c.ChainNotifier.RegisterConfirmationsNtfn( + actionCtx, &outpoint.Hash, output.PkScript, 1, + int32(firstHeight), + ) + if err != nil { + return result, err + } + + select { + case conf, ok := <-confs: + if !ok || conf == nil || conf.Tx == nil || + conf.Tx.TxHash() != outpoint.Hash || conf.BlockHeight == 0 || + uint64(outpoint.Index) >= uint64(len(conf.Tx.TxOut)) { + + return result, ErrChainNotReady + } + got := conf.Tx.TxOut[outpoint.Index] + if got == nil || got.Value != output.Value || + !bytes.Equal(got.PkScript, output.PkScript) { + + return result, ErrInvalidReservation + } + result.ConfirmationHeight = conf.BlockHeight + + case err := <-confErrors: + if err == nil { + err = ErrChainNotReady + } + return result, err + + case <-ctx.Done(): + return result, ctx.Err() + } + + info, err := c.Node.GetInfo(ctx) + if err != nil { + return result, err + } + if info == nil || !info.SyncedToChain || + info.BlockHeight < result.ConfirmationHeight { + + return result, ErrChainNotReady + } + result.Height = info.BlockHeight + + // Like Instant Out, readiness means no spend is known. This does not + // wait for an acknowledgement that LND's historical scan has finished. + select { + case spend, ok := <-spends: + if !ok || spend == nil || spend.SpentOutPoint == nil || + *spend.SpentOutPoint != outpoint { + + return result, ErrChainNotReady + } + result.Spent = true + + case err := <-spendErrors: + if err == nil { + err = ErrChainNotReady + } + return result, err + + default: + } + return result, nil +} + +// WaitForChange watches a verified reservation for a spend or a new block. +// The manager bounds the call and restores it through OnRecover. Unlike a +// nonblocking poll, this also receives delayed historical spend notifications. +func (c *ChainReader) WaitForChange(ctx context.Context, point wire.OutPoint, + script []byte, hint uint32, status FundingStatus) (FundingStatus, + error) { + + if err := c.Validate(); err != nil { + return status, err + } + if hint == 0 || hint > math.MaxInt32 || len(script) == 0 { + return status, ErrInvalidReservation + } + watchCtx, cancel := context.WithCancel(ctx) + defer cancel() + spends, spendErrors, err := c.ChainNotifier.RegisterSpendNtfn( + watchCtx, &point, script, int32(hint), + ) + if err != nil { + return status, err + } + blocks, blockErrors, err := c.ChainNotifier.RegisterBlockEpochNtfn(watchCtx) + if err != nil { + return status, err + } + for { + select { + case spend, ok := <-spends: + if !ok || spend == nil || spend.SpentOutPoint == nil || + *spend.SpentOutPoint != point { + + return status, ErrChainNotReady + } + status.Spent = true + return status, nil + + case height, ok := <-blocks: + if !ok || height <= 0 { + return status, ErrChainNotReady + } + // LND first reports the current tip. Keep watching rather + // than ending every subscription on that initial snapshot. + if uint32(height) <= status.Height { + continue + } + info, err := c.Node.GetInfo(ctx) + if err != nil { + return status, err + } + if info == nil || !info.SyncedToChain || + info.BlockHeight < uint32(height) { + + return status, ErrChainNotReady + } + status.Height = info.BlockHeight + return status, nil + + case err := <-spendErrors: + if err == nil { + err = ErrChainNotReady + } + return status, err + + case err := <-blockErrors: + if err == nil { + err = ErrChainNotReady + } + return status, err + + case <-ctx.Done(): + // Reaching the bounded watch interval without a block is normal. + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return status, nil + } + return status, ctx.Err() + } + } +} diff --git a/assets/reservation/confirmation_test.go b/assets/reservation/confirmation_test.go new file mode 100644 index 000000000..bf96633aa --- /dev/null +++ b/assets/reservation/confirmation_test.go @@ -0,0 +1,187 @@ +package reservation + +import ( + "context" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/stretchr/testify/require" +) + +type confirmationNode struct { + lndclient.LightningClient +} + +func (confirmationNode) GetInfo(context.Context) (*lndclient.Info, error) { + return &lndclient.Info{ + SyncedToChain: true, + BlockHeight: 102, + }, nil +} + +type confirmationNotifier struct { + lndclient.ChainNotifierClient + + t *testing.T + tx *wire.MsgTx + outpoint wire.OutPoint + spent bool + spends chan *chainntnfs.SpendDetail + blocks chan int32 +} + +func (n *confirmationNotifier) RegisterConfirmationsNtfn(_ context.Context, + txid *chainhash.Hash, script []byte, depth, hint int32, + _ ...lndclient.NotifierOption) (chan *chainntnfs.TxConfirmation, + chan error, error) { + + require.Equal(n.t, n.tx.TxHash(), *txid) + require.Equal(n.t, n.tx.TxOut[1].PkScript, script) + require.EqualValues(n.t, 1, depth) + require.EqualValues(n.t, 99, hint) + confs := make(chan *chainntnfs.TxConfirmation, 1) + confs <- &chainntnfs.TxConfirmation{ + Tx: n.tx, + BlockHeight: 100, + } + return confs, make(chan error), nil +} + +func (n *confirmationNotifier) RegisterSpendNtfn(_ context.Context, + outpoint *wire.OutPoint, _ []byte, hint int32, + _ ...lndclient.NotifierOption) (chan *chainntnfs.SpendDetail, + chan error, error) { + + require.Equal(n.t, n.outpoint, *outpoint) + require.EqualValues(n.t, 99, hint) + if n.spends != nil { + return n.spends, make(chan error), nil + } + spends := make(chan *chainntnfs.SpendDetail, 1) + if n.spent { + spends <- &chainntnfs.SpendDetail{ + SpentOutPoint: outpoint, + } + } + return spends, make(chan error), nil +} + +func (n *confirmationNotifier) RegisterBlockEpochNtfn(context.Context) ( + chan int32, chan error, error) { + + return n.blocks, make(chan error), nil +} + +// TestConfirmationObservation checks the exact output without wallet history. +func TestConfirmationObservation(t *testing.T) { + const spentCase = "spent" + const wrongCase = "wrong" + for _, name := range []string{"confirmed", wrongCase, spentCase} { + t.Run(name, func(t *testing.T) { + tx := wire.NewMsgTx(2) + tx.AddTxOut(wire.NewTxOut(1000, []byte{0x51})) + tx.AddTxOut(wire.NewTxOut(2000, []byte{0x52})) + outpoint := wire.OutPoint{ + Hash: tx.TxHash(), + Index: 1, + } + reader := ChainReader{ + Chain: struct{ ChainSource }{}, + Node: confirmationNode{}, + ChainNotifier: &confirmationNotifier{ + t: t, + tx: tx, + outpoint: outpoint, + spent: name == spentCase, + }, + } + output := *tx.TxOut[1] + if name == wrongCase { + output.Value++ + } + status, err := reader.Observe( + t.Context(), outpoint, &output, 99, + ) + switch name { + case wrongCase: + require.ErrorIs(t, err, ErrInvalidReservation) + + default: + require.NoError(t, err) + require.EqualValues(t, 100, status.ConfirmationHeight) + require.EqualValues(t, 102, status.Height) + require.Equal(t, name == spentCase, status.Spent) + } + }) + } +} + +// TestReservationWatchRecovery receives a historical spend after the initial +// block snapshot, without requiring a wallet import or a scan cursor. +func TestReservationWatchRecovery(t *testing.T) { + point := wire.OutPoint{ + Index: 2, + } + for range 2 { + spends := make(chan *chainntnfs.SpendDetail) + blocks := make(chan int32) + reader := ChainReader{ + Chain: struct{ ChainSource }{}, + Node: confirmationNode{}, + ChainNotifier: &confirmationNotifier{ + t: t, + outpoint: point, + spends: spends, + blocks: blocks, + }, + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + finished := make(chan struct{}) + go func() { + defer close(finished) + select { + case blocks <- 102: + + case <-ctx.Done(): + return + } + select { + case spends <- &chainntnfs.SpendDetail{ + SpentOutPoint: &point, + }: + + case <-ctx.Done(): + } + }() + status, err := reader.WaitForChange(ctx, point, []byte{0x51}, 99, + FundingStatus{ + Height: 102, + ConfirmationHeight: 100, + }) + cancel() + <-finished + require.NoError(t, err) + require.True(t, status.Spent) + } +} + +func TestReservationWatchIdleDeadline(t *testing.T) { + point := wire.OutPoint{Index: 2} + reader := ChainReader{ + Chain: struct{ ChainSource }{}, Node: confirmationNode{}, + ChainNotifier: &confirmationNotifier{ + t: t, outpoint: point, + spends: make(chan *chainntnfs.SpendDetail), blocks: make(chan int32), + }, + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + initial := FundingStatus{Height: 102, ConfirmationHeight: 100} + result, err := reader.WaitForChange(ctx, point, []byte{0x51}, 99, initial) + require.NoError(t, err) + require.Equal(t, initial, result) +} diff --git a/assets/reservation/fsm.md b/assets/reservation/fsm.md index 5de78022e..be02fee66 100644 --- a/assets/reservation/fsm.md +++ b/assets/reservation/fsm.md @@ -63,7 +63,11 @@ Quote validation and payment limits remain mandatory. Each invoice must match its own RFQ; the prepay and probe may use different rates. Approval displays the exact BTC prepay and implied prepay/probe prices. -Server status is only a delivery hint. Verify the full exported proof with +Server status is only a delivery hint. Before asking tapd, compare the +proof's anchor block with the local node's block at that height. tapd reports +an unverifiable proof and an invalid one alike, so a node that has not reached +the anchor block yet retries instead of parking a paid purchase; a different +block at that height is definitive. Verify the full exported proof with tapd and the shared deposit kit, then check exact output, local keys, amount, three required confirmations, known spends, and the original CSV clock. Spend and expiry tracking follow Instant Out's LND notifications. Recovery diff --git a/assets/reservation/group.go b/assets/reservation/group.go new file mode 100644 index 000000000..e6a37ac41 --- /dev/null +++ b/assets/reservation/group.go @@ -0,0 +1,42 @@ +package reservation + +import ( + "context" + "errors" + + "github.com/lightninglabs/loop/swapkit" + "github.com/lightninglabs/loop/swapkit/tapsdk" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightninglabs/taproot-assets/asset" +) + +// MaxGroupProofBytes leaves room in the ordinary quote RPC for both invoices +// and RFQs. Only the anchor and up to two distinct member issuances are needed. +const MaxGroupProofBytes = 3 << 20 + +// IsGroupMember checks the fungible asset's declared group identity. Proof +// verification is still required before trusting that declaration. +func IsGroupMember(a *asset.Asset, key []byte) bool { + return tapsdk.IsGroupMember(a, key) +} + +// VerifyGroup binds the quote's funding and payment issuances to the requested +// group before probing or paying. Swapkit authenticates the supplied evidence. +func (w *WalletVerifier) VerifyGroup(ctx context.Context, + q *swapserverrpc.AssetReservationQuote) error { + + g := q.GetGroup() + if w == nil || w.Tap == nil || len(g.GetGroupKey()) != 33 || + len(q.GetTerms().GetAssetId()) != 32 || len(g.GetPaymentAssetId()) != 32 || + len(g.GetIssuanceProofs()) == 0 || len(g.GetIssuanceProofs()) > 3 { + + return ErrInvalidReservation + } + err := tapsdk.VerifyGroupMembership(ctx, w.Tap, w.Params, g.GroupKey, + []asset.ID{asset.ID(q.Terms.AssetId), asset.ID(g.PaymentAssetId)}, + g.IssuanceProofs, MaxGroupProofBytes) + if errors.Is(err, swapkit.ErrInvalid) { + return ErrInvalidReservation + } + return err +} diff --git a/assets/reservation/kit.go b/assets/reservation/kit.go new file mode 100644 index 000000000..a6fceca18 --- /dev/null +++ b/assets/reservation/kit.go @@ -0,0 +1,214 @@ +package reservation + +import ( + "bytes" + "context" + "errors" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + assetpolicy "github.com/lightninglabs/loop/internal/assetpolicy" + "github.com/lightninglabs/loop/swapkit" + "github.com/lightninglabs/loop/swapkit/tapsdk" + sdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/commitment" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/rpcutils" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightningnetwork/lnd/keychain" + "google.golang.org/grpc" +) + +// AddressProofClient creates reservation addresses and exports their proofs. +type AddressProofClient interface { + NewAddr(context.Context, *taprpc.NewAddrRequest, + ...grpc.CallOption) (*taprpc.Addr, error) + ExportProof(context.Context, *taprpc.ExportProofRequest, + ...grpc.CallOption) (*taprpc.ProofFile, error) +} + +// ProofVerifier checks the running tapd and verifies a complete proof file. +type ProofVerifier interface { + assetpolicy.TapdInfoClient + VerifyProof(context.Context, *taprpc.ProofFile, + ...grpc.CallOption) (*taprpc.VerifyProofResponse, error) +} + +// Kit adapts the shared CSV deposit contract to server-funded reservations. +// The server is the sender and owns the unilateral refund key. +type Kit struct { + contract *swapkit.DepositKit + assetID asset.ID + params *address.ChainParams +} + +// NewKit constructs a server-funded CSV reservation. Explicit key roles put +// the server on the timeout path. +func NewKit(server, client *btcec.PublicKey, + assetID asset.ID, csvDelay uint32, + params *address.ChainParams) (*Kit, error) { + + if server == nil || client == nil || assetID == asset.ZeroID || + params == nil || params.Params == nil || params.TapHRP == "" { + + return nil, errors.New("invalid reservation contract") + } + serverPub := swapkit.PubKey(server.SerializeCompressed()) + clientPub := swapkit.PubKey(client.SerializeCompressed()) + contract, err := swapkit.NewDepositKit(swapkit.DepositKitParams{ + SenderInternalKey: serverPub, + ReceiverInternalKey: clientPub, + SenderScriptKey: serverPub, + ReceiverScriptKey: clientPub, + CSVExpiry: csvDelay, + }) + if err != nil { + return nil, err + } + return &Kit{ + contract: contract, + assetID: assetID, params: assetpolicy.CloneAddressParams(*params), + }, nil +} + +// GenTimeoutPathScript returns the server's CSV refund leaf. +func (k *Kit) GenTimeoutPathScript() ([]byte, error) { + if k == nil || k.contract == nil { + return nil, errors.New("reservation contract is required") + } + return k.contract.TimeoutScript(), nil +} + +func (k *Kit) sibling() (*commitment.TapscriptPreimage, error) { + script, err := k.GenTimeoutPathScript() + if err != nil { + return nil, err + } + return commitment.NewPreimageFromLeaf(txscript.NewBaseTapLeaf(script)) +} + +// TapScriptKey returns the x-only NUMS/OP_TRUE key used in asset proofs. +func (k *Kit) TapScriptKey() (asset.ScriptKey, error) { + key, err := k.tapAddressScriptKey() + if err != nil { + return asset.ScriptKey{}, err + } + return asset.NewScriptKey(key.PubKey), nil +} + +// tapAddressScriptKey retains the tweaked key's parity and derivation for +// tapd's address request. Asset proofs serialize only its x coordinate. +func (k *Kit) tapAddressScriptKey() (asset.ScriptKey, error) { + if k == nil || k.contract == nil { + return asset.ScriptKey{}, errors.New("reservation contract is required") + } + descriptor, _, err := tapsdk.LegacyOPTrueDescriptor() + if err != nil { + return asset.ScriptKey{}, err + } + // SDK keys use an even x-only representative. Tapd's address request + // retains the tweaked output key's parity; proofs use only its x value. + pub := txscript.ComputeTaprootOutputKey( + asset.NUMSPubKey, descriptor.TapTweak, + ) + return asset.ScriptKey{ + PubKey: pub, + TweakedScriptKey: &asset.TweakedScriptKey{ + RawKey: keychain.KeyDescriptor{PubKey: asset.NUMSPubKey}, + Tweak: bytes.Clone(descriptor.TapTweak), + }, + }, nil +} + +// NewAddr creates a V1 reservation address with its CSV sibling. +func (k *Kit) NewAddr(ctx context.Context, tap AddressProofClient, + amount uint64) (*taprpc.Addr, error) { + + if tap == nil || amount == 0 { + return nil, errors.New("invalid reservation address request") + } + scriptKey, err := k.tapAddressScriptKey() + if err != nil { + return nil, err + } + sibling, err := k.sibling() + if err != nil { + return nil, err + } + raw, _, err := commitment.MaybeEncodeTapscriptPreimage(sibling) + if err != nil { + return nil, err + } + plan := k.contract.AnchorPlan() + return tap.NewAddr(ctx, &taprpc.NewAddrRequest{ + AssetId: bytes.Clone(k.assetID[:]), Amt: amount, + ScriptKey: rpcutils.MarshalScriptKey(scriptKey), + InternalKey: &taprpc.KeyDescriptor{ + RawKeyBytes: bytes.Clone(plan.InternalKey[:]), + }, + TapscriptSibling: raw, + AssetVersion: taprpc.AssetVersion_ASSET_VERSION_V1, + AddressVersion: taprpc.AddrVersion_ADDR_VERSION_V1, + }) +} + +// ExportProof requests the proof for one exact reservation output. +func (k *Kit) ExportProof(ctx context.Context, tap AddressProofClient, + point *wire.OutPoint) (*taprpc.ProofFile, error) { + + if k == nil || tap == nil || point == nil { + return nil, errors.New("invalid reservation proof request") + } + scriptKey, err := k.TapScriptKey() + if err != nil { + return nil, err + } + return tap.ExportProof(ctx, &taprpc.ExportProofRequest{ + AssetId: bytes.Clone(k.assetID[:]), + ScriptKey: scriptKey.PubKey.SerializeCompressed(), + Outpoint: &taprpc.OutPoint{ + Txid: bytes.Clone(point.Hash[:]), OutputIndex: point.Index, + }, + }) +} + +// AnchorPlan identifies the Bitcoin contract for a reservation allocation. +func (k *Kit) AnchorPlan() swapkit.AnchorPlan { + return k.contract.AnchorPlan() +} + +// TimeoutSpend binds the server's refund to the exact isolated asset output. +func (k *Kit) TimeoutSpend(p *proof.Proof, amount uint64) ( + *swapkit.SwapAnchorSpendInfo, error) { + + if k == nil || k.contract == nil || p == nil { + return nil, ErrInvalidReservation + } + key, descriptor, err := tapsdk.LegacyOPTrueDescriptor() + if err != nil { + return nil, err + } + anchor := k.AnchorPlan() + point := p.OutPoint() + binding := tapsdk.InputBinding{ + ID: "reservation", IssuanceID: sdk.AssetID(k.assetID), + Amount: amount, ScriptKey: key.PubKey, Anchor: &anchor, + AnchorOutpoint: sdk.Outpoint{ + Txid: sdk.Hash(point.Hash), + Index: point.Index, + }, + OPTrue: descriptor, + } + if group := p.Asset.GroupKey; group != nil { + key := sdk.PubKey(group.GroupPubKey.SerializeCompressed()) + binding.GroupKey = &key + } + root, err := binding.IsolatedAssetRoot(p) + if err != nil { + return nil, err + } + return k.contract.TimeoutSpend(root) +} diff --git a/assets/reservation/kit_test.go b/assets/reservation/kit_test.go new file mode 100644 index 000000000..37ae3b0ab --- /dev/null +++ b/assets/reservation/kit_test.go @@ -0,0 +1,57 @@ +package reservation + +import ( + "encoding/hex" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/loop/swapkit" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/stretchr/testify/require" +) + +// TestReservationContractVector fixes the server-funded CSV contract, asset +// script key, and Bitcoin output that both sides must agree on. +func TestReservationContractVector(t *testing.T) { + serverScalar := make([]byte, 32) + clientScalar := make([]byte, 32) + serverScalar[31] = 1 + clientScalar[31] = 2 + _, server := btcec.PrivKeyFromBytes(serverScalar) + _, client := btcec.PrivKeyFromBytes(clientScalar) + kit, err := NewKit(server, client, asset.ID{1}, + 1440, &address.RegressionNetTap) + require.NoError(t, err) + + leaf, err := kit.GenTimeoutPathScript() + require.NoError(t, err) + require.Equal(t, "2079be667ef9dcbbac55a06295ce870b07029bfc"+ + "db2dce28d959f2815b16f81798ad02a005b2", + hex.EncodeToString(leaf)) + plan := kit.contract.AnchorPlan() + require.Equal(t, "023b46d262d2f610e9038b44beabdfe97ab5a0feb"+ + "89870acc2264edfb7f63ec2ec", + hex.EncodeToString(plan.InternalKey[:])) + var root swapkit.Hash + for i := range root { + root[i] = byte(0xa0 + i) + } + spend, err := kit.contract.TimeoutSpend(root) + require.NoError(t, err) + require.Equal(t, uint32(1440), spend.Sequence) + require.Zero(t, spend.LockTime) + require.Equal(t, "5120495c236364588781056c0d9f3fb69cadecd9628"+ + "f02b9b96ec07f2ebd2a9258f1", + hex.EncodeToString(spend.PkScript)) + addressKey, err := kit.tapAddressScriptKey() + require.NoError(t, err) + require.Equal(t, "037bbbb85ff774141ee36c2cc9f38b40be4a02c2a"+ + "156c8a1def8781cf39177deca", + hex.EncodeToString(addressKey.PubKey.SerializeCompressed())) + assetKey, err := kit.TapScriptKey() + require.NoError(t, err) + require.Equal(t, "027bbbb85ff774141ee36c2cc9f38b40be4a02c2a"+ + "156c8a1def8781cf39177deca", + hex.EncodeToString(assetKey.PubKey.SerializeCompressed())) +} diff --git a/assets/reservation/proof.go b/assets/reservation/proof.go new file mode 100644 index 000000000..62f376d40 --- /dev/null +++ b/assets/reservation/proof.go @@ -0,0 +1,82 @@ +package reservation + +import ( + "bytes" + "context" + "fmt" + + "github.com/btcsuite/btcd/wire" + assetpolicy "github.com/lightninglabs/loop/internal/assetpolicy" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/taprpc" +) + +const ( + // maxReservationProofSize bounds the raw proof accepted by the verifier. + maxReservationProofSize = 16 << 20 + + // Allow room for the reservation ID, outpoint, and protobuf framing in + // addition to a maximum-sized proof. This applies only to the proof RPC. + maxReservationProofMessageSize = maxReservationProofSize + 1024 +) + +// VerifyReservationProof binds the terminal asset to the shared deposit script, +// amount, and outpoint, then verifies its full history with tapd. RPC failures +// remain retryable; invalid proofs are reported as ErrInvalidReservation. +func VerifyReservationProof(ctx context.Context, verifier ProofVerifier, + kit *Kit, raw []byte, outpoint wire.OutPoint, + amount uint64) (*proof.Proof, error) { + + if verifier == nil || kit == nil || len(raw) == 0 || + len(raw) > maxReservationProofSize { + + return nil, ErrInvalidReservation + } + if err := assetpolicy.RequireOnchainSwapTapd(ctx, verifier); err != nil { + return nil, err + } + raw = bytes.Clone(raw) + file, err := proof.DecodeFile(raw) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidReservation, err) + } + terminal, err := file.LastProof() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidReservation, err) + } + if terminal.OutPoint() != outpoint || terminal.BlockHeight == 0 { + return nil, ErrInvalidReservation + } + if err := assetpolicy.ValidateProvenance(ctx, file, kit.params); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidReservation, err) + } + if _, err := kit.TimeoutSpend(terminal, amount); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidReservation, err) + } + // The shared kit rejects ownership-only and incomplete histories, + // including nested inputs, before asking tapd to verify them. Retain the + // RPC error separately so transport failures remain retryable. + response, err := verifier.VerifyProof(ctx, &taprpc.ProofFile{ + RawProofFile: raw, + }) + if err != nil { + return nil, err + } + if response == nil || !response.Valid { + return nil, ErrInvalidReservation + } + // Bind the authenticated node's answer to the exact locally checked + // proof before the FSM can accept delivery. + a := response.GetDecodedProof().GetAsset() + id := terminal.Asset.ID() + if a == nil || !bytes.Equal(a.GetAssetGenesis().GetAssetId(), id[:]) || + a.GetAmount() != amount || a.GetChainAnchor().GetAnchorOutpoint() != outpoint.String() || + !bytes.Equal( + a.GetScriptKey(), + terminal.Asset.ScriptKey.PubKey.SerializeCompressed(), + ) { + + return nil, ErrInvalidReservation + } + return terminal, nil +} diff --git a/assets/reservation/proof_rpc_test.go b/assets/reservation/proof_rpc_test.go new file mode 100644 index 000000000..e37ff65c8 --- /dev/null +++ b/assets/reservation/proof_rpc_test.go @@ -0,0 +1,93 @@ +package reservation + +import ( + "context" + "net" + "testing" + + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +type reservationProofRPCServer struct { + swapserverrpc.UnimplementedAssetReservationServiceServer + + response *swapserverrpc.AssetReservationProof +} + +func (s *reservationProofRPCServer) GetAssetReservationProof(context.Context, + *swapserverrpc.AssetReservationSelector) ( + *swapserverrpc.AssetReservationProof, error) { + + return s.response, nil +} + +// TestReservationProofReceiveLimit exercises the FSM through real gRPC. The +// wallet double isolates transport and persistence from proof validation. +func TestReservationProofReceiveLimit(t *testing.T) { + for _, tc := range []struct { + name string + size int + oversized bool + }{ + {name: "above grpc default", size: 5 << 20}, + {name: "maximum proof plus metadata", size: maxReservationProofSize}, + { + name: "oversized response", size: maxReservationProofMessageSize, + oversized: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + h := newClientHarness(t) + h.event(OnRecover, nil) + h.approve() + h.settle() + h.deliver = true + h.event(OnRecover, nil) + h.state(VerifyReservation) + + listener := bufconn.Listen(1 << 20) + server := grpc.NewServer() + response := &swapserverrpc.AssetReservationProof{ + ReservationId: h.id[:], Outpoint: h.outpoint.String(), + Proof: make([]byte, tc.size), + } + swapserverrpc.RegisterAssetReservationServiceServer(server, + &reservationProofRPCServer{response: response}) + serveErr := make(chan error, 1) + go func() { serveErr <- server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + require.NoError(t, <-serveErr) + }) + conn, err := grpc.NewClient("passthrough:///reservation-proof", + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(ctx context.Context, + _ string) (net.Conn, error) { + + return listener.DialContext(ctx) + }), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) + h.cfg.Server = swapserverrpc.NewAssetReservationServiceClient(conn) + h.status.Height = 102 + h.event(OnRecover, nil) + if tc.oversized { + require.Equal(t, codes.ResourceExhausted, + status.Code(h.machine.LastActionError)) + h.state(VerifyReservation) + require.Empty(t, h.record().ReservationProof) + return + } + require.NoError(t, h.machine.LastActionError) + h.state(Ready) + require.Equal(t, response.Proof, h.record().ReservationProof) + }) + } +} diff --git a/assets/reservation/proof_test.go b/assets/reservation/proof_test.go new file mode 100644 index 000000000..06c4fb1c0 --- /dev/null +++ b/assets/reservation/proof_test.go @@ -0,0 +1,286 @@ +package reservation + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + assetpolicy "github.com/lightninglabs/loop/internal/assetpolicy" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/commitment" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightninglabs/taproot-assets/taprpc/universerpc" + "github.com/lightninglabs/taproot-assets/tapscript" + "github.com/lightningnetwork/lnd/input" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type reservationProofVerifier struct { + decoded *taprpc.DecodedProof + valid bool + err error + version string +} + +const issuanceCall = "issuance" + +func (v reservationProofVerifier) GetInfo(context.Context, + *taprpc.GetInfoRequest, ...grpc.CallOption) ( + *taprpc.GetInfoResponse, error) { + + return &taprpc.GetInfoResponse{Version: v.version}, nil +} + +type issuanceProofClient struct { + request *universerpc.AssetProof + calls []string + insertErr error + valid bool +} + +func (v *issuanceProofClient) InsertProof(_ context.Context, + req *universerpc.AssetProof, _ ...grpc.CallOption) ( + *universerpc.AssetProofResponse, error) { + + v.calls = append(v.calls, issuanceCall) + v.request = req + return &universerpc.AssetProofResponse{}, v.insertErr +} + +func (v *issuanceProofClient) VerifyProof( + _ context.Context, + file *taprpc.ProofFile, + _ ...grpc.CallOption, +) (*taprpc.VerifyProofResponse, error) { + + v.calls = append(v.calls, "history") + return &taprpc.VerifyProofResponse{ + Valid: v.valid, DecodedProof: decodedTestProof(file), + }, nil +} + +func (v *issuanceProofClient) GetInfo(context.Context, + *taprpc.GetInfoRequest, ...grpc.CallOption) ( + *taprpc.GetInfoResponse, error) { + + return &taprpc.GetInfoResponse{Version: testTapdVersion}, nil +} + +func (v reservationProofVerifier) VerifyProof( + _ context.Context, + file *taprpc.ProofFile, + _ ...grpc.CallOption, +) (*taprpc.VerifyProofResponse, error) { + + decoded := v.decoded + if decoded == nil { + decoded = decodedTestProof(file) + } + return &taprpc.VerifyProofResponse{ + Valid: v.valid, DecodedProof: decoded, + }, v.err +} + +func decodedTestProof(file *taprpc.ProofFile) *taprpc.DecodedProof { + f, err := proof.DecodeFile(file.RawProofFile) + if err != nil { + return nil + } + p, err := f.LastProof() + if err != nil { + return nil + } + id := p.Asset.ID() + return &taprpc.DecodedProof{Asset: &taprpc.Asset{ + AssetGenesis: &taprpc.GenesisInfo{AssetId: id[:]}, Amount: p.Asset.Amount, + ScriptKey: p.Asset.ScriptKey.PubKey.SerializeCompressed(), + ChainAnchor: &taprpc.AnchorInfo{AnchorOutpoint: p.OutPoint().String()}, + }} +} + +func TestReservationProofBinding(t *testing.T) { + _, server := btcec.PrivKeyFromBytes([]byte{1}) + _, client := btcec.PrivKeyFromBytes([]byte{2}) + genesis := asset.Genesis{ + FirstPrevOut: wire.OutPoint{ + Index: 4, + }, + Tag: "reservation proof", + Type: asset.Normal, + } + kit, err := NewKit(server, client, + genesis.ID(), 1440, &address.RegressionNetTap) + require.NoError(t, err) + opTrue, err := kit.TapScriptKey() + require.NoError(t, err) + a, err := asset.New(genesis, 100, 0, 0, + opTrue, nil, + asset.WithAssetVersion(asset.V1)) + require.NoError(t, err) + version := commitment.TapCommitmentV2 + root, err := commitment.FromAssets(&version, a) + require.NoError(t, err) + _, inclusion, err := root.Proof(a.TapCommitmentKey(), a.AssetCommitmentKey()) + require.NoError(t, err) + key, err := input.MuSig2CombineKeys(input.MuSig2Version100RC2, + []*btcec.PublicKey{server, client}, true, &input.MuSig2Tweaks{}) + require.NoError(t, err) + timeout, err := kit.GenTimeoutPathScript() + require.NoError(t, err) + sibling, err := commitment.NewPreimageFromLeaf( + txscript.NewBaseTapLeaf(timeout), + ) + require.NoError(t, err) + siblingHash, err := sibling.TapHash() + require.NoError(t, err) + script, err := tapscript.PayToAddrScript(*key.PreTweakedKey, + siblingHash, *root) + require.NoError(t, err) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&genesis.FirstPrevOut, nil, nil)) + tx.AddTxOut(wire.NewTxOut(330, []byte{txscript.OP_TRUE})) + tx.AddTxOut(wire.NewTxOut(2000, script)) + p := proof.Proof{ + AnchorTx: *tx, + Asset: *a, + BlockHeight: 100, + InclusionProof: proof.TaprootProof{ + OutputIndex: 1, + InternalKey: key.PreTweakedKey, + CommitmentProof: &proof.CommitmentProof{ + Proof: *inclusion, + TapSiblingPreimage: sibling, + }, + }, + } + file, err := proof.NewFile(proof.V0, p) + require.NoError(t, err) + var raw bytes.Buffer + require.NoError(t, file.Encode(&raw)) + verifier := reservationProofVerifier{ + valid: true, version: testTapdVersion, + } + got, err := VerifyReservationProof( + t.Context(), verifier, kit, raw.Bytes(), + p.OutPoint(), 100) + require.NoError(t, err) + require.Equal(t, p.OutPoint(), got.OutPoint()) + _, err = VerifyReservationProof(t.Context(), verifier, kit, raw.Bytes(), + p.OutPoint(), 99) + require.ErrorIs(t, err, ErrInvalidReservation) + wrong := p.OutPoint() + wrong.Index = 0 + _, err = VerifyReservationProof( + t.Context(), verifier, kit, raw.Bytes(), wrong, 100) + require.ErrorIs(t, err, ErrInvalidReservation) + t.Run("verified summary for another output", func(t *testing.T) { + other := verifier + other.decoded = decodedTestProof(&taprpc.ProofFile{RawProofFile: raw.Bytes()}) + other.decoded.Asset.ChainAnchor.AnchorOutpoint = wrong.String() + _, err := VerifyReservationProof(t.Context(), other, kit, raw.Bytes(), + p.OutPoint(), 100) + require.ErrorIs(t, err, ErrInvalidReservation) + }) + verifier.valid = false + _, err = VerifyReservationProof( + t.Context(), verifier, kit, raw.Bytes(), p.OutPoint(), 100) + require.ErrorIs(t, err, ErrInvalidReservation) + verifier.err = errors.New("node unavailable") + _, err = VerifyReservationProof( + t.Context(), verifier, kit, raw.Bytes(), p.OutPoint(), 100) + require.ErrorIs(t, err, verifier.err) + require.NotErrorIs(t, err, ErrInvalidReservation) + verifier.version = "v0.8.7" + _, err = VerifyReservationProof( + t.Context(), verifier, kit, raw.Bytes(), p.OutPoint(), 100) + require.ErrorIs(t, err, assetpolicy.ErrTapdVersionUnsupported) + verifier.version = testTapdVersion + + for _, nested := range []bool{false, true} { + name := "outer ownership challenge" + if nested { + name = "nested ownership challenge" + } + t.Run(name, func(t *testing.T) { + challenged := p + challenged.ChallengeWitness = wire.TxWitness{[]byte{1}} + outer := challenged + if nested { + input, err := proof.NewFile(proof.V0, challenged) + require.NoError(t, err) + outer = p + outer.AdditionalInputs = []proof.File{*input} + } + file, err := proof.NewFile(proof.V0, outer) + require.NoError(t, err) + var encoded bytes.Buffer + require.NoError(t, file.Encode(&encoded)) + node := &issuanceProofClient{valid: true} + _, err = VerifyReservationProof( + t.Context(), + tapProofVerifier{ + tap: node, + }, + kit, + encoded.Bytes(), + p.OutPoint(), + 100, + ) + require.ErrorIs(t, err, ErrInvalidReservation) + require.Empty(t, node.calls) + }) + } + + t.Run("issuance before full history", func(t *testing.T) { + node := &issuanceProofClient{ + valid: true, + } + v := NewTapProofVerifier(node) + _, err := VerifyReservationProof( + t.Context(), v, kit, raw.Bytes(), + p.OutPoint(), 100) + require.NoError(t, err) + require.Equal(t, []string{issuanceCall, "history"}, node.calls) + id := genesis.ID() + require.Equal(t, id[:], node.request.Key.Id.GetAssetId()) + require.Equal(t, universerpc.ProofType_PROOF_TYPE_ISSUANCE, + node.request.Key.Id.ProofType) + require.Equal(t, p.OutPoint().String(), + node.request.Key.LeafKey.GetOpStr()) + encoded, err := file.RawProofAt(0) + require.NoError(t, err) + require.Equal(t, encoded, node.request.AssetLeaf.Proof) + + // Recovery repeats the verified metadata insert, not a wallet + // import. Full history must still pass on every invocation. + node.calls = nil + node.valid = false + _, err = VerifyReservationProof( + t.Context(), v, kit, raw.Bytes(), + p.OutPoint(), 100) + require.ErrorIs(t, err, ErrInvalidReservation) + require.Equal(t, []string{issuanceCall, "history"}, node.calls) + + node.calls = nil + node.insertErr = errors.New("issuance rejected") + _, err = VerifyReservationProof( + t.Context(), v, kit, raw.Bytes(), + p.OutPoint(), 100) + require.ErrorIs(t, err, node.insertErr) + require.Equal(t, []string{issuanceCall}, node.calls) + + node.calls = nil + _, err = VerifyReservationProof( + t.Context(), v, kit, raw.Bytes(), + p.OutPoint(), 99) + require.ErrorIs(t, err, ErrInvalidReservation) + require.Empty(t, node.calls) + }) +} diff --git a/assets/reservation/tap_proof.go b/assets/reservation/tap_proof.go new file mode 100644 index 000000000..d8f1aad0a --- /dev/null +++ b/assets/reservation/tap_proof.go @@ -0,0 +1,53 @@ +package reservation + +import ( + "context" + "errors" + + "github.com/lightninglabs/loop/swapkit" + "github.com/lightninglabs/loop/swapkit/tapsdk" + "github.com/lightninglabs/taproot-assets/taprpc" + "google.golang.org/grpc" +) + +// ProofClient verifies proofs and records verified issuance metadata in the +// client's local tapd. Recording issuance does not import spendable assets. +type ProofClient interface { + ProofVerifier + tapsdk.IssuanceProofClient +} + +type tapProofVerifier struct{ tap ProofClient } + +// NewTapProofVerifier wraps the local tapd client to register issuance metadata +// before verifying a reservation's full proof history. +func NewTapProofVerifier(tap ProofClient) ProofVerifier { + return tapProofVerifier{tap: tap} +} + +// GetInfo passes through the version check before a new purchase begins. +func (v tapProofVerifier) GetInfo(ctx context.Context, + req *taprpc.GetInfoRequest, opts ...grpc.CallOption) ( + *taprpc.GetInfoResponse, error) { + + return v.tap.GetInfo(ctx, req, opts...) +} + +// VerifyProof supplies issuance metadata before full verification. Tapd +// needs that metadata to return a decoded transfer proof, including on +// a BTC-only client that has never received this asset. InsertProof verifies +// the issuance against tapd's chain view and safely repeats after a restart. +func (v tapProofVerifier) VerifyProof(ctx context.Context, req *taprpc.ProofFile, + opts ...grpc.CallOption) (*taprpc.VerifyProofResponse, error) { + + if req == nil || len(req.RawProofFile) == 0 || + len(req.RawProofFile) > maxReservationProofSize { + + return nil, ErrInvalidReservation + } + result, err := tapsdk.VerifyProofWithIssuance(ctx, v.tap, req, opts...) + if errors.Is(err, swapkit.ErrInvalid) { + return nil, ErrInvalidReservation + } + return result, err +} diff --git a/assets/reservation/verifier_test.go b/assets/reservation/verifier_test.go new file mode 100644 index 000000000..055db40a0 --- /dev/null +++ b/assets/reservation/verifier_test.go @@ -0,0 +1,418 @@ +package reservation + +import ( + "bytes" + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/commitment" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightninglabs/taproot-assets/tapscript" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/status" +) + +// verifierLog captures the package logger so a test can count ERR lines. +func newVerifierHarness(t *testing.T, r *Reservation) *clientHarness { + t.Helper() + db := loopdb.NewTestDB(t) + h := &clientHarness{ + t: t, + id: r.ID, + quote: testPurchaseQuote(r), + store: &failingStore{ + Store: NewSqlStore(db.BaseDB), + }, + preimage: lntypes.Preimage{9}, + probeResult: ProbeSucceeded, + status: FundingStatus{ + Height: 101, + ConfirmationHeight: 100, + }, + } + h.cfg = &Config{ + Store: h.store, + Server: h, + Payments: h, + Wallet: h, + Clock: clock.NewTestClock(time.Unix(1800000000, 0)), + NotifyAdmin: func(ID, error) { h.notifications++ }, + } + h.store.Store.(*SqlStore).clock = h.cfg.Clock + require.NoError(t, h.store.CreateReservation(t.Context(), r)) + h.restart() + return h +} + +// verifierProof is a well-formed single-transition proof file for the shared +// reservation contract, built exactly like TestReservationProofBinding. +type verifierProof struct { + raw []byte + tx *wire.MsgTx + outpoint wire.OutPoint + header wire.BlockHeader + assetID asset.ID +} + +const verifierProofAmount = 100 + +func newVerifierProof(t *testing.T, server, + client *btcec.PublicKey) *verifierProof { + + t.Helper() + genesis := asset.Genesis{ + FirstPrevOut: wire.OutPoint{Index: 4}, + Tag: "reservation proof", + Type: asset.Normal, + } + kit, err := NewKit(server, client, genesis.ID(), 1440, + &address.RegressionNetTap) + require.NoError(t, err) + opTrue, err := kit.TapScriptKey() + require.NoError(t, err) + a, err := asset.New(genesis, verifierProofAmount, 0, 0, opTrue, nil, + asset.WithAssetVersion(asset.V1)) + require.NoError(t, err) + version := commitment.TapCommitmentV2 + root, err := commitment.FromAssets(&version, a) + require.NoError(t, err) + _, inclusion, err := root.Proof( + a.TapCommitmentKey(), a.AssetCommitmentKey(), + ) + require.NoError(t, err) + key, err := input.MuSig2CombineKeys(input.MuSig2Version100RC2, + []*btcec.PublicKey{server, client}, true, &input.MuSig2Tweaks{}) + require.NoError(t, err) + timeout, err := kit.GenTimeoutPathScript() + require.NoError(t, err) + sibling, err := commitment.NewPreimageFromLeaf( + txscript.NewBaseTapLeaf(timeout), + ) + require.NoError(t, err) + siblingHash, err := sibling.TapHash() + require.NoError(t, err) + script, err := tapscript.PayToAddrScript( + *key.PreTweakedKey, siblingHash, *root, + ) + require.NoError(t, err) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&genesis.FirstPrevOut, nil, nil)) + tx.AddTxOut(wire.NewTxOut(330, []byte{txscript.OP_TRUE})) + tx.AddTxOut(wire.NewTxOut(2000, script)) + p := proof.Proof{ + AnchorTx: *tx, + Asset: *a, + BlockHeight: 100, + InclusionProof: proof.TaprootProof{ + OutputIndex: 1, + InternalKey: key.PreTweakedKey, + CommitmentProof: &proof.CommitmentProof{ + Proof: *inclusion, + TapSiblingPreimage: sibling, + }, + }, + } + file, err := proof.NewFile(proof.V0, p) + require.NoError(t, err) + var raw bytes.Buffer + require.NoError(t, file.Encode(&raw)) + return &verifierProof{ + raw: raw.Bytes(), tx: tx, outpoint: p.OutPoint(), + header: p.BlockHeader, assetID: genesis.ID(), + } +} + +// verifierTapd is the local tapd. Like tapd v0.8.8 (rpcserver.go VerifyProof) +// it answers any verification error, including a failed chain-backend lookup, +// with Valid=false and a nil RPC error. +type verifierTapd struct { + verifyCalls int + invalidCalls int + + // chain, when set, is the chain backend tapd checks the anchor header + // against (tapd ChainBridge.VerifyBlock calls GetBlockHash by height). + chain *verifierChain +} + +func (v *verifierTapd) GetInfo(context.Context, *taprpc.GetInfoRequest, + ...grpc.CallOption) (*taprpc.GetInfoResponse, error) { + + return &taprpc.GetInfoResponse{Version: testTapdVersion}, nil +} + +func (v *verifierTapd) VerifyProof(ctx context.Context, file *taprpc.ProofFile, + _ ...grpc.CallOption) (*taprpc.VerifyProofResponse, error) { + + v.verifyCalls++ + if v.invalidCalls > 0 { + v.invalidCalls-- + return &taprpc.VerifyProofResponse{Valid: false}, nil + } + if v.chain != nil { + decoded, err := proof.DecodeFile(file.RawProofFile) + if err != nil { + return nil, err + } + last, err := decoded.LastProof() + if err != nil { + return nil, err + } + hash, err := v.chain.GetBlockHash(ctx, int64(last.BlockHeight)) + if err != nil || hash != last.BlockHeader.BlockHash() { + // A failed header check is a verification error. + // tapd reports it as an invalid proof, not as an + // RPC error. + return &taprpc.VerifyProofResponse{Valid: false}, nil + } + } + return &taprpc.VerifyProofResponse{ + Valid: true, DecodedProof: decodedTestProof(file), + }, nil +} + +type verifierKeys struct{ key keychain.KeyDescriptor } + +func (k verifierKeys) DeriveNextKey(context.Context, int32) ( + *keychain.KeyDescriptor, error) { + + return nil, errors.New("unused") +} + +func (k verifierKeys) DeriveKey(context.Context, *keychain.KeyLocator) ( + *keychain.KeyDescriptor, error) { + + key := k.key + return &key, nil +} + +// verifierChain is the local LND chain view shared by every chain interface. +type verifierChain struct { + lndclient.ChainNotifierClient + + mu sync.Mutex + tip uint32 + tx *wire.MsgTx + header wire.BlockHeader + height uint32 + + // syncGaps makes the next GetInfo calls report an unsynced node. + syncGaps int + watches int +} + +func (c *verifierChain) GetBlockHash(_ context.Context, height int64) ( + chainhash.Hash, error) { + + c.mu.Lock() + defer c.mu.Unlock() + if height != int64(c.height) || uint32(height) > c.tip { + return chainhash.Hash{}, errors.New("block number out of range") + } + return c.header.BlockHash(), nil +} + +func (c *verifierChain) GetInfo(context.Context) (*lndclient.Info, error) { + c.mu.Lock() + defer c.mu.Unlock() + synced := true + if c.syncGaps > 0 { + c.syncGaps-- + synced = false + } + return &lndclient.Info{SyncedToChain: synced, BlockHeight: c.tip}, nil +} + +// streamError mirrors lndclient: its receive goroutine pushes the stream +// error into the buffered error channel once the stream context ends. +func verifierStreamError(ctx context.Context) chan error { + errs := make(chan error, 1) + go func() { + <-ctx.Done() + errs <- status.FromContextError(ctx.Err()).Err() + }() + return errs +} + +func (c *verifierChain) RegisterConfirmationsNtfn(ctx context.Context, + _ *chainhash.Hash, _ []byte, _, _ int32, + _ ...lndclient.NotifierOption) (chan *chainntnfs.TxConfirmation, + chan error, error) { + + confs := make(chan *chainntnfs.TxConfirmation, 1) + confs <- &chainntnfs.TxConfirmation{Tx: c.tx, BlockHeight: c.height} + return confs, verifierStreamError(ctx), nil +} + +func (c *verifierChain) RegisterSpendNtfn(ctx context.Context, _ *wire.OutPoint, + _ []byte, _ int32, _ ...lndclient.NotifierOption) ( + chan *chainntnfs.SpendDetail, chan error, error) { + + spends := make(chan *chainntnfs.SpendDetail, 1) + return spends, verifierStreamError(ctx), nil +} + +func (c *verifierChain) RegisterBlockEpochNtfn(ctx context.Context) (chan int32, + chan error, error) { + + c.mu.Lock() + c.watches++ + tip := int32(c.tip) + c.mu.Unlock() + blocks := make(chan int32) + go func() { + // LND first reports the current tip, then stays quiet. + select { + case blocks <- tip: + case <-ctx.Done(): + } + }() + return blocks, verifierStreamError(ctx), nil +} + +// verifierProofServer serves the real proof file instead of placeholder bytes. +type verifierProofServer struct { + *clientHarness + + raw []byte + fetches int +} + +func (s *verifierProofServer) GetAssetReservationProof(context.Context, + *swapserverrpc.AssetReservationSelector, ...grpc.CallOption) ( + *swapserverrpc.AssetReservationProof, error) { + + s.fetches++ + return &swapserverrpc.AssetReservationProof{ + ReservationId: s.quote.ReservationId, + Outpoint: s.outpoint.String(), + Proof: bytes.Clone(s.raw), + }, nil +} + +// realVerifier is a paid purchase whose delivery is checked by the real +// WalletVerifier, VerifyReservationProof and ChainReader. +type realVerifier struct { + h *clientHarness + tapd *verifierTapd + chain *verifierChain + server *verifierProofServer +} + +func newRealVerifier(t *testing.T) *realVerifier { + t.Helper() + r := testReservation() + _, serverKey := btcec.PrivKeyFromBytes([]byte{2}) + fixture := newVerifierProof(t, serverKey, r.ClientKey.PubKey) + r.AssetID = fixture.assetID + r.Amount = verifierProofAmount + r.Terms = Terms{AssetID: r.AssetID, Amount: r.Amount} + r.State = RequestQuote + h := newVerifierHarness(t, r) + h.outpoint = fixture.outpoint + + // Quote, probe, approve and settle with the package's node doubles. + h.event(OnRecover, nil) + h.approve() + h.settle() + h.event(OnRecover, nil) + h.state(WaitForDelivery) + + v := &realVerifier{ + h: h, + tapd: &verifierTapd{}, + chain: &verifierChain{ + tip: 102, tx: fixture.tx, header: fixture.header, + height: 100, + }, + server: &verifierProofServer{ + clientHarness: h, raw: fixture.raw, + }, + } + wallet := &WalletVerifier{ + Keys: verifierKeys{key: r.ClientKey}, + Tap: v.tapd, + Params: &address.RegressionNetTap, + KeyFamily: 99, + Chain: &ChainReader{ + Chain: v.chain, Node: v.chain, ChainNotifier: v.chain, + }, + } + require.NoError(t, wallet.Validate()) + h.cfg.Wallet = wallet + h.cfg.Server = v.server + h.deliver = true + h.restart() + return v +} + +// TestRealVerifierReachesReady proves the fixture: with a healthy node the +// real verification path takes the paid purchase to Ready. +func TestRealVerifierReachesReady(t *testing.T) { + v := newRealVerifier(t) + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + require.NoError(t, v.h.machine.SendEvent(ctx, OnRecover, nil)) + require.NoError(t, v.h.machine.LastActionError) + r := v.h.record() + require.Equal(t, Ready, r.State) + require.EqualValues(t, 100, r.ConfirmationHeight) + require.NotEmpty(t, r.ReservationProof) + require.Equal(t, 1, v.tapd.verifyCalls) + require.Zero(t, v.h.notifications) +} + +// TestVerifyWaitsForLaggingChainView checks that a local node which has not +// reached the proof's anchor block is not taken for an invalid proof. tapd +// reports an unverifiable proof and an invalid one alike, so the client reads +// its own chain first and retries a paid purchase instead of parking it. +func TestVerifyWaitsForLaggingChainView(t *testing.T) { + v := newRealVerifier(t) + h := v.h + v.tapd.chain = v.chain + v.chain.tip = 99 + require.NoError(t, h.machine.SendEvent(t.Context(), OnRecover, nil)) + h.state(VerifyReservation) + require.Error(t, h.machine.LastActionError) + require.NotErrorIs(t, h.machine.LastActionError, ErrInvalidReservation) + require.Zero(t, v.tapd.verifyCalls) + require.Zero(t, h.notifications) + + // The node catches up and the same purchase verifies. + v.chain.tip = 102 + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + require.NoError(t, h.machine.SendEvent(ctx, OnRecover, nil)) + h.state(Ready) + require.Equal(t, 1, v.tapd.verifyCalls) + require.Zero(t, h.notifications) +} + +// TestVerifyRejectsProofFromAnotherChain keeps the definitive case: a proof +// whose anchor block differs from the local block at that height is invalid. +func TestVerifyRejectsProofFromAnotherChain(t *testing.T) { + v := newRealVerifier(t) + h := v.h + v.chain.header.Nonce++ + require.NoError(t, h.machine.SendEvent(t.Context(), OnRecover, nil)) + h.state(NeedAdminAttention) + require.Zero(t, v.tapd.verifyCalls) +} diff --git a/assets/reservation/wallet.go b/assets/reservation/wallet.go new file mode 100644 index 000000000..c62e35cbc --- /dev/null +++ b/assets/reservation/wallet.go @@ -0,0 +1,195 @@ +package reservation + +import ( + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + assetpolicy "github.com/lightninglabs/loop/internal/assetpolicy" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightningnetwork/lnd/keychain" +) + +// ReservationKeys is the LND wallet key interface used by both parties. +type ReservationKeys interface { + DeriveNextKey(context.Context, int32) (*keychain.KeyDescriptor, error) + DeriveKey(context.Context, *keychain.KeyLocator) (*keychain.KeyDescriptor, + error) +} + +// WalletVerifier checks reservations with the local tapd and LND nodes. +// It never asks the server whether the client can use its reservation. +type WalletVerifier struct { + Keys ReservationKeys + Tap ProofVerifier + Chain *ChainReader + Params *address.ChainParams + KeyFamily int32 +} + +var _ ReservationVerifier = (*WalletVerifier)(nil) + +// Validate checks dependencies before the reservation manager starts. +func (w *WalletVerifier) Validate() error { + if w == nil || w.Keys == nil || w.Tap == nil || w.Params == nil || + w.Params.Params == nil || w.KeyFamily <= 0 { + + return errors.New("incomplete reservation proof verifier") + } + return w.Chain.Validate() +} + +// DeriveKey obtains the client's local co-signing key from LND. +func (w *WalletVerifier) DeriveKey(ctx context.Context) ( + *keychain.KeyDescriptor, error) { + + if err := w.Validate(); err != nil { + return nil, err + } + if err := assetpolicy.RequireOnchainSwapTapd(ctx, w.Tap); err != nil { + return nil, err + } + return w.Keys.DeriveNextKey(ctx, w.KeyFamily) +} + +// Verify checks the full proof, exact confirmed anchor, and any known spend. +// The FSM enforces confirmation depth and the original CSV lifetime. +func (w *WalletVerifier) Verify(ctx context.Context, r *Reservation, + raw []byte) (FundingStatus, error) { + + var result FundingStatus + if err := w.Validate(); err != nil { + return result, err + } + if err := r.Validate(); err != nil { + return result, err + } + if r.Quote == nil || r.FundingOutpoint == nil { + return result, ErrInvalidReservation + } + key, err := w.Keys.DeriveKey(ctx, &r.ClientKey.KeyLocator) + if err != nil { + return result, err + } + if key == nil || key.PubKey == nil || + !key.PubKey.IsEqual(r.ClientKey.PubKey) { + + return result, ErrInvalidReservation + } + server, err := btcec.ParsePubKey(r.Quote.ServerKey) + if err != nil { + return result, ErrInvalidReservation + } + kit, err := NewKit(server, key.PubKey, + asset.ID(r.AssetID), r.CSVDelay, w.Params) + if err != nil { + return result, err + } + // tapd answers an unverifiable proof and an invalid one alike. Check + // our own chain view of the anchor block first, so that a node which + // has not reached that block yet is retried and not taken for a bad + // proof. + if err := w.checkAnchorBlock(ctx, raw); err != nil { + return result, err + } + terminal, err := VerifyReservationProof(ctx, w.Tap, kit, raw, + *r.FundingOutpoint, r.Amount) + if err != nil { + return result, err + } + if r.GroupKey != ([33]byte{}) && !IsGroupMember(&terminal.Asset, r.GroupKey[:]) { + return result, ErrInvalidReservation + } + index := r.FundingOutpoint.Index + if uint64(index) >= uint64(len(terminal.AnchorTx.TxOut)) { + return result, ErrInvalidReservation + } + result, err = w.Chain.Observe(ctx, *r.FundingOutpoint, + terminal.AnchorTx.TxOut[index], terminal.BlockHeight) + if err != nil { + return result, err + } + if result.ConfirmationHeight != terminal.BlockHeight { + return result, ErrInvalidReservation + } + return result, nil +} + +// checkAnchorBlock compares the proof's anchor block with the local chain. A +// block the node cannot serve yet is a temporary error. A different block at +// that height means the proof does not belong to the active chain. +func (w *WalletVerifier) checkAnchorBlock(ctx context.Context, + raw []byte) error { + + if len(raw) == 0 || len(raw) > maxReservationProofSize { + return ErrInvalidReservation + } + file, err := proof.DecodeFile(raw) + if err != nil { + return fmt.Errorf("%w: %v", ErrInvalidReservation, err) + } + terminal, err := file.LastProof() + if err != nil { + return fmt.Errorf("%w: %v", ErrInvalidReservation, err) + } + hash, err := w.Chain.Chain.GetBlockHash( + ctx, int64(terminal.BlockHeight), + ) + if err != nil { + return err + } + if hash != terminal.BlockHeader.BlockHash() { + return ErrInvalidReservation + } + return nil +} + +// Inspect watches the verified reservation for a spend or expiry progress. +func (w *WalletVerifier) Inspect(ctx context.Context, + r *Reservation) (FundingStatus, error) { + + var status FundingStatus + if err := w.Validate(); err != nil { + return status, err + } + if r.FundingOutpoint == nil || r.ConfirmationHeight == 0 { + return status, ErrInvalidReservation + } + file, err := proof.DecodeFile(r.ReservationProof) + if err != nil { + return status, err + } + terminal, err := file.LastProof() + if err != nil { + return status, err + } + point := *r.FundingOutpoint + if terminal.AnchorTx.TxHash() != point.Hash || + uint64(point.Index) >= uint64(len(terminal.AnchorTx.TxOut)) { + + return status, ErrInvalidReservation + } + // A reorg can reconfirm the exact transaction at the same height. + // Check its block as well as its outpoint on every ready watch. + hash, err := w.Chain.Chain.GetBlockHash(ctx, int64(terminal.BlockHeight)) + if err != nil { + return status, err + } + if hash != terminal.BlockHeader.BlockHash() { + return status, ErrInvalidReservation + } + output := terminal.AnchorTx.TxOut[point.Index] + status, err = w.Chain.Observe(ctx, point, output, r.ConfirmationHeight) + if err != nil || status.Spent { + return status, err + } + lifetime, err := r.Terms.Lifetime(status.ConfirmationHeight) + if err != nil || status.Height >= lifetime.TimeoutHeight { + return status, err + } + return w.Chain.WaitForChange(ctx, point, output.PkScript, + status.ConfirmationHeight, status) +} diff --git a/assets/reservation/wallet_version_test.go b/assets/reservation/wallet_version_test.go new file mode 100644 index 000000000..6f946958a --- /dev/null +++ b/assets/reservation/wallet_version_test.go @@ -0,0 +1,71 @@ +package reservation + +import ( + "context" + "testing" + + "github.com/lightninglabs/lndclient" + assetpolicy "github.com/lightninglabs/loop/internal/assetpolicy" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type versionedProofNode struct { + ProofVerifier + + version string +} + +const testTapdVersion = "v0.8.8" + +func (v versionedProofNode) GetInfo(context.Context, + *taprpc.GetInfoRequest, ...grpc.CallOption) ( + *taprpc.GetInfoResponse, error) { + + return &taprpc.GetInfoResponse{Version: v.version}, nil +} + +type reservationTestKeys struct { + called bool +} + +func (k *reservationTestKeys) DeriveNextKey(context.Context, int32) ( + *keychain.KeyDescriptor, error) { + + k.called = true + return &keychain.KeyDescriptor{}, nil +} + +func (k *reservationTestKeys) DeriveKey(context.Context, + *keychain.KeyLocator) (*keychain.KeyDescriptor, error) { + + return nil, nil +} + +// TestNewPurchaseTapdFloor checks that a new reservation cannot start with an +// old tapd while existing reservation recovery remains a separate operation. +func TestNewPurchaseTapdFloor(t *testing.T) { + keys := &reservationTestKeys{} + wallet := &WalletVerifier{ + Keys: keys, Params: &address.RegressionNetTap, KeyFamily: 1, + Chain: &ChainReader{ + Chain: struct{ ChainSource }{}, + Node: confirmationNode{}, + ChainNotifier: &struct { + lndclient.ChainNotifierClient + }{}, + }, + } + wallet.Tap = versionedProofNode{version: "v0.8.7"} + _, err := wallet.DeriveKey(t.Context()) + require.ErrorIs(t, err, assetpolicy.ErrTapdVersionUnsupported) + require.False(t, keys.called) + + wallet.Tap = versionedProofNode{version: testTapdVersion} + _, err = wallet.DeriveKey(t.Context()) + require.NoError(t, err) + require.True(t, keys.called) +} From 626eb51b92e632b477729a7e1066844b839d9f8d Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 15:56:32 +0200 Subject: [PATCH 21/25] assets/payment: Check asset quotes and signed invoices Calculate a BTC invoice amount that converts to exactly the requested asset units, and reject rates or RFQ capacities that cannot carry it. Bind the signed invoice to the asset, payee, payment secret, receiving edge, and expiry. Keep incoming HTLC receipt validation with the server's probe and prepay consumers. This client package owns shared quote and invoice checks. --- assets/payment/invoice.go | 95 +++++++++++++ assets/payment/quote.go | 161 ++++++++++++++++++++++ assets/payment/validation_test.go | 217 ++++++++++++++++++++++++++++++ 3 files changed, 473 insertions(+) create mode 100644 assets/payment/invoice.go create mode 100644 assets/payment/quote.go create mode 100644 assets/payment/validation_test.go diff --git a/assets/payment/invoice.go b/assets/payment/invoice.go new file mode 100644 index 000000000..eb87ad55d --- /dev/null +++ b/assets/payment/invoice.go @@ -0,0 +1,95 @@ +package payment + +import ( + "bytes" + "fmt" + "math" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/zpay32" +) + +// InvoiceTerms binds BOLT11's Bitcoin amount to an independently agreed asset +// contract. PaymentAddress, when known, binds the saved invoice. +type InvoiceTerms struct { + AssetID [32]byte + Amount uint64 + Hash [32]byte + Payee [33]byte + PaymentAddress [32]byte + MinFinalCLTV uint64 + MinLifetime time.Duration +} + +// ValidateInvoice checks the signed invoice, not just the creation request. +// Invoice creation must use the supplied RFQ without negotiating another one. +// This function is an admission check; use raw receipt validation to reconcile +// a payment that already settled after its quote or invoice expired. +func ValidateInvoice(encoded string, quote *rfqrpc.PeerAcceptedBuyQuote, + terms InvoiceTerms, params *chaincfg.Params, now time.Time) ( + *zpay32.Invoice, error) { + + if params == nil || terms.Hash == ([32]byte{}) || + terms.MinFinalCLTV == 0 || terms.MinFinalCLTV > math.MaxUint16 || + terms.MinLifetime <= 0 { + + return nil, fmt.Errorf("invalid invoice contract") + } + invoice, err := zpay32.Decode(encoded, params) + if err != nil { + return nil, err + } + expires := invoice.Timestamp.Add(invoice.Expiry()) + if !expires.After(now.Add(terms.MinLifetime)) || + invoice.MinFinalCLTVExpiry() < terms.MinFinalCLTV || + invoice.MinFinalCLTVExpiry() > math.MaxUint16 { + + return nil, fmt.Errorf("unsafe invoice lifetime") + } + expected, err := ReceivingAmount(quote, terms.AssetID, terms.Amount, + expires) + if err != nil { + return nil, err + } + if invoice.PaymentHash == nil || *invoice.PaymentHash != terms.Hash || + invoice.Destination == nil || !bytes.Equal( + invoice.Destination.SerializeCompressed(), terms.Payee[:], + ) || invoice.MilliSat == nil || + uint64(*invoice.MilliSat) != expected { + + return nil, fmt.Errorf("invoice does not match asset contract") + } + if invoice.PaymentAddr.IsNone() || invoice.Features == nil || + !invoice.Features.HasFeature(lnwire.PaymentAddrOptional) || + !invoice.Features.HasFeature(lnwire.TLVOnionPayloadOptional) || + invoice.Features.HasFeature(lnwire.AMPOptional) { + + return nil, fmt.Errorf("invoice requires non-AMP payment secret") + } + secret := invoice.PaymentAddr.UnsafeFromSome() + if secret == ([32]byte{}) || (terms.PaymentAddress != ([32]byte{}) && + secret != terms.PaymentAddress) { + + return nil, fmt.Errorf("invoice payment secret mismatch") + } + if len(invoice.RouteHints) != 1 || len(invoice.RouteHints[0]) != 1 || + len(invoice.BlindedPaymentPaths) != 0 { + + return nil, fmt.Errorf("invoice must use one receiving edge hint") + } + peer, err := parsePeer(quote.Peer) + if err != nil { + return nil, err + } + hint := invoice.RouteHints[0][0] + if hint.NodeID == nil || !bytes.Equal( + hint.NodeID.SerializeCompressed(), peer, + ) || hint.ChannelID != quote.Scid || hint.CLTVExpiryDelta == 0 { + + return nil, fmt.Errorf("invoice receiving quote mismatch") + } + return invoice, nil +} diff --git a/assets/payment/quote.go b/assets/payment/quote.go new file mode 100644 index 000000000..acca31cb1 --- /dev/null +++ b/assets/payment/quote.go @@ -0,0 +1,161 @@ +// Package payment checks asset quotes and signed Lightning invoices. +// Bitcoin routing amounts are millisatoshis; assets are integer base units. +package payment + +import ( + "bytes" + "encoding/hex" + "errors" + "fmt" + "math" + "math/big" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/taproot-assets/rfqmath" + "github.com/lightninglabs/taproot-assets/rfqmsg" + "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" + "github.com/lightningnetwork/lnd/lnwire" +) + +var ( + // ErrQuoteUnavailable means no usable quote exists. It does not prove + // that an outstanding payment failed or that an invoice was canceled. + ErrQuoteUnavailable = errors.New("asset payment quote unavailable") +) + +// Rate decodes canonical positive decimal coefficients with bounded scale. +func Rate(value *rfqrpc.FixedPoint) (rfqmath.BigIntFixedPoint, error) { + if value == nil || value.Scale > 18 || len(value.Coefficient) > 78 { + return rfqmath.BigIntFixedPoint{}, fmt.Errorf("invalid asset rate") + } + n, ok := new(big.Int).SetString(value.Coefficient, 10) + if !ok || n.Sign() <= 0 || n.String() != value.Coefficient { + return rfqmath.BigIntFixedPoint{}, fmt.Errorf("invalid asset rate") + } + return rfqmath.BigIntFixedPoint{ + Coefficient: rfqmath.NewBigInt(n), + Scale: uint8(value.Scale), + }, nil +} + +// Units uses the deployed edge's millisatoshi-to-unit rounding. +func Units(msat uint64, rate rfqmath.BigIntFixedPoint) (uint64, error) { + units := rfqmath.MilliSatoshiToUnits(lnwire.MilliSatoshi(msat), rate) + result, ok := units.ScaleTo(0).ToUint64Checked() + if !ok { + return 0, fmt.Errorf("asset amount overflow") + } + return result, nil +} + +// ExactMSat returns the least millisatoshi amount that delivers exactly the +// requested integer assets in one HTLC. Rounding an asset amount DOWN to msat +// and back can lose one asset unit. Round UP, then verify using the deployed +// conversion instead of granting a tolerance. Some rates cannot represent a +// given asset amount at millisatoshi resolution; reject those prices. +func ExactMSat(amount uint64, value *rfqrpc.FixedPoint) (uint64, error) { + rate, err := Rate(value) + if err != nil { + return 0, err + } + if amount == 0 || amount > math.MaxInt64 { + return 0, fmt.Errorf("invalid asset amount") + } + scale := new(big.Int).Exp(big.NewInt(10), + new(big.Int).SetUint64(uint64(value.Scale)), nil) + numerator := new(big.Int).Mul(new(big.Int).SetUint64(amount), scale) + numerator.Mul(numerator, big.NewInt(100_000_000_000)) + coefficient, _ := new(big.Int).SetString(value.Coefficient, 10) + quotient, remainder := new(big.Int), new(big.Int) + quotient.QuoRem(numerator, coefficient, remainder) + if remainder.Sign() != 0 { + quotient.Add(quotient, big.NewInt(1)) + } + if !quotient.IsInt64() || quotient.Sign() <= 0 { + return 0, fmt.Errorf("converted invoice amount overflow") + } + msat := quotient.Uint64() + actual, err := Units(msat, rate) + if err != nil || actual != amount { + return 0, fmt.Errorf("rate cannot deliver exact asset amount") + } + return msat, nil +} + +func quoteIdentity(id, peer []byte, spec *rfqrpc.AssetSpec, + expected [32]byte) error { + + if expected == ([32]byte{}) || len(id) != 32 || + bytes.Equal(id, make([]byte, 32)) || len(peer) != 33 || + spec == nil || !bytes.Equal(spec.Id, expected[:]) || + len(spec.GroupPubKey) != 0 { + + return fmt.Errorf("quote identity does not match asset contract") + } + key, err := btcec.ParsePubKey(peer) + if err != nil || !bytes.Equal(key.SerializeCompressed(), peer) { + return fmt.Errorf("noncanonical quote peer") + } + return nil +} + +func parsePeer(value string) ([]byte, error) { + peer, err := hex.DecodeString(value) + if err != nil || hex.EncodeToString(peer) != value { + return nil, fmt.Errorf("invalid quote peer") + } + return peer, nil +} + +// ReceivingAmount validates the receiving RFQ and returns the exact invoice +// amount. A quote's capacity is not an instruction to transfer that capacity. +// The edge floors its capacity's msat bound: a quote for A units may not permit +// the ceil-rounded payment for A. The caller may obtain capacity for A+1, but +// must keep the invoice, actual receipt and credit at A. Never negotiate a new +// rate implicitly while creating an invoice. +func ReceivingAmount(quote *rfqrpc.PeerAcceptedBuyQuote, + assetID [32]byte, amount uint64, validThrough time.Time) (uint64, error) { + + if quote == nil { + return 0, ErrQuoteUnavailable + } + peer, err := parsePeer(quote.Peer) + if err != nil { + return 0, err + } + if err := quoteIdentity(quote.Id, peer, quote.AssetSpec, assetID); err != nil { + return 0, err + } + var id rfqmsg.ID + copy(id[:], quote.Id) + if quote.Scid != uint64(id.Scid()) || quote.Expiry > math.MaxInt64 || + !time.Unix(int64(quote.Expiry), 0).After(validThrough) { + + return 0, ErrQuoteUnavailable + } + capacity := quote.AssetMaxAmount + if quote.AcceptedMaxAmount != 0 { + capacity = min(capacity, quote.AcceptedMaxAmount) + } + if amount < quote.MinTransportableUnits || capacity < amount || + capacity > math.MaxInt64 { + + return 0, fmt.Errorf("receiving quote lacks required asset capacity") + } + msat, err := ExactMSat(amount, quote.AskAssetRate) + if err != nil { + return 0, err + } + rate, err := Rate(quote.AskAssetRate) + if err != nil { + return 0, err + } + maximum, err := rfqmath.UnitsToMilliSatoshi( + rfqmath.NewBigIntFixedPoint(capacity, 0), rate, + ) + if err != nil || uint64(maximum) < msat { + return 0, fmt.Errorf("receiving quote lacks rounding capacity") + } + return msat, nil +} diff --git a/assets/payment/validation_test.go b/assets/payment/validation_test.go new file mode 100644 index 000000000..f2cada66b --- /dev/null +++ b/assets/payment/validation_test.go @@ -0,0 +1,217 @@ +package payment + +import ( + "crypto/sha256" + "encoding/hex" + "math" + "strings" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/taproot-assets/rfqmath" + "github.com/lightninglabs/taproot-assets/rfqmsg" + "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/zpay32" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +type fixture struct { + now time.Time + terms InvoiceTerms + buy *rfqrpc.PeerAcceptedBuyQuote + peer []byte + encoded string +} + +func newFixture(t *testing.T) fixture { + t.Helper() + key, _ := btcec.PrivKeyFromBytes([]byte{1}) + _, peer := btcec.PrivKeyFromBytes([]byte{2}) + f := fixture{ + now: time.Unix(1_800_000_000, 0), + } + f.terms = InvoiceTerms{ + AssetID: [32]byte{7}, + Amount: 1000, + MinFinalCLTV: 40, + MinLifetime: time.Minute, + PaymentAddress: [32]byte{9}, + } + preimage := [32]byte{8} + f.terms.Hash = sha256.Sum256(preimage[:]) + copy(f.terms.Payee[:], key.PubKey().SerializeCompressed()) + var id rfqmsg.ID + id[0], id[31] = 1, 2 + f.peer = peer.SerializeCompressed() + f.buy = &rfqrpc.PeerAcceptedBuyQuote{ + Id: id[:], + Peer: hex.EncodeToString(f.peer), + Scid: uint64(id.Scid()), + AssetMaxAmount: 1000, + AskAssetRate: &rfqrpc.FixedPoint{ + Coefficient: "100000000", + }, + Expiry: uint64(f.now.Add(2 * time.Hour).Unix()), + AssetSpec: &rfqrpc.AssetSpec{ + Id: f.terms.AssetID[:], + }, + } + inv, err := zpay32.NewInvoice( + &chaincfg.RegressionNetParams, f.terms.Hash, f.now, + zpay32.Amount(1_000_000), zpay32.Description("asset purchase"), + zpay32.Expiry(time.Hour), zpay32.CLTVExpiry(80), + zpay32.PaymentAddr(f.terms.PaymentAddress), + zpay32.Features(lnwire.NewFeatureVector(lnwire.NewRawFeatureVector( + lnwire.TLVOnionPayloadOptional, lnwire.PaymentAddrOptional, + ), lnwire.Features)), + zpay32.RouteHint([]zpay32.HopHint{{ + NodeID: peer, + ChannelID: f.buy.Scid, + CLTVExpiryDelta: 40, + }}), + ) + require.NoError(t, err) + f.encoded, err = inv.Encode(zpay32.MessageSigner{ + SignCompact: func(data []byte) ([]byte, error) { + return ecdsa.SignCompact(key, chainhash.HashB(data), true), nil + }, + }) + require.NoError(t, err) + return f +} + +func TestInvoiceBinding(t *testing.T) { + f := newFixture(t) + _, err := ValidateInvoice(f.encoded, f.buy, f.terms, + &chaincfg.RegressionNetParams, f.now) + require.NoError(t, err) + for _, test := range []struct { + name string + edit func(*InvoiceTerms, *rfqrpc.PeerAcceptedBuyQuote) + }{ + { + "hash", + func(v *InvoiceTerms, _ *rfqrpc.PeerAcceptedBuyQuote) { + v.Hash[0]++ + }, + }, + { + "payee", + func(v *InvoiceTerms, _ *rfqrpc.PeerAcceptedBuyQuote) { + v.Payee[1]++ + }, + }, + { + "amount", + func(v *InvoiceTerms, _ *rfqrpc.PeerAcceptedBuyQuote) { + v.Amount-- + }, + }, + { + "secret", + func(v *InvoiceTerms, _ *rfqrpc.PeerAcceptedBuyQuote) { + v.PaymentAddress[0]++ + }, + }, + { + "cltv", + func(v *InvoiceTerms, _ *rfqrpc.PeerAcceptedBuyQuote) { + v.MinFinalCLTV = 81 + }, + }, + { + "capacity", + func(_ *InvoiceTerms, q *rfqrpc.PeerAcceptedBuyQuote) { + q.AcceptedMaxAmount = 999 + }, + }, + { + "expiry", + func(_ *InvoiceTerms, q *rfqrpc.PeerAcceptedBuyQuote) { + q.Expiry = uint64(f.now.Add(time.Minute).Unix()) + }, + }, + { + "asset", + func(_ *InvoiceTerms, q *rfqrpc.PeerAcceptedBuyQuote) { + q.AssetSpec.Id[0]++ + }, + }, + { + "hint", + func(_ *InvoiceTerms, q *rfqrpc.PeerAcceptedBuyQuote) { + q.Scid++ + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + terms := f.terms + quote := proto.Clone(f.buy).(*rfqrpc.PeerAcceptedBuyQuote) + test.edit(&terms, quote) + _, err := ValidateInvoice(f.encoded, quote, terms, + &chaincfg.RegressionNetParams, f.now) + require.Error(t, err) + }) + } + _, err = ValidateInvoice(f.encoded, f.buy, f.terms, + &chaincfg.MainNetParams, f.now) + require.Error(t, err) +} + +func TestExactConversionAndCapacity(t *testing.T) { + f := newFixture(t) + f.buy.AskAssetRate.Coefficient = "300000000" + rate, err := Rate(f.buy.AskAssetRate) + require.NoError(t, err) + floor, err := rfqmath.UnitsToMilliSatoshi( + rfqmath.NewBigIntFixedPoint(1000, 0), rate, + ) + require.NoError(t, err) + units, err := Units(uint64(floor), rate) + require.NoError(t, err) + require.EqualValues(t, 999, units) + msat, err := ExactMSat(1000, f.buy.AskAssetRate) + require.NoError(t, err) + require.EqualValues(t, 333_334, msat) + units, err = Units(msat, rate) + require.NoError(t, err) + require.EqualValues(t, 1000, units) + _, err = ReceivingAmount(f.buy, f.terms.AssetID, 1000, f.now) + require.ErrorContains(t, err, "rounding capacity") + f.buy.AssetMaxAmount = 1001 + actual, err := ReceivingAmount(f.buy, f.terms.AssetID, 1000, f.now) + require.NoError(t, err) + require.Equal(t, msat, actual) + f.buy.AcceptedMaxAmount = 1000 + _, err = ReceivingAmount(f.buy, f.terms.AssetID, 1000, f.now) + require.ErrorContains(t, err, "rounding capacity") + + // More than one asset unit per msat can make an amount unrepresentable. + _, err = ExactMSat(1, &rfqrpc.FixedPoint{ + Coefficient: "200000000000", + }) + require.ErrorContains(t, err, "exact asset amount") + _, err = ExactMSat(math.MaxInt64, &rfqrpc.FixedPoint{ + Coefficient: "1", + }) + require.ErrorContains(t, err, "overflow") + for _, coefficient := range []string{ + "", "0", "-1", "+1", "01", "1.5", strings.Repeat("9", 79), + } { + _, err := Rate(&rfqrpc.FixedPoint{ + Coefficient: coefficient, + }) + require.Error(t, err) + } + _, err = Rate(&rfqrpc.FixedPoint{ + Coefficient: "1", + Scale: 19, + }) + require.Error(t, err) +} From dac86aa2f687e335c0763c6da5f9c624d10a6a29 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 15:56:46 +0200 Subject: [PATCH 22/25] assets/reservation: Send hold probes and approved BTC prepays Validate both receiving RFQs and invoices. Prepare one full-amount hold probe for the FSM to save, then dispatch it with SendPaymentV2. Keep the SendPaymentV2 stream open to capture in-flight routes before LND prunes failed attempts. Resume observation with TrackPaymentV2 on the saved node, passing live updates to the FSM for immediate persistence. Bound streams by the saved deadline and recheck it before dispatch. Enforce approved prepay fees, payment lifetime, route CLTV limits, and enough BTC in one channel after its reserve, even when probing is skipped. Send one part to preserve exact asset conversion. Resolve uncertain payments by tracking their saved identity and validate the settled amount and routing fee. Check exact payment issuance and validate each invoice against its own receiving RFQ. The client approves the separately quoted payment prices. --- assets/reservation/actions.go | 9 +- assets/reservation/fsm.md | 4 +- assets/reservation/fsm_test.go | 26 +- assets/reservation/payments.go | 567 ++++++++++++++++++++++++++++ assets/reservation/payments_test.go | 423 +++++++++++++++++++++ assets/reservation/probe_test.go | 7 + 6 files changed, 1033 insertions(+), 3 deletions(-) create mode 100644 assets/reservation/payments.go create mode 100644 assets/reservation/payments_test.go diff --git a/assets/reservation/actions.go b/assets/reservation/actions.go index bca74c3d3..97b7a1642 100644 --- a/assets/reservation/actions.go +++ b/assets/reservation/actions.go @@ -95,6 +95,12 @@ func (f *FSM) RequestQuoteAction(ctx context.Context, return f.rejectQuote(err) } if err := f.cfg.Payments.ValidateQuote(ctx, response.Quote); err != nil { + // The server repeats its saved quote, so an invoice dated just + // ahead of our clock validates on a later attempt. The quote + // deadline above still bounds the wait. + if errors.Is(err, errInvoiceNotYetValid) { + return f.stayInState(err) + } return f.rejectQuote(err) } if response.Quote.Group != nil { @@ -160,7 +166,8 @@ func (f *FSM) ProbeRoutesAction(ctx context.Context, return f.stayInState(err) } if errors.Is(err, context.DeadlineExceeded) || - errors.Is(err, context.Canceled) { + errors.Is(err, context.Canceled) || + errors.Is(err, ErrChainNotReady) { return f.stayInState(err) } diff --git a/assets/reservation/fsm.md b/assets/reservation/fsm.md index be02fee66..6dfe367d2 100644 --- a/assets/reservation/fsm.md +++ b/assets/reservation/fsm.md @@ -31,7 +31,9 @@ recovery, and cannot resume when inventory returns. The CLI reports `cannot initiate swap: rpc error: code = OutOfRange desc = amount above current maximum`. A new attempt requires a fresh purchase ID. Transient quote RPC errors and missing quotes retry for at most one minute from persisted purchase creation. Invalid quotes end immediately in -`QuoteFailed`, before any probe or payment. That state is terminal and excluded +`QuoteFailed`, before any probe or payment. An invoice dated ahead of the +local clock is not invalid: the server repeats its saved quote, so the same +deadline retries it until the clock has passed the invoice timestamp. That state is terminal and excluded from recovery; server invoices expire independently. A restart cannot reset the quote deadline. The same terminal state and CLI error represent a later funding refusal: the diff --git a/assets/reservation/fsm_test.go b/assets/reservation/fsm_test.go index 69244e8e8..87df5b335 100644 --- a/assets/reservation/fsm_test.go +++ b/assets/reservation/fsm_test.go @@ -724,12 +724,18 @@ func TestReadyVerifiesOncePerRestart(t *testing.T) { } func TestQuoteFailuresStopBeforePayment(t *testing.T) { - for _, failure := range []string{invalidCase, "pending", "unavailable"} { + for _, failure := range []string{ + invalidCase, "pending", "unavailable", "clock behind", + } { t.Run(failure, func(t *testing.T) { h := newClientHarness(t) switch failure { case invalidCase: h.quoteValidationErr = errors.New("invalid invoice") + case "clock behind": + // An invoice dated ahead of the local clock + // retries within the quote deadline. + h.quoteValidationErr = errInvoiceNotYetValid case "pending": h.quotePending = true case "unavailable": @@ -758,3 +764,21 @@ func TestQuoteFailuresStopBeforePayment(t *testing.T) { }) } } + +// TestQuoteDatedAheadOfClockIsRetried checks that a local clock slightly +// behind the server's does not end the purchase. The server repeats its saved +// quote, which validates once the clock has passed the invoice timestamp. +func TestQuoteDatedAheadOfClockIsRetried(t *testing.T) { + h := newClientHarness(t) + h.quoteValidationErr = errInvoiceNotYetValid + h.event(OnRecover, nil) + h.state(RequestQuote) + require.ErrorIs(t, h.machine.LastActionError, errInvoiceNotYetValid) + require.Nil(t, h.record().Quote) + + h.quoteValidationErr = nil + h.event(OnRecover, nil) + h.state(AwaitApproval) + require.NotNil(t, h.record().Quote) + require.Zero(t, h.cancels) +} diff --git a/assets/reservation/payments.go b/assets/reservation/payments.go new file mode 100644 index 000000000..5b9af56f6 --- /dev/null +++ b/assets/reservation/payments.go @@ -0,0 +1,567 @@ +package reservation + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "fmt" + "math" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/assets/payment" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/routing" + "github.com/lightningnetwork/lnd/zpay32" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +const ( + // probeRoutingFeeDivisor makes the variable routing-fee limit 2% of + // the estimated main amount (the exact probe invoice amount). + probeRoutingFeeDivisor = 50 + + // probeRoutingFeeBaseMsat adds a fixed 10 satoshis to the probe's + // routing-fee limit, expressed in millisatoshis. + probeRoutingFeeBaseMsat = 10_000 +) + +// PaymentConfig supplies the client's own LND. Prepays use BTC channels; the +// server's independent edge delivers the quoted asset fee. Authenticated raw +// clients preserve the saved request and full payment response used in recovery. +type PaymentConfig struct { + // Node supplies authenticated RPCs for checking the paying node's + // identity, chain sync, and available BTC channel balance. + Node lndclient.ServiceClient[lnrpc.LightningClient] + + // Router supplies authenticated probe, send, and payment-tracking RPCs. + // It must connect to the same LND instance as Node. + Router lndclient.ServiceClient[routerrpc.RouterClient] + + // Params identifies the Bitcoin network accepted when decoding invoices. + Params *chaincfg.Params + + // Now supplies the clock used to check invoice and quote expiry. + Now func() time.Time + + // PaymentTimeout limits how long LND may attempt to route the prepay. + // It must be a positive whole number of seconds, at most one minute. + // Quotes and invoices must remain valid for this duration plus 30 seconds. + // This is not a deadline for settling an already dispatched HTLC. + PaymentTimeout time.Duration + + // ProbeTimeout bounds the sole probe attempt before requesting receiver + // cancellation. It is also passed to LND. It must be a positive + // whole number of seconds, at most one minute. + ProbeTimeout time.Duration + + // CLTVLimit caps the prepay route's total timelock delta, in blocks, + // including the final hop and routing safety padding. + CLTVLimit uint32 + + // MinFinalCLTV is the minimum final-hop timelock delta, in blocks, + // accepted in either the prepay invoice or the main-payment probe invoice. + MinFinalCLTV uint32 +} + +// LndPayments validates, probes, and pays without owning purchase state. +// The FSM saves the returned request before dispatch and reconciles its hash. +type LndPayments struct { + cfg PaymentConfig +} + +// NewLndPayments checks the required clients, clock, network, and timing and +// CLTV limits, then creates the adapter. It makes no node +// calls and does not change the user's approved quote or routing fee limit. +func NewLndPayments(cfg PaymentConfig) (*LndPayments, error) { + if cfg.Node == nil || cfg.Router == nil || cfg.Params == nil || + cfg.Now == nil || cfg.PaymentTimeout <= 0 || + cfg.PaymentTimeout > time.Minute || + cfg.PaymentTimeout%time.Second != 0 || + cfg.ProbeTimeout <= 0 || cfg.ProbeTimeout > time.Minute || + cfg.ProbeTimeout%time.Second != 0 || + cfg.MinFinalCLTV == 0 || cfg.MinFinalCLTV > math.MaxUint16 || + cfg.CLTVLimit == 0 || cfg.CLTVLimit > math.MaxInt32 { + + return nil, errors.New("invalid reservation payment configuration") + } + return &LndPayments{ + cfg: cfg, + }, nil +} + +// ValidateQuote checks that the prepay invoice matches the asset fee under the +// server-supplied receiving RFQ. Both invoices must match the quoted BTC amounts, +// receiver, and edge, and remain valid long enough to attempt payment. The main +// invoice supplies an estimate for probing; it does not lock the later swap rate. +// +// These are local consistency and payment-safety checks. The server supplies +// both the RFQ and invoices, so passing does not independently prove that the RFQ +// was negotiated or will be honored. This method neither probes nor pays, and +// does not establish user approval; the FSM binds approval to the quote hash. +func (p *LndPayments) ValidateQuote(_ context.Context, + q *swapserverrpc.AssetReservationQuote) error { + + if q == nil || len(q.PrepayRfq) == 0 || len(q.PrepayRfq) > 1<<20 || + len(q.ReceivingNodeKey) != 33 || len(q.EdgeKey) != 33 { + + return errors.New("missing receiving quote") + } + terms, err := TermsFromRPC(q.Terms) + if err != nil { + return err + } + paymentID := terms.AssetID + if q.Group != nil { + if len(q.Group.PaymentAssetId) != 32 { + return errors.New("invalid payment issuance") + } + paymentID = [32]byte(q.Group.PaymentAssetId) + } + var rfq rfqrpc.PeerAcceptedBuyQuote + if err := proto.Unmarshal(q.PrepayRfq, &rfq); err != nil { + return err + } + prepay, err := p.decode(q.PrepayInvoice) + if err != nil { + return err + } + _, err = payment.ValidateInvoice(q.PrepayInvoice, &rfq, + payment.InvoiceTerms{ + AssetID: paymentID, + Amount: terms.Fee, + Hash: *prepay.PaymentHash, + Payee: [33]byte(q.ReceivingNodeKey), + MinFinalCLTV: uint64(p.cfg.MinFinalCLTV), + MinLifetime: p.cfg.PaymentTimeout + 30*time.Second, + }, p.cfg.Params, p.cfg.Now(), + ) + if err != nil { + return err + } + var probeRFQ rfqrpc.PeerAcceptedBuyQuote + if len(q.ReservationId) != 32 || len(q.ProbeRfq) == 0 || + len(q.ProbeRfq) > 1<<20 { + + return errors.New("missing asset probe quote") + } + if err := proto.Unmarshal(q.ProbeRfq, &probeRFQ); err != nil { + return err + } + _, err = payment.ValidateInvoice(q.ProbeInvoice, &probeRFQ, + payment.InvoiceTerms{ + AssetID: paymentID, + Amount: terms.Amount, + Hash: ProbeHash(ID(q.ReservationId)), + Payee: [33]byte(q.ReceivingNodeKey), + MinFinalCLTV: uint64(p.cfg.MinFinalCLTV), + MinLifetime: p.cfg.PaymentTimeout + 30*time.Second, + }, p.cfg.Params, p.cfg.Now(), + ) + if err != nil { + return err + } + if probeRFQ.Peer != hex.EncodeToString(q.EdgeKey) { + return errors.New("probe changed edge") + } + main, err := p.decode(q.ProbeInvoice) + if err != nil { + return err + } + if *main.PaymentHash == *prepay.PaymentHash || + rfq.Peer != hex.EncodeToString(q.EdgeKey) || + uint64(*prepay.MilliSat) != q.PrepayAmountMsat || + uint64(*main.MilliSat) != q.EstimatedMainAmountMsat { + + return errors.New("quote invoice amount or identity mismatch") + } + validUntil := p.cfg.Now().Add(p.cfg.PaymentTimeout + 30*time.Second) + if !time.Unix(q.ExpiresAt, 0).After(validUntil) { + return errors.New("quote lacks time for payment") + } + for _, invoice := range []*zpay32.Invoice{prepay, main} { + if !bytes.Equal(invoice.Destination.SerializeCompressed(), + q.ReceivingNodeKey) || + len(invoice.RouteHints) != 1 || + len(invoice.RouteHints[0]) != 1 || + len(invoice.BlindedPaymentPaths) != 0 || + !bytes.Equal(invoice.RouteHints[0][0].NodeID.SerializeCompressed(), + q.EdgeKey) || + q.ExpiresAt > invoice.Timestamp.Add(invoice.Expiry()).Unix() { + + return errors.New("quote changed invoice edge or expiry") + } + } + return nil +} + +// errInvoiceNotYetValid means the invoice is dated after the local clock. +var errInvoiceNotYetValid = errors.New("payment invoice is not valid yet") + +// decode parses a size-bounded BOLT11 invoice for the configured network. It +// requires a nonzero payment hash, recipient, positive amount, acceptable final +// CLTV delta, and enough remaining lifetime for payment plus a safety margin. +// Matching those values to the reservation and RFQ belongs to ValidateQuote. +func (p *LndPayments) decode(encoded string) (*zpay32.Invoice, error) { + if len(encoded) == 0 || len(encoded) > 16*1024 { + return nil, errors.New("invalid payment invoice size") + } + invoice, err := zpay32.Decode(encoded, p.cfg.Params) + if err != nil { + return nil, err + } + if invoice.PaymentHash == nil || *invoice.PaymentHash == ([32]byte{}) || + invoice.Destination == nil || invoice.MilliSat == nil || + *invoice.MilliSat <= 0 || uint64(*invoice.MilliSat) > math.MaxInt64 || + invoice.MinFinalCLTVExpiry() < uint64(p.cfg.MinFinalCLTV) || + invoice.MinFinalCLTVExpiry() > math.MaxUint16 || + !invoice.Timestamp.Add(invoice.Expiry()).After( + p.cfg.Now().Add(p.cfg.PaymentTimeout+30*time.Second), + ) { + + return nil, errors.New("unsafe payment invoice") + } + // A local clock slightly behind the issuing node's sees a fresh + // invoice as dated in the future. That passes with time, unlike the + // checks above, so report it separately. + if invoice.Timestamp.After(p.cfg.Now()) { + return nil, errInvoiceNotYetValid + } + + return invoice, nil +} + +// PrepareProbe constructs the sole, non-settleable payment for this quote. +// The caller saves the returned request and node before dispatch. +func (p *LndPayments) PrepareProbe(ctx context.Context, + r *Reservation) (*PaymentPlan, error) { + + if err := p.ValidateQuote(ctx, r.Quote); err != nil { + return nil, err + } + node, err := p.node(ctx, nil, true) + if err != nil { + return nil, err + } + // A probe precedes approval. Use a bounded routing budget independently + // of the later, user-approved prepay fee cap. The limit is 2% of the + // estimated main amount plus 10 satoshis; it is not an invoice charge. + // Integer division rounds the percentage down to whole millisatoshis. + fee := r.Quote.EstimatedMainAmountMsat/probeRoutingFeeDivisor + + probeRoutingFeeBaseMsat + return &PaymentPlan{ + Hash: ProbeHash(r.ID), NodeKey: node, + Request: &routerrpc.SendPaymentRequest{ + PaymentRequest: r.Quote.ProbeInvoice, + FeeLimitMsat: int64(fee), + TimeoutSeconds: int32(p.cfg.ProbeTimeout / time.Second), + CltvLimit: int32(p.cfg.CLTVLimit), + MaxParts: 1, + }, + }, nil +} + +// SendProbe streams updates from dispatch so in-flight routes can be saved +// before LND prunes failed attempts. The observer must persist updates inline. +func (p *LndPayments) SendProbe(ctx context.Context, r *Reservation, + observe func(*lnrpc.Payment) error) error { + + if err := p.ValidateQuote(ctx, r.Quote); err != nil { + return err + } + if _, err := p.node(ctx, r.ProbeNodeKey, true); err != nil { + return err + } + req := r.ProbeRequest + if req == nil { + return errors.New("missing probe request") + } + fee := r.Quote.EstimatedMainAmountMsat/probeRoutingFeeDivisor + + probeRoutingFeeBaseMsat + expected := &routerrpc.SendPaymentRequest{ + PaymentRequest: r.Quote.ProbeInvoice, + FeeLimitMsat: int64(fee), + TimeoutSeconds: req.TimeoutSeconds, + CltvLimit: int32(p.cfg.CLTVLimit), MaxParts: 1, + } + if !proto.Equal(req, expected) || req.TimeoutSeconds <= 0 || + time.Duration(req.TimeoutSeconds)*time.Second > p.cfg.ProbeTimeout { + + return errors.New("invalid probe request") + } + ctx, cancel := context.WithDeadline(ctx, r.ProbeDeadline) + defer cancel() + rpcCtx, _, router := p.cfg.Router.RawClientWithMacAuth(ctx) + // The node lookup may have consumed the remaining dispatch budget. + // Never send a fresh HTLC after the saved deadline or quote expiry. + now := p.cfg.Now() + if !now.Before(r.ProbeDeadline) || now.Unix() >= r.Quote.ExpiresAt { + return context.DeadlineExceeded + } + stream, err := router.SendPaymentV2( + rpcCtx, proto.Clone(req).(*routerrpc.SendPaymentRequest), + ) + if err != nil { + return err + } + return receiveProbeUpdates(stream, observe) +} + +// TrackProbe resumes live observation of the original payment after restart. +func (p *LndPayments) TrackProbe(ctx context.Context, r *Reservation, + observe func(*lnrpc.Payment) error) error { + + if _, err := p.node(ctx, r.ProbeNodeKey, false); err != nil { + return err + } + ctx, cancel := context.WithDeadline(ctx, r.ProbeDeadline) + defer cancel() + rpcCtx, _, router := p.cfg.Router.RawClientWithMacAuth(ctx) + hash := ProbeHash(r.ID) + stream, err := router.TrackPaymentV2( + rpcCtx, &routerrpc.TrackPaymentRequest{ + PaymentHash: hash[:], NoInflightUpdates: false, + }, + ) + if err != nil { + return err + } + return receiveProbeUpdates(stream, observe) +} + +// receiveProbeUpdates keeps the stream open through route attempts until the +// payment resolves. A failed observation stops further reads and persistence. +func receiveProbeUpdates(stream interface { + Recv() (*lnrpc.Payment, error) +}, observe func(*lnrpc.Payment) error) error { + + for { + update, err := stream.Recv() + if err != nil { + return err + } + if err := observe(update); err != nil { + return err + } + if update.Status == lnrpc.Payment_FAILED || + update.Status == lnrpc.Payment_SUCCEEDED { + + return nil + } + } +} + +// node reads and validates the connected LND's identity. If want is set, the +// identity must match the saved paying node so recovery cannot mistake another +// node's missing payment for permission to send again. When synced is true, LND +// must also report chain sync and a usable block height before preparing or +// sending payment; looking up an existing payment does not require chain sync. +func (p *LndPayments) node(ctx context.Context, want *btcec.PublicKey, + synced bool) (*btcec.PublicKey, error) { + + rpcCtx, _, node := p.cfg.Node.RawClientWithMacAuth(ctx) + info, err := node.GetInfo(rpcCtx, &lnrpc.GetInfoRequest{}) + if err != nil { + return nil, err + } + if info == nil || (synced && (!info.SyncedToChain || + info.BlockHeight == 0 || info.BlockHeight > math.MaxInt32)) { + + return nil, fmt.Errorf("paying node is not synchronized: %w", + ErrChainNotReady) + } + raw, err := hex.DecodeString(info.IdentityPubkey) + if err != nil || len(raw) != 33 || + hex.EncodeToString(raw) != info.IdentityPubkey { + + return nil, errors.New("invalid paying node key") + } + key, err := btcec.ParsePubKey(raw) + if err != nil { + return nil, err + } + if want != nil && !want.IsEqual(key) { + return nil, errors.New("paying node differs from saved identity") + } + return key, nil +} + +// Prepare checks approval, quote validity, node identity and sync, and available +// BTC before returning the payment hash, paying node, and exact LND request. +// The request carries the approved routing cap and permits one part to avoid +// asset conversion rounding across shards. It sends nothing: the FSM must save +// the returned plan before dispatch so recovery can track the same payment. +func (p *LndPayments) Prepare(ctx context.Context, + r *Reservation) (*PaymentPlan, error) { + + if err := r.checkPrepay(); err != nil { + return nil, err + } + if err := p.ValidateQuote(ctx, r.Quote); err != nil { + return nil, err + } + invoice, err := p.decode(r.Quote.PrepayInvoice) + if err != nil { + return nil, err + } + key, err := p.node(ctx, nil, true) + if err != nil { + return nil, err + } + req := &routerrpc.SendPaymentRequest{ + PaymentRequest: r.Quote.PrepayInvoice, + FeeLimitMsat: int64(r.MaxRouteFeeMsat), + TimeoutSeconds: int32(p.cfg.PaymentTimeout / time.Second), + CltvLimit: int32(p.cfg.CLTVLimit), + MaxParts: 1, + } + if err := p.checkRequest(ctx, req); err != nil { + return nil, err + } + return &PaymentPlan{ + Hash: *invoice.PaymentHash, + NodeKey: key, + Request: req, + }, nil +} + +// checkRequest accepts only the fields used by a single-part BTC prepay and +// checks its invoice lifetime, timeout, CLTV budget, and amount arithmetic. +// It also requires one active BTC channel whose balance, after its reserve, +// covers the invoice and full routing cap. This check does not reserve funds +// or establish an end-to-end route, and runs even when probing is skipped. +func (p *LndPayments) checkRequest(ctx context.Context, + req *routerrpc.SendPaymentRequest) error { + + if req == nil { + return errors.New("missing prepay payment request") + } + // These are the only fields this adapter sets. Refuse requests that + // could redirect the payment, change its amount, or debit assets. + expected := &routerrpc.SendPaymentRequest{ + PaymentRequest: req.PaymentRequest, + FeeLimitMsat: req.FeeLimitMsat, + TimeoutSeconds: req.TimeoutSeconds, + CltvLimit: req.CltvLimit, + MaxParts: 1, + } + if !proto.Equal(req, expected) || req.FeeLimitMsat < 0 || + req.TimeoutSeconds <= 0 || + req.TimeoutSeconds > int32(p.cfg.PaymentTimeout/time.Second) || + req.CltvLimit <= 0 || uint32(req.CltvLimit) > p.cfg.CLTVLimit { + + return errors.New("invalid saved prepay request") + } + invoice, err := p.decode(req.PaymentRequest) + if err != nil { + return err + } + needed := invoice.MinFinalCLTVExpiry() + uint64(routing.BlockPadding) + if len(invoice.RouteHints) != 1 || len(invoice.RouteHints[0]) != 1 { + return errors.New("prepay must use one edge") + } + needed += uint64(invoice.RouteHints[0][0].CLTVExpiryDelta) + if needed > uint64(req.CltvLimit) || + uint64(req.FeeLimitMsat) > math.MaxInt64-uint64(*invoice.MilliSat) { + + return errors.New("prepay exceeds route or amount limit") + } + required := uint64(*invoice.MilliSat) + uint64(req.FeeLimitMsat) + rpcCtx, _, node := p.cfg.Node.RawClientWithMacAuth(ctx) + channels, err := node.ListChannels(rpcCtx, + &lnrpc.ListChannelsRequest{ + ActiveOnly: true, + }, + ) + if err != nil { + return err + } + for _, ch := range channels.GetChannels() { + if ch == nil || !ch.Active || len(ch.CustomChannelData) != 0 || + ch.LocalConstraints == nil || ch.LocalBalance <= 0 || + uint64(ch.LocalBalance) <= ch.LocalConstraints.ChanReserveSat { + + continue + } + sats := uint64(ch.LocalBalance) - ch.LocalConstraints.ChanReserveSat + if sats <= math.MaxInt64/1000 && sats*1000 >= required { + return nil + } + } + return errors.New("insufficient single-channel BTC balance for prepay") +} + +// Lookup verifies the saved node identity and returns the first payment update +// for the saved hash, which may still be in flight. It works after invoice +// expiry and checks that the response carries the requested hash. Only LND's +// NotFound becomes ErrPaymentNotFound; other errors leave the outcome unknown +// and must not be treated by the FSM as permission to send again. +func (p *LndPayments) Lookup(ctx context.Context, node *btcec.PublicKey, + hash lntypes.Hash) (*lnrpc.Payment, error) { + + if node == nil || hash == (lntypes.Hash{}) { + return nil, errors.New("missing saved payment identity") + } + if _, err := p.node(ctx, node, false); err != nil { + return nil, err + } + rpcCtx, _, router := p.cfg.Router.RawClientWithMacAuth(ctx) + stream, err := router.TrackPaymentV2(rpcCtx, + &routerrpc.TrackPaymentRequest{ + PaymentHash: hash[:], + }, + ) + if err == nil { + var result *lnrpc.Payment + result, err = stream.Recv() + if err == nil { + if result == nil || result.PaymentHash != hash.String() { + return nil, errors.New("payment lookup hash mismatch") + } + return result, nil + } + } + if status.Code(err) == codes.NotFound { + return nil, ErrPaymentNotFound + } + return nil, err +} + +// Pay rechecks the saved node's identity and sync and the request's limits and +// available balance, then sends a copy of the persisted request. It reads only +// the first stream update, so a nil error does not establish settlement. The +// FSM must use Lookup to resolve the saved hash after either success or error; +// this method neither saves a result nor retries the payment. +func (p *LndPayments) Pay(ctx context.Context, node *btcec.PublicKey, + req *routerrpc.SendPaymentRequest) error { + + if node == nil { + return errors.New("missing saved paying node") + } + if _, err := p.node(ctx, node, true); err != nil { + return err + } + if err := p.checkRequest(ctx, req); err != nil { + return err + } + rpcCtx, _, router := p.cfg.Router.RawClientWithMacAuth(ctx) + stream, err := router.SendPaymentV2(rpcCtx, + proto.Clone(req).(*routerrpc.SendPaymentRequest), + ) + if err != nil { + return err + } + _, err = stream.Recv() + return err +} + +var _ Payments = (*LndPayments)(nil) diff --git a/assets/reservation/payments_test.go b/assets/reservation/payments_test.go new file mode 100644 index 000000000..2dd00c2f2 --- /dev/null +++ b/assets/reservation/payments_test.go @@ -0,0 +1,423 @@ +package reservation + +import ( + "context" + "encoding/hex" + "errors" + "math" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/taproot-assets/rfqmsg" + "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/zpay32" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +type paymentNode struct { + lnrpc.LightningClient + + t *testing.T + info *lnrpc.GetInfoResponse + balance int64 + onInfo func() +} + +func (n *paymentNode) RawClientWithMacAuth(ctx context.Context) ( + context.Context, time.Duration, lnrpc.LightningClient) { + + return metadata.AppendToOutgoingContext(ctx, "macaroon", "node"), + time.Minute, n +} + +func (n *paymentNode) GetInfo(ctx context.Context, _ *lnrpc.GetInfoRequest, + _ ...grpc.CallOption) (*lnrpc.GetInfoResponse, error) { + + assertPaymentAuth(n.t, ctx, "node") + if n.onInfo != nil { + n.onInfo() + } + return n.info, nil +} + +func (n *paymentNode) ListChannels(ctx context.Context, + _ *lnrpc.ListChannelsRequest, _ ...grpc.CallOption) ( + *lnrpc.ListChannelsResponse, error) { + + assertPaymentAuth(n.t, ctx, "node") + return &lnrpc.ListChannelsResponse{ + Channels: []*lnrpc.Channel{{ + Active: true, + LocalBalance: n.balance, + LocalConstraints: &lnrpc.ChannelConstraints{ + ChanReserveSat: 1, + }, + }}, + }, nil +} + +type paymentRouter struct { + routerrpc.RouterClient + + t *testing.T + + sends []*routerrpc.SendPaymentRequest + result *lnrpc.Payment + updates []*lnrpc.Payment + tracks []*routerrpc.TrackPaymentRequest + trackErr, sendErr error +} + +func (r *paymentRouter) RawClientWithMacAuth(ctx context.Context) ( + context.Context, time.Duration, routerrpc.RouterClient) { + + return metadata.AppendToOutgoingContext(ctx, "macaroon", "router"), + time.Minute, r +} + +type paymentStream struct { + grpc.ClientStream + + result *lnrpc.Payment + updates []*lnrpc.Payment + err error +} + +func (s *paymentStream) Recv() (*lnrpc.Payment, error) { + if len(s.updates) != 0 { + update := s.updates[0] + s.updates = s.updates[1:] + return update, nil + } + return s.result, s.err +} + +func (r *paymentRouter) TrackPaymentV2(ctx context.Context, + req *routerrpc.TrackPaymentRequest, + _ ...grpc.CallOption) (routerrpc.Router_TrackPaymentV2Client, error) { + + assertPaymentAuth(r.t, ctx, "router") + r.tracks = append(r.tracks, proto.Clone(req).(*routerrpc.TrackPaymentRequest)) + return &paymentStream{ + updates: r.updates, + result: r.result, + err: r.trackErr, + }, nil +} + +func (r *paymentRouter) SendPaymentV2(ctx context.Context, + req *routerrpc.SendPaymentRequest, + _ ...grpc.CallOption) (routerrpc.Router_SendPaymentV2Client, error) { + + assertPaymentAuth(r.t, ctx, "router") + r.sends = append(r.sends, proto.Clone(req).(*routerrpc.SendPaymentRequest)) + return &paymentStream{ + updates: r.updates, + result: r.result, + err: r.sendErr, + }, nil +} + +func newPaymentTest(t *testing.T) (*LndPayments, *Reservation, + *paymentNode, *paymentRouter) { + + t.Helper() + r := testReservation() + r.Quote = testPurchaseQuote(r) + key, _ := btcec.PrivKeyFromBytes([]byte{2}) + _, edge := btcec.PrivKeyFromBytes([]byte{3}) + now := time.Unix(1800000000, 0) + id := rfqmsg.ID{1} + q := &rfqrpc.PeerAcceptedBuyQuote{ + Id: id[:], + Scid: uint64(id.Scid()), + Peer: hex.EncodeToString(edge.SerializeCompressed()), + AssetSpec: &rfqrpc.AssetSpec{ + Id: r.AssetID[:], + }, + AssetMaxAmount: 12, + AskAssetRate: &rfqrpc.FixedPoint{ + Coefficient: "100000000", + }, + Expiry: uint64(now.Add(2 * time.Hour).Unix()), + } + var err error + r.Quote.PrepayRfq, err = proto.Marshal(q) + require.NoError(t, err) + encode := func(hash [32]byte, amount uint64) string { + t.Helper() + invoice, err := zpay32.NewInvoice( + &chaincfg.RegressionNetParams, hash, now, + zpay32.Amount(lnwire.MilliSatoshi(amount)), + zpay32.Description("asset prepay test"), zpay32.Expiry(time.Hour), + zpay32.CLTVExpiry(40), zpay32.PaymentAddr([32]byte{7}), + zpay32.Features(lnwire.NewFeatureVector(lnwire.NewRawFeatureVector( + lnwire.TLVOnionPayloadOptional, lnwire.PaymentAddrOptional, + ), lnwire.Features)), + zpay32.RouteHint([]zpay32.HopHint{{ + NodeID: edge, + ChannelID: q.Scid, + CLTVExpiryDelta: 40, + }}), + ) + require.NoError(t, err) + encoded, err := invoice.Encode(zpay32.MessageSigner{ + SignCompact: func(data []byte) ([]byte, error) { + return ecdsa.SignCompact(key, chainhash.HashB(data), true), nil + }, + }) + require.NoError(t, err) + return encoded + } + r.Quote.PrepayInvoice = encode([32]byte{8}, r.Quote.PrepayAmountMsat) + q.AssetMaxAmount = r.Amount + 1 + r.Quote.ProbeRfq, err = proto.Marshal(q) + require.NoError(t, err) + r.Quote.ProbeInvoice = encode(ProbeHash(r.ID), r.Quote.EstimatedMainAmountMsat) + r.Quote.ExpiresAt = now.Add(10 * time.Minute).Unix() + r.Terms, err = TermsFromRPC(r.Quote.Terms) + require.NoError(t, err) + r.Probes = ProbeResults{ + Main: ProbeSucceeded, + CheckedAt: now, + } + r.State = PayPrepay + r.MaxRouteFeeMsat = 100 + node := &paymentNode{ + t: t, + info: &lnrpc.GetInfoResponse{ + IdentityPubkey: hex.EncodeToString(r.ClientKey.PubKey.SerializeCompressed()), + SyncedToChain: true, + BlockHeight: 100, + }, + balance: 100000, + } + router := &paymentRouter{ + t: t, + trackErr: status.Error(codes.NotFound, "no payment"), + result: &lnrpc.Payment{ + PaymentHash: ProbeHash(r.ID).String(), + Status: lnrpc.Payment_FAILED, + FailureReason: lnrpc.PaymentFailureReason_FAILURE_REASON_INCORRECT_PAYMENT_DETAILS, + }, + } + p, err := NewLndPayments(PaymentConfig{ + Node: node, + Router: router, + Params: &chaincfg.RegressionNetParams, + Now: func() time.Time { return now }, + PaymentTimeout: time.Minute, + ProbeTimeout: 10 * time.Second, + CLTVLimit: 160, + MinFinalCLTV: 40, + }) + require.NoError(t, err) + return p, r, node, router +} + +func TestPaymentPreparationAndProbe(t *testing.T) { + p, r, _, router := newPaymentTest(t) + require.NoError(t, p.ValidateQuote(t.Context(), r.Quote)) + probe, err := p.PrepareProbe(t.Context(), r) + require.NoError(t, err) + r.ProbeNodeKey, r.ProbeRequest = probe.NodeKey, probe.Request + r.ProbeDeadline = p.cfg.Now().Add(p.cfg.ProbeTimeout) + require.Equal(t, ProbeHash(r.ID), probe.Hash) + require.NoError(t, p.SendProbe(t.Context(), r, func(*lnrpc.Payment) error { + return nil + })) + require.Len(t, router.sends, 1) + require.EqualValues(t, 1, router.sends[0].MaxParts) + router.sends = nil + plan, err := p.Prepare(t.Context(), r) + require.NoError(t, err) + require.EqualValues(t, 1, plan.Request.MaxParts) + require.EqualValues(t, r.MaxRouteFeeMsat, plan.Request.FeeLimitMsat) + require.Equal(t, r.Quote.PrepayInvoice, plan.Request.PaymentRequest) + router.sendErr = errors.New("lost reply") + require.Error(t, p.Pay(t.Context(), plan.NodeKey, plan.Request)) + router.result = &lnrpc.Payment{ + PaymentHash: plan.Hash.String(), + Status: lnrpc.Payment_IN_FLIGHT, + } + router.trackErr = nil + result, err := p.Lookup(t.Context(), plan.NodeKey, plan.Hash) + require.NoError(t, err) + require.Equal(t, lnrpc.Payment_IN_FLIGHT, result.Status) + require.Len(t, router.sends, 1) +} + +func TestProbeDispatchChecksDeadlineAfterNodeLookup(t *testing.T) { + for _, elapsed := range []time.Duration{ + 9 * time.Second, 10 * time.Second, 11 * time.Second, + } { + t.Run(elapsed.String(), func(t *testing.T) { + p, r, node, router := newPaymentTest(t) + now := p.cfg.Now() + p.cfg.Now = func() time.Time { return now } + plan, err := p.PrepareProbe(t.Context(), r) + require.NoError(t, err) + r.ProbeNodeKey, r.ProbeRequest = plan.NodeKey, plan.Request + r.ProbeDeadline = now.Add(p.cfg.ProbeTimeout) + node.onInfo = func() { now = now.Add(elapsed) } + + err = p.SendProbe(t.Context(), r, func(*lnrpc.Payment) error { + return nil + }) + if elapsed >= p.cfg.ProbeTimeout { + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Empty(t, router.sends) + } else { + require.NoError(t, err) + require.Len(t, router.sends, 1) + } + }) + } +} + +func TestProbeStreamsLiveRoutes(t *testing.T) { + for _, recoverPayment := range []bool{false, true} { + name := "send" + if recoverPayment { + name = "track" + } + t.Run(name, func(t *testing.T) { + p, r, _, router := newPaymentTest(t) + plan, err := p.PrepareProbe(t.Context(), r) + require.NoError(t, err) + r.ProbeNodeKey, r.ProbeRequest = plan.NodeKey, plan.Request + r.ProbeDeadline = p.cfg.Now().Add(p.cfg.ProbeTimeout) + router.trackErr = nil + live := &lnrpc.Payment{ + PaymentHash: ProbeHash(r.ID).String(), + Status: lnrpc.Payment_IN_FLIGHT, + Htlcs: []*lnrpc.HTLCAttempt{{ + Status: lnrpc.HTLCAttempt_IN_FLIGHT, + Route: &lnrpc.Route{TotalFeesMsat: 17}, + }}, + } + router.updates = []*lnrpc.Payment{live} + var updates []*lnrpc.Payment + observe := func(update *lnrpc.Payment) error { + updates = append(updates, update) + return nil + } + if recoverPayment { + err = p.TrackProbe(t.Context(), r, observe) + require.Len(t, router.tracks, 1) + require.False(t, router.tracks[0].NoInflightUpdates) + require.Empty(t, router.sends) + } else { + err = p.SendProbe(t.Context(), r, observe) + require.Len(t, router.sends, 1) + require.False(t, router.sends[0].NoInflightUpdates) + } + require.NoError(t, err) + require.Len(t, updates, 2) + require.Same(t, live, updates[0]) + require.Equal(t, lnrpc.Payment_FAILED, updates[1].Status) + require.Empty(t, updates[1].Htlcs) + + // A failed durable observation must stop the stream. + writes := 0 + writeErr := errors.New("failed to save route") + err = p.TrackProbe(t.Context(), r, + func(*lnrpc.Payment) error { + writes++ + return writeErr + }) + require.ErrorIs(t, err, writeErr) + require.Equal(t, 1, writes) + }) + } +} + +func TestPaymentLookupRequiresSavedNode(t *testing.T) { + p, r, node, router := newPaymentTest(t) + hash := lntypes.Hash{8} + _, err := p.Lookup(t.Context(), r.ClientKey.PubKey, hash) + require.ErrorIs(t, err, ErrPaymentNotFound) + router.trackErr = status.Error(codes.Unavailable, "offline") + _, err = p.Lookup(t.Context(), r.ClientKey.PubKey, hash) + require.Error(t, err) + require.NotErrorIs(t, err, ErrPaymentNotFound) + router.trackErr = status.Error(codes.NotFound, "other node") + node.info.IdentityPubkey = hex.EncodeToString(r.Quote.ReceivingNodeKey) + _, err = p.Lookup(t.Context(), r.ClientKey.PubKey, hash) + require.Error(t, err) + require.NotErrorIs(t, err, ErrPaymentNotFound) +} + +func TestPaymentApprovalCannotBeBypassed(t *testing.T) { + for _, test := range []string{"unapproved", "fee", "balance", "rfq", "hash", "expiry"} { + t.Run(test, func(t *testing.T) { + p, r, node, router := newPaymentTest(t) + r.Probes.Main = ProbeFailed + r.SkipProbe = true + switch test { + case "unapproved": + r.State = AwaitApproval + + case "fee": + r.MaxRouteFeeMsat = math.MaxUint64 + + case "balance": + node.balance = 1 + + case "rfq": + r.Quote.PrepayRfq = nil + + case "hash": + r.Quote.ProbeInvoice = r.Quote.PrepayInvoice + + case "expiry": + r.Quote.ExpiresAt = p.cfg.Now().Add(time.Minute).Unix() + } + _, err := p.Prepare(t.Context(), r) + require.Error(t, err) + require.Empty(t, router.sends) + }) + } +} + +func TestProbeRejectsUnboundInvoice(t *testing.T) { + p, r, _, router := newPaymentTest(t) + r.Quote.ReservationId[0] ^= 1 + _, err := p.PrepareProbe(t.Context(), r) + require.Error(t, err) + require.Empty(t, router.sends) +} + +func assertPaymentAuth(t *testing.T, ctx context.Context, service string) { + t.Helper() + md, ok := metadata.FromOutgoingContext(ctx) + require.True(t, ok) + require.Equal(t, []string{service}, md.Get("macaroon")) +} + +// TestInvoiceDatedAheadOfClock checks that an invoice dated after the local +// clock is reported as not valid yet, apart from definitive invoice failures, +// and that the same quote validates once the clock has caught up. +func TestInvoiceDatedAheadOfClock(t *testing.T) { + p, r, _, _ := newPaymentTest(t) + require.NoError(t, p.ValidateQuote(t.Context(), r.Quote)) + now := p.cfg.Now() + p.cfg.Now = func() time.Time { return now.Add(-time.Hour) } + err := p.ValidateQuote(t.Context(), r.Quote) + require.ErrorIs(t, err, errInvoiceNotYetValid) + p.cfg.Now = func() time.Time { return now } + require.NoError(t, p.ValidateQuote(t.Context(), r.Quote)) +} diff --git a/assets/reservation/probe_test.go b/assets/reservation/probe_test.go index 89f4c5ee0..d1efe9695 100644 --- a/assets/reservation/probe_test.go +++ b/assets/reservation/probe_test.go @@ -3,6 +3,7 @@ package reservation import ( "context" "errors" + "fmt" "testing" "time" @@ -322,6 +323,12 @@ func TestProbeRetriesTemporaryFailures(t *testing.T) { name string arm func(*flakyProbePayments, *flakyProbeServer) }{ + {"paying node not synchronized", func(p *flakyProbePayments, + _ *flakyProbeServer) { + + p.prepareErr = fmt.Errorf("not synchronized: %w", + ErrChainNotReady) + }}, {"dispatch never reached the node", func(p *flakyProbePayments, _ *flakyProbeServer) { From b5cb9c22e193adf38b96e3d4a627bcfc5807a5a4 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 16:54:42 +0200 Subject: [PATCH 23/25] assets/reservation: Wire purchases into loopd and local RPC Connect the purchase manager to loopd's database, configured tapd, server connection, and authenticated LND services. Start recovery during daemon initialization. Reservation calls wait for saved purchases to load; recovery does not block the daemon's other services. Use the proof adapter to record issuance metadata before verifying reservation proofs through tapd. Implement quote approval, outpoint selection, and paginated status. Pass the skip preference on quote and approval requests to the manager. Enable purchases when experimental features and tapd are active. Test separate approval, explicit consent to skip probing, and disabled- service responses. Pass the list state or active-only filter to the manager before pagination. Reject conflicting filters and unknown state names, and preserve stable ID pagination for filtered results. Expose fee availability only for successful probes with a saved live route observation. Parse exact-issuance and group selectors at the local RPC boundary. --- docs/asset-reservation-rpc.md | 4 +- loopd/daemon.go | 80 ++++++++- loopd/log.go | 3 + loopd/swapclient_server.go | 275 +++++++++++++++++++++++++++--- loopd/swapclient_server_test.go | 290 ++++++++++++++++++++++++++++++++ 5 files changed, 628 insertions(+), 24 deletions(-) diff --git a/docs/asset-reservation-rpc.md b/docs/asset-reservation-rpc.md index f0e192d6c..214a6b5d3 100644 --- a/docs/asset-reservation-rpc.md +++ b/docs/asset-reservation-rpc.md @@ -5,7 +5,9 @@ quote) from `Approve` (permit payment under saved limits). `Get`, `List`, and `Cancel` support inspection and recovery without buying again. Funded records accept canonical outpoints; pre-funding retries use a stable ID. Local calls require the existing swap read or execute macaroon -permissions. Registration is part of the client runtime wiring. +permissions. Standalone loopd registers the service; it becomes usable with +experimental features and tapd enabled. Embedders must also register this +separate service to expose it. The wire contract lives in `swapserverrpc/asset_reservation.proto`. It is separate from Bitcoin Instant Out reservations. These definitions do not diff --git a/loopd/daemon.go b/loopd/daemon.go index 36164ce5d..f32a30f81 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -17,6 +17,7 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/assets" + assetreservation "github.com/lightninglabs/loop/assets/reservation" "github.com/lightninglabs/loop/instantout" "github.com/lightninglabs/loop/instantout/reservation" "github.com/lightninglabs/loop/loopdb" @@ -27,10 +28,13 @@ import ( "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/lightninglabs/loop/staticaddr/openchannel" "github.com/lightninglabs/loop/staticaddr/withdraw" + "github.com/lightninglabs/loop/swap" loop_swaprpc "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/sweepbatcher" + tapaddress "github.com/lightninglabs/taproot-assets/address" "github.com/lightninglabs/taproot-assets/taprpc" "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/htlcswitch" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/macaroons" bbolterrors "go.etcd.io/bbolt/errors" @@ -268,6 +272,7 @@ func (d *Daemon) startWebServers() error { grpc.StreamInterceptor(streamInterceptor), ) loop_looprpc.RegisterSwapClientServer(d.grpcServer, d) + loop_looprpc.RegisterAssetReservationsServer(d.grpcServer, d) // Register our debug server if it is compiled in. d.registerDebugServer() @@ -755,9 +760,67 @@ func (d *Daemon) initialize(withMacaroonService bool) error { } var ( - reservationManager *reservation.Manager - instantOutManager *instantout.Manager + reservationManager *reservation.Manager + assetReservationManager *assetreservation.Manager + instantOutManager *instantout.Manager ) + if d.cfg.EnableExperimental && d.assetClient != nil { + params := tapaddress.ParamsForChain(d.lnd.ChainParams.Name) + wallet := &assetreservation.WalletVerifier{ + Keys: d.lnd.WalletKit, + Tap: assetreservation.NewTapProofVerifier( + d.assetClient, + ), + Params: ¶ms, + KeyFamily: swap.KeyFamily, + Chain: &assetreservation.ChainReader{ + Node: d.lnd.Client, + Chain: d.lnd.ChainKit, + ChainNotifier: d.lnd.ChainNotifier, + }, + } + if err := wallet.Validate(); err != nil { + return fmt.Errorf("asset reservation wallet: %w", err) + } + payments, err := assetreservation.NewLndPayments( + assetreservation.PaymentConfig{ + Node: d.lnd.Client, + Router: d.lnd.Router, + Params: d.lnd.ChainParams, + Now: time.Now, + PaymentTimeout: 30 * time.Second, + ProbeTimeout: 10 * time.Second, + CLTVLimit: htlcswitch.DefaultMaxOutgoingCltvExpiry, + MinFinalCLTV: 40, + }, + ) + if err != nil { + return fmt.Errorf("asset reservation payments: %w", err) + } + assetReservationManager, err = assetreservation.NewManager( + assetreservation.ManagerConfig{ + FSM: &assetreservation.Config{ + Store: assetreservation.NewSqlStore(baseDb), + Server: loop_swaprpc.NewAssetReservationServiceClient( + swapClient.Conn, + ), + Payments: payments, + Wallet: wallet, + Clock: clock.NewDefaultClock(), + NotifyAdmin: func(id assetreservation.ID, err error) { + errorf("Asset reservation %x needs attention: %v", + id[:], err) + }, + }, + PollInterval: time.Second, + CallTimeout: time.Minute, + MaxActiveReservations: 100, + }, + ) + if err != nil { + return fmt.Errorf("asset reservation initialization: %w", err) + } + } // Create the reservation and instantout managers. if d.cfg.EnableExperimental { @@ -826,6 +889,11 @@ func (d *Daemon) initialize(withMacaroonService bool) error { stopDaemon: d.Stop, } + // Leave the interface nil when asset reservations are disabled. + if assetReservationManager != nil { + d.swapClientServer.assetReservationManager = assetReservationManager + } + // Retrieve all currently existing swaps from the database. swapsList, err := d.impl.FetchSwaps(d.mainCtx) if err != nil { @@ -882,6 +950,14 @@ func (d *Daemon) initialize(withMacaroonService bool) error { }) initManagerTimeout := 10 * time.Second + if assetReservationManager != nil { + d.wg.Go(func() { + err := assetReservationManager.Run(d.mainCtx) + if err != nil && !errors.Is(err, context.Canceled) { + errorf("Asset reservations unavailable: %v", err) + } + }) + } // Start the reservation manager. if d.reservationManager != nil { diff --git a/loopd/log.go b/loopd/log.go index 7d92c120b..95cd79c00 100644 --- a/loopd/log.go +++ b/loopd/log.go @@ -7,6 +7,7 @@ import ( "github.com/lightninglabs/aperture/l402" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop" + assetreservation "github.com/lightninglabs/loop/assets/reservation" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/instantout" "github.com/lightninglabs/loop/instantout/reservation" @@ -74,6 +75,8 @@ func SetupLoggers(root *build.SubLoggerManager, intercept signal.Interceptor) { lnd.SetSubLogger(root, Subsystem, logger) lnd.AddSubLogger(root, "LOOP", intercept, loop.UseLogger) + lnd.AddSubLogger(root, assetreservation.Subsystem, intercept, + assetreservation.UseLogger) lnd.AddSubLogger(root, "SWEEP", intercept, sweepbatcher.UseLogger) lnd.AddSubLogger(root, "LNDC", intercept, lndclient.UseLogger) lnd.AddSubLogger(root, "STORE", intercept, loopdb.UseLogger) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 38afba267..8ef5b066c 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -23,6 +23,7 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/assets" + assetreservation "github.com/lightninglabs/loop/assets/reservation" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/instantout" "github.com/lightninglabs/loop/instantout/reservation" @@ -89,27 +90,29 @@ type swapClientServer struct { // Required by the grpc-gateway/v2 library for forward compatibility. looprpc.UnimplementedSwapClientServer looprpc.UnimplementedDebugServer - - config *Config - network lndclient.Network - impl *loop.Client - loopInQuoter loopInQuoter - liquidityMgr *liquidity.Manager - lnd *lndclient.LndServices - reservationManager *reservation.Manager - instantOutManager *instantout.Manager - staticAddressManager *address.Manager - depositManager staticAddressDepositManager - withdrawalManager *withdraw.Manager - staticLoopInManager *loopin.Manager - openChannelManager *openchannel.Manager - assetClient *assets.TapdClient - swaps map[lntypes.Hash]loop.SwapInfo - subscribers map[int]chan<- any - statusChan chan loop.SwapInfo - nextSubscriberID int - swapsLock sync.Mutex - mainCtx context.Context + looprpc.UnimplementedAssetReservationsServer + + config *Config + network lndclient.Network + impl *loop.Client + loopInQuoter loopInQuoter + liquidityMgr *liquidity.Manager + lnd *lndclient.LndServices + reservationManager *reservation.Manager + assetReservationManager assetReservationManager + instantOutManager *instantout.Manager + staticAddressManager *address.Manager + depositManager staticAddressDepositManager + withdrawalManager *withdraw.Manager + staticLoopInManager *loopin.Manager + openChannelManager *openchannel.Manager + assetClient *assets.TapdClient + swaps map[lntypes.Hash]loop.SwapInfo + subscribers map[int]chan<- any + statusChan chan loop.SwapInfo + nextSubscriberID int + swapsLock sync.Mutex + mainCtx context.Context // stopDaemon is invoked to trigger a graceful shutdown of the daemon. stopDaemon func() @@ -147,6 +150,32 @@ type staticAddressDepositManager interface { GetAllDeposits(context.Context) ([]*deposit.Deposit, error) } +// assetReservationManager is the reservation behavior used by the RPC server. +type assetReservationManager interface { + // NewPurchase saves an unpaid purchase or resumes the same ID when its + // asset and amount match. The skip flag applies only to new purchases; + // payment requires a separate approval. + NewPurchase(context.Context, assetreservation.ID, assetreservation.AssetSelector, uint64, + bool) (*assetreservation.Reservation, error) + + // Get returns a saved snapshot, including completed purchases, without + // triggering payment or changing the worker's progress. + Get(context.Context, assetreservation.ID) ( + *assetreservation.Reservation, error) + + // List returns saved snapshots matching the state filter. The RPC + // handler sorts and paginates them before returning a response. + List(context.Context, assetreservation.StateFilter) ( + []*assetreservation.Reservation, error) + + // Approve authorizes the exact quoted terms and payment limits. The + // worker saves consent before paying and continues recovery after the + // call returns; the response does not imply delivery is complete. + Approve(context.Context, assetreservation.ID, + *looprpc.ApproveAssetReservationRequest) ( + *assetreservation.Reservation, error) +} + // LoopOut initiates a loop out swap with the given parameters. The call returns // after the swap has been set up with the swap server. From that point onwards, // progress can be tracked via the LoopOutStatus stream that is returned from @@ -3303,3 +3332,207 @@ func marshalFixedPoint(bigIntFixedPoint *rfqmath.BigIntFixedPoint, Scale: uint32(bigIntFixedPoint.Scale), } } + +func assetReservationResult(r *assetreservation.Reservation, err error) ( + *looprpc.ClientAssetReservation, error) { + + if err != nil { + return nil, status.Error(codes.Unavailable, "reservation unavailable") + } + if r == nil { + return nil, status.Error(codes.Unavailable, "reservation unavailable") + } + result := &looprpc.ClientAssetReservation{ + ReservationId: bytes.Clone(r.ID[:]), + State: string(r.State), + AssetId: bytes.Clone(r.AssetID[:]), + Amount: r.Amount, + AssetFee: r.Fee, + CsvDelay: r.CSVDelay, + RequiredConfirmations: r.RequiredConfirmations, + ExecutionDelta: r.ExecutionDelta, + ConfirmationHeight: r.ConfirmationHeight, + PrepayCredit: r.PrepayCredit, + ProofVerified: len(r.ReservationProof) != 0 && + (r.State == assetreservation.Ready || r.State == assetreservation.Expired), + SkipProbe: r.SkipProbe, + MainProbe: looprpc.ReservationProbeResult(r.Probes.Main), + EstimatedPrepayRouteFeeMsat: r.Probes.PrepayFeeMsat, + MainProbeFeeMsat: r.Probes.MainFeeMsat, + ProbeFeeKnown: r.Probes.FeeKnown && + r.Probes.Main == assetreservation.ProbeSucceeded, + } + if r.GroupKey != ([33]byte{}) { + result.GroupKey = bytes.Clone(r.GroupKey[:]) + if r.Quote == nil { + result.AssetId = nil + } + result.PaymentAssetId = bytes.Clone(r.Quote.GetGroup().GetPaymentAssetId()) + } + if !r.Probes.CheckedAt.IsZero() { + result.ProbesCheckedAt = r.Probes.CheckedAt.Unix() + } + if r.FundingOutpoint != nil { + result.Outpoint = r.FundingOutpoint.String() + } + if result.ProofVerified { + lifetime, err := r.Terms.Lifetime(r.ConfirmationHeight) + if err != nil { + return nil, status.Error(codes.Internal, "invalid reservation lifetime") + } + result.TimeoutHeight = lifetime.TimeoutHeight + result.ExecutionCutoff = lifetime.ExecutionCutoff + } + if r.Quote != nil { + hash, err := assetreservation.QuoteHash(r.Quote) + if err != nil { + return nil, status.Error(codes.Internal, "invalid saved quote") + } + result.QuoteHash = hash[:] + result.EdgeKey = bytes.Clone(r.Quote.EdgeKey) + result.PrepayAmountMsat = r.Quote.PrepayAmountMsat + result.EstimatedMainAmountMsat = r.Quote.EstimatedMainAmountMsat + result.QuoteExpiresAt = r.Quote.ExpiresAt + } + return result, nil +} + +// Buy starts an unpaid purchase under a caller-supplied idempotency key. +func (s *swapClientServer) Buy(ctx context.Context, + req *looprpc.BuyAssetReservationRequest) (*looprpc.ClientAssetReservation, + error) { + + if s.assetReservationManager == nil { + return assetReservationResult(nil, errors.New("purchases are disabled")) + } + selector, err := assetreservation.ParseAssetSelector(req.GetAssetId(), req.GetGroupKey()) + if err != nil || len(req.GetReservationId()) != 32 { + return nil, status.Error(codes.InvalidArgument, "invalid purchase request") + } + return assetReservationResult(s.assetReservationManager.NewPurchase( + ctx, assetreservation.ID(req.ReservationId), selector, + req.Amount, req.SkipProbe, + )) +} + +func (s *swapClientServer) resolveAssetReservation(ctx context.Context, + req *looprpc.ClientAssetReservationSelector) ( + *assetreservation.Reservation, error) { + + if s.assetReservationManager == nil || req == nil { + return nil, errors.New("missing reservation service or selector") + } + switch v := req.Selector.(type) { + case *looprpc.ClientAssetReservationSelector_ReservationId: + if len(v.ReservationId) != 32 || + assetreservation.ID(v.ReservationId) == (assetreservation.ID{}) { + + return nil, errors.New("invalid reservation ID") + } + return s.assetReservationManager.Get( + ctx, assetreservation.ID(v.ReservationId), + ) + + case *looprpc.ClientAssetReservationSelector_Outpoint: + point, err := assetreservation.ParseOutpoint(v.Outpoint) + if err != nil { + return nil, err + } + records, err := s.assetReservationManager.List( + ctx, assetreservation.StateFilter{}, + ) + if err != nil { + return nil, err + } + for _, r := range records { + if r.FundingOutpoint != nil && *r.FundingOutpoint == *point { + return r, nil + } + } + } + return nil, assetreservation.ErrNotFound +} + +// Get returns saved progress; it cannot trigger a new payment. +func (s *swapClientServer) Get(ctx context.Context, + req *looprpc.ClientAssetReservationSelector) (*looprpc.ClientAssetReservation, + error) { + + return assetReservationResult(s.resolveAssetReservation(ctx, req)) +} + +// Approve binds payment to the displayed quote and explicit limits. +func (s *swapClientServer) Approve(ctx context.Context, + req *looprpc.ApproveAssetReservationRequest) (*looprpc.ClientAssetReservation, + error) { + + if req == nil || len(req.QuoteHash) != 32 { + return nil, status.Error(codes.InvalidArgument, "invalid approval") + } + r, err := s.resolveAssetReservation(ctx, req.Reservation) + if err != nil { + return assetReservationResult(nil, err) + } + r, err = s.assetReservationManager.Approve(ctx, r.ID, req) + // Once consent is durable, a retryable payment or delivery error does + // not undo approval. Report the saved progress; the worker logs the + // action error and continues recovery. + if r != nil { + switch r.State { + case assetreservation.PayPrepay, assetreservation.WaitForDelivery, + assetreservation.VerifyReservation, assetreservation.Ready: + + return assetReservationResult(r, nil) + } + } + return assetReservationResult(r, err) +} + +// List returns one bounded page of matching states in stable ID order. +func (s *swapClientServer) List(ctx context.Context, + req *looprpc.ListClientAssetReservationsRequest) ( + *looprpc.ListClientAssetReservationsResponse, error) { + + if s.assetReservationManager == nil { + return nil, status.Error(codes.Unavailable, "reservation unavailable") + } + if req == nil || req.Limit > 1000 || + (len(req.AfterId) != 0 && len(req.AfterId) != 32) { + + return nil, status.Error(codes.InvalidArgument, "invalid reservation page") + } + filter := assetreservation.StateFilter{ + State: fsm.StateType(req.State), ActiveOnly: req.ActiveOnly, + } + if err := filter.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + records, err := s.assetReservationManager.List(ctx, filter) + if err != nil { + return nil, status.Error(codes.Unavailable, "reservation unavailable") + } + slices.SortFunc(records, func(a, b *assetreservation.Reservation) int { + return bytes.Compare(a.ID[:], b.ID[:]) + }) + limit := req.Limit + if limit == 0 { + limit = 100 + } + result := &looprpc.ListClientAssetReservationsResponse{} + for _, r := range records { + if bytes.Compare(r.ID[:], req.AfterId) <= 0 { + continue + } + if len(result.Reservations) == int(limit) { + last := result.Reservations[len(result.Reservations)-1] + result.NextAfterId = bytes.Clone(last.ReservationId) + break + } + value, err := assetReservationResult(r, nil) + if err != nil { + return nil, err + } + result.Reservations = append(result.Reservations, value) + } + return result, nil +} diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index 9452b593c..c368d2c8c 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -16,6 +16,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop" + assetreservation "github.com/lightninglabs/loop/assets/reservation" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/instantout" "github.com/lightninglabs/loop/labels" @@ -27,6 +28,7 @@ import ( "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swap" + "github.com/lightninglabs/loop/swapserverrpc" mock_lnd "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" @@ -2796,3 +2798,291 @@ func TestListUnspentDeposits(t *testing.T) { ) }) } + +type assetReservationRPCManager struct { + record *assetreservation.Reservation + records []*assetreservation.Reservation + state fsm.StateType + activeOnly bool + approval *looprpc.ApproveAssetReservationRequest + approvalErr error + approvalState fsm.StateType + buys int + skipProbe bool +} + +func (m *assetReservationRPCManager) NewPurchase(_ context.Context, + _ assetreservation.ID, _ assetreservation.AssetSelector, _ uint64, skipProbe bool) ( + *assetreservation.Reservation, error) { + + m.buys++ + m.skipProbe = skipProbe + return m.record, nil +} + +func (m *assetReservationRPCManager) Get(context.Context, assetreservation.ID) ( + *assetreservation.Reservation, error) { + + return m.record, nil +} + +func (m *assetReservationRPCManager) List(_ context.Context, + filter assetreservation.StateFilter) ( + []*assetreservation.Reservation, error) { + + m.state = filter.State + m.activeOnly = filter.ActiveOnly + records := m.records + if records == nil { + records = []*assetreservation.Reservation{m.record} + } + var matches []*assetreservation.Reservation + for _, r := range records { + if filter.ActiveOnly && assetreservation.IsFinal(r.State) { + continue + } + if filter.State == "" || filter.State == r.State { + matches = append(matches, r) + } + } + return matches, nil +} + +func TestAssetReservationRPCStateFilterPagination(t *testing.T) { + m := &assetReservationRPCManager{} + for i, state := range []fsm.StateType{ + assetreservation.Ready, assetreservation.Expired, + assetreservation.AwaitApproval, assetreservation.Canceled, + assetreservation.Ready, + } { + r := testRPCAssetReservation() + r.ID, r.State = assetreservation.ID{byte(i + 1)}, state + m.records = append(m.records, r) + } + s := &swapClientServer{assetReservationManager: m} + req := &looprpc.ListClientAssetReservationsRequest{ + Limit: 1, State: "Ready", + } + for i, id := range []byte{1, 5} { + page, err := s.List(t.Context(), req) + require.NoError(t, err) + require.Equal(t, assetreservation.Ready, m.state) + require.Len(t, page.Reservations, 1) + require.Equal(t, id, page.Reservations[0].ReservationId[0]) + if i == 0 { + require.NotEmpty(t, page.NextAfterId) + } else { + require.Empty(t, page.NextAfterId) + } + req.AfterId = page.NextAfterId + } + page, err := s.List(t.Context(), &looprpc.ListClientAssetReservationsRequest{}) + require.NoError(t, err) + require.Len(t, page.Reservations, 5) + page, err = s.List(t.Context(), &looprpc.ListClientAssetReservationsRequest{ + State: "NeedAdminAttention", + }) + require.NoError(t, err) + require.Empty(t, page.Reservations) + _, err = s.List(t.Context(), &looprpc.ListClientAssetReservationsRequest{ + State: "ready", + }) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + _, err = s.List(t.Context(), &looprpc.ListClientAssetReservationsRequest{ + State: "Ready", ActiveOnly: true, + }) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + + // Active-only filtering must survive pagination and retain reservations + // awaiting administrative attention, as startup recovery does. + admin := testRPCAssetReservation() + admin.ID, admin.State = assetreservation.ID{6}, assetreservation.NeedAdminAttention + m.records = append(m.records, admin) + req = &looprpc.ListClientAssetReservationsRequest{ + Limit: 2, ActiveOnly: true, + } + var ids []byte + for { + page, err := s.List(t.Context(), req) + require.NoError(t, err) + require.True(t, m.activeOnly) + require.Empty(t, m.state) + for _, r := range page.Reservations { + ids = append(ids, r.ReservationId[0]) + } + if len(page.NextAfterId) == 0 { + break + } + req.AfterId = page.NextAfterId + } + require.Equal(t, []byte{1, 3, 5, 6}, ids) +} + +func (m *assetReservationRPCManager) Approve(_ context.Context, + _ assetreservation.ID, a *looprpc.ApproveAssetReservationRequest) ( + *assetreservation.Reservation, error) { + + if err := m.record.CheckApproval(a); err != nil { + return nil, err + } + m.approval = a + if m.approvalState != "" { + m.record.State = m.approvalState + } + return m.record, m.approvalErr +} + +func TestAssetReservationRPCRequiresSeparateApproval(t *testing.T) { + r := testRPCAssetReservation() + r.FundingOutpoint = &wire.OutPoint{ + Index: 1, + } + r.Probes = assetreservation.ProbeResults{ + Main: assetreservation.ProbeSucceeded, + CheckedAt: time.Now(), + MainFeeMsat: 10_000, + FeeKnown: true, + PrepayFeeMsat: 11, + } + m := &assetReservationRPCManager{ + record: r, + } + s := &swapClientServer{ + assetReservationManager: m, + } + got, err := s.Buy(t.Context(), &looprpc.BuyAssetReservationRequest{ + ReservationId: r.ID[:], + AssetId: r.AssetID[:], + Amount: r.Amount, + }) + require.NoError(t, err) + require.Equal(t, r.Fee, got.AssetFee) + require.EqualValues(t, 10_000, got.MainProbeFeeMsat) + require.True(t, got.ProbeFeeKnown) + require.EqualValues(t, 11, got.EstimatedPrepayRouteFeeMsat) + require.Nil(t, m.approval) + selector := &looprpc.ClientAssetReservationSelector{ + Selector: &looprpc.ClientAssetReservationSelector_Outpoint{ + Outpoint: r.FundingOutpoint.String(), + }, + } + _, err = s.Get(t.Context(), selector) + require.NoError(t, err) + require.Equal(t, 1, m.buys) + require.Nil(t, m.approval) + + a := &looprpc.ApproveAssetReservationRequest{ + Reservation: selector, + QuoteHash: got.QuoteHash, + MaxRouteFeeMsat: 1000, + } + a.QuoteHash[0] ^= 1 + _, err = s.Approve(t.Context(), a) + require.Error(t, err) + require.Nil(t, m.approval) + a.QuoteHash[0] ^= 1 + _, err = s.Approve(t.Context(), a) + require.NoError(t, err) + require.NotNil(t, m.approval) +} + +func TestAssetReservationRPCDisabled(t *testing.T) { + s := &swapClientServer{} + _, err := s.Buy(t.Context(), &looprpc.BuyAssetReservationRequest{}) + require.Error(t, err) + _, err = s.List(t.Context(), &looprpc.ListClientAssetReservationsRequest{}) + require.Error(t, err) +} + +func TestAssetReservationRPCSkipProbe(t *testing.T) { + r := testRPCAssetReservation() + r.SkipProbe = true + m := &assetReservationRPCManager{ + record: r, + } + s := &swapClientServer{ + assetReservationManager: m, + } + got, err := s.Buy(t.Context(), &looprpc.BuyAssetReservationRequest{ + ReservationId: r.ID[:], + AssetId: r.AssetID[:], + Amount: r.Amount, + SkipProbe: true, + }) + require.NoError(t, err) + require.True(t, m.skipProbe) + require.True(t, got.SkipProbe) + require.Zero(t, got.MainProbe) + require.Zero(t, got.ProbesCheckedAt) + require.Nil(t, m.approval) + + a := &looprpc.ApproveAssetReservationRequest{ + Reservation: &looprpc.ClientAssetReservationSelector{ + Selector: &looprpc.ClientAssetReservationSelector_ReservationId{ + ReservationId: r.ID[:], + }, + }, + QuoteHash: got.QuoteHash, + MaxRouteFeeMsat: 1000, + } + _, err = s.Approve(t.Context(), a) + require.Error(t, err) + require.Nil(t, m.approval) + a.SkipProbe = true + _, err = s.Approve(t.Context(), a) + require.NoError(t, err) + require.True(t, m.approval.SkipProbe) +} + +// testRPCAssetReservation supplies the saved terms exposed by the RPC handlers. +func testRPCAssetReservation() *assetreservation.Reservation { + return &assetreservation.Reservation{ + ID: assetreservation.ID{1}, + State: assetreservation.AwaitApproval, + AssetID: [32]byte{2}, + Amount: 10000, + Fee: 11, + CSVDelay: 1440, + RequiredConfirmations: 3, + ExecutionDelta: 90, + Quote: &swapserverrpc.AssetReservationQuote{ + ReservationId: []byte{1}, + PrepayAmountMsat: 11000, + EstimatedMainAmountMsat: 10000000, + ExpiresAt: 1900000000, + }, + } +} + +func TestAssetReservationRPCReportsCommittedApproval(t *testing.T) { + for _, state := range []fsm.StateType{ + assetreservation.PayPrepay, assetreservation.WaitForDelivery, + assetreservation.VerifyReservation, assetreservation.Ready, + assetreservation.AwaitApproval, + } { + t.Run(string(state), func(t *testing.T) { + r := testRPCAssetReservation() + m := &assetReservationRPCManager{ + record: r, approvalState: state, + approvalErr: errors.New("lost payment reply"), + } + s := &swapClientServer{assetReservationManager: m} + quoteHash, err := assetreservation.QuoteHash(r.Quote) + require.NoError(t, err) + got, err := s.Approve(t.Context(), &looprpc.ApproveAssetReservationRequest{ + Reservation: &looprpc.ClientAssetReservationSelector{ + Selector: &looprpc.ClientAssetReservationSelector_ReservationId{ + ReservationId: r.ID[:], + }, + }, + QuoteHash: quoteHash[:], SkipProbe: true, + }) + if state == assetreservation.AwaitApproval { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, string(state), got.State) + } + }) + } +} From 52470f6c239fc9b31255b2c38f695987c19f03fe Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 16:55:18 +0200 Subject: [PATCH 24/25] cmd/loop: Add asset reservation purchase and inspection commands Add buy, list, and get under loop asset reservation. Probing and cancellation stay internal; unapproved purchases expire automatically. Print the purchase ID before dispatch so a lost reply can be resumed. Accept canonical outpoints once funding is known. Display the full-amount probe result, quoted asset service fee, and routing cap before approval. Estimate prepay routing fees from the main probe and keep the conventional routing limit unless overridden. Failed probes cancel the unpaid purchase; another attempt requires a fresh purchase ID. The --skip_probe flag allows a new purchase without a successful probe. It preserves quote approval and payment limits; --yes only skips the prompt. After approval, wait for verified delivery while the daemon continues recovery. Support mutually exclusive --state and --active_only list filters. Preserve filters across pages; active-only excludes Canceled and Expired while retaining Ready and NeedAdminAttention. Show unavailable fee estimates separately from observed zero fees. A successful probe remains eligible for approval without a captured route. Accept an asset ID or group key and display the exact BTC prepay and implied prepay and probe prices before approval. --- cmd/loop/asset_reservations.go | 500 ++++++++++++++++++++++++++++ cmd/loop/asset_reservations_test.go | 444 ++++++++++++++++++++++++ cmd/loop/main.go | 1 + docs/asset-reservation-rpc.md | 24 ++ docs/loop.1 | 60 ++++ docs/loop.md | 90 +++++ 6 files changed, 1119 insertions(+) create mode 100644 cmd/loop/asset_reservations.go create mode 100644 cmd/loop/asset_reservations_test.go diff --git a/cmd/loop/asset_reservations.go b/cmd/loop/asset_reservations.go new file mode 100644 index 000000000..57610cc75 --- /dev/null +++ b/cmd/loop/asset_reservations.go @@ -0,0 +1,500 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "math" + "math/big" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop/assets/reservation" + "github.com/lightninglabs/loop/looprpc" + "github.com/urfave/cli/v3" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + assetIDFlag = "asset_id" + groupKeyFlag = "group_key" + assetAmountFlag = "amt" +) + +var assetCommand = &cli.Command{ + Name: "asset", + Usage: "manage on-chain asset reservations", + Commands: []*cli.Command{{ + Name: "reservation", + Usage: "buy and inspect asset reservations", + Commands: []*cli.Command{ + { + Name: "buy", + Usage: "quote, approve, and buy a reservation", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: assetIDFlag, + Usage: "require this exact issuance on chain and on Lightning", + }, + &cli.StringFlag{ + Name: groupKeyFlag, + Usage: "accept a fungible member of this compressed group key", + }, + &cli.Uint64Flag{ + Name: assetAmountFlag, + Required: true, + Usage: "asset amount in indivisible units", + }, + &cli.StringFlag{ + Name: "reservation_id", + Usage: "resume this purchase ID", + }, + &cli.BoolFlag{ + Name: "yes", + Usage: "approve the displayed terms without a prompt", + }, + &cli.BoolFlag{ + Name: "skip_probe", + Usage: "skip probing and buy without a successful main-payment probe", + }, + &cli.Uint64Flag{ + Name: "max_routing_fee", + Usage: "maximum prepay routing fee in satoshis", + }, + }, + Action: buyAssetReservation, + }, + { + Name: "list", + Usage: "list saved purchases", + MutuallyExclusiveFlags: []cli.MutuallyExclusiveFlags{{ + Flags: [][]cli.Flag{ + {&cli.StringFlag{ + Name: "state", + Usage: "filter by one client state (for example Ready)", + OnlyOnce: true, + }}, + {&cli.BoolFlag{ + Name: "active_only", + Usage: "exclude Canceled and Expired; " + + "include Ready and NeedAdminAttention", + }}, + }, + }}, + Action: listAssetReservations, + }, + { + Name: "get", + Usage: "show a reservation by outpoint", + ArgsUsage: "txid:vout", + Flags: pendingReservationFlag(), + Action: inspectAssetReservation, + }, + }, + }}, +} + +func pendingReservationFlag() []cli.Flag { + return []cli.Flag{&cli.StringFlag{ + Name: "reservation_id", + Usage: "identify a purchase before funding", + }} +} + +func reservationID(value string) ([]byte, error) { + raw, err := hex.DecodeString(value) + if err != nil || len(raw) != 32 || hex.EncodeToString(raw) != value || + [32]byte(raw) == ([32]byte{}) { + + return nil, errors.New("expected a nonzero lowercase 32-byte hex ID") + } + return raw, nil +} + +func localReservationSelector(cmd *cli.Command) ( + *looprpc.ClientAssetReservationSelector, error) { + + if cmd.IsSet("reservation_id") && cmd.NArg() == 0 { + id, err := reservationID(cmd.String("reservation_id")) + if err != nil { + return nil, err + } + return reservationSelectorID(id), nil + } + if cmd.IsSet("reservation_id") || cmd.NArg() != 1 { + return nil, errors.New("supply one outpoint or a pre-funding ID") + } + point, err := reservation.ParseOutpoint(cmd.Args().First()) + if err != nil { + return nil, err + } + return &looprpc.ClientAssetReservationSelector{ + Selector: &looprpc.ClientAssetReservationSelector_Outpoint{ + Outpoint: point.String(), + }, + }, nil +} + +func reservationSelectorID(id []byte) *looprpc.ClientAssetReservationSelector { + return &looprpc.ClientAssetReservationSelector{ + Selector: &looprpc.ClientAssetReservationSelector_ReservationId{ + ReservationId: id, + }, + } +} + +func buyAssetReservation(ctx context.Context, cmd *cli.Command) error { + idText, groupText := cmd.String(assetIDFlag), cmd.String(groupKeyFlag) + assetID, err := hex.DecodeString(idText) + if err != nil || hex.EncodeToString(assetID) != idText { + return errors.New("invalid asset ID hex") + } + group, err := hex.DecodeString(groupText) + if err != nil || hex.EncodeToString(group) != groupText { + return errors.New("invalid group key hex") + } + _, err = reservation.ParseAssetSelector(assetID, group) + if err != nil { + return err + } + if cmd.Uint64(assetAmountFlag) == 0 || + cmd.Uint64(assetAmountFlag) > math.MaxInt64 { + + return errors.New("asset amount is out of range") + } + id := make([]byte, 32) + if cmd.IsSet("reservation_id") { + id, err = reservationID(cmd.String("reservation_id")) + } else { + _, err = rand.Read(id) + } + if err != nil { + return err + } + _, conn, closeConn, err := getClientWithConn(cmd) + if err != nil { + return err + } + defer closeConn() + client := looprpc.NewAssetReservationsClient(conn) + // Print before dispatch so a lost reply does not lose the retry key. + fmt.Printf("Purchase ID (until funded): %x\n", id) + r, err := client.Buy(ctx, &looprpc.BuyAssetReservationRequest{ + ReservationId: id, + AssetId: assetID, + GroupKey: group, + Amount: cmd.Uint64(assetAmountFlag), + SkipProbe: cmd.Bool("skip_probe"), + }) + if err != nil { + return err + } + selector := reservationSelectorID(id) + r, err = waitAssetReservation( + ctx, client, selector, r, true, + ) + if err != nil { + return err + } + if r.State == string(reservation.AwaitApproval) { + printRespJSON(r) + if len(r.GroupKey) != 0 { + fmt.Printf("Asset group: %x\nOn-chain issuance: %x\n"+ + "Lightning issuance: %x\n", r.GroupKey, r.AssetId, + r.PaymentAssetId) + } + fmt.Print(reservationFeeInfo(r)) + fmt.Print(reservationProbeInfo(r)) + approval, err := reservationApproval(cmd, r) + if err != nil { + return err + } + if approval.SkipProbe { + fmt.Println("Buying without requiring a successful main-payment probe.") + } + fmt.Printf("Maximum prepay routing fee: %d msat\n", + approval.MaxRouteFeeMsat) + fmt.Println("The main BTC price is an estimate, not a rate lock.") + fmt.Println("Prepay credits the later swap fee; unused reservations " + + "do not refund it.") + if !cmd.Bool("yes") { + fmt.Print("BUY RESERVATION? (y/n): ") + var answer string + if _, err := fmt.Scanln(&answer); err != nil || answer != "y" { + fmt.Println("Not approved. The unpaid purchase remains saved.") + return nil + } + } + r, err = approveAssetReservation(ctx, client, r, approval) + if err != nil { + return err + } + } + fmt.Println("Waiting for confirmed, verified delivery. It is safe to " + + "close this command; the daemon will continue.") + r, err = waitAssetReservation(ctx, client, selector, r, false) + if err != nil { + return err + } + printRespJSON(r) + return nil +} + +// approveAssetReservation reconciles a lost approval reply using the same +// purchase ID. It never submits a fresh purchase or repeats approval. +func approveAssetReservation(ctx context.Context, + client looprpc.AssetReservationsClient, quoted *looprpc.ClientAssetReservation, + approval *looprpc.ApproveAssetReservationRequest) ( + *looprpc.ClientAssetReservation, error) { + + r, err := client.Approve(ctx, approval) + if err == nil { + return r, nil + } + saved, readErr := client.Get(ctx, reservationSelectorID(quoted.ReservationId)) + if readErr == nil && saved != nil { + switch saved.State { + case string(reservation.PayPrepay), string(reservation.WaitForDelivery), + string(reservation.VerifyReservation), string(reservation.Ready): + + return saved, nil + } + } + // Approval may have committed even if neither reply reached us. Keep + // the retry key explicit so the user does not buy another reservation. + flag, key := "--asset_id", quoted.AssetId + if len(quoted.GroupKey) != 0 { + flag, key = "--group_key", quoted.GroupKey + } + return nil, fmt.Errorf("approval reply failed: %w; the daemon may still "+ + "complete this purchase. Inspect it with `loop asset reservation get "+ + "--reservation_id %x` or resume with `loop asset reservation buy "+ + "%s %x --amt %d --reservation_id %x`", + err, quoted.ReservationId, flag, key, quoted.Amount, + quoted.ReservationId) +} + +// reservationFeeInfo displays the total prepaid fee, including the server's +// funding estimate and any transport minimum. Integer arithmetic avoids +// overflow and precision loss. +func reservationFeeInfo(r *looprpc.ClientAssetReservation) string { + if r.Amount == 0 || r.AssetFee == 0 { + return "" + } + percent := new(big.Rat).SetFrac( + new(big.Int).Mul(new(big.Int).SetUint64(r.AssetFee), + big.NewInt(100)), + new(big.Int).SetUint64(r.Amount), + ) + sats := func(msat, units uint64) string { + denominator := new(big.Int).Mul(new(big.Int).SetUint64(units), + big.NewInt(1000)) + return new(big.Rat).SetFrac(new(big.Int).SetUint64(msat), + denominator).FloatString(6) + } + return fmt.Sprintf("BTC prepay: %s sats (%d msat), plus routing fees.\n"+ + "Implied prepay price: %s sats/asset unit; probe price: %s.\n"+ + "Service fee paid upfront: %d asset units "+ + "(%s%% of principal).\n"+ + "The fee includes any minimum needed to transport the prepay.\n"+ + "Remaining swap payment: %d asset units; routing and miner "+ + "fees are separate.\n", sats(r.PrepayAmountMsat, 1), r.PrepayAmountMsat, + sats(r.PrepayAmountMsat, r.AssetFee), + sats(r.EstimatedMainAmountMsat, r.Amount), r.AssetFee, percent.FloatString(3), + r.Amount) +} + +// reservationProbeInfo describes the main probe without promising future +// liquidity. The prepay estimate is derived; no prepay probe was sent. +func reservationProbeInfo(r *looprpc.ClientAssetReservation) string { + switch r.MainProbe { + case looprpc.ReservationProbeResult_RESERVATION_PROBE_SUCCEEDED: + if !r.ProbeFeeKnown { + return "Main-payment probe: full asset amount reached " + + "the server; probe canceled.\n" + + "Routing fee estimates are unavailable.\n" + + "The probe does not reserve liquidity for the later swap.\n" + } + return fmt.Sprintf("Main-payment probe: full asset amount reached "+ + "the server; probe canceled.\n"+ + "Main routing fee estimate: %d msat\n"+ + "Approximate prepay routing fee: %d msat "+ + "(scaled by amount; fixed hop fees ignored).\n"+ + "The probe does not reserve liquidity for the later swap.\n", + r.MainProbeFeeMsat, r.EstimatedPrepayRouteFeeMsat) + + case looprpc.ReservationProbeResult_RESERVATION_PROBE_FAILED: + return "Main-payment probe: full asset delivery was not verified; " + + "purchase cancellation required.\n" + + "Routing fee estimates are unavailable.\n" + + case looprpc.ReservationProbeResult_RESERVATION_PROBE_TIMED_OUT: + return "Main-payment probe: timed out; routability is unknown.\n" + + "Routing fee estimates are unavailable.\n" + + case looprpc.ReservationProbeResult_RESERVATION_PROBE_UNSUPPORTED: + return "Main-payment probe: unsupported; routability is unknown.\n" + + "Routing fee estimates are unavailable.\n" + + default: + if r.SkipProbe { + return "Main-payment probe: skipped by request.\n" + + "Routing fee estimates are unavailable.\n" + } + return "Main-payment probe: no result available.\n" + + "Routing fee estimates are unavailable.\n" + } +} + +func reservationApproval(cmd *cli.Command, r *looprpc.ClientAssetReservation) ( + *looprpc.ApproveAssetReservationRequest, error) { + + if r == nil || len(r.QuoteHash) != 32 || r.PrepayAmountMsat == 0 || + r.PrepayAmountMsat > math.MaxInt64 { + + return nil, errors.New("invalid reservation quote") + } + if !cmd.Bool("skip_probe") && + r.MainProbe != looprpc.ReservationProbeResult_RESERVATION_PROBE_SUCCEEDED { + + return nil, errors.New("main-payment probe did not succeed; " + + "start a fresh purchase, optionally with --skip_probe") + } + sats := (r.PrepayAmountMsat + 999) / 1000 + if sats > math.MaxInt64/uint64(maxRoutingFeeRate) { + return nil, errors.New("prepay exceeds routing fee calculation range") + } + fee := uint64(getMaxRoutingFee(btcutil.Amount(sats))) + if cmd.IsSet("max_routing_fee") { + fee = cmd.Uint64("max_routing_fee") + } + if fee > math.MaxInt64/1000 { + return nil, errors.New("routing fee exceeds payment range") + } + return &looprpc.ApproveAssetReservationRequest{ + Reservation: reservationSelectorID(r.ReservationId), + QuoteHash: r.QuoteHash, + MaxRouteFeeMsat: fee * 1000, + SkipProbe: cmd.Bool("skip_probe"), + }, nil +} + +func waitAssetReservation(ctx context.Context, + client looprpc.AssetReservationsClient, + selector *looprpc.ClientAssetReservationSelector, + r *looprpc.ClientAssetReservation, quoteOnly bool) ( + *looprpc.ClientAssetReservation, error) { + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + if r == nil { + return nil, errors.New("missing reservation response") + } + switch r.State { + case string(reservation.QuoteFailed): + return nil, errors.New("reservation quote unavailable or invalid; " + + "no payment was sent") + case string(reservation.QuoteRejected): + return nil, fmt.Errorf("cannot initiate swap: %w", + status.Error(codes.OutOfRange, + "amount above current maximum")) + + case string(reservation.Ready): + return r, nil + + case string(reservation.Canceled), string(reservation.Expired), + string(reservation.NeedAdminAttention): + + return nil, fmt.Errorf("reservation ended in %s", r.State) + + case string(reservation.AwaitApproval): + if quoteOnly { + return r, nil + } + + default: + if quoteOnly && r.State != string(reservation.RequestQuote) && + r.State != string(reservation.ProbeRoutes) && + r.State != string(reservation.CancelPrepay) { + + return r, nil + } + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + + case <-ticker.C: + } + var err error + r, err = client.Get(ctx, selector) + if err != nil { + return nil, err + } + } +} + +func listAssetReservations(ctx context.Context, cmd *cli.Command) error { + _, conn, closeConn, err := getClientWithConn(cmd) + if err != nil { + return err + } + defer closeConn() + client := looprpc.NewAssetReservationsClient(conn) + result, err := loadAssetReservations( + ctx, client, cmd.String("state"), cmd.Bool("active_only"), + ) + if err != nil { + return err + } + printRespJSON(result) + return nil +} + +// loadAssetReservations keeps the state filter unchanged across list pages. +func loadAssetReservations(ctx context.Context, + client looprpc.AssetReservationsClient, state string, activeOnly bool) ( + *looprpc.ListClientAssetReservationsResponse, error) { + + request := &looprpc.ListClientAssetReservationsRequest{ + Limit: 100, + State: state, + ActiveOnly: activeOnly, + } + result := &looprpc.ListClientAssetReservationsResponse{} + for { + page, err := client.List(ctx, request) + if err != nil { + return nil, err + } + result.Reservations = append(result.Reservations, page.Reservations...) + if len(page.NextAfterId) == 0 { + break + } + request.AfterId = page.NextAfterId + } + return result, nil +} + +func inspectAssetReservation(ctx context.Context, cmd *cli.Command) error { + selector, err := localReservationSelector(cmd) + if err != nil { + return err + } + _, conn, closeConn, err := getClientWithConn(cmd) + if err != nil { + return err + } + defer closeConn() + client := looprpc.NewAssetReservationsClient(conn) + r, err := client.Get(ctx, selector) + if err != nil { + return err + } + printRespJSON(r) + return nil +} diff --git a/cmd/loop/asset_reservations_test.go b/cmd/loop/asset_reservations_test.go new file mode 100644 index 000000000..452b8f744 --- /dev/null +++ b/cmd/loop/asset_reservations_test.go @@ -0,0 +1,444 @@ +package main + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "testing" + "time" + + "github.com/lightninglabs/loop/assets/reservation" + "github.com/lightninglabs/loop/looprpc" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + reservationStateArg = "--state" + reservationTestCommandName = "test" + unavailableState = "unavailable" + readyState = "Ready" +) + +type reservationListClient struct { + looprpc.AssetReservationsClient + + t *testing.T + wantState string + wantActiveOnly bool + calls int +} + +func (c *reservationListClient) List(_ context.Context, + req *looprpc.ListClientAssetReservationsRequest, _ ...grpc.CallOption) ( + *looprpc.ListClientAssetReservationsResponse, error) { + + c.t.Helper() + require.Equal(c.t, c.wantState, req.State) + require.Equal(c.t, c.wantActiveOnly, req.ActiveOnly) + c.calls++ + page := &looprpc.ListClientAssetReservationsResponse{ + Reservations: []*looprpc.ClientAssetReservation{{Amount: uint64(c.calls)}}, + } + if c.calls == 1 { + require.Empty(c.t, req.AfterId) + page.NextAfterId = make([]byte, 32) + page.NextAfterId[0] = 1 + } else { + require.Equal(c.t, 2, c.calls) + require.Len(c.t, req.AfterId, 32) + require.EqualValues(c.t, 1, req.AfterId[0]) + } + return page, nil +} + +func TestAssetReservationListStates(t *testing.T) { + for _, tc := range []struct { + name string + args []string + state string + activeOnly bool + conflict bool + }{ + {name: "all states"}, + { + name: "single state", args: []string{reservationStateArg, readyState}, + state: readyState, + }, + { + name: "active only", args: []string{"--active_only"}, + activeOnly: true, + }, + { + name: "repeated state", + args: []string{ + reservationStateArg, readyState, + reservationStateArg, "AwaitApproval", + }, + conflict: true, + }, + { + name: "conflicting filters", + args: []string{reservationStateArg, readyState, "--active_only"}, + conflict: true, + }, + { + name: "explicit false still conflicts", + args: []string{ + reservationStateArg, readyState, "--active_only=false", + }, + conflict: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + list := assetCommand.Commands[0].Commands[1] + require.Equal(t, "list", list.Name) + flags, groups := cloneFlagsWithGroups( + list.Flags, list.MutuallyExclusiveFlags, + ) + client := &reservationListClient{ + t: t, wantState: tc.state, wantActiveOnly: tc.activeOnly, + } + cmd := &cli.Command{ + Name: "list", Flags: flags, MutuallyExclusiveFlags: groups, + Action: func(ctx context.Context, cmd *cli.Command) error { + result, err := loadAssetReservations(ctx, client, + cmd.String("state"), cmd.Bool("active_only")) + if err != nil { + return err + } + require.Len(t, result.Reservations, 2) + require.Equal(t, 2, client.calls) + return nil + }, + } + err := cmd.Run(t.Context(), append([]string{"list"}, tc.args...)) + if tc.conflict { + require.Error(t, err) + require.Zero(t, client.calls) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestReservationFeeInfo(t *testing.T) { + for _, tc := range []struct { + amount, fee uint64 + percent string + }{ + {100_000, 100, "0.100%"}, + {10_000, 354, "3.540%"}, + {354, 354, "100.000%"}, + {10, 20, "200.000%"}, + {math.MaxInt64, math.MaxInt64, "100.000%"}, + } { + info := reservationFeeInfo(&looprpc.ClientAssetReservation{ + Amount: tc.amount, AssetFee: tc.fee, + }) + require.Contains(t, info, tc.percent+" of principal") + require.Contains(t, info, "Service fee paid upfront:") + require.Contains(t, info, "routing and miner fees are separate") + } + require.Empty(t, reservationFeeInfo(&looprpc.ClientAssetReservation{})) +} + +func TestReservationApprovalRoutingLimit(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want uint64 + }{ + { + name: "conventional default", + want: 210_000, + }, + { + name: "explicit zero", + args: []string{"--max_routing_fee=0"}, + }, + { + name: "override", + args: []string{"--max_routing_fee=5"}, + want: 5000, + }, + } { + t.Run(tc.name, func(t *testing.T) { + cmd := &cli.Command{ + Name: reservationTestCommandName, + Flags: []cli.Flag{&cli.Uint64Flag{ + Name: "max_routing_fee", + }}, + Action: func(_ context.Context, cmd *cli.Command) error { + a, err := reservationApproval(cmd, + &looprpc.ClientAssetReservation{ + QuoteHash: make([]byte, 32), + PrepayAmountMsat: 10_000_000, + AssetFee: 10, + MainProbe: looprpc.ReservationProbeResult_RESERVATION_PROBE_SUCCEEDED, + }) + require.NoError(t, err) + require.Equal(t, tc.want, a.MaxRouteFeeMsat) + require.Equal(t, make([]byte, 32), a.QuoteHash) + return nil + }, + } + require.NoError(t, cmd.Run(t.Context(), append( + []string{reservationTestCommandName}, + tc.args...))) + }) + } +} + +func TestReservationIDCanonical(t *testing.T) { + for _, value := range []string{ + "", strings.Repeat("00", 32), strings.Repeat("AB", 32), + strings.Repeat("aa", 31), + } { + _, err := reservationID(value) + require.Error(t, err) + } + id, err := reservationID(strings.Repeat("ab", 32)) + require.NoError(t, err) + require.Len(t, id, 32) +} + +func TestReservationProbeInfo(t *testing.T) { + r := &looprpc.ClientAssetReservation{ + MainProbe: looprpc.ReservationProbeResult_RESERVATION_PROBE_SUCCEEDED, + MainProbeFeeMsat: 10_000, + EstimatedPrepayRouteFeeMsat: 11, + } + r.ProbeFeeKnown = true + info := reservationProbeInfo(r) + require.Contains(t, info, "full asset amount reached the server") + require.Contains(t, info, "Main routing fee estimate: 10000 msat") + require.Contains(t, info, "Approximate prepay routing fee: 11 msat") + require.Contains(t, info, "fixed hop fees ignored") + + r.ProbeFeeKnown = false + require.Contains(t, reservationProbeInfo(r), "estimates are unavailable") + require.Contains(t, reservationProbeInfo(r), "full asset amount reached") + r.ProbeFeeKnown, r.MainProbeFeeMsat = true, 0 + require.Contains(t, reservationProbeInfo(r), "Main routing fee estimate: 0 msat") + + for _, tc := range []struct { + result looprpc.ReservationProbeResult + want string + }{ + { + looprpc.ReservationProbeResult_RESERVATION_PROBE_FAILED, + "full asset delivery was not verified", + }, + { + looprpc.ReservationProbeResult_RESERVATION_PROBE_TIMED_OUT, + "timed out; routability is unknown", + }, + { + looprpc.ReservationProbeResult_RESERVATION_PROBE_UNSUPPORTED, + "unsupported; routability is unknown", + }, + } { + r.MainProbe = tc.result + info := reservationProbeInfo(r) + require.Contains(t, info, tc.want) + require.Contains(t, info, "Routing fee estimates are unavailable") + require.NotContains(t, info, "Approximate prepay routing fee:") + } + r.MainProbe = looprpc.ReservationProbeResult_RESERVATION_PROBE_NOT_RUN + r.SkipProbe = true + require.Contains(t, reservationProbeInfo(r), "skipped by request") + require.Contains(t, reservationProbeInfo(r), "estimates are unavailable") +} + +func TestReservationApprovalRequiresProbeOrSkipProbe(t *testing.T) { + for _, outcome := range []looprpc.ReservationProbeResult{ + looprpc.ReservationProbeResult_RESERVATION_PROBE_NOT_RUN, + looprpc.ReservationProbeResult_RESERVATION_PROBE_SUCCEEDED, + looprpc.ReservationProbeResult_RESERVATION_PROBE_FAILED, + looprpc.ReservationProbeResult_RESERVATION_PROBE_TIMED_OUT, + looprpc.ReservationProbeResult_RESERVATION_PROBE_UNSUPPORTED, + } { + t.Run(outcome.String(), func(t *testing.T) { + for _, skipProbe := range []bool{false, true} { + cmd := &cli.Command{ + Name: reservationTestCommandName, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "skip_probe", + }, + &cli.BoolFlag{ + Name: "yes", + }, + }, + Action: func(_ context.Context, cmd *cli.Command) error { + a, err := reservationApproval(cmd, + &looprpc.ClientAssetReservation{ + QuoteHash: make([]byte, 32), + PrepayAmountMsat: 10_000_000, + MainProbe: outcome, + }) + if !skipProbe && outcome != + looprpc.ReservationProbeResult_RESERVATION_PROBE_SUCCEEDED { + + require.ErrorContains(t, err, "--skip_probe") + require.Nil(t, a) + return nil + } + require.NoError(t, err) + require.Equal(t, skipProbe, a.SkipProbe) + require.EqualValues(t, 210_000, a.MaxRouteFeeMsat) + return nil + }, + } + // Suppressing the prompt does not bypass probe approval. + args := []string{reservationTestCommandName, "--yes"} + if skipProbe { + args = append(args, "--skip_probe") + } + require.NoError(t, cmd.Run(t.Context(), args)) + } + }) + } +} + +// approvalRecoveryClient models a committed approval whose RPC reply was lost. +type approvalRecoveryClient struct { + looprpc.AssetReservationsClient + + t *testing.T + saved *looprpc.ClientAssetReservation + getErr error + calls []string +} + +func (c *approvalRecoveryClient) Approve(context.Context, + *looprpc.ApproveAssetReservationRequest, ...grpc.CallOption) ( + *looprpc.ClientAssetReservation, error) { + + c.calls = append(c.calls, "approve") + return nil, errors.New("lost approval reply") +} + +func (c *approvalRecoveryClient) Get(_ context.Context, + selector *looprpc.ClientAssetReservationSelector, _ ...grpc.CallOption) ( + *looprpc.ClientAssetReservation, error) { + + c.calls = append(c.calls, "get") + require.Equal(c.t, []byte{1}, selector.GetReservationId()) + return c.saved, c.getErr +} + +func TestReservationApprovalRecovery(t *testing.T) { + quoted := &looprpc.ClientAssetReservation{ + ReservationId: []byte{1}, AssetId: []byte{2}, Amount: 100, + } + for _, state := range []string{ + string(reservation.PayPrepay), string(reservation.WaitForDelivery), + string(reservation.VerifyReservation), string(reservation.Ready), + string(reservation.AwaitApproval), unavailableState, + } { + t.Run(state, func(t *testing.T) { + client := &approvalRecoveryClient{ + t: t, saved: &looprpc.ClientAssetReservation{State: state}, + } + if state == unavailableState { + client.getErr = errors.New("disconnected") + } + got, err := approveAssetReservation(t.Context(), client, quoted, + &looprpc.ApproveAssetReservationRequest{}) + if state == string(reservation.AwaitApproval) || + state == unavailableState { + + require.ErrorContains(t, err, "daemon may still complete") + require.ErrorContains(t, err, fmt.Sprintf( + "--asset_id %x --amt 100 --reservation_id %x", + quoted.AssetId, quoted.ReservationId)) + } else { + require.NoError(t, err) + require.Equal(t, client.saved, got) + } + require.Equal(t, []string{"approve", "get"}, client.calls) + }) + } +} + +func TestReservationProbeIsInternal(t *testing.T) { + for _, cmd := range assetCommand.Commands[0].Commands { + require.NotEqual(t, "probe", cmd.Name) + } +} + +func TestReservationWaitsForProbeOutcome(t *testing.T) { + for _, tc := range []struct { + name string + state string + outcome looprpc.ReservationProbeResult + reads int + canceled bool + }{ + {"success awaits approval", string(reservation.AwaitApproval), + looprpc.ReservationProbeResult_RESERVATION_PROBE_SUCCEEDED, + 0, false}, + {"skip awaits approval", string(reservation.AwaitApproval), + looprpc.ReservationProbeResult_RESERVATION_PROBE_NOT_RUN, + 0, false}, + {"failed purchase ended", string(reservation.Canceled), + looprpc.ReservationProbeResult_RESERVATION_PROBE_FAILED, + 0, true}, + {"wait for cancellation", string(reservation.CancelPrepay), + looprpc.ReservationProbeResult_RESERVATION_PROBE_FAILED, + 1, true}, + } { + t.Run(tc.name, func(t *testing.T) { + initial := &looprpc.ClientAssetReservation{ + State: tc.state, MainProbe: tc.outcome, + } + client := &approvalRecoveryClient{ + t: t, saved: &looprpc.ClientAssetReservation{ + State: string(reservation.Canceled), + }, + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + _, err := waitAssetReservation(ctx, client, + reservationSelectorID([]byte{1}), initial, true) + if tc.canceled { + require.ErrorContains(t, err, "Canceled") + } else { + require.NoError(t, err) + } + require.Len(t, client.calls, tc.reads) + }) + } +} + +// TestWaitAssetReservationRejected returns the historical funding error without +// another poll or an approval prompt. +func TestWaitAssetReservationRejected(t *testing.T) { + for _, quoteOnly := range []bool{true, false} { + _, err := waitAssetReservation(t.Context(), nil, nil, + &looprpc.ClientAssetReservation{ + State: string(reservation.QuoteRejected), + }, quoteOnly) + require.Equal(t, codes.OutOfRange, status.Code(err)) + require.EqualError(t, err, "cannot initiate swap: rpc error: "+ + "code = OutOfRange desc = amount above current maximum") + } +} + +func TestReservationApprovalShowsBitcoinPrice(t *testing.T) { + info := reservationFeeInfo(&looprpc.ClientAssetReservation{ + Amount: 10000, AssetFee: 10, PrepayAmountMsat: 12345, + EstimatedMainAmountMsat: 12345000, + }) + require.Contains(t, info, "BTC prepay: 12.345000 sats (12345 msat)") + require.Contains(t, info, "Implied prepay price: 1.234500 sats/asset unit") + require.Contains(t, info, "probe price: 1.234500") +} diff --git a/cmd/loop/main.go b/cmd/loop/main.go index 127634d2f..c3439b517 100644 --- a/cmd/loop/main.go +++ b/cmd/loop/main.go @@ -93,6 +93,7 @@ var ( } commands = []*cli.Command{ + assetCommand, loopOutCommand, loopInCommand, termsCommand, monitorCommand, quoteCommand, listAuthCommand, fetchL402Command, listSwapsCommand, swapInfoCommand, getLiquidityParamsCommand, diff --git a/docs/asset-reservation-rpc.md b/docs/asset-reservation-rpc.md index 214a6b5d3..61cf4b5f1 100644 --- a/docs/asset-reservation-rpc.md +++ b/docs/asset-reservation-rpc.md @@ -9,6 +9,23 @@ permissions. Standalone loopd registers the service; it becomes usable with experimental features and tapd enabled. Embedders must also register this separate service to expose it. +Local `List.state` filters by one latest saved client state. The query reads +matching reservations and their latest state together, without loading full +histories. Alternatively, `List.active_only` excludes `QuoteRejected`, +`Canceled` and `Expired`, while retaining `Ready` and `NeedAdminAttention`. +The filters are mutually exclusive; omitting both returns all reservations. Names are case-sensitive, +and unknown names are rejected. Keep the same filter when following +`next_after_id`. For example: + +```shell +loop asset reservation list --state Ready +loop asset reservation list --active_only +``` + +Startup excludes `QuoteRejected`, `Canceled` and `Expired` in the query. +It retains `Ready` for expiry monitoring and `NeedAdminAttention` for +administrative care. + The wire contract lives in `swapserverrpc/asset_reservation.proto`. It is separate from Bitcoin Instant Out reservations. These definitions do not enable a service; handlers and authentication wiring remain separate. @@ -22,6 +39,13 @@ including after expiry. It must never create a fresh bill or reprice it. Changing the asset, amount, or key under the same ID is rejected. A fresh quote requires a new request after cancellation of the old unpaid purchase. +Before saving a new purchase, the server checks funding capacity. A shortage +returns `OutOfRange` with `amount above current maximum`. The client saves +`QuoteRejected`, a terminal state visible through local `Get` and `List`; +`buy` reports the error and stops. No payment or server cancellation is needed. +Transient RPC errors remain retryable. An accepted, paid purchase still recovers +funding even when inventory later disappears. + The response may still be `QUOTING`. `GetAssetReservation` retrieves progress and the immutable quote once available. The quote supplies both invoices, asset terms, keys, Bitcoin equivalents, edge, expiry, and `prepay_rfq`. That diff --git a/docs/loop.1 b/docs/loop.1 index b906c4a99..86b58cf40 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -49,6 +49,66 @@ loop [GLOBAL OPTIONS] [command [COMMAND OPTIONS]] [ARGUMENTS...] .SH COMMANDS +.SH asset +manage on-chain asset reservations + +.PP +\fB--help, -h\fP: show help + +.SS reservation +buy and inspect asset reservations + +.PP +\fB--help, -h\fP: show help + +.SS buy +quote, approve, and buy a reservation + +.PP +\fB--amt\fP="": asset amount in indivisible units (default: 0) + +.PP +\fB--asset_id\fP="": require this exact issuance on chain and on Lightning + +.PP +\fB--group_key\fP="": accept a fungible member of this compressed group key + +.PP +\fB--help, -h\fP: show help + +.PP +\fB--max_routing_fee\fP="": maximum prepay routing fee in satoshis (default: 0) + +.PP +\fB--reservation_id\fP="": resume this purchase ID + +.PP +\fB--skip_probe\fP: skip probing and buy without a successful main-payment probe + +.PP +\fB--yes\fP: approve the displayed terms without a prompt + +.SS list +list saved purchases + +.PP +\fB--active_only\fP: exclude Canceled and Expired; include Ready and NeedAdminAttention + +.PP +\fB--help, -h\fP: show help + +.PP +\fB--state\fP="": filter by one client state (for example Ready) + +.SS get +show a reservation by outpoint + +.PP +\fB--help, -h\fP: show help + +.PP +\fB--reservation_id\fP="": identify a purchase before funding + .SH out perform an off-chain to on-chain swap (looping out) diff --git a/docs/loop.md b/docs/loop.md index 3c1c37642..03dc875c3 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -20,6 +20,96 @@ Global flags: | `--help` (`-h`) | show help | bool | `false` | *none* | | `--version` (`-v`) | print the version | bool | `false` | *none* | +### `asset` command + +manage on-chain asset reservations. + +Usage: + +```bash +$ loop [GLOBAL FLAGS] asset [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | +|-----------------|-------------|------|:-------------:| +| `--help` (`-h`) | show help | bool | `false` | + +### `asset reservation` subcommand + +buy and inspect asset reservations. + +Usage: + +```bash +$ loop [GLOBAL FLAGS] asset reservation [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | +|-----------------|-------------|------|:-------------:| +| `--help` (`-h`) | show help | bool | `false` | + +### `asset reservation buy` subcommand + +quote, approve, and buy a reservation. + +Usage: + +```bash +$ loop [GLOBAL FLAGS] asset reservation buy [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | +|-------------------------|--------------------------------------------------------------|--------|:-------------:| +| `--asset_id="…"` | require this exact issuance on chain and on Lightning | string | +| `--group_key="…"` | accept a fungible member of this compressed group key | string | +| `--amt="…"` | asset amount in indivisible units | uint | `0` | +| `--reservation_id="…"` | resume this purchase ID | string | +| `--yes` | approve the displayed terms without a prompt | bool | `false` | +| `--skip_probe` | skip probing and buy without a successful main-payment probe | bool | `false` | +| `--max_routing_fee="…"` | maximum prepay routing fee in satoshis | uint | `0` | +| `--help` (`-h`) | show help | bool | `false` | + +### `asset reservation list` subcommand + +list saved purchases. + +Usage: + +```bash +$ loop [GLOBAL FLAGS] asset reservation list [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | +|-----------------|--------------------------------------------------------------------|--------|:-------------:| +| `--help` (`-h`) | show help | bool | `false` | +| `--state="…"` | filter by one client state (for example Ready) | string | +| `--active_only` | exclude Canceled and Expired; include Ready and NeedAdminAttention | bool | `false` | + +### `asset reservation get` subcommand + +show a reservation by outpoint. + +Usage: + +```bash +$ loop [GLOBAL FLAGS] asset reservation get [COMMAND FLAGS] txid:vout +``` + +The following flags are supported: + +| Name | Description | Type | Default value | +|------------------------|------------------------------------|--------|:-------------:| +| `--reservation_id="…"` | identify a purchase before funding | string | +| `--help` (`-h`) | show help | bool | `false` | + ### `out` command perform an off-chain to on-chain swap (looping out). From 08be4be6fbaf822eff914188bd82af3174679f63 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 10 Sep 2026 18:03:01 +0200 Subject: [PATCH 25/25] docs: Describe reservation hold probes and recorded test runs Document one full-amount hold probe per quote, verified asset receipt, cancellation, and recovery of the original payment. Failed probes cancel the unpaid purchase; retrying requires a fresh reservation ID. Distinguish the early prototype runs from the Taproot Assets v0.8.8 reservation itests. Describe the BTC-only payer, restart recovery and the 144-block CSV sweep. Swap execution is a separate operation. Document live stream fee capture and success with pruned HTLC attempts. Recovery without a route reports unavailable estimates; retaining failed payment attempts in LND is not required. --- docs/asset-reservation-rpc.md | 92 +++++++++++++++++------- docs/release-notes/release-notes-next.md | 6 +- 2 files changed, 70 insertions(+), 28 deletions(-) diff --git a/docs/asset-reservation-rpc.md b/docs/asset-reservation-rpc.md index 61cf4b5f1..ea7ccf0f8 100644 --- a/docs/asset-reservation-rpc.md +++ b/docs/asset-reservation-rpc.md @@ -1,9 +1,8 @@ # Asset reservation protocol The local `looprpc.AssetReservations` service separates `Buy` (request a -quote) from `Approve` (permit payment under saved limits). `Get`, `List`, -and `Cancel` support inspection and recovery without buying -again. Funded records accept canonical outpoints; pre-funding retries use a +quote) from `Approve` (permit payment under saved limits). `Get` and `List` support inspection and recovery without buying again. +Unapproved purchases expire automatically. Funded records accept canonical outpoints; pre-funding retries use a stable ID. Local calls require the existing swap read or execute macaroon permissions. Standalone loopd registers the service; it becomes usable with experimental features and tapd enabled. Embedders must also register this @@ -12,7 +11,7 @@ separate service to expose it. Local `List.state` filters by one latest saved client state. The query reads matching reservations and their latest state together, without loading full histories. Alternatively, `List.active_only` excludes `QuoteRejected`, -`Canceled` and `Expired`, while retaining `Ready` and `NeedAdminAttention`. +`QuoteFailed`, `Canceled` and `Expired`, while retaining `Ready` and `NeedAdminAttention`. The filters are mutually exclusive; omitting both returns all reservations. Names are case-sensitive, and unknown names are rejected. Keep the same filter when following `next_after_id`. For example: @@ -37,7 +36,7 @@ before `QuoteAssetReservation`. The server binds that ID to the authenticated owner and request. An exact retry returns the same purchase and invoice, including after expiry. It must never create a fresh bill or reprice it. Changing the asset, amount, or key under the same ID is rejected. A fresh quote -requires a new request after cancellation of the old unpaid purchase. +requires a new purchase ID. Old unpaid quotes expire automatically. Before saving a new purchase, the server checks funding capacity. A shortage returns `OutOfRange` with `amount above current maximum`. The client saves @@ -47,15 +46,17 @@ Transient RPC errors remain retryable. An accepted, paid purchase still recovers funding even when inventory later disappears. The response may still be `QUOTING`. `GetAssetReservation` retrieves progress -and the immutable quote once available. The quote supplies both invoices, -asset terms, keys, Bitcoin equivalents, edge, expiry, and `prepay_rfq`. That -field carries the native receiving RFQ, without secrets, so the client can -bind the prepay invoice to its exact asset fee and route hint. The client -validates both invoices, probes only the estimated main amount, then waits -for approval. Paying the hold prepay is the only signal that permits funding. -The main Bitcoin equivalent is an estimate, not an exchange-rate guarantee. - -The local response reports `main_probe` and `main_probe_fee_msat`. +and the immutable quote once available. The quote supplies both invoices, asset +terms, keys, Bitcoin equivalents, edge, expiry, and `prepay_rfq` plus +`probe_rfq`. These fields bind the invoices to their exact asset amounts and +route hints. The client validates both invoices, probes the full reserved asset +amount, then waits for approval. Settlement of the ordinary prepay is the only +signal that permits funding. The main Bitcoin equivalent is an estimate, not an +exchange-rate guarantee. + +The local response reports `main_probe`, `main_probe_fee_msat`, and +`probe_fee_known`. The fee fields are available only when `probe_fee_known` is +true; a successful probe can lack fee data if recovery missed the live route. `estimated_prepay_route_fee_msat` scales that fee by the prepay/main BTC amount ratio, rounded up to a millisatoshi, without accounting for fixed hop fees. Both fee estimates are unavailable unless the main probe succeeds. The prepay @@ -64,11 +65,13 @@ invoice is validated and paid under the approved limits, but is not probed. Normal approval requires a successful main probe. `Buy.skip_probe` skips the automatic probe for a new purchase; it is saved before the worker starts and survives a restart. Repeating `Buy` preserves the existing purchase's progress. -`Approve.skip_probe` separately records consent to buy without a successful -probe, including after a failed attempt. Skipping the probe never authorizes -payment. Missing or unsuccessful probes retry in the background while awaiting -approval, including after restart. A successful probe is reused for the saved -quote. There is no public probe command or RPC. +`Approve.skip_probe` separately records consent to buy when probing was skipped. +Skipping the probe never authorizes payment. There is one probe payment per +purchase. A failed probe cancels the unpaid purchase; another attempt requires +a fresh purchase ID, optionally using `Buy.skip_probe`. Recovery resumes an +unfinished probe or completes cancellation. It never replaces the saved quote +or revives a canceled purchase. +There is no public probe command or RPC. The approval request accepts the displayed quote by hash, including its asset fee and BTC prepay amount. The client checks the hash against the @@ -97,11 +100,12 @@ Client's LND -- BTC through Lightning --> conversion peer (edge_key) ``` Both keys are 33-byte compressed Lightning node identity keys. The client -checks both invoice destinations against `receiving_node_key`, both route -hints against `edge_key`, and the prepay RFQ's peer against `edge_key`. The -probe invoice estimates a later payment through the same edge; it is never -paid. The reservation's separate `client_key` and `server_key` control the -funded reservation output. +checks both invoice destinations against `receiving_node_key`, both route hints +against `edge_key`, and the prepay RFQ's peer against `edge_key`. The probe +invoice is a registered hold invoice for the full reserved asset amount. The +client attempts it through `SendPaymentV2`; the server verifies accepted asset +HTLCs and cancels it. It is never settled. The reservation's separate +`client_key` and `server_key` control the funded reservation output. `AssetReservationStatus` summarizes progress reported by the server. It groups internal server states into public statuses and does not define a shared state @@ -134,10 +138,44 @@ lookup or dispatch. Page size is bounded; list results contain only owned records. This version uses RPC calls and periodic status checks, without notification-stream integration. Later notifications can prompt fresh reads. -The local client API will expose quote, approve, cancel, get, and list. +The local client API exposes buy, approve, get, and list. The approval request includes the displayed quote hash and prepay routing cap. `skip_probe` bypasses only the requirement for a successful main probe. Approval never reaches the server as a "paid" message. The CLI buy command runs quote, probe, approval, payment, and verification in sequence; the daemon keeps -recovery running after the CLI disconnects. Local RPC handlers and CLI wiring -remain a separate step. +recovery running after the CLI disconnects. Standalone loopd registers the local service. + +## Full-amount hold probe + +The client verifies the probe hash against `ProbeHash(reservation_id)` and its +native RFQ against the reserved amount. Quote contents remain immutable. +`GetAssetReservation` reports `probe_succeeded` (a saved exact asset receipt) +and `probe_canceled` separately. A failed Lightning payment by itself is not +probe success. Both remote cancellation and local payment resolution are +required before the client completes probing. + +`CancelAssetReservation` accepts `probe_only` to cancel only the probe invoice, +in its dedicated `CancelAssetReservationRequest`, when probing is skipped or +a successful probe needs its hold released. Failed probes cancel the entire +unpaid purchase. The request embeds an `AssetReservationSelector` and keeps +the cancellation option separate from reservation identity. Cancellation is +idempotent and never recreates an invoice. A failed probe requires a fresh +purchase ID to try again. A successful probe also ends in invoice cancellation, +but leaves the purchase available for approval. + +Quote acquisition has a one-minute deadline measured from persisted purchase +creation. An invalid quote or an exhausted deadline ends in `QuoteFailed` +without payment. Transient server errors may retry within that budget; a +restart does not extend it. The CLI reports that the quote was unavailable or +invalid and no payment was sent. Server-side unpaid invoices expire separately. + +Before approval the CLI prints the exact BTC prepay in sats and millisatoshis, +and the implied prepay and probe prices in sats per asset unit. Each invoice +must match its own RFQ; no relative rate limit is imposed between the two. +Approval binds the client's consent to the saved quote and payment limits. + +Reservation recovery runs asynchronously. A bad row or failed initial read +makes this feature unavailable and logs the cause without stopping ordinary +Loop In or Out. Repair the underlying issue and restart for recovery. Ready +reservations verify their saved proof once per process and subsequently refresh +chain state; an idle bounded chain watch does not log an error. diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index c1a309047..c296b0199 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -8,7 +8,8 @@ a fungible asset group and verify both quoted issuances before payment. A reorg that invalidates a delivered proof withdraws its readiness and advertised deadlines. Delivery uses the execution cutoff without an - additional minimum usable-window term. + additional minimum usable-window term. Probe and prepay prices are approved + with the quote; each invoice is checked against its own receiving RFQ. * Share asset deposit, reservation and CSV/CLTV HTLC contracts through `swapkit`, with transfer, signing, proof and recovery helpers backed by @@ -36,4 +37,7 @@ #### Maintenance +* Keep reservation quote and invoice checks in the client library; move + incoming Lightning receipt validation to its server consumers. + #### Contributors (Alphabetical Order)