mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
4c5bef42c6
commit
f4010e239a
2 changed files with 171 additions and 8 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
109
swaps/swaps_service_test.go
Normal file
109
swaps/swaps_service_test.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue