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/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) +} diff --git a/assets/reservation/actions.go b/assets/reservation/actions.go new file mode 100644 index 000000000..97b7a1642 --- /dev/null +++ b/assets/reservation/actions.go @@ -0,0 +1,779 @@ +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" + "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 { + // 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 { + 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) || + errors.Is(err, ErrChainNotReady) { + + 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")) + } + // 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) + } + 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/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.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..6dfe367d2 --- /dev/null +++ b/assets/reservation/fsm.md @@ -0,0 +1,105 @@ +# 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. 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 +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. 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 +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..87df5b335 --- /dev/null +++ b/assets/reservation/fsm_test.go @@ -0,0 +1,784 @@ +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", "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": + 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) + }) + } +} + +// 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/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/interface.go b/assets/reservation/interface.go new file mode 100644 index 000000000..b4fb66010 --- /dev/null +++ b/assets/reservation/interface.go @@ -0,0 +1,71 @@ +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") + + // 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 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, + // 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 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/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/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) +} 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/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.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/probe_test.go b/assets/reservation/probe_test.go new file mode 100644 index 000000000..d1efe9695 --- /dev/null +++ b/assets/reservation/probe_test.go @@ -0,0 +1,370 @@ +package reservation + +import ( + "context" + "errors" + "fmt" + "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) + }{ + {"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) { + + 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/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/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 new file mode 100644 index 000000000..948093519 --- /dev/null +++ b/assets/reservation/reservation.go @@ -0,0 +1,384 @@ +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. +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 +} + +// 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 { + // Terms holds the requested asset and amount, then the quoted fee and + // lifetime once a quote is validated. + Terms + + // GroupKey preserves the original selector even 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 + + // 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 +// 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 + } + 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.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/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/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 +} 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 new file mode 100644 index 000000000..2446a739b --- /dev/null +++ b/assets/reservation/sql_store.go @@ -0,0 +1,269 @@ +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" + "google.golang.org/protobuf/proto" +) + +// 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 { + selector := r.selector() + _, group := selector.Bytes() + count, err := q.CreateAssetReservation(ctx, + sqlc.CreateAssetReservationParams{ + ReservationID: r.ID[:], + AssetID: selector.AssetID[:], + GroupKey: group, + 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 + } + + if err := updatePurchase(ctx, q, &next); err != nil { + return err + } + return insertUpdate(ctx, q, &next) + }) + if err == nil { + *r = next + } + return err +} + +// 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 + } + next := *r + 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 { + *r = next + } + return err +} + +// 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) { + + return loadReservation(ctx, s.db.Queries, id) +} + +// 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 +} + +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.GetAssetReservationSnapshot(ctx, id[:]) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if !row.UpdateState.Valid || !row.UpdateTimestamp.Valid { + return nil, errors.New("missing reservation state history") + } + return toReservation(row.AssetReservation, row.UpdateState.String, + row.UpdateTimestamp.Time) +} + +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 || + state == "" || 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 + } + r := &Reservation{ + 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{ + Family: keychain.KeyFamily(row.ClientKeyFamily), + Index: uint32(row.ClientKeyIndex), + }, + }, + State: fsm.StateType(state), + CreatedAt: row.CreatedAt.UTC(), + UpdatedAt: updatedAt.UTC(), + } + if len(row.GroupKey) != 0 { + s, err := ParseAssetSelector(nil, row.GroupKey) + if err != nil { + return nil, err + } + r.GroupKey = s.GroupKey + } + if err := readPurchase(row, r); err != nil { + return nil, err + } + if err := r.Validate(); err != nil { + return nil, err + } + return r, nil +} + +func sameRequest(a, b *Reservation) bool { + 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 new file mode 100644 index 000000000..921e7c75f --- /dev/null +++ b/assets/reservation/sql_store_test.go @@ -0,0 +1,278 @@ +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}, + 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, StateFilter{}) + 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, "", time.Time{}) + require.Error(t, err) + row.ClientPubkey = make([]byte, 33) + _, err = toReservation(row, updates[1].UpdateState, updates[1].UpdateTimestamp) + 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) +} 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/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()) +} 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) +} 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/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 new file mode 100644 index 000000000..ea7ccf0f8 --- /dev/null +++ b/docs/asset-reservation-rpc.md @@ -0,0 +1,181 @@ +# Asset reservation protocol + +The local `looprpc.AssetReservations` service separates `Buy` (request 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 +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`, +`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: + +```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. + +## 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 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 +`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` 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 +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 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 +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. + 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 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 +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. This version uses RPC calls and periodic status checks, without +notification-stream integration. Later notifications can prompt fresh reads. + +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. 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/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). diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 8ea78eedd..c296b0199 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -2,6 +2,21 @@ #### 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. 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 + `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 @@ -15,6 +30,14 @@ 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 +* Keep reservation quote and invoice checks in the client library; move + incoming Lightning receipt validation to its server consumers. + #### Contributors (Alphabetical Order) diff --git a/go.mod b/go.mod index e55dcf2f6..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,35 +16,37 @@ 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 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 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 ( @@ -85,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 @@ -94,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 @@ -115,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 @@ -123,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 @@ -133,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 @@ -178,8 +181,8 @@ 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/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 @@ -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/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/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/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/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/loopd/daemon.go b/loopd/daemon.go index 530514d30..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() @@ -487,6 +492,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. @@ -746,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 { @@ -817,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 { @@ -873,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) + } + }) + } +} diff --git a/loopdb/asset_reservations_test.go b/loopdb/asset_reservations_test.go new file mode 100644 index 000000000..301080312 --- /dev/null +++ b/loopdb/asset_reservations_test.go @@ -0,0 +1,211 @@ +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) + }) + } +} + +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 new file mode 100644 index 000000000..3d1ddb667 --- /dev/null +++ b/loopdb/sqlc/asset_reservations.sql.go @@ -0,0 +1,423 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.25.0 +// source: asset_reservations.sql + +package sqlc + +import ( + "context" + "database/sql" + "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, 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) { + 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, + &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 +} + +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 +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 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 +` + +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 []GetAssetReservationsRow + for rows.Next() { + var i GetAssetReservationsRow + if err := rows.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, + ); 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 +} + +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/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/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 b6385ebb7..9e805460c 100644 --- a/loopdb/sqlc/models.go +++ b/loopdb/sqlc/models.go @@ -9,6 +9,49 @@ 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 + 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 { + 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..e1d5b9c6d 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,10 @@ 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) + GetAssetReservationSnapshot(ctx context.Context, reservationID []byte) (GetAssetReservationSnapshotRow, error) + GetAssetReservationUpdates(ctx context.Context, reservationID []byte) ([]AssetReservationUpdate, 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) @@ -50,6 +55,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 @@ -71,6 +77,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 new file mode 100644 index 000000000..62de3cf57 --- /dev/null +++ b/loopdb/sqlc/queries/asset_reservations.sql @@ -0,0 +1,95 @@ +-- 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: 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 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 ( + 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; + +-- 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; 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", 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/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/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/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/asset_types.go b/swapkit/asset_types.go new file mode 100644 index 000000000..66096387e --- /dev/null +++ b/swapkit/asset_types.go @@ -0,0 +1,184 @@ +package swapkit + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "time" +) + +var ( + ErrInvalid = errors.New("invalid asset transaction") + 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. +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"` +} + +// 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/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..7eba0712b --- /dev/null +++ b/swapkit/roles_test.go @@ -0,0 +1,267 @@ +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) +} + +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/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..08f96f96f --- /dev/null +++ b/swapkit/swapkit.go @@ -0,0 +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/tapsdk/README.md b/swapkit/tapsdk/README.md new file mode 100644 index 000000000..1a5ef8e87 --- /dev/null +++ b/swapkit/tapsdk/README.md @@ -0,0 +1,190 @@ +# 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. 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 + 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. + +`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 +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/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..8919fd842 --- /dev/null +++ b/swapkit/tapsdk/approval_test.go @@ -0,0 +1,239 @@ +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)) + } +} + +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/asset.go b/swapkit/tapsdk/asset.go new file mode 100644 index 000000000..4690da8c5 --- /dev/null +++ b/swapkit/tapsdk/asset.go @@ -0,0 +1,138 @@ +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 +} + +// 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/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..6f319f3b4 --- /dev/null +++ b/swapkit/tapsdk/binding_test.go @@ -0,0 +1,602 @@ +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" + "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/lightningnetwork/lnd/keychain" + "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}, + } +} + +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 + 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/bindings_test.go b/swapkit/tapsdk/bindings_test.go new file mode 100644 index 000000000..79a7f31ef --- /dev/null +++ b/swapkit/tapsdk/bindings_test.go @@ -0,0 +1,124 @@ +package tapsdk + +import ( + "context" + "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) + } + }) + } +} + +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( + 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/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/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/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/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/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/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.go b/swapkit/tapsdk/deposit.go new file mode 100644 index 000000000..efd8ac803 --- /dev/null +++ b/swapkit/tapsdk/deposit.go @@ -0,0 +1,163 @@ +package tapsdk + +import ( + "bytes" + "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 +// 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/deposit_cache_test.go b/swapkit/tapsdk/deposit_cache_test.go new file mode 100644 index 000000000..bc5600440 --- /dev/null +++ b/swapkit/tapsdk/deposit_cache_test.go @@ -0,0 +1,174 @@ +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. +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/fallback.go b/swapkit/tapsdk/fallback.go new file mode 100644 index 000000000..8639f76d3 --- /dev/null +++ b/swapkit/tapsdk/fallback.go @@ -0,0 +1,198 @@ +package tapsdk + +import ( + "bytes" + "reflect" + + "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. +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 + } + // 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 + } + 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 + } + // 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 + } + 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/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_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/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..41dcd90e1 --- /dev/null +++ b/swapkit/tapsdk/group_test.go @@ -0,0 +1,101 @@ +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) +} + +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/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/inputs.go b/swapkit/tapsdk/inputs.go new file mode 100644 index 000000000..2ee321573 --- /dev/null +++ b/swapkit/tapsdk/inputs.go @@ -0,0 +1,240 @@ +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, + 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 +} + +// 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, + 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/native_package_regression_test.go b/swapkit/tapsdk/native_package_regression_test.go new file mode 100644 index 000000000..0c9e7c519 --- /dev/null +++ b/swapkit/tapsdk/native_package_regression_test.go @@ -0,0 +1,215 @@ +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, + *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) + }) + } +} + +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..09925638d --- /dev/null +++ b/swapkit/tapsdk/original_refund.go @@ -0,0 +1,118 @@ +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 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 + } + 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/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/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/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 new file mode 100644 index 000000000..96ed08c5d --- /dev/null +++ b/swapkit/tapsdk/paths_test.go @@ -0,0 +1,448 @@ +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 +} + +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") + } +} 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/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/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/proofs.go b/swapkit/tapsdk/proofs.go new file mode 100644 index 000000000..97430345f --- /dev/null +++ b/swapkit/tapsdk/proofs.go @@ -0,0 +1,559 @@ +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" + "github.com/lightninglabs/taproot-assets/tappsbt" +) + +// 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 +} + +// 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, + 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) +} + +// 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 + +// 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) +} + +// 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) + 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 + } + // 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, + 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} +} + +// 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 +} + +// 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..a132bc8d9 --- /dev/null +++ b/swapkit/tapsdk/recovery.go @@ -0,0 +1,314 @@ +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 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 + } + 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 + } + found := spendingTransaction(block, point) + if found == nil { + return swapkit.PreimageResult{}, nil + } + for _, input := range found.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/signing.go b/swapkit/tapsdk/signing.go new file mode 100644 index 000000000..e416f9352 --- /dev/null +++ b/swapkit/tapsdk/signing.go @@ -0,0 +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.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..bb851d0d5 --- /dev/null +++ b/swapkit/tapsdk/signing_policy_test.go @@ -0,0 +1,93 @@ +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 { + + 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 +} + +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.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 new file mode 100644 index 000000000..3d0e98896 --- /dev/null +++ b/swapkit/tapsdk/spend_test.go @@ -0,0 +1,169 @@ +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 { + 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 +} + +// 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/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/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" +) 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 diff --git a/swapkit/tapsdk/testdata/musig.package b/swapkit/tapsdk/testdata/musig.package new file mode 100644 index 000000000..2cea2383f Binary files /dev/null and b/swapkit/tapsdk/testdata/musig.package differ diff --git a/swapkit/tapsdk/testdata/script.package b/swapkit/tapsdk/testdata/script.package new file mode 100644 index 000000000..4f9b01413 Binary files /dev/null and b/swapkit/tapsdk/testdata/script.package differ diff --git a/swapkit/tapsdk/wallet_spend.go b/swapkit/tapsdk/wallet_spend.go new file mode 100644 index 000000000..93aff111f --- /dev/null +++ b/swapkit/tapsdk/wallet_spend.go @@ -0,0 +1,204 @@ +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 + } + // 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 + } + 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 + } + // 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 + } + 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 := 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 +} 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)) + } + }) + } +} 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", +}