mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Before we send HTLC signatures to the server, the server cannot publish the HTLC transaction. After those signatures are handed over, the server can publish an HTLC that spends the selected deposits even if it never pays the swap invoice. Defend against stale local deposit state by checking the wallet's current txout view immediately before signing. A deposit can have been spent by a known withdrawal, channel open, timeout sweep, replacement, or another wallet transaction while the loop-in FSM is recovering or while earlier state still marked it as selected. Failing before signing leaves the server without spend authority over an unavailable input. Include mempool spends in the check so wallet-known unconfirmed spends are treated as unavailable too.
1118 lines
32 KiB
Go
1118 lines
32 KiB
Go
package loopin
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcd/btcec/v2"
|
|
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
|
|
"github.com/btcsuite/btcd/btcutil"
|
|
"github.com/btcsuite/btcd/txscript"
|
|
"github.com/btcsuite/btcd/wire"
|
|
"github.com/btcsuite/btcwallet/chain"
|
|
"github.com/lightninglabs/lndclient"
|
|
"github.com/lightninglabs/loop"
|
|
"github.com/lightninglabs/loop/fsm"
|
|
"github.com/lightninglabs/loop/staticaddr/deposit"
|
|
"github.com/lightninglabs/loop/staticaddr/staticutil"
|
|
"github.com/lightninglabs/loop/staticaddr/version"
|
|
"github.com/lightninglabs/loop/swap"
|
|
"github.com/lightninglabs/loop/swapserverrpc"
|
|
"github.com/lightningnetwork/lnd/chainntnfs"
|
|
"github.com/lightningnetwork/lnd/input"
|
|
"github.com/lightningnetwork/lnd/invoices"
|
|
"github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
|
|
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"github.com/lightningnetwork/lnd/lnwallet"
|
|
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
|
"github.com/lightningnetwork/lnd/lnwire"
|
|
)
|
|
|
|
const (
|
|
defaultConfTarget = 3
|
|
|
|
DefaultPaymentTimeoutSeconds = 60
|
|
|
|
defaultInvoiceCleanupTimeout = 5 * time.Second
|
|
)
|
|
|
|
var (
|
|
// ErrFeeTooHigh is returned if the server sets a fee rate for the htlc
|
|
// tx that is too high. We prevent here against a low htlc timeout sweep
|
|
// amount.
|
|
ErrFeeTooHigh = errors.New("server htlc tx fee is higher than the " +
|
|
"configured allowed maximum")
|
|
|
|
// ErrBackupFeeTooHigh is returned if the server sets a fee rate for the
|
|
// htlc backup tx that is too high. We prevent here against a low htlc
|
|
// timeout sweep amount.
|
|
ErrBackupFeeTooHigh = errors.New("server htlc backup tx fee is " +
|
|
"higher than the configured allowed maximum")
|
|
)
|
|
|
|
// InitHtlcAction is executed if all loop-in information has been validated. We
|
|
// assemble a loop-in request and send it to the server.
|
|
func (f *FSM) InitHtlcAction(ctx context.Context,
|
|
_ fsm.EventContext) fsm.EventType {
|
|
|
|
var event fsm.EventType
|
|
invoiceNeedsCleanup := false
|
|
defer func() {
|
|
// If we created the private invoice but failed before persisting the
|
|
// swap, cancel it so retries do not accumulate orphan invoices.
|
|
if !invoiceNeedsCleanup || event != fsm.OnError {
|
|
return
|
|
}
|
|
|
|
f.cancelSwapInvoice()
|
|
}()
|
|
|
|
returnError := func(err error) fsm.EventType {
|
|
event = f.HandleError(err)
|
|
|
|
return event
|
|
}
|
|
|
|
// Lock the deposits and transition them to the LoopingIn state.
|
|
err := f.cfg.DepositManager.TransitionDeposits(
|
|
ctx, f.loopIn.Deposits, deposit.OnLoopInInitiated,
|
|
deposit.LoopingIn,
|
|
)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to loop-in deposits: %w", err)
|
|
|
|
return returnError(err)
|
|
}
|
|
|
|
// Calculate the swap invoice amount. The server needs to pay us the
|
|
// swap amount minus the fees that the server charges for the swap. The
|
|
// swap amount is either the total value of the selected deposits, or
|
|
// the selected amount if a specific amount was requested.
|
|
totalDepositAmount := f.loopIn.TotalDepositAmount()
|
|
swapAmount := totalDepositAmount
|
|
var changeAmount btcutil.Amount
|
|
var hasChange bool
|
|
if f.loopIn.SelectedAmount > 0 {
|
|
swapAmount = f.loopIn.SelectedAmount
|
|
changeAmount = totalDepositAmount - swapAmount
|
|
hasChange = changeAmount > 0 && changeAmount < totalDepositAmount
|
|
}
|
|
swapInvoiceAmt := swapAmount - f.loopIn.QuotedSwapFee
|
|
|
|
// Generate random preimage.
|
|
var swapPreimage lntypes.Preimage
|
|
if _, err = rand.Read(swapPreimage[:]); err != nil {
|
|
err = fmt.Errorf("unable to create random swap preimage: %w",
|
|
err)
|
|
|
|
return returnError(err)
|
|
}
|
|
f.loopIn.SwapPreimage = swapPreimage
|
|
f.loopIn.SwapHash = swapPreimage.Hash()
|
|
|
|
// Derive a client key for the HTLC.
|
|
keyDesc, err := f.cfg.WalletKit.DeriveNextKey(
|
|
ctx, swap.StaticAddressKeyFamily,
|
|
)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to derive client htlc key: %w", err)
|
|
|
|
return returnError(err)
|
|
}
|
|
f.loopIn.ClientPubkey = keyDesc.PubKey
|
|
f.loopIn.HtlcKeyLocator = keyDesc.KeyLocator
|
|
|
|
// Create the swap invoice in lnd.
|
|
_, swapInvoice, err := f.cfg.LndClient.AddInvoice(
|
|
ctx, &invoicesrpc.AddInvoiceData{
|
|
Preimage: &swapPreimage,
|
|
Value: lnwire.NewMSatFromSatoshis(swapInvoiceAmt),
|
|
Memo: "static address loop-in",
|
|
Expiry: 3600 * 24 * 365,
|
|
RouteHints: f.loopIn.RouteHints,
|
|
Private: true,
|
|
},
|
|
)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to create swap invoice: %w", err)
|
|
|
|
return returnError(err)
|
|
}
|
|
f.loopIn.SwapInvoice = swapInvoice
|
|
|
|
// From here until CreateLoopIn succeeds, any error path would otherwise
|
|
// leave behind a live invoice with no persisted swap to recover it.
|
|
invoiceNeedsCleanup = true
|
|
|
|
f.loopIn.ProtocolVersion = version.AddressProtocolVersion(
|
|
version.CurrentRPCProtocolVersion(),
|
|
)
|
|
|
|
loopInReq := &swapserverrpc.ServerStaticAddressLoopInRequest{
|
|
SwapHash: f.loopIn.SwapHash[:],
|
|
DepositOutpoints: f.loopIn.DepositOutpoints,
|
|
Amount: uint64(f.loopIn.SelectedAmount),
|
|
HtlcClientPubKey: f.loopIn.ClientPubkey.SerializeCompressed(),
|
|
SwapInvoice: f.loopIn.SwapInvoice,
|
|
ProtocolVersion: version.CurrentRPCProtocolVersion(),
|
|
UserAgent: loop.UserAgent(f.loopIn.Initiator),
|
|
PaymentTimeoutSeconds: f.loopIn.PaymentTimeoutSeconds,
|
|
Fast: f.loopIn.Fast,
|
|
}
|
|
if f.loopIn.LastHop != nil {
|
|
loopInReq.LastHop = f.loopIn.LastHop
|
|
}
|
|
|
|
loopInResp, err := f.cfg.Server.ServerStaticAddressLoopIn(
|
|
ctx, loopInReq,
|
|
)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to initiate the loop-in with the "+
|
|
"server: %w", err)
|
|
|
|
return returnError(err)
|
|
}
|
|
|
|
// Pushing empty sigs signals the server that we abandoned the swap
|
|
// attempt.
|
|
pushEmptySigs := func() {
|
|
_, err = f.cfg.Server.PushStaticAddressHtlcSigs(
|
|
ctx, &swapserverrpc.PushStaticAddressHtlcSigsRequest{
|
|
SwapHash: f.loopIn.SwapHash[:],
|
|
},
|
|
)
|
|
if err != nil {
|
|
log.Warnf("unable to push htlc tx sigs to server: %v",
|
|
err)
|
|
}
|
|
}
|
|
|
|
serverPubkey, err := btcec.ParsePubKey(loopInResp.HtlcServerPubKey)
|
|
if err != nil {
|
|
pushEmptySigs()
|
|
err = fmt.Errorf("unable to parse server pubkey: %w", err)
|
|
|
|
return returnError(err)
|
|
}
|
|
f.loopIn.ServerPubkey = serverPubkey
|
|
|
|
// Validate if the response parameters are outside our allowed range
|
|
// preventing us from continuing with a swap.
|
|
err = f.cfg.ValidateLoopInContract(
|
|
int32(f.loopIn.InitiationHeight), loopInResp.HtlcExpiry,
|
|
)
|
|
if err != nil {
|
|
pushEmptySigs()
|
|
err = fmt.Errorf("server response parameters are outside "+
|
|
"our allowed range: %w", err)
|
|
|
|
return returnError(err)
|
|
}
|
|
|
|
f.loopIn.HtlcCltvExpiry = loopInResp.HtlcExpiry
|
|
f.htlcServerNonces, err = toNonces(loopInResp.StandardHtlcInfo.Nonces)
|
|
if err != nil {
|
|
pushEmptySigs()
|
|
err = fmt.Errorf("unable to convert server nonces: %w", err)
|
|
|
|
return returnError(err)
|
|
}
|
|
f.htlcServerNoncesHighFee, err = toNonces(
|
|
loopInResp.HighFeeHtlcInfo.Nonces,
|
|
)
|
|
if err != nil {
|
|
pushEmptySigs()
|
|
|
|
return returnError(err)
|
|
}
|
|
f.htlcServerNoncesExtremelyHighFee, err = toNonces(
|
|
loopInResp.ExtremeFeeHtlcInfo.Nonces,
|
|
)
|
|
if err != nil {
|
|
pushEmptySigs()
|
|
|
|
return returnError(err)
|
|
}
|
|
|
|
// We need to defend against the server setting high fees for the htlc
|
|
// tx since we might have to sweep the timeout path. We maximally allow
|
|
// a configured percentage of the swap value to be spent on fees.
|
|
amt := float64(swapAmount)
|
|
maxHtlcTxFee := btcutil.Amount(amt *
|
|
f.cfg.MaxStaticAddrHtlcFeePercentage)
|
|
|
|
maxHtlcTxBackupFee := btcutil.Amount(amt *
|
|
f.cfg.MaxStaticAddrHtlcBackupFeePercentage)
|
|
|
|
htlcWeight := f.loopIn.htlcWeight(hasChange)
|
|
feeRate := chainfee.SatPerKWeight(loopInResp.StandardHtlcInfo.FeeRate)
|
|
fee := feeRate.FeeForWeight(htlcWeight)
|
|
highFeeRate := chainfee.SatPerKWeight(loopInResp.HighFeeHtlcInfo.FeeRate)
|
|
highFee := highFeeRate.FeeForWeight(htlcWeight)
|
|
extremelyHighFeeRate := chainfee.SatPerKWeight(
|
|
loopInResp.ExtremeFeeHtlcInfo.FeeRate,
|
|
)
|
|
extremelyHighFee := extremelyHighFeeRate.FeeForWeight(htlcWeight)
|
|
|
|
f.Debugf("htlc fee validation: "+
|
|
"deposit_count=%d, total_deposit=%v, "+
|
|
"swap_amount=%v, change_amount=%v, has_change=%v, "+
|
|
"htlc_weight=%v, standard_fee_rate=%v, standard_fee=%v, "+
|
|
"high_fee_rate=%v, high_fee=%v, extreme_fee_rate=%v, "+
|
|
"extreme_fee=%v, max_fee=%v, max_backup_fee=%v",
|
|
len(f.loopIn.Deposits), totalDepositAmount, swapAmount,
|
|
changeAmount, hasChange, htlcWeight, feeRate, fee, highFeeRate,
|
|
highFee, extremelyHighFeeRate, extremelyHighFee, maxHtlcTxFee,
|
|
maxHtlcTxBackupFee)
|
|
|
|
if fee > maxHtlcTxFee {
|
|
// Abort the swap by pushing empty sigs to the server.
|
|
pushEmptySigs()
|
|
|
|
f.Errorf("server standard htlc tx fee is higher than the "+
|
|
"configured allowed maximum: %v > %v "+
|
|
"(fee_rate=%v, weight=%v)",
|
|
fee, maxHtlcTxFee, feeRate, htlcWeight)
|
|
|
|
return returnError(ErrFeeTooHigh)
|
|
}
|
|
f.loopIn.HtlcTxFeeRate = feeRate
|
|
|
|
if highFee > maxHtlcTxBackupFee {
|
|
// Abort the swap by pushing empty sigs to the server.
|
|
pushEmptySigs()
|
|
|
|
f.Errorf("server high-fee htlc backup tx fee is higher "+
|
|
"than the configured allowed maximum: %v > %v "+
|
|
"(fee_rate=%v, weight=%v)",
|
|
highFee, maxHtlcTxBackupFee, highFeeRate, htlcWeight)
|
|
|
|
return returnError(ErrFeeTooHigh)
|
|
}
|
|
f.loopIn.HtlcTxHighFeeRate = highFeeRate
|
|
|
|
if extremelyHighFee > maxHtlcTxBackupFee {
|
|
// Abort the swap by pushing empty sigs to the server.
|
|
pushEmptySigs()
|
|
|
|
f.Errorf("server extreme-fee htlc backup tx fee is "+
|
|
"higher than the configured allowed maximum: %v > %v "+
|
|
"(fee_rate=%v, weight=%v)",
|
|
extremelyHighFee, maxHtlcTxBackupFee,
|
|
extremelyHighFeeRate, htlcWeight)
|
|
|
|
return returnError(ErrFeeTooHigh)
|
|
}
|
|
f.loopIn.HtlcTxExtremelyHighFeeRate = extremelyHighFeeRate
|
|
|
|
// Derive the sweep address for the htlc timeout sweep tx.
|
|
sweepAddress, err := f.cfg.WalletKit.NextAddr(
|
|
ctx, lnwallet.DefaultAccountName,
|
|
walletrpc.AddressType_TAPROOT_PUBKEY, false,
|
|
)
|
|
if err != nil {
|
|
pushEmptySigs()
|
|
err = fmt.Errorf("unable to derive htlc timeout sweep "+
|
|
"address: %w", err)
|
|
|
|
return returnError(err)
|
|
}
|
|
f.loopIn.HtlcTimeoutSweepAddress = sweepAddress
|
|
|
|
// Once the htlc tx is initiated, we store the loop-in in the database.
|
|
err = f.cfg.Store.CreateLoopIn(ctx, f.loopIn)
|
|
if err != nil {
|
|
pushEmptySigs()
|
|
err = fmt.Errorf("unable to store loop-in in db: %w", err)
|
|
|
|
return returnError(err)
|
|
}
|
|
|
|
// Once the swap is stored, restart/recovery code owns invoice lifecycle.
|
|
invoiceNeedsCleanup = false
|
|
|
|
event = OnHtlcInitiated
|
|
|
|
return event
|
|
}
|
|
|
|
// cancelSwapInvoice best-effort cancels the current swap invoice using a
|
|
// detached timeout-limited context.
|
|
func (f *FSM) cancelSwapInvoice() {
|
|
if f.loopIn.SwapInvoice == "" {
|
|
return
|
|
}
|
|
|
|
cleanupCtx, cancel := context.WithTimeout(
|
|
context.Background(), defaultInvoiceCleanupTimeout,
|
|
)
|
|
defer cancel()
|
|
|
|
err := f.cfg.InvoicesClient.CancelInvoice(cleanupCtx, f.loopIn.SwapHash)
|
|
if err != nil {
|
|
f.Warnf("unable to cancel invoice for swap %v: %v",
|
|
f.loopIn.SwapHash, err)
|
|
}
|
|
}
|
|
|
|
// handleInvoiceUpdate applies the monitor state's invoice-update semantics and
|
|
// reports whether the update produced a terminal event.
|
|
func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) (
|
|
fsm.EventType, bool) {
|
|
|
|
switch update.State {
|
|
case invoices.ContractOpen:
|
|
return fsm.NoOp, false
|
|
|
|
case invoices.ContractAccepted:
|
|
return fsm.NoOp, false
|
|
|
|
case invoices.ContractSettled:
|
|
f.Debugf("received off-chain payment update %v", update.State)
|
|
return OnPaymentReceived, true
|
|
|
|
case invoices.ContractCanceled:
|
|
// If the invoice was canceled we only log here since we still need
|
|
// to monitor until the htlc timed out.
|
|
log.Warnf("invoice for swap hash %v canceled", f.loopIn.SwapHash)
|
|
return fsm.NoOp, false
|
|
|
|
default:
|
|
err := fmt.Errorf("unexpected invoice state %v for swap hash %v "+
|
|
"canceled", update.State, f.loopIn.SwapHash)
|
|
return f.HandleError(err), true
|
|
}
|
|
}
|
|
|
|
// SignHtlcTxAction is called if the htlc was initialized and the server
|
|
// provided the necessary information to construct the htlc tx. We sign the htlc
|
|
// tx and send the signatures to the server.
|
|
func (f *FSM) SignHtlcTxAction(ctx context.Context,
|
|
_ fsm.EventContext) fsm.EventType {
|
|
|
|
var err error
|
|
|
|
f.loopIn.AddressParams, err =
|
|
f.cfg.AddressManager.GetStaticAddressParameters(ctx)
|
|
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to get static address parameters: "+
|
|
"%w", err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
f.loopIn.Address, err = f.cfg.AddressManager.GetStaticAddress(ctx)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to get static address: %w", err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
err = f.checkDepositsAvailable(ctx)
|
|
if err != nil {
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
// Create a musig2 session for each deposit and different htlc tx fee
|
|
// rates.
|
|
createSession := staticutil.CreateMusig2Sessions
|
|
htlcSessions, clientHtlcNonces, err := createSession(
|
|
ctx, f.cfg.Signer, f.loopIn.Deposits, f.loopIn.AddressParams,
|
|
f.loopIn.Address,
|
|
)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to create musig2 sessions: %w", err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
defer f.cleanUpSessions(ctx, htlcSessions)
|
|
|
|
htlcSessionsHighFee, highFeeNonces, err := createSession(
|
|
ctx, f.cfg.Signer, f.loopIn.Deposits, f.loopIn.AddressParams,
|
|
f.loopIn.Address,
|
|
)
|
|
if err != nil {
|
|
return f.HandleError(err)
|
|
}
|
|
defer f.cleanUpSessions(ctx, htlcSessionsHighFee)
|
|
|
|
htlcSessionsExtremelyHighFee, extremelyHighNonces, err := createSession(
|
|
ctx, f.cfg.Signer, f.loopIn.Deposits, f.loopIn.AddressParams,
|
|
f.loopIn.Address,
|
|
)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to convert nonces: %w", err)
|
|
return f.HandleError(err)
|
|
}
|
|
defer f.cleanUpSessions(ctx, htlcSessionsExtremelyHighFee)
|
|
|
|
// Create the htlc txns for different fee rates.
|
|
htlcTx, err := f.loopIn.createHtlcTx(
|
|
f.cfg.ChainParams, f.loopIn.HtlcTxFeeRate,
|
|
f.cfg.MaxStaticAddrHtlcFeePercentage,
|
|
)
|
|
if err != nil {
|
|
return f.HandleError(err)
|
|
}
|
|
htlcTxHighFee, err := f.loopIn.createHtlcTx(
|
|
f.cfg.ChainParams, f.loopIn.HtlcTxHighFeeRate,
|
|
f.cfg.MaxStaticAddrHtlcBackupFeePercentage,
|
|
)
|
|
if err != nil {
|
|
return f.HandleError(err)
|
|
}
|
|
htlcTxExtremelyHighFee, err := f.loopIn.createHtlcTx(
|
|
f.cfg.ChainParams, f.loopIn.HtlcTxExtremelyHighFeeRate,
|
|
f.cfg.MaxStaticAddrHtlcBackupFeePercentage,
|
|
)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to create the htlc tx: %w", err)
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
// Next we'll get our htlc tx signatures for different fee rates.
|
|
htlcSigs, err := f.loopIn.signMusig2Tx(
|
|
ctx, htlcTx, f.cfg.Signer, htlcSessions, f.htlcServerNonces,
|
|
)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to sign htlc tx: %w", err)
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
htlcSigsHighFee, err := f.loopIn.signMusig2Tx(
|
|
ctx, htlcTxHighFee, f.cfg.Signer, htlcSessionsHighFee,
|
|
f.htlcServerNoncesHighFee,
|
|
)
|
|
if err != nil {
|
|
return f.HandleError(err)
|
|
}
|
|
htlcSigsExtremelyHighFee, err := f.loopIn.signMusig2Tx(
|
|
ctx, htlcTxExtremelyHighFee, f.cfg.Signer,
|
|
htlcSessionsExtremelyHighFee, f.htlcServerNoncesExtremelyHighFee,
|
|
)
|
|
if err != nil {
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
// Push htlc tx sigs to server.
|
|
pushHtlcReq := &swapserverrpc.PushStaticAddressHtlcSigsRequest{
|
|
SwapHash: f.loopIn.SwapHash[:],
|
|
StandardHtlcInfo: &swapserverrpc.ClientHtlcSigningInfo{
|
|
Nonces: clientHtlcNonces,
|
|
Sigs: htlcSigs,
|
|
},
|
|
HighFeeHtlcInfo: &swapserverrpc.ClientHtlcSigningInfo{
|
|
Nonces: highFeeNonces,
|
|
Sigs: htlcSigsHighFee,
|
|
},
|
|
ExtremeFeeHtlcInfo: &swapserverrpc.ClientHtlcSigningInfo{
|
|
Nonces: extremelyHighNonces,
|
|
Sigs: htlcSigsExtremelyHighFee,
|
|
},
|
|
}
|
|
_, err = f.cfg.Server.PushStaticAddressHtlcSigs(ctx, pushHtlcReq)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to push htlc tx sigs to server: %w",
|
|
err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
// Note:
|
|
// From here on we need to monitor for the htlc tx hitting the chain
|
|
// until the invoice is settled because the server can now publish the
|
|
// htlc tx without paying the invoice. In this case we need to wait till
|
|
// the htlc times out and then sweep it back to us.
|
|
return OnHtlcTxSigned
|
|
}
|
|
|
|
// checkDepositsAvailable verifies that all loop-in deposits are still available
|
|
// before the client signs the HTLC transaction.
|
|
func (f *FSM) checkDepositsAvailable(ctx context.Context) error {
|
|
outpoints, err := f.validateSigningDepositOutpoints()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if f.cfg.TxOutChecker == nil {
|
|
return nil
|
|
}
|
|
|
|
txOuts, err := f.cfg.TxOutChecker.GetTxOuts(ctx, outpoints)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to check deposits: %w", err)
|
|
}
|
|
|
|
for _, outpoint := range outpoints {
|
|
if txOuts[outpoint] == nil {
|
|
return fmt.Errorf("deposit %v is no longer available",
|
|
outpoint)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateSigningDepositOutpoints verifies that the current deposit rows match
|
|
// the server-side outpoint snapshot before signing the HTLC transaction.
|
|
func (f *FSM) validateSigningDepositOutpoints() ([]wire.OutPoint, error) {
|
|
currentOutpoints := f.loopIn.Outpoints()
|
|
if len(f.loopIn.DepositOutpoints) == 0 {
|
|
return currentOutpoints, nil
|
|
}
|
|
|
|
if len(f.loopIn.DepositOutpoints) != len(currentOutpoints) {
|
|
return nil, fmt.Errorf("deposit outpoint snapshot has %d "+
|
|
"outpoints, current deposits have %d",
|
|
len(f.loopIn.DepositOutpoints), len(currentOutpoints))
|
|
}
|
|
|
|
snapshotOutpoints := make(
|
|
[]wire.OutPoint, len(f.loopIn.DepositOutpoints),
|
|
)
|
|
for i, snapshot := range f.loopIn.DepositOutpoints {
|
|
outpoint, err := wire.NewOutPointFromString(snapshot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to parse deposit "+
|
|
"outpoint snapshot %q: %w", snapshot, err)
|
|
}
|
|
|
|
snapshotOutpoints[i] = *outpoint
|
|
if *outpoint != currentOutpoints[i] {
|
|
return nil, fmt.Errorf("deposit outpoint snapshot "+
|
|
"mismatch at index %d: snapshot %v, "+
|
|
"current %v", i, outpoint, currentOutpoints[i])
|
|
}
|
|
}
|
|
|
|
return snapshotOutpoints, nil
|
|
}
|
|
|
|
// cleanUpSessions releases allocated memory of the musig2 sessions.
|
|
func (f *FSM) cleanUpSessions(ctx context.Context,
|
|
sessions []*input.MuSig2SessionInfo) {
|
|
|
|
for _, s := range sessions {
|
|
err := f.cfg.Signer.MuSig2Cleanup(
|
|
context.WithoutCancel(ctx), s.SessionID,
|
|
)
|
|
if err != nil {
|
|
f.Warnf("unable to cleanup musig2 session: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MonitorInvoiceAndHtlcTxAction is called after the htlc tx has been signed by
|
|
// us. The server from here on has the ability to publish the htlc tx. If the
|
|
// server publishes the htlc tx without paying the invoice, we have to monitor
|
|
// for the timeout path and sweep the funds back to us. If, while waiting for
|
|
// the htlc timeout, our invoice gets paid, the swap is considered successful,
|
|
// and we can stop monitoring the htlc confirmation and continue to sign the
|
|
// sweepless sweep.
|
|
func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|
_ fsm.EventContext) fsm.EventType {
|
|
|
|
// Subscribe to the state of the swap invoice. If upon restart recovery,
|
|
// we land here and observe that the invoice is already canceled, it can
|
|
// only be the case where a user-provided payment timeout was hit, the
|
|
// invoice got canceled and the timeout of the htlc was not reached yet.
|
|
// So we want to wait until the htlc timeout path opens up so that we
|
|
// could sweep the funds back to us if the server published it without
|
|
// paying the invoice.
|
|
subscribeCtx, cancelInvoiceSubscription := context.WithCancel(ctx)
|
|
defer cancelInvoiceSubscription()
|
|
|
|
invoiceUpdateChan, invoiceErrChan, err :=
|
|
f.cfg.InvoicesClient.SubscribeSingleInvoice(
|
|
subscribeCtx, f.loopIn.SwapHash,
|
|
)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return fsm.NoOp
|
|
}
|
|
|
|
err = fmt.Errorf("unable to subscribe to swap "+
|
|
"invoice: %w", err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
htlc, err := f.loopIn.getHtlc(f.cfg.ChainParams)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to get htlc: %w", err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
// Subscribe to htlc tx confirmation.
|
|
reorgChan := make(chan struct{}, 1)
|
|
registerHtlcConf := func() (chan *chainntnfs.TxConfirmation, chan error,
|
|
error) {
|
|
|
|
return f.cfg.ChainNotifier.RegisterConfirmationsNtfn(
|
|
ctx, nil, htlc.PkScript, defaultConfTarget,
|
|
int32(f.loopIn.InitiationHeight),
|
|
lndclient.WithReOrgChan(reorgChan),
|
|
)
|
|
}
|
|
|
|
htlcConfChan, htlcErrConfChan, err := registerHtlcConf()
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return fsm.NoOp
|
|
}
|
|
|
|
err = fmt.Errorf("unable to monitor htlc tx confirmation: %w",
|
|
err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
// Subscribe to new blocks.
|
|
registerBlocks := f.cfg.ChainNotifier.RegisterBlockEpochNtfn
|
|
blockChan, blockChanErr, err := registerBlocks(ctx)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return fsm.NoOp
|
|
}
|
|
|
|
err = fmt.Errorf("unable to subscribe to new blocks: %w", err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
// Look up the current invoice state after registering subscriptions so
|
|
// recovery can resume the payment deadline from the latest known state.
|
|
invoice, err := f.cfg.LndClient.LookupInvoice(ctx, f.loopIn.SwapHash)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return fsm.NoOp
|
|
}
|
|
|
|
err = fmt.Errorf("unable to look up invoice by swap hash: %w",
|
|
err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
// Create the swap payment timeout timer. If it runs out we cancel the
|
|
// invoice, but keep monitoring the htlc confirmation.
|
|
// If the invoice was canceled, e.g. before a restart, we don't need to
|
|
// set a new deadline.
|
|
var deadlineChan <-chan time.Time
|
|
if invoice.State != invoices.ContractCanceled {
|
|
// If the invoice is still live we set the timeout to the
|
|
// remaining payment time. If too much time has elapsed, e.g.
|
|
// after a restart, we cancel the invoice immediately and keep
|
|
// monitoring the HTLC until it can no longer confirm.
|
|
remainingTimeSeconds := f.loopIn.RemainingPaymentTimeSeconds()
|
|
|
|
// If the invoice isn't cancelled yet and the payment timeout
|
|
// elapsed, we set the timeout to 0 to cancel the invoice and
|
|
// unlock the deposits immediately. Otherwise, we start the
|
|
// timer with the remaining seconds to timeout.
|
|
timeout := time.Duration(0) * time.Second
|
|
if remainingTimeSeconds > 0 {
|
|
timeout = time.Duration(remainingTimeSeconds) *
|
|
time.Second
|
|
}
|
|
|
|
deadlineChan = time.NewTimer(timeout).C
|
|
} else {
|
|
// If the invoice was canceled previously we end our
|
|
// subscription to invoice updates.
|
|
cancelInvoiceSubscription()
|
|
}
|
|
|
|
cancelInvoice := func() {
|
|
f.Errorf("timeout waiting for invoice to be " +
|
|
"paid, canceling invoice")
|
|
|
|
// Cancel the lndclient invoice subscription.
|
|
cancelInvoiceSubscription()
|
|
|
|
// Reuse the same helper as InitHtlcAction so timeout cleanup
|
|
// follows the same detached-context path as early-init cleanup.
|
|
f.cancelSwapInvoice()
|
|
}
|
|
|
|
htlcConfirmed := false
|
|
for {
|
|
select {
|
|
case <-htlcConfChan:
|
|
f.Infof("htlc tx confirmed")
|
|
|
|
htlcConfirmed = true
|
|
|
|
case err = <-htlcErrConfChan:
|
|
if ctx.Err() != nil {
|
|
return fsm.NoOp
|
|
}
|
|
|
|
f.Errorf("htlc tx conf chan error, re-registering: "+
|
|
"%v", err)
|
|
|
|
// A previous confirmation may no longer be valid if the
|
|
// subscription failed, so reset and wait for a fresh one.
|
|
htlcConfirmed = false
|
|
|
|
// Re-register for htlc confirmation.
|
|
htlcConfChan, htlcErrConfChan, err = registerHtlcConf()
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return fsm.NoOp
|
|
}
|
|
|
|
err = fmt.Errorf("unable to re-register for "+
|
|
"htlc tx confirmation: %w", err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
case <-reorgChan:
|
|
// A reorg happened. We invalidate a previous htlc
|
|
// confirmation and re-register for the next
|
|
// confirmation.
|
|
htlcConfirmed = false
|
|
|
|
htlcConfChan, htlcErrConfChan, err = registerHtlcConf()
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return fsm.NoOp
|
|
}
|
|
|
|
err = fmt.Errorf("unable to monitor htlc tx "+
|
|
"confirmation: %v", err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
case <-deadlineChan:
|
|
// If the server didn't pay the invoice on time, we
|
|
// cancel the invoice and keep monitoring the htlc tx
|
|
// confirmation. We also need to unlock the deposits to
|
|
// re-enable them for loop-ins and withdrawals.
|
|
cancelInvoice()
|
|
|
|
err = f.unlockDeposits(ctx)
|
|
if err != nil {
|
|
f.Errorf("unable to unlock deposits after "+
|
|
"payment deadline: %v", err)
|
|
}
|
|
|
|
case currentHeight := <-blockChan:
|
|
// If the htlc is confirmed but blockChan fires before
|
|
// htlcConfChan, we would wrongfully assume that the
|
|
// htlc tx was not confirmed which would lead to
|
|
// returning OnSwapTimedOut in the code below. This in
|
|
// turn would prevent us from sweeping the htlc timeout
|
|
// path back to us.
|
|
// Hence, we delay the timeout check here by one block
|
|
// to ensure that htlcConfChan fires first.
|
|
if !f.loopIn.isHtlcTimedOut(currentHeight - 1) {
|
|
// If the htlc hasn't timed out yet, we continue
|
|
// monitoring the htlc confirmation and the
|
|
// invoice settlement.
|
|
continue
|
|
}
|
|
|
|
f.Infof("htlc timed out at block height %v",
|
|
currentHeight)
|
|
|
|
// If the timeout path opened up we consider the swap
|
|
// failed and cancel the invoice.
|
|
cancelInvoice()
|
|
|
|
if !htlcConfirmed {
|
|
f.Infof("swap timed out, htlc not confirmed")
|
|
|
|
// If the htlc hasn't confirmed but the timeout
|
|
// path opened up, and we didn't receive the
|
|
// swap payment, we consider the swap attempt to
|
|
// be failed. We cancelled the invoice, but
|
|
// don't need to unlock the deposits because
|
|
// that happened when the payment deadline was
|
|
// reached.
|
|
return OnSwapTimedOut
|
|
}
|
|
|
|
// If the htlc has confirmed and the timeout path has
|
|
// opened up we sweep the funds back to us.
|
|
err = f.cfg.DepositManager.TransitionDeposits(
|
|
ctx, f.loopIn.Deposits,
|
|
deposit.OnSweepingHtlcTimeout,
|
|
deposit.SweepHtlcTimeout,
|
|
)
|
|
if err != nil {
|
|
log.Errorf("unable to transition "+
|
|
"deposits to the htlc timeout "+
|
|
"sweeping state: %v", err)
|
|
}
|
|
|
|
return OnSweepHtlcTimeout
|
|
|
|
case err = <-blockChanErr:
|
|
if ctx.Err() != nil {
|
|
return fsm.NoOp
|
|
}
|
|
|
|
f.Errorf("block subscription error: %v", err)
|
|
|
|
return f.HandleError(err)
|
|
|
|
case update, ok := <-invoiceUpdateChan:
|
|
if !ok {
|
|
invoiceUpdateChan = nil
|
|
continue
|
|
}
|
|
|
|
if event, done := f.handleInvoiceUpdate(update); done {
|
|
return event
|
|
}
|
|
|
|
case err, ok := <-invoiceErrChan:
|
|
if !ok {
|
|
invoiceErrChan = nil
|
|
continue
|
|
}
|
|
|
|
f.Errorf("invoice subscription error: %v", err)
|
|
|
|
case <-ctx.Done():
|
|
return fsm.NoOp
|
|
}
|
|
}
|
|
}
|
|
|
|
// htlcTimeoutSweepRetryDelay is the delay between retries when publishing the
|
|
// htlc timeout sweep transaction fails.
|
|
const htlcTimeoutSweepRetryDelay = time.Hour
|
|
|
|
// SweepHtlcTimeoutAction is called if the server published the htlc tx without
|
|
// paying the invoice. We wait for the timeout path to open up and sweep the
|
|
// funds back to us.
|
|
func (f *FSM) SweepHtlcTimeoutAction(ctx context.Context,
|
|
_ fsm.EventContext) fsm.EventType {
|
|
|
|
for {
|
|
err := f.createAndPublishHtlcTimeoutSweepTx(ctx)
|
|
if err == nil {
|
|
return OnHtlcTimeoutSweepPublished
|
|
}
|
|
|
|
f.Errorf("unable to create and publish htlc timeout sweep "+
|
|
"tx: %v, retrying in %v", err, htlcTimeoutSweepRetryDelay)
|
|
|
|
select {
|
|
// The context is cancelled when the server is shutting
|
|
// down. Keep the current state so recovery resumes
|
|
// broadcasting attempts after restart.
|
|
case <-ctx.Done():
|
|
return fsm.NoOp
|
|
|
|
case <-time.After(htlcTimeoutSweepRetryDelay):
|
|
}
|
|
}
|
|
}
|
|
|
|
// MonitorHtlcTimeoutSweepAction is called after the htlc timeout sweep tx has
|
|
// been published. We monitor the confirmation of the htlc timeout sweep tx and
|
|
// finalize the deposits once swept.
|
|
func (f *FSM) MonitorHtlcTimeoutSweepAction(ctx context.Context,
|
|
_ fsm.EventContext) fsm.EventType {
|
|
|
|
f.Infof("monitoring htlc timeout sweep tx %v",
|
|
f.loopIn.HtlcTimeoutSweepTxHash)
|
|
|
|
timeoutSweepPkScript, err := txscript.PayToAddrScript(
|
|
f.loopIn.HtlcTimeoutSweepAddress,
|
|
)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to convert timeout sweep address to "+
|
|
"pkscript: %w", err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
htlcTimeoutTxidChan, errChan, err :=
|
|
f.cfg.ChainNotifier.RegisterConfirmationsNtfn(
|
|
ctx, f.loopIn.HtlcTimeoutSweepTxHash,
|
|
timeoutSweepPkScript, defaultConfTarget,
|
|
int32(f.loopIn.InitiationHeight),
|
|
)
|
|
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return fsm.NoOp
|
|
}
|
|
|
|
err = fmt.Errorf("unable to register to the htlc timeout "+
|
|
"sweep tx: %w", err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case err := <-errChan:
|
|
if ctx.Err() != nil {
|
|
return fsm.NoOp
|
|
}
|
|
|
|
return f.HandleError(err)
|
|
|
|
case conf := <-htlcTimeoutTxidChan:
|
|
err = f.cfg.DepositManager.TransitionDeposits(
|
|
ctx, f.loopIn.Deposits,
|
|
deposit.OnHtlcTimeoutSwept,
|
|
deposit.HtlcTimeoutSwept,
|
|
)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to transition the "+
|
|
"deposits to the htlc timeout swept "+
|
|
"state: %w", err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
f.Infof("htlc timeout sweep tx got %d confirmations "+
|
|
"at block %d", defaultConfTarget,
|
|
conf.BlockHeight-defaultConfTarget+1)
|
|
|
|
return OnHtlcTimeoutSwept
|
|
|
|
case <-ctx.Done():
|
|
return fsm.NoOp
|
|
}
|
|
}
|
|
}
|
|
|
|
// PaymentReceivedAction is called if the invoice was settled. We finalize the
|
|
// deposits by transitioning them to the LoopedIn state.
|
|
func (f *FSM) PaymentReceivedAction(ctx context.Context,
|
|
_ fsm.EventContext) fsm.EventType {
|
|
|
|
// Unlock the deposits and transition them to the LoopedIn state.
|
|
err := f.cfg.DepositManager.TransitionDeposits(
|
|
ctx, f.loopIn.Deposits, deposit.OnLoopedIn, deposit.LoopedIn,
|
|
)
|
|
if err != nil {
|
|
err = fmt.Errorf("payment received, but unable to transition "+
|
|
"deposits into the final state: %w", err)
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
return OnSucceeded
|
|
}
|
|
|
|
// UnlockDepositsAction is called if the loop-in failed and its deposits should
|
|
// be available in a future loop-in request.
|
|
func (f *FSM) UnlockDepositsAction(ctx context.Context,
|
|
_ fsm.EventContext) fsm.EventType {
|
|
|
|
f.cancelSwapInvoice()
|
|
|
|
err := f.unlockDeposits(ctx)
|
|
if err != nil {
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
return fsm.OnError
|
|
}
|
|
|
|
func (f *FSM) unlockDeposits(ctx context.Context) error {
|
|
err := f.cfg.DepositManager.TransitionDeposits(
|
|
ctx, f.loopIn.Deposits, fsm.OnError, deposit.Deposited,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to unlock deposits: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createAndPublishHtlcTimeoutSweepTx creates and publishes the htlc timeout
|
|
// sweep transaction.
|
|
func (f *FSM) createAndPublishHtlcTimeoutSweepTx(ctx context.Context) error {
|
|
// Get a fee rate.
|
|
feeRate, err := f.cfg.WalletKit.EstimateFeeRate(ctx, defaultConfTarget)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
getInfo, err := f.cfg.LndClient.GetInfo(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create htlc timeout transaction.
|
|
timeoutTx, err := f.loopIn.createHtlcSweepTx(
|
|
ctx, f.cfg.Signer, f.loopIn.HtlcTimeoutSweepAddress, feeRate,
|
|
f.cfg.ChainParams, getInfo.BlockHeight,
|
|
f.cfg.MaxStaticAddrHtlcFeePercentage,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create htlc timeout sweep tx: %w",
|
|
err)
|
|
}
|
|
|
|
// Broadcast htlc timeout transaction.
|
|
txLabel := fmt.Sprintf(
|
|
"htlc-timeout-sweep-%v", f.loopIn.SwapHash,
|
|
)
|
|
|
|
err = f.cfg.WalletKit.PublishTransaction(ctx, timeoutTx, txLabel)
|
|
if err != nil {
|
|
e := err.Error()
|
|
if !strings.Contains(e, "output already spent") ||
|
|
strings.Contains(e, chain.ErrInsufficientFee.Error()) {
|
|
|
|
f.Errorf("%v: %v", txLabel, err)
|
|
f.LastActionError = err
|
|
return err
|
|
}
|
|
} else {
|
|
f.Debugf("published htlc timeout sweep with txid: %v",
|
|
timeoutTx.TxHash())
|
|
|
|
hash := timeoutTx.TxHash()
|
|
f.loopIn.HtlcTimeoutSweepTxHash = &hash
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// toNonces converts a byte slice to a 66 byte slice.
|
|
func toNonces(nonces [][]byte) ([][musig2.PubNonceSize]byte, error) {
|
|
res := make([][musig2.PubNonceSize]byte, 0, len(nonces))
|
|
for _, n := range nonces {
|
|
nonce, err := byteSliceTo66ByteSlice(n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res = append(res, nonce)
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// byteSliceTo66ByteSlice converts a byte slice to a 66 byte slice.
|
|
func byteSliceTo66ByteSlice(b []byte) ([musig2.PubNonceSize]byte, error) {
|
|
if len(b) != musig2.PubNonceSize {
|
|
return [musig2.PubNonceSize]byte{},
|
|
fmt.Errorf("invalid byte slice length")
|
|
}
|
|
|
|
var res [musig2.PubNonceSize]byte
|
|
copy(res[:], b)
|
|
|
|
return res, nil
|
|
}
|