From f4010e239acde5ac91678e3b018f604e8299c5b8 Mon Sep 17 00:00:00 2001 From: Roland <33993199+rolznz@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:33:40 +0700 Subject: [PATCH] fix: validate swap out invoice before payment (#2536) Verify the invoice returned when creating a swap out before storing and paying it: - the invoice payment hash must match the payment hash of the locally generated preimage - the invoice amount must not exceed the requested amount plus the quoted service and miner fees (with a small rounding tolerance) - the lockup address is checked against the swap tree, matching the checks already performed for swap in and refunds - the invoice is verified again directly before it is paid Also renames AlbySwapServiceFee to AlbySwapServiceFeePercentage for clarity. Co-authored-by: Claude Fable 5 --- swaps/swaps_service.go | 70 ++++++++++++++++++++--- swaps/swaps_service_test.go | 109 ++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 swaps/swaps_service_test.go diff --git a/swaps/swaps_service.go b/swaps/swaps_service.go index 7a8c2f31..8bb815a0 100644 --- a/swaps/swaps_service.go +++ b/swaps/swaps_service.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "math" "net/http" "strconv" "sync" @@ -68,7 +69,7 @@ type SwapsService interface { } const ( - AlbySwapServiceFee = 1.0 + AlbySwapServiceFeePercentage = 1.0 ) type SwapInfo struct { @@ -275,7 +276,7 @@ func (svc *swapsService) SwapOut(amountSat uint64, destination string, autoSwap, }).Info("Calculated fees for swap out") albyFee := &boltz.ExtraFees{ - Percentage: AlbySwapServiceFee, + Percentage: AlbySwapServiceFeePercentage, Id: "albyServiceFee", } @@ -334,14 +335,15 @@ func (svc *swapsService) SwapOut(amountSat uint64, destination string, autoSwap, return err } - paymentRequest, err := decodepay.Decodepay(swap.Invoice) + maxSendAmountSat := calculateMaxSwapOutSendAmountSat(amountSat, fees.Percentage, fees.MinerFees.Lockup, fees.MinerFees.Claim) + sendAmountSat, err := verifySwapOutInvoice(swap.Invoice, paymentHash, maxSendAmountSat) if err != nil { - return fmt.Errorf("failed to decode bolt11 invoice") + return fmt.Errorf("invalid swap invoice: %w", err) } err = tx.Model(&dbSwap).Updates(&db.Swap{ SwapId: swap.Id, - SendAmountSat: uint64(paymentRequest.MSatoshi / 1000), + SendAmountSat: sendAmountSat, Invoice: swap.Invoice, LockupAddress: swap.LockupAddress, TimeoutBlockHeight: swap.TimeoutBlockHeight, @@ -379,6 +381,44 @@ func (svc *swapsService) SwapOut(amountSat uint64, destination string, autoSwap, }, nil } +// swapOutInvoiceToleranceSat covers rounding differences that can occur when +// the swap provider converts the requested on-chain amount into an invoice amount. +const swapOutInvoiceToleranceSat = 10 + +// calculateMaxSwapOutSendAmountSat returns the maximum invoice amount accepted for +// a swap out: the requested on-chain amount plus the quoted miner fees, marked up +// by the quoted percentage fees (which are charged on the invoice amount), plus a +// small rounding tolerance. +func calculateMaxSwapOutSendAmountSat(receiveAmountSat uint64, serviceFeePercentage float64, lockupFeeSat uint64, claimFeeSat uint64) uint64 { + totalFeePercentage := serviceFeePercentage + AlbySwapServiceFeePercentage + if totalFeePercentage >= 100 { + return 0 + } + onchainAmountSat := float64(receiveAmountSat + claimFeeSat + lockupFeeSat) + expectedSendAmountSat := math.Ceil(onchainAmountSat / (1 - totalFeePercentage/100)) + return uint64(expectedSendAmountSat) + swapOutInvoiceToleranceSat +} + +// verifySwapOutInvoice checks that a swap out invoice is bound to the swap's +// payment hash and does not exceed maxSendAmountSat, and returns its amount. +func verifySwapOutInvoice(invoice string, expectedPaymentHash string, maxSendAmountSat uint64) (uint64, error) { + paymentRequest, err := decodepay.Decodepay(invoice) + if err != nil { + return 0, fmt.Errorf("failed to decode bolt11 invoice: %w", err) + } + if paymentRequest.PaymentHash != expectedPaymentHash { + return 0, fmt.Errorf("invoice payment hash %s does not match swap payment hash %s", paymentRequest.PaymentHash, expectedPaymentHash) + } + if paymentRequest.MSatoshi <= 0 { + return 0, errors.New("invoice does not have an amount") + } + sendAmountSat := uint64(paymentRequest.MSatoshi) / 1000 + if sendAmountSat > maxSendAmountSat { + return 0, fmt.Errorf("invoice amount %d sat exceeds maximum expected amount %d sat", sendAmountSat, maxSendAmountSat) + } + return sendAmountSat, nil +} + func (svc *swapsService) SwapIn(amountSat uint64, autoSwap bool) (*SwapResponse, error) { amountMsat := amountSat * 1000 invoice, err := svc.transactionsService.MakeInvoice(svc.ctx, amountMsat, "On-chain to lightning swap", "", 0, nil, svc.lnClient, nil, nil, nil) @@ -409,7 +449,7 @@ func (svc *swapsService) SwapIn(amountSat uint64, autoSwap bool) (*SwapResponse, }).Info("Calculated fees for swap in") albyFee := &boltz.ExtraFees{ - Percentage: AlbySwapServiceFee, + Percentage: AlbySwapServiceFeePercentage, Id: "albyServiceFee", } @@ -522,7 +562,7 @@ func (svc *swapsService) GetSwapOutInfo() (*SwapInfo, error) { limits := pairInfo.Limits return &SwapInfo{ - AlbyServiceFee: AlbySwapServiceFee, + AlbyServiceFee: AlbySwapServiceFeePercentage, BoltzServiceFee: fees.Percentage, BoltzNetworkFeeSat: fees.MinerFees.Lockup + fees.MinerFees.Claim, MinAmountSat: limits.Minimal, @@ -546,7 +586,7 @@ func (svc *swapsService) GetSwapInInfo() (*SwapInfo, error) { limits := pairInfo.Limits return &SwapInfo{ - AlbyServiceFee: AlbySwapServiceFee, + AlbyServiceFee: AlbySwapServiceFeePercentage, BoltzServiceFee: fees.Percentage, BoltzNetworkFeeSat: fees.MinerFees, MinAmountSat: limits.Minimal, @@ -1095,6 +1135,13 @@ func (svc *swapsService) startSwapOutListener(swap *db.Swap) { return } + if err = tree.CheckAddress(swap.LockupAddress, network, nil); err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "swapId": swap.SwapId, + }).Error("Failed to check address") + return + } + claimTicker := time.NewTicker(10 * time.Second) defer claimTicker.Stop() @@ -1158,6 +1205,13 @@ func (svc *swapsService) startSwapOutListener(swap *db.Swap) { logger.Logger.WithError(err).WithField("swapId", swap.SwapId).Warn("Failed to lookup transaction") return } + if _, err := verifySwapOutInvoice(swap.Invoice, swap.PaymentHash, swap.SendAmountSat); err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "swapId": swap.SwapId, + }).Error("Refusing to pay swap invoice") + paymentErrorCh <- err + return + } metadata := map[string]interface{}{ "swap_id": swap.SwapId, } diff --git a/swaps/swaps_service_test.go b/swaps/swaps_service_test.go new file mode 100644 index 00000000..a6cb3a81 --- /dev/null +++ b/swaps/swaps_service_test.go @@ -0,0 +1,109 @@ +package swaps + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "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/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/zpay32" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func makeTestInvoice(t *testing.T, paymentHash [32]byte, amountMsat uint64) string { + t.Helper() + + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + invoice, err := zpay32.NewInvoice( + &chaincfg.MainNetParams, + paymentHash, + time.Now(), + zpay32.Amount(lnwire.MilliSatoshi(amountMsat)), + zpay32.Description("test swap invoice"), + ) + require.NoError(t, err) + + encoded, err := invoice.Encode(zpay32.MessageSigner{ + SignCompact: func(msg []byte) ([]byte, error) { + return ecdsa.SignCompact(privKey, chainhash.HashB(msg), true), nil + }, + }) + require.NoError(t, err) + + return encoded +} + +func makeTestPaymentHash(t *testing.T) ([32]byte, string) { + t.Helper() + + preimage := make([]byte, 32) + _, err := rand.Read(preimage) + require.NoError(t, err) + paymentHash := sha256.Sum256(preimage) + return paymentHash, hex.EncodeToString(paymentHash[:]) +} + +func TestVerifySwapOutInvoice(t *testing.T) { + paymentHash, paymentHashHex := makeTestPaymentHash(t) + + t.Run("accepts invoice with matching payment hash and amount", func(t *testing.T) { + invoice := makeTestInvoice(t, paymentHash, 100_000_000) + + sendAmountSat, err := verifySwapOutInvoice(invoice, paymentHashHex, 100_000) + require.NoError(t, err) + assert.Equal(t, uint64(100_000), sendAmountSat) + }) + + t.Run("rejects invoice with different payment hash", func(t *testing.T) { + otherPaymentHash, _ := makeTestPaymentHash(t) + invoice := makeTestInvoice(t, otherPaymentHash, 100_000_000) + + _, err := verifySwapOutInvoice(invoice, paymentHashHex, 100_000) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match swap payment hash") + }) + + t.Run("rejects invoice exceeding maximum amount", func(t *testing.T) { + invoice := makeTestInvoice(t, paymentHash, 100_001_000) + + _, err := verifySwapOutInvoice(invoice, paymentHashHex, 100_000) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum expected amount") + }) + + t.Run("rejects invoice without an amount", func(t *testing.T) { + invoice := makeTestInvoice(t, paymentHash, 0) + + _, err := verifySwapOutInvoice(invoice, paymentHashHex, 100_000) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not have an amount") + }) + + t.Run("rejects unparseable invoice", func(t *testing.T) { + _, err := verifySwapOutInvoice("lnbc1notaninvoice", paymentHashHex, 100_000) + require.Error(t, err) + }) +} + +func TestCalculateMaxSwapOutSendAmountSat(t *testing.T) { + // 100_000 requested + 300 claim fee + 500 lockup fee = 100_800, + // marked up by 0.5% boltz + 1% alby fee on the invoice amount: + // ceil(100_800 / 0.985) = 102_336, plus 10 sat tolerance + assert.Equal(t, uint64(102_346), calculateMaxSwapOutSendAmountSat(100_000, 0.5, 500, 300)) + + // with a 0% boltz fee only the alby fee percentage applies: + // ceil(100_800 / 0.99) = 101_819, plus 10 sat tolerance + assert.Equal(t, uint64(101_829), calculateMaxSwapOutSendAmountSat(100_000, 0, 500, 300)) + + // invalid fee rates of 100% or more are never accepted + assert.Equal(t, uint64(0), calculateMaxSwapOutSendAmountSat(100_000, 100, 500, 300)) +}