Merge pull request #1194 from hieblmi/instantout-fixes
Some checks failed
CI / Commit Message (push) Has been cancelled
CI / RPC compilation check (push) Has been cancelled
CI / SQL compilation check (push) Has been cancelled
CI / go mod check (push) Has been cancelled
CI / build and lint code (push) Has been cancelled
CI / verify that auto-generated documentation is up-to-date (push) Has been cancelled
CI / run unit-test sqlite3 race (push) Has been cancelled
CI / run unit-test postgres race (push) Has been cancelled
CI / run LiT itests (push) Has been cancelled
CI / run LiT unit tests (push) Has been cancelled

instantout: improve reservation and swap handling
This commit is contained in:
Slyghtning 2026-08-12 10:55:24 +02:00 committed by GitHub
commit 6d1dbcb599
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1086 additions and 36 deletions

View file

@ -183,6 +183,8 @@ func instantOut(ctx context.Context, cmd *cli.Command) error {
fmt.Println("Starting instant swap out")
maxSwapFee := quote.ServiceFeeSat
// Now we can request the instant out swap.
instantOutRes, err := client.InstantOut(
ctx,
@ -190,6 +192,9 @@ func instantOut(ctx context.Context, cmd *cli.Command) error {
ReservationIds: selectedReservations,
OutgoingChanSet: outgoingChanSet,
DestAddr: cmd.String("addr"),
MaxSwapFee: &looprpc.InstantOutRequest_MaxSwapFeeSat{
MaxSwapFeeSat: maxSwapFee,
},
},
)
if err != nil {

View file

@ -139,7 +139,8 @@
"Mu65fbhayEtRzougKLBnoeRN8f+tEM1+O9QuNvUIfbI="
],
"outgoing_chan_set": [],
"dest_addr": ""
"dest_addr": "",
"max_swap_fee_sat": "4800"
}
}
},

View file

@ -148,7 +148,8 @@
"outgoing_chan_set": [
"125344325763072"
],
"dest_addr": ""
"dest_addr": "",
"max_swap_fee_sat": "3200"
}
}
},

View file

@ -162,7 +162,8 @@
"cSfKVONNmsK9+p4Uc5nc3ZtE+37uOODHeq1vprhh/x4="
],
"outgoing_chan_set": [],
"dest_addr": ""
"dest_addr": "",
"max_swap_fee_sat": "1600"
}
}
},

View file

@ -4,12 +4,21 @@
#### Breaking Changes
* Instant Out and reservation RPCs now require the `loop:out` permission.
Operators using custom scoped macaroons must rebake them before calling
`ListReservations`, `InstantOut`, `InstantOutQuote`, or `ListInstantOuts`.
[PR #1194](https://github.com/lightninglabs/loop/pull/1194)
#### Bug Fixes
* Loop Out requests now account for channel reserves when checking outbound
capacity, preventing swaps from starting when their off-chain payment cannot
be funded.
* Improved Instant Out and reservation validation, lifecycle cleanup, recovery
timing, fee limits, and macaroon permissions.
[PR #1194](https://github.com/lightninglabs/loop/pull/1194)
* Taproot Asset Loop Out handling now validates RFQ timeouts and asset rates,
keeps cached asset-name lookups responsive during slow `tapd` queries, and
closes `tapd` connections cleanly during shutdown and startup failures.

View file

@ -20,6 +20,7 @@ import (
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwire"
)
const (
@ -51,6 +52,10 @@ const (
// htlcExpiryDelta is the delta in blocks we require between the htlc
// expiry and reservation expiry.
htlcExpiryDelta = int32(40)
// htlcRecoverySafetyDelta leaves one urgent confirmation target for
// the HTLC and another for its preimage sweep after recovery.
htlcRecoverySafetyDelta = 2 * urgentConfTarget
)
// InitInstantOutCtx contains the context for the InitInstantOutAction.
@ -61,8 +66,12 @@ type InitInstantOutCtx struct {
outgoingChanSet loopdb.ChannelSet
protocolVersion ProtocolVersion
sweepAddress btcutil.Address
maxSwapFee *btcutil.Amount
}
// RecoverInstantOutCtx marks an action as being resumed after restart.
type RecoverInstantOutCtx struct{}
// InitInstantOutAction is the first action that is executed when the instant
// out FSM is started. It will send the instant out request to the server.
func (f *FSM) InitInstantOutAction(ctx context.Context,
@ -78,7 +87,7 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
}
var (
reservationAmt uint64
reservationAmt btcutil.Amount
reservationIds = make([][]byte, 0, len(initCtx.reservations))
reservations = make(
[]*reservation.Reservation, 0, len(initCtx.reservations),
@ -99,7 +108,7 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
"locked", reservationId))
}
reservationAmt += uint64(res.Value)
reservationAmt += res.Value
reservationIds = append(reservationIds, resId[:])
reservations = append(reservations, res)
@ -161,6 +170,11 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
return f.HandleError(fmt.Errorf("invalid swap invoice hash: "+
"expected %x got %x", preimage.Hash(), payReq.Hash))
}
if err := validateInstantOutInvoiceAmount(
payReq.Value, reservationAmt, initCtx.maxSwapFee,
); err != nil {
return f.HandleError(err)
}
serverPubkey, err := btcec.ParsePubKey(instantOutResponse.SenderKey)
if err != nil {
return f.HandleError(err)
@ -179,6 +193,11 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
}
// Now we can create the instant out.
var maxSwapFee btcutil.Amount
if initCtx.maxSwapFee != nil {
maxSwapFee = *initCtx.maxSwapFee
}
instantOut := &InstantOut{
SwapHash: swapHash,
swapPreimage: preimage,
@ -188,7 +207,8 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
CltvExpiry: initCtx.cltvExpiry,
clientPubkey: keyRes.PubKey,
serverPubkey: serverPubkey,
Value: btcutil.Amount(reservationAmt),
Value: reservationAmt,
MaxSwapFee: maxSwapFee,
htlcFeeRate: feeRate,
swapInvoice: instantOutResponse.SwapInvoice,
Reservations: reservations,
@ -206,6 +226,37 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
return OnInit
}
// validateInstantOutInvoiceAmount verifies that the server invoice doesn't
// charge more than the client-approved swap fee. Sub-satoshi fees are rounded
// up so the cap cannot be bypassed with millisatoshi precision.
func validateInstantOutInvoiceAmount(invoiceAmount lnwire.MilliSatoshi,
swapAmount btcutil.Amount, maxSwapFee *btcutil.Amount) error {
// Omitting the cap preserves the behavior of clients that predate this
// field. In-tree callers set it explicitly after accepting a quote.
if maxSwapFee == nil {
return nil
}
if *maxSwapFee < 0 {
return fmt.Errorf("maximum swap fee must not be negative")
}
swapAmountMsat := lnwire.NewMSatFromSatoshis(swapAmount)
if invoiceAmount <= swapAmountMsat {
return nil
}
swapFeeMsat := invoiceAmount - swapAmountMsat
swapFeeSat := btcutil.Amount((int64(swapFeeMsat)-1)/1000 + 1)
if swapFeeSat > *maxSwapFee {
return fmt.Errorf("instant out swap fee %d exceeds maximum %d",
swapFeeSat, *maxSwapFee)
}
return nil
}
// PollPaymentAcceptedAction locks the reservations, sends the payment to the
// server and polls the server for the payment status.
func (f *FSM) PollPaymentAcceptedAction(ctx context.Context,
@ -293,6 +344,15 @@ func (f *FSM) BuildHTLCAction(ctx context.Context,
}
f.htlcMusig2Sessions = htlcSessions
defer func() {
err := cleanupMuSig2Sessions(
ctx, f.cfg.Signer, f.htlcMusig2Sessions,
)
if err != nil {
f.Errorf("unable to clean up HTLC MuSig2 sessions: %v", err)
}
f.htlcMusig2Sessions = nil
}()
// Send the server the client nonces.
htlcInitRes, err := f.cfg.InstantOutClient.InitHtlcSig(
@ -373,6 +433,46 @@ func (f *FSM) BuildHTLCAction(ctx context.Context,
func (f *FSM) PushPreimageAction(ctx context.Context,
eventCtx fsm.EventContext) fsm.EventType {
// A recovered swap may have been offline long enough that the server's
// reservation timeout is now close. Fall back to the already finalized
// HTLC instead of revealing the preimage without enough time to publish
// that safety transaction.
if _, ok := eventCtx.(*RecoverInstantOutCtx); ok {
info, err := f.cfg.LndClient.GetInfo(ctx)
if err != nil {
f.LastActionError = fmt.Errorf(
"unable to get recovery chain height: %w", err,
)
return OnErrorPublishHtlc
}
currentHeight := int64(info.BlockHeight)
minHtlcExpiry := currentHeight +
int64(htlcRecoverySafetyDelta)
if int64(f.InstantOut.CltvExpiry) < minHtlcExpiry {
f.LastActionError = fmt.Errorf("instant out HTLC expires at "+
"height %d, before recovery safety height %d",
f.InstantOut.CltvExpiry, minHtlcExpiry)
return OnErrorPublishHtlc
}
minReservationExpiry := currentHeight +
int64(htlcExpiryDelta)
for _, res := range f.InstantOut.Reservations {
if int64(res.Expiry) >= minReservationExpiry {
continue
}
f.LastActionError = fmt.Errorf("reservation %x expires at "+
"height %d, before recovery safety height %d",
res.ID, res.Expiry, minReservationExpiry)
return OnErrorPublishHtlc
}
}
// First we'll create the musig2 context.
coopSessions, coopClientNonces, err := f.InstantOut.createMusig2Session(
ctx, f.cfg.Signer,
@ -382,6 +482,15 @@ func (f *FSM) PushPreimageAction(ctx context.Context,
}
f.sweeplessSweepSessions = coopSessions
defer func() {
err := cleanupMuSig2Sessions(
ctx, f.cfg.Signer, f.sweeplessSweepSessions,
)
if err != nil {
f.Errorf("unable to clean up sweep MuSig2 sessions: %v", err)
}
f.sweeplessSweepSessions = nil
}()
// Get the feerate for the coop sweep.
feeRate, err := f.cfg.Wallet.EstimateFeeRate(ctx, normalConfTarget)
@ -617,20 +726,23 @@ func (f *FSM) WaitForHtlcSweepConfirmedAction(ctx context.Context,
// handleErrorAndUnlockReservations handles an error and unlocks the
// reservations.
func (f *FSM) handleErrorAndUnlockReservations(ctx context.Context,
err error) fsm.EventType {
actionErr error) fsm.EventType {
// We might get here from a canceled context, we create a new context
// with a timeout to unlock the reservations.
ctx, cancel := context.WithTimeout(ctx, time.Second*30)
cleanupCtx, cancel := context.WithTimeout(
context.WithoutCancel(ctx), time.Second*30,
)
defer cancel()
// Unlock the reservations.
var unlockErr error
for _, reservation := range f.InstantOut.Reservations {
err := f.cfg.ReservationManager.UnlockReservation(
ctx, reservation.ID,
cleanupCtx, reservation.ID,
)
if err != nil {
f.Errorf("error unlocking reservation: %v", err)
return f.HandleError(err)
unlockErr = errors.Join(unlockErr, err)
}
}
@ -638,10 +750,12 @@ func (f *FSM) handleErrorAndUnlockReservations(ctx context.Context,
// release the reservations. This can be done in a goroutine as we
// wan't to fail the fsm early.
go func() {
ctx, cancel := context.WithTimeout(ctx, time.Second*30)
cancelCtx, cancel := context.WithTimeout(
context.WithoutCancel(ctx), time.Second*30,
)
defer cancel()
_, cancelErr := f.cfg.InstantOutClient.CancelInstantSwap(
ctx, &swapserverrpc.CancelInstantSwapRequest{
cancelCtx, &swapserverrpc.CancelInstantSwapRequest{
SwapHash: f.InstantOut.SwapHash[:],
},
)
@ -652,7 +766,13 @@ func (f *FSM) handleErrorAndUnlockReservations(ctx context.Context,
}
}()
return f.HandleError(err)
// Preserve the action failure when cleanup also fails. If cleanup was
// the only failure, report it to the state machine.
if actionErr != nil {
return f.HandleError(actionErr)
}
return f.HandleError(unlockErr)
}
func getMaxRoutingFee(amt btcutil.Amount) btcutil.Amount {

View file

@ -0,0 +1,77 @@
package instantout
import (
"context"
"errors"
"testing"
"time"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/instantout/reservation"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)
type cleanupTestReservationManager struct {
ReservationManager
unlockErr error
}
func (m *cleanupTestReservationManager) UnlockReservation(context.Context,
reservation.ID) error {
return m.unlockErr
}
type cleanupTestInstantOutClient struct {
swapserverrpc.InstantSwapServerClient
canceled chan struct{}
}
func (c *cleanupTestInstantOutClient) CancelInstantSwap(context.Context,
*swapserverrpc.CancelInstantSwapRequest, ...grpc.CallOption) (
*swapserverrpc.CancelInstantSwapResponse, error) {
close(c.canceled)
return &swapserverrpc.CancelInstantSwapResponse{}, nil
}
// TestCleanupPreservesActionError verifies that an unlock failure doesn't
// replace the action failure or prevent the cancellation notification.
func TestCleanupPreservesActionError(t *testing.T) {
actionErr := errors.New("action failed")
cancelClient := &cleanupTestInstantOutClient{
canceled: make(chan struct{}),
}
instantOutFSM := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
ReservationManager: &cleanupTestReservationManager{
unlockErr: errors.New("unlock failed"),
},
InstantOutClient: cancelClient,
},
InstantOut: &InstantOut{
Reservations: []*reservation.Reservation{
{ID: reservation.ID{1}},
},
},
}
event := instantOutFSM.handleErrorAndUnlockReservations(
t.Context(), actionErr,
)
require.Equal(t, fsm.OnError, event)
require.ErrorIs(t, instantOutFSM.LastActionError, actionErr)
require.Eventually(t, func() bool {
select {
case <-cancelClient.canceled:
return true
default:
return false
}
}, time.Second, time.Millisecond)
}

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"reflect"
"time"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
@ -25,6 +26,8 @@ import (
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
)
const muSig2CleanupTimeout = 5 * time.Second
// InstantOut holds the necessary information to execute an instant out swap.
type InstantOut struct {
// SwapHash is the hash of the swap.
@ -57,6 +60,9 @@ type InstantOut struct {
// Value is the amount that is swapped.
Value btcutil.Amount
// MaxSwapFee is the maximum off-chain swap fee accepted by the client.
MaxSwapFee btcutil.Amount
// keyLocator is the key locator that is used for the swap.
keyLocator keychain.KeyLocator
@ -112,7 +118,10 @@ func (i *InstantOut) createMusig2Session(ctx context.Context,
for idx, reservation := range i.Reservations {
session, err := reservation.Musig2CreateSession(ctx, signer)
if err != nil {
return nil, nil, err
cleanupErr := cleanupMuSig2Sessions(
ctx, signer, musig2Sessions[:idx],
)
return nil, nil, errors.Join(err, cleanupErr)
}
musig2Sessions[idx] = session
@ -122,6 +131,32 @@ func (i *InstantOut) createMusig2Session(ctx context.Context,
return musig2Sessions, clientNonces, nil
}
// cleanupMuSig2Sessions removes completed or abandoned MuSig2 sessions from
// lnd. Cleanup uses a bounded context that survives cancellation of the swap
// action that created the sessions.
func cleanupMuSig2Sessions(ctx context.Context, signer lndclient.SignerClient,
sessions []*input.MuSig2SessionInfo) error {
cleanupCtx, cancel := context.WithTimeout(
context.WithoutCancel(ctx), muSig2CleanupTimeout,
)
defer cancel()
var cleanupErr error
for _, session := range sessions {
if session == nil {
continue
}
err := signer.MuSig2Cleanup(cleanupCtx, session.SessionID)
if err != nil {
cleanupErr = errors.Join(cleanupErr, err)
}
}
return cleanupErr
}
// getInputReservations returns the input reservations for the instant out.
func (i *InstantOut) getInputReservations() (InputReservations, error) {
if len(i.Reservations) == 0 {
@ -263,12 +298,31 @@ func (i *InstantOut) signMusig2Tx(ctx context.Context,
if err != nil {
return nil, err
}
if tx == nil {
return nil, errors.New("transaction is nil")
}
if len(tx.TxIn) != len(inputs) {
return nil, fmt.Errorf("invalid number of transaction inputs: "+
"expected %d, got %d", len(inputs), len(tx.TxIn))
}
if len(musig2sessions) != len(inputs) {
return nil, fmt.Errorf("invalid number of MuSig2 sessions: "+
"expected %d, got %d", len(inputs), len(musig2sessions))
}
if len(counterPartyNonces) != len(inputs) {
return nil, fmt.Errorf("invalid number of server nonces: "+
"expected %d, got %d", len(inputs), len(counterPartyNonces))
}
prevOutFetcher := inputs.GetPrevoutFetcher()
sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher)
sigs := make([][]byte, len(inputs))
for idx, reservation := range inputs {
if musig2sessions[idx] == nil {
return nil, fmt.Errorf("MuSig2 session %d is nil", idx)
}
if !reflect.DeepEqual(tx.TxIn[idx].PreviousOutPoint,
reservation.Outpoint) {
@ -329,8 +383,30 @@ func (i *InstantOut) finalizeMusig2Transaction(ctx context.Context,
if err != nil {
return nil, err
}
if tx == nil {
return nil, errors.New("transaction is nil")
}
if len(tx.TxIn) != len(inputs) {
return nil, fmt.Errorf("invalid number of transaction inputs: "+
"expected %d, got %d", len(inputs), len(tx.TxIn))
}
if len(musig2Sessions) != len(inputs) {
return nil, fmt.Errorf("invalid number of MuSig2 sessions: "+
"expected %d, got %d", len(inputs), len(musig2Sessions))
}
if len(serverSigs) != len(inputs) {
return nil, fmt.Errorf("invalid number of server signatures: "+
"expected %d, got %d", len(inputs), len(serverSigs))
}
prevOutFetcher := inputs.GetPrevoutFetcher()
sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher)
for idx := range inputs {
if musig2Sessions[idx] == nil {
return nil, fmt.Errorf("MuSig2 session %d is nil", idx)
}
haveAllSigs, finalSig, err := signer.MuSig2CombineSig(
ctx, musig2Sessions[idx].SessionID,
[][]byte{serverSigs[idx]},
@ -343,7 +419,26 @@ func (i *InstantOut) finalizeMusig2Transaction(ctx context.Context,
return nil, fmt.Errorf("missing sigs")
}
// lnd removes a MuSig2 session automatically once all signatures
// have been combined. Clear the local entry so the caller's deferred
// cleanup only targets sessions abandoned on an error path.
musig2Sessions[idx] = nil
tx.TxIn[idx].Witness = wire.TxWitness{finalSig}
vm, err := txscript.NewEngine(
inputs[idx].PkScript, tx, idx,
txscript.StandardVerifyFlags, nil, sigHashes,
int64(inputs[idx].Value), prevOutFetcher,
)
if err != nil {
return nil, fmt.Errorf("unable to verify final MuSig2 "+
"signature for input %d: %w", idx, err)
}
if err := vm.Execute(); err != nil {
return nil, fmt.Errorf("invalid final MuSig2 signature "+
"for input %d: %w", idx, err)
}
}
return tx, nil

View file

@ -0,0 +1,261 @@
package instantout
import (
"context"
"testing"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/instantout/reservation"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
)
type invalidFinalSigSigner struct {
lndclient.SignerClient
}
func (s *invalidFinalSigSigner) MuSig2CombineSig(context.Context, [32]byte,
[][]byte) (bool, []byte, error) {
return true, make([]byte, 64), nil
}
type cleanupTrackingSigner struct {
lndclient.SignerClient
cleaned [][32]byte
}
type fixedHeightLightningClient struct {
lndclient.LightningClient
height uint32
err error
}
func (c *fixedHeightLightningClient) GetInfo(context.Context) (
*lndclient.Info, error) {
if c.err != nil {
return nil, c.err
}
return &lndclient.Info{BlockHeight: c.height}, nil
}
func (s *cleanupTrackingSigner) MuSig2Cleanup(_ context.Context,
sessionID [32]byte) error {
s.cleaned = append(s.cleaned, sessionID)
return nil
}
// TestMuSig2VectorLengthValidation verifies that malformed server-controlled
// vectors are rejected before they can be indexed.
func TestMuSig2VectorLengthValidation(t *testing.T) {
_, pubKey := btcec.PrivKeyFromBytes([]byte{1})
instantOut := &InstantOut{
Reservations: []*reservation.Reservation{
{
ClientPubkey: pubKey,
ServerPubkey: pubKey,
Value: btcutil.Amount(100_000),
Expiry: 200,
Outpoint: &wire.OutPoint{},
},
},
}
tx := wire.NewMsgTx(2)
tx.AddTxIn(&wire.TxIn{})
sessions := []*input.MuSig2SessionInfo{{}}
require.NotPanics(t, func() {
_, err := instantOut.signMusig2Tx(
context.Background(), nil, tx, sessions, nil,
)
require.ErrorContains(t, err, "server nonces")
})
require.NotPanics(t, func() {
_, err := instantOut.finalizeMusig2Transaction(
context.Background(), nil, sessions, tx, nil,
)
require.ErrorContains(t, err, "server signatures")
})
}
// TestFinalizeMuSig2TransactionVerifiesSignature verifies that a combined
// signature is validated locally before the transaction can be used as the
// instant-out safety net.
func TestFinalizeMuSig2TransactionVerifiesSignature(t *testing.T) {
_, pubKey := btcec.PrivKeyFromBytes([]byte{1})
res := &reservation.Reservation{
ClientPubkey: pubKey,
ServerPubkey: pubKey,
Value: btcutil.Amount(100_000),
Expiry: 200,
Outpoint: &wire.OutPoint{},
}
instantOut := &InstantOut{
Reservations: []*reservation.Reservation{res},
}
tx := wire.NewMsgTx(2)
tx.AddTxIn(&wire.TxIn{PreviousOutPoint: *res.Outpoint})
tx.AddTxOut(&wire.TxOut{Value: 90_000})
sessions := []*input.MuSig2SessionInfo{{}}
_, err := instantOut.finalizeMusig2Transaction(
context.Background(), &invalidFinalSigSigner{},
sessions, tx, [][]byte{{1}},
)
require.ErrorContains(t, err, "invalid final MuSig2 signature")
require.Nil(t, sessions[0])
}
// TestCleanupMuSig2Sessions verifies that all allocated sessions are released
// while nil entries from partial session creation are skipped.
func TestCleanupMuSig2Sessions(t *testing.T) {
firstID := [32]byte{1}
secondID := [32]byte{2}
signer := &cleanupTrackingSigner{}
err := cleanupMuSig2Sessions(
t.Context(), signer, []*input.MuSig2SessionInfo{
{SessionID: firstID}, nil, {SessionID: secondID},
},
)
require.NoError(t, err)
require.Equal(t, [][32]byte{firstID, secondID}, signer.cleaned)
}
// TestPushPreimageRejectsExpiringReservation verifies that recovery takes the
// on-chain fallback before revealing the preimage when a reservation is too
// close to its server-controlled timeout.
func TestPushPreimageRejectsExpiringReservation(t *testing.T) {
instantOutFSM := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
LndClient: &fixedHeightLightningClient{height: 100},
},
InstantOut: &InstantOut{
CltvExpiry: 200,
Reservations: []*reservation.Reservation{
{
ID: reservation.ID{1},
Expiry: 139,
},
},
},
}
event := instantOutFSM.PushPreimageAction(
t.Context(), &RecoverInstantOutCtx{},
)
require.Equal(t, OnErrorPublishHtlc, event)
require.ErrorContains(
t, instantOutFSM.LastActionError, "before recovery safety height",
)
}
// TestPushPreimageRejectsExpiringHtlc verifies that recovery uses a fresh
// chain height and leaves time to confirm both fallback transactions.
func TestPushPreimageRejectsExpiringHtlc(t *testing.T) {
instantOutFSM := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
LndClient: &fixedHeightLightningClient{height: 100},
},
InstantOut: &InstantOut{
CltvExpiry: 105,
Reservations: []*reservation.Reservation{
{
ID: reservation.ID{1},
Expiry: 200,
},
},
},
}
event := instantOutFSM.PushPreimageAction(
t.Context(), &RecoverInstantOutCtx{},
)
require.Equal(t, OnErrorPublishHtlc, event)
require.ErrorContains(
t, instantOutFSM.LastActionError,
"instant out HTLC expires at height 105",
)
}
// TestValidateInstantOutInvoiceAmount verifies enforcement of the fee cap at
// millisatoshi precision.
func TestValidateInstantOutInvoiceAmount(t *testing.T) {
const swapAmount = btcutil.Amount(100_000)
maxSwapFee := btcutil.Amount(200)
zeroSwapFee := btcutil.Amount(0)
negativeSwapFee := btcutil.Amount(-1)
tests := []struct {
name string
invoiceAmount lnwire.MilliSatoshi
maxSwapFee *btcutil.Amount
expectErr bool
}{
{
name: "exact fee cap",
invoiceAmount: lnwire.NewMSatFromSatoshis(
swapAmount + maxSwapFee,
),
maxSwapFee: &maxSwapFee,
},
{
name: "one millisatoshi over fee cap",
invoiceAmount: lnwire.NewMSatFromSatoshis(
swapAmount+maxSwapFee,
) + 1,
maxSwapFee: &maxSwapFee,
expectErr: true,
},
{
name: "discounted invoice",
invoiceAmount: lnwire.NewMSatFromSatoshis(
swapAmount - 1,
),
maxSwapFee: &zeroSwapFee,
},
{
name: "negative cap",
invoiceAmount: lnwire.NewMSatFromSatoshis(
swapAmount,
),
maxSwapFee: &negativeSwapFee,
expectErr: true,
},
{
name: "omitted cap",
invoiceAmount: lnwire.NewMSatFromSatoshis(
swapAmount + maxSwapFee + 1,
),
maxSwapFee: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateInstantOutInvoiceAmount(
tc.invoiceAmount, swapAmount, tc.maxSwapFee,
)
if tc.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}

View file

@ -20,6 +20,20 @@ var (
ErrSwapDoesNotExist = errors.New("swap does not exist")
)
type newInstantOutOptions struct {
maxSwapFee *btcutil.Amount
}
// NewInstantOutOption customizes an instant out request.
type NewInstantOutOption func(*newInstantOutOptions)
// WithMaxSwapFee limits the off-chain fee accepted for an instant out.
func WithMaxSwapFee(maxSwapFee btcutil.Amount) NewInstantOutOption {
return func(options *newInstantOutOptions) {
options.maxSwapFee = &maxSwapFee
}
}
// Manager manages the instantout state machines.
type Manager struct {
sync.Mutex
@ -119,8 +133,9 @@ func (m *Manager) recoverInstantOuts(ctx context.Context) error {
// As SendEvent can block, we'll start a goroutine to process
// the event.
recoverCtx := &RecoverInstantOutCtx{}
go func() {
err := instantOutFSM.SendEvent(ctx, OnRecover, nil)
err := instantOutFSM.SendEvent(ctx, OnRecover, recoverCtx)
if err != nil {
log.Errorf("FSM %v Error sending recover "+
"event %v, state: %v",
@ -135,7 +150,21 @@ func (m *Manager) recoverInstantOuts(ctx context.Context) error {
// NewInstantOut creates a new instantout.
func (m *Manager) NewInstantOut(ctx context.Context,
reservations []reservation.ID, sweepAddress string) (*FSM, error) {
reservations []reservation.ID, sweepAddress string,
options ...NewInstantOutOption) (*FSM, error) {
requestOptions := &newInstantOutOptions{}
for _, option := range options {
if option != nil {
option(requestOptions)
}
}
if requestOptions.maxSwapFee != nil &&
*requestOptions.maxSwapFee < 0 {
return nil, fmt.Errorf("maximum swap fee must not be negative")
}
var (
sweepAddr btcutil.Address
@ -158,6 +187,7 @@ func (m *Manager) NewInstantOut(ctx context.Context,
initationHeight: m.currentHeight,
protocolVersion: CurrentProtocolVersion(),
sweepAddress: sweepAddr,
maxSwapFee: requestOptions.maxSwapFee,
}
instantOut, err := NewFSM(m.cfg, ProtocolVersionFullReservation)

View file

@ -203,6 +203,7 @@ func TestSubscribeToConfirmationAction(t *testing.T) {
blockHeight int32
blockErr error
sendTxConf bool
outputValue btcutil.Amount
confErr error
expectedEvent fsm.EventType
}{
@ -210,8 +211,15 @@ func TestSubscribeToConfirmationAction(t *testing.T) {
name: "success",
blockHeight: 0,
sendTxConf: true,
outputValue: defaultValue,
expectedEvent: OnConfirmed,
},
{
name: "reservation value mismatch",
sendTxConf: true,
outputValue: defaultValue - 1,
expectedEvent: fsm.OnError,
},
{
name: "expired",
blockHeight: 100,
@ -273,7 +281,7 @@ func TestSubscribeToConfirmationAction(t *testing.T) {
TxIn: []*wire.TxIn{},
TxOut: []*wire.TxOut{
{
Value: int64(defaultValue),
Value: int64(tc.outputValue),
PkScript: pkScript,
},
},

View file

@ -8,14 +8,18 @@ import (
)
var (
ErrReservationAlreadyExists = fmt.Errorf("reservation already exists")
ErrReservationNotFound = fmt.Errorf("reservation not found")
ErrReservationAlreadyExists = fmt.Errorf("reservation already exists")
ErrReservationNotFound = fmt.Errorf("reservation not found")
ErrTooManyActiveReservations = fmt.Errorf(
"too many active reservations",
)
)
const (
KeyFamily = int32(42068)
DefaultConfTarget = int32(3)
IdLength = 32
KeyFamily = int32(42068)
DefaultConfTarget = int32(3)
IdLength = 32
maxActiveReservations = 1000
)
// Store is the interface that stores the reservations.

View file

@ -2,6 +2,7 @@ package reservation
import (
"context"
"errors"
"fmt"
"strings"
"sync"
@ -13,6 +14,11 @@ import (
reservationrpc "github.com/lightninglabs/loop/swapserverrpc"
)
var (
reservationStateWaitTimeout = 5 * time.Second
reservationStatePollDelay = time.Second
)
// Manager manages the reservation state machines.
type Manager struct {
sync.Mutex
@ -25,6 +31,28 @@ type Manager struct {
activeReservations map[ID]*FSM
}
// finalStateObserver removes a reservation FSM from the active set once it
// reaches a terminal state.
type finalStateObserver struct {
manager *Manager
id ID
fsm *FSM
}
// Notify implements the fsm.Observer interface.
func (o *finalStateObserver) Notify(notification fsm.Notification) {
if !isFinalState(notification.NextState) {
return
}
o.manager.Lock()
defer o.manager.Unlock()
if o.manager.activeReservations[o.id] == o.fsm {
delete(o.manager.activeReservations, o.id)
}
}
// NewManager creates a new reservation manager.
func NewManager(cfg *Config) *Manager {
return &Manager{
@ -80,7 +108,8 @@ func (m *Manager) Run(ctx context.Context, height int32,
runCtx, uint32(currentHeight), reservationRes,
)
if err != nil {
return err
log.Errorf("Unable to create reservation %x: %v",
reservationRes.ReservationId, err)
}
case err := <-newBlockErrChan:
@ -110,16 +139,41 @@ func (m *Manager) newReservation(ctx context.Context, currentHeight uint32,
return nil, err
}
_, err = m.cfg.Store.GetReservation(ctx, reservationID)
switch {
case err == nil:
return nil, ErrReservationAlreadyExists
case !errors.Is(err, ErrReservationNotFound):
return nil, err
}
// Create the reservation state machine. We need to pass in the runCtx
// of the reservation manager so that the state machine will keep on
// running even if the grpc conte
reservationFSM := NewFSM(m.cfg)
// Add the reservation to the active reservations map.
// Add the reservation to the active reservations map. Check the map while
// holding the lock as concurrent callers may both have completed the store
// lookup above.
m.Lock()
if _, ok := m.activeReservations[reservationID]; ok {
m.Unlock()
return nil, ErrReservationAlreadyExists
}
if len(m.activeReservations) >= maxActiveReservations {
m.Unlock()
return nil, ErrTooManyActiveReservations
}
m.activeReservations[reservationID] = reservationFSM
m.Unlock()
reservationFSM.RegisterObserver(&finalStateObserver{
manager: m,
id: reservationID,
fsm: reservationFSM,
})
initContext := &InitReservationContext{
reservationID: reservationID,
serverPubkey: serverKey,
@ -130,17 +184,19 @@ func (m *Manager) newReservation(ctx context.Context, currentHeight uint32,
// Send the init event to the state machine.
go func() {
err = reservationFSM.SendEvent(ctx, OnServerRequest, initContext)
if err != nil {
log.Errorf("Error sending init event: %v", err)
sendErr := reservationFSM.SendEvent(
ctx, OnServerRequest, initContext,
)
if sendErr != nil {
log.Errorf("Error sending init event: %v", sendErr)
}
}()
// We'll now wait for the reservation to be in the state where it is
// waiting to be confirmed.
err = reservationFSM.DefaultObserver.WaitForState(
ctx, 5*time.Second, WaitForConfirmation,
fsm.WithWaitForStateOption(time.Second),
ctx, reservationStateWaitTimeout, WaitForConfirmation,
fsm.WithWaitForStateOption(reservationStatePollDelay),
)
if err != nil {
if reservationFSM.LastActionError != nil {
@ -173,7 +229,14 @@ func (m *Manager) RecoverReservations(ctx context.Context) error {
reservationFSM := NewFSMFromReservation(m.cfg, reservation)
m.Lock()
m.activeReservations[reservation.ID] = reservationFSM
m.Unlock()
reservationFSM.RegisterObserver(&finalStateObserver{
manager: m,
id: reservation.ID,
fsm: reservationFSM,
})
// As SendEvent can block, we'll start a goroutine to process
// the event.
@ -211,7 +274,7 @@ func (m *Manager) LockReservation(ctx context.Context, id ID) error {
m.Unlock()
if !ok {
return fmt.Errorf("reservation not found")
return ErrReservationNotFound
}
// Try to send the lock event to the reservation.
@ -231,7 +294,20 @@ func (m *Manager) UnlockReservation(ctx context.Context, id ID) error {
m.Unlock()
if !ok {
return fmt.Errorf("reservation not found")
storedReservation, err := m.cfg.Store.GetReservation(ctx, id)
if err != nil {
return err
}
// Terminal reservations are removed from the active set. Treat an
// unlock after that removal as idempotent, while still surfacing a
// missing active FSM for reservations that should be running.
if isFinalState(storedReservation.State) {
return nil
}
return fmt.Errorf("%w: reservation %x is in state %v",
ErrReservationNotFound, id, storedReservation.State)
}
// Try to send the unlock event to the reservation.

View file

@ -12,6 +12,7 @@ import (
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/keychain"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@ -57,6 +58,7 @@ func TestManager(t *testing.T) {
confTx := &wire.MsgTx{
TxOut: []*wire.TxOut{
{
Value: int64(defaultValue),
PkScript: pkScript,
},
},
@ -97,6 +99,245 @@ func TestManager(t *testing.T) {
// We'll now expect the reservation to be expired.
err = reservationFSM.DefaultObserver.WaitForState(ctxb, 5*time.Second, Spent)
require.NoError(t, err)
testContext.manager.Lock()
_, ok := testContext.manager.activeReservations[defaultReservationId]
testContext.manager.Unlock()
require.False(t, ok)
}
// TestManagerContinuesAfterInvalidNotification verifies that a malformed
// server notification doesn't stop the reservation manager from processing
// later notifications.
func TestManagerContinuesAfterInvalidNotification(t *testing.T) {
testContext := newManagerTestContext(t)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
initChan := make(chan struct{})
errChan := make(chan error, 1)
go func() {
errChan <- testContext.manager.Run(
ctx, testContext.mockLnd.Height, initChan,
)
}()
<-initChan
// A malformed ID is rejected by newReservation. The manager should log
// the error and continue processing the stream.
testContext.reservationNotificationChan <- &swapserverrpc.ServerReservationNotification{
ReservationId: []byte{1},
}
testContext.reservationNotificationChan <- &swapserverrpc.ServerReservationNotification{
ReservationId: defaultReservationId[:],
Value: uint64(defaultValue),
ServerKey: defaultPubkeyBytes,
Expiry: uint32(testContext.mockLnd.Height) +
defaultExpiry,
}
select {
case <-testContext.mockLnd.RegisterConfChannel:
case err := <-errChan:
require.NoError(t, err)
t.Fatal("reservation manager stopped after malformed notification")
case <-time.After(5 * time.Second):
t.Fatal("valid reservation notification was not processed")
}
cancel()
require.NoError(t, <-errChan)
}
// TestManagerRejectsDuplicateReservation verifies that a duplicate server
// notification cannot replace the active FSM for an existing reservation.
func TestManagerRejectsDuplicateReservation(t *testing.T) {
testContext := newManagerTestContext(t)
ctx := t.Context()
req := &swapserverrpc.ServerReservationNotification{
ReservationId: defaultReservationId[:],
Value: uint64(defaultValue),
ServerKey: defaultPubkeyBytes,
Expiry: uint32(testContext.mockLnd.Height) +
defaultExpiry,
}
firstFSM, err := testContext.manager.newReservation(
ctx, uint32(testContext.mockLnd.Height), req,
)
require.NoError(t, err)
secondFSM, err := testContext.manager.newReservation(
ctx, uint32(testContext.mockLnd.Height), req,
)
require.ErrorIs(t, err, ErrReservationAlreadyExists)
require.Nil(t, secondFSM)
require.Same(
t, firstFSM,
testContext.manager.activeReservations[defaultReservationId],
)
}
// TestManagerLimitsActiveReservations verifies that server notifications
// cannot grow the active FSM set without bound.
func TestManagerLimitsActiveReservations(t *testing.T) {
testContext := newManagerTestContext(t)
for i := range maxActiveReservations {
var id ID
id[0] = byte(i)
id[1] = byte(i >> 8)
testContext.manager.activeReservations[id] = NewFSM(
testContext.manager.cfg,
)
}
reservationFSM, err := testContext.manager.newReservation(
t.Context(), uint32(testContext.mockLnd.Height),
&swapserverrpc.ServerReservationNotification{
ReservationId: defaultReservationId[:],
Value: uint64(defaultValue),
ServerKey: defaultPubkeyBytes,
Expiry: uint32(testContext.mockLnd.Height) +
defaultExpiry,
},
)
require.ErrorIs(t, err, ErrTooManyActiveReservations)
require.Nil(t, reservationFSM)
require.Len(
t, testContext.manager.activeReservations,
maxActiveReservations,
)
}
// TestManagerKeepsReservationAfterWaitTimeout verifies that a caller-side
// wait timeout doesn't evict a reservation FSM that is still initializing.
func TestManagerKeepsReservationAfterWaitTimeout(t *testing.T) {
testContext := newManagerTestContext(t)
originalWaitTimeout := reservationStateWaitTimeout
originalPollDelay := reservationStatePollDelay
reservationStateWaitTimeout = 20 * time.Millisecond
reservationStatePollDelay = time.Millisecond
t.Cleanup(func() {
reservationStateWaitTimeout = originalWaitTimeout
reservationStatePollDelay = originalPollDelay
})
releaseOpen := make(chan struct{})
testContext.mockReservationClient.ExpectedCalls = nil
testContext.mockReservationClient.On(
"OpenReservation", mock.Anything, mock.Anything, mock.Anything,
).Run(func(mock.Arguments) {
<-releaseOpen
}).Return(
&swapserverrpc.ServerOpenReservationResponse{}, nil,
)
reservationFSM, err := testContext.manager.newReservation(
t.Context(), uint32(testContext.mockLnd.Height),
&swapserverrpc.ServerReservationNotification{
ReservationId: defaultReservationId[:],
Value: uint64(defaultValue),
ServerKey: defaultPubkeyBytes,
Expiry: uint32(testContext.mockLnd.Height) +
defaultExpiry,
},
)
require.Error(t, err)
require.Nil(t, reservationFSM)
testContext.manager.Lock()
activeFSM := testContext.manager.activeReservations[defaultReservationId]
testContext.manager.Unlock()
require.NotNil(t, activeFSM)
close(releaseOpen)
require.NoError(t, activeFSM.DefaultObserver.WaitForState(
t.Context(), 5*time.Second, WaitForConfirmation,
))
}
// TestManagerRecoversAllPersistedReservations verifies that the cap applied to
// new notifications doesn't prevent the manager from resuming obligations
// already recorded in the database. The terminal transitions also exercise
// concurrent observer-driven removal from the active map.
func TestManagerRecoversAllPersistedReservations(t *testing.T) {
reservations := make([]*Reservation, maxActiveReservations+1)
for i := range reservations {
reservations[i] = &Reservation{
ID: ID{
byte(i), byte(i >> 8), byte(i >> 16),
},
State: Init,
ProtocolVersion: ProtocolVersionServerInitiated,
}
}
manager := NewManager(&Config{
Store: &recoveryStore{reservations: reservations},
})
require.NoError(t, manager.RecoverReservations(t.Context()))
require.Eventually(t, func() bool {
manager.Lock()
defer manager.Unlock()
return len(manager.activeReservations) == 0
}, 5*time.Second, time.Millisecond)
}
// TestUnlockTerminalReservationIsIdempotent verifies that cleanup can safely
// race with terminal-state eviction without masking the original swap result.
func TestUnlockTerminalReservationIsIdempotent(t *testing.T) {
testContext := newManagerTestContext(t)
storedReservation := &Reservation{
ID: defaultReservationId,
State: Init,
ClientPubkey: defaultPubkey,
ServerPubkey: defaultPubkey,
Value: defaultValue,
Expiry: defaultExpiry,
ProtocolVersion: ProtocolVersionServerInitiated,
KeyLocator: keychain.KeyLocator{
Family: keychain.KeyFamily(KeyFamily),
Index: 1,
},
}
require.NoError(t, testContext.manager.cfg.Store.CreateReservation(
t.Context(), storedReservation,
))
storedReservation.State = TimedOut
require.NoError(t, testContext.manager.cfg.Store.UpdateReservation(
t.Context(), storedReservation,
))
require.NoError(t, testContext.manager.UnlockReservation(
t.Context(), defaultReservationId,
))
require.ErrorIs(t, testContext.manager.UnlockReservation(
t.Context(), ID{1},
), ErrReservationNotFound)
}
type recoveryStore struct {
Store
reservations []*Reservation
}
func (s *recoveryStore) ListReservations(context.Context) ([]*Reservation,
error) {
return s.reservations, nil
}
func (s *recoveryStore) UpdateReservation(context.Context,
*Reservation) error {
return nil
}
// ManagerTestContext is a helper struct that contains all the necessary

View file

@ -142,8 +142,14 @@ func (r *Reservation) findReservationOutput(tx *wire.MsgTx) (*wire.OutPoint,
return nil, err
}
var foundScript bool
for i, txOut := range tx.TxOut {
if bytes.Equal(txOut.PkScript, pkScript) {
foundScript = true
if txOut.Value != int64(r.Value) {
continue
}
return &wire.OutPoint{
Hash: tx.TxHash(),
Index: uint32(i),
@ -151,6 +157,11 @@ func (r *Reservation) findReservationOutput(tx *wire.MsgTx) (*wire.OutPoint,
}
}
if foundScript {
return nil, fmt.Errorf("reservation output value mismatch: "+
"expected %d", r.Value)
}
return nil, errors.New("reservation output not found")
}

View file

@ -180,6 +180,10 @@ func (r *SQLStore) GetReservation(ctx context.Context,
return nil
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrReservationNotFound
}
return nil, err
}

View file

@ -104,7 +104,7 @@ func (s *SQLStore) CreateInstantLoopOut(ctx context.Context,
AmountRequested: int64(instantOut.Value),
CltvExpiry: instantOut.CltvExpiry,
MaxMinerFee: 0,
MaxSwapFee: 0,
MaxSwapFee: int64(instantOut.MaxSwapFee),
InitiationHeight: instantOut.initiationHeight,
ProtocolVersion: int32(instantOut.protocolVersion),
Label: "",
@ -368,6 +368,7 @@ func (s *SQLStore) sqlInstantOutToInstantOut(ctx context.Context,
protocolVersion: ProtocolVersion(row.ProtocolVersion),
initiationHeight: row.InitiationHeight,
Value: btcutil.Amount(row.AmountRequested),
MaxSwapFee: btcutil.Amount(row.MaxSwapFee),
keyLocator: keychain.KeyLocator{
Family: keychain.KeyFamily(row.ClientKeyFamily),
Index: uint32(row.ClientKeyIndex),

View file

@ -1763,8 +1763,15 @@ func (s *swapClientServer) InstantOut(ctx context.Context,
reservationIds[i] = resId
}
var options []instantout.NewInstantOutOption
if req.GetMaxSwapFee() != nil {
options = append(options, instantout.WithMaxSwapFee(
btcutil.Amount(req.GetMaxSwapFeeSat()),
))
}
instantOutFsm, err := s.instantOutManager.NewInstantOut(
ctx, reservationIds, req.DestAddr,
ctx, reservationIds, req.DestAddr, options...,
)
if err != nil {
return nil, err

View file

@ -4467,7 +4467,11 @@ type InstantOutRequest struct {
OutgoingChanSet []uint64 `protobuf:"varint,2,rep,packed,name=outgoing_chan_set,json=outgoingChanSet,proto3" json:"outgoing_chan_set,omitempty"`
// An optional address to sweep the onchain funds to. If not set, the funds
// will be swept to the wallet's internal address.
DestAddr string `protobuf:"bytes,3,opt,name=dest_addr,json=destAddr,proto3" json:"dest_addr,omitempty"`
DestAddr string `protobuf:"bytes,3,opt,name=dest_addr,json=destAddr,proto3" json:"dest_addr,omitempty"`
// Types that are valid to be assigned to MaxSwapFee:
//
// *InstantOutRequest_MaxSwapFeeSat
MaxSwapFee isInstantOutRequest_MaxSwapFee `protobuf_oneof:"max_swap_fee"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -4523,6 +4527,36 @@ func (x *InstantOutRequest) GetDestAddr() string {
return ""
}
func (x *InstantOutRequest) GetMaxSwapFee() isInstantOutRequest_MaxSwapFee {
if x != nil {
return x.MaxSwapFee
}
return nil
}
func (x *InstantOutRequest) GetMaxSwapFeeSat() int64 {
if x != nil {
if x, ok := x.MaxSwapFee.(*InstantOutRequest_MaxSwapFeeSat); ok {
return x.MaxSwapFeeSat
}
}
return 0
}
type isInstantOutRequest_MaxSwapFee interface {
isInstantOutRequest_MaxSwapFee()
}
type InstantOutRequest_MaxSwapFeeSat struct {
// The maximum off-chain swap fee that may be charged for the swap. If
// this field is omitted, no fee cap is applied for compatibility with
// clients that predate this field. An explicitly set value of zero
// rejects any positive swap fee.
MaxSwapFeeSat int64 `protobuf:"varint,4,opt,name=max_swap_fee_sat,json=maxSwapFeeSat,proto3,oneof"`
}
func (*InstantOutRequest_MaxSwapFeeSat) isInstantOutRequest_MaxSwapFee() {}
type InstantOutResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The hash of the swap preimage.
@ -6964,11 +6998,13 @@ const file_client_proto_rawDesc = "" +
"\x06amount\x18\x03 \x01(\x04R\x06amount\x12\x13\n" +
"\x05tx_id\x18\x04 \x01(\tR\x04txId\x12\x12\n" +
"\x04vout\x18\x05 \x01(\rR\x04vout\x12\x16\n" +
"\x06expiry\x18\x06 \x01(\rR\x06expiry\"\x85\x01\n" +
"\x06expiry\x18\x06 \x01(\rR\x06expiry\"\xc0\x01\n" +
"\x11InstantOutRequest\x12'\n" +
"\x0freservation_ids\x18\x01 \x03(\fR\x0ereservationIds\x12*\n" +
"\x11outgoing_chan_set\x18\x02 \x03(\x04R\x0foutgoingChanSet\x12\x1b\n" +
"\tdest_addr\x18\x03 \x01(\tR\bdestAddr\"t\n" +
"\tdest_addr\x18\x03 \x01(\tR\bdestAddr\x12)\n" +
"\x10max_swap_fee_sat\x18\x04 \x01(\x03H\x00R\rmaxSwapFeeSatB\x0e\n" +
"\fmax_swap_fee\"t\n" +
"\x12InstantOutResponse\x12(\n" +
"\x10instant_out_hash\x18\x01 \x01(\fR\x0einstantOutHash\x12\x1e\n" +
"\vsweep_tx_id\x18\x02 \x01(\tR\tsweepTxId\x12\x14\n" +
@ -7490,6 +7526,9 @@ func file_client_proto_init() {
(*SweepHtlcResponse_Published)(nil),
(*SweepHtlcResponse_Failed)(nil),
}
file_client_proto_msgTypes[48].OneofWrappers = []any{
(*InstantOutRequest_MaxSwapFeeSat)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{

View file

@ -1685,6 +1685,16 @@ message InstantOutRequest {
will be swept to the wallet's internal address.
*/
string dest_addr = 3;
oneof max_swap_fee {
/*
The maximum off-chain swap fee that may be charged for the swap. If
this field is omitted, no fee cap is applied for compatibility with
clients that predate this field. An explicitly set value of zero
rejects any positive swap fee.
*/
int64 max_swap_fee_sat = 4;
}
}
message InstantOutResponse {

View file

@ -1917,6 +1917,11 @@
"dest_addr": {
"type": "string",
"description": "An optional address to sweep the onchain funds to. If not set, the funds\nwill be swept to the wallet's internal address."
},
"max_swap_fee_sat": {
"type": "string",
"format": "int64",
"description": "The maximum off-chain swap fee that may be charged for the swap. If\nthis field is omitted, no fee cap is applied for compatibility with\nclients that predate this field. An explicitly set value of zero\nrejects any positive swap fee."
}
}
},

32
looprpc/client_test.go Normal file
View file

@ -0,0 +1,32 @@
package looprpc
import (
"testing"
"google.golang.org/protobuf/proto"
)
// TestInstantOutMaxSwapFeePresence verifies that an omitted fee cap remains
// distinguishable from an explicitly encoded zero while retaining the scalar
// field's original wire representation.
func TestInstantOutMaxSwapFeePresence(t *testing.T) {
request := &InstantOutRequest{}
if err := proto.Unmarshal(nil, request); err != nil {
t.Fatalf("unable to unmarshal omitted cap: %v", err)
}
if request.GetMaxSwapFee() != nil {
t.Fatal("omitted cap unexpectedly has presence")
}
// Field four, encoded as a varint with value zero. This is the same wire
// representation used before the field gained presence semantics.
if err := proto.Unmarshal([]byte{0x20, 0x00}, request); err != nil {
t.Fatalf("unable to unmarshal explicit zero cap: %v", err)
}
if request.GetMaxSwapFee() == nil {
t.Fatal("explicit zero cap lost presence")
}
if request.GetMaxSwapFeeSat() != 0 {
t.Fatalf("expected zero cap, got %d", request.GetMaxSwapFeeSat())
}
}

View file

@ -177,18 +177,30 @@ var RequiredPermissions = map[string][]bakery.Op{
"/looprpc.SwapClient/ListReservations": {{
Entity: "swap",
Action: "read",
}, {
Entity: "loop",
Action: "out",
}},
"/looprpc.SwapClient/InstantOut": {{
Entity: "swap",
Action: "execute",
}, {
Entity: "loop",
Action: "out",
}},
"/looprpc.SwapClient/InstantOutQuote": {{
Entity: "swap",
Action: "read",
}, {
Entity: "loop",
Action: "out",
}},
"/looprpc.SwapClient/ListInstantOuts": {{
Entity: "swap",
Action: "read",
}, {
Entity: "loop",
Action: "out",
}},
"/looprpc.SwapClient/StopDaemon": {{
Entity: "loop",