mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
1590 lines
44 KiB
Go
1590 lines
44 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
|
|
|
|
monitorRetryDelay = 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
|
|
}
|
|
|
|
if err := f.cancelSwapInvoice(); err != nil {
|
|
f.Warnf("unable to clean up invoice for swap %v: %v",
|
|
f.loopIn.SwapHash, err)
|
|
}
|
|
}()
|
|
|
|
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
|
|
|
|
err = f.sendUpdate(ctx)
|
|
if err != nil {
|
|
f.Errorf("Error sending loop-in update: %v", err)
|
|
}
|
|
|
|
event = OnHtlcInitiated
|
|
|
|
return event
|
|
}
|
|
|
|
// cancelSwapInvoice cancels the current swap invoice using a detached,
|
|
// timeout-limited context. Callers that must not proceed while the invoice may
|
|
// still be payable can use the returned error to retry.
|
|
func (f *FSM) cancelSwapInvoice() error {
|
|
if f.loopIn.SwapHash == (lntypes.Hash{}) {
|
|
return nil
|
|
}
|
|
|
|
cleanupCtx, cancel := context.WithTimeout(
|
|
context.Background(), defaultInvoiceCleanupTimeout,
|
|
)
|
|
defer cancel()
|
|
|
|
err := f.cfg.InvoicesClient.CancelInvoice(cleanupCtx, f.loopIn.SwapHash)
|
|
return 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:
|
|
// An unknown state is not evidence that the invoice can no longer
|
|
// settle. Keep monitoring rather than leaving the deposits available
|
|
// for reuse.
|
|
f.Warnf("unexpected invoice state %v for swap hash %v",
|
|
update.State, f.loopIn.SwapHash)
|
|
|
|
return fsm.NoOp, false
|
|
}
|
|
}
|
|
|
|
// selectedDepositConfirmationHeights returns current confirmation heights for
|
|
// the original deposit outpoints selected by this loop-in.
|
|
func selectedDepositConfirmationHeights(
|
|
loopIn *StaticAddressLoopIn) map[string]int64 {
|
|
|
|
confirmations := make(map[string]int64, len(loopIn.Deposits))
|
|
outpoints := make(map[string]struct{}, len(loopIn.DepositOutpoints))
|
|
for _, outpoint := range loopIn.DepositOutpoints {
|
|
outpoints[outpoint] = struct{}{}
|
|
}
|
|
|
|
for _, d := range loopIn.Deposits {
|
|
if d == nil {
|
|
continue
|
|
}
|
|
|
|
outpoint := d.OutPoint.String()
|
|
confirmationHeight := d.GetConfirmationHeight()
|
|
|
|
if _, ok := outpoints[outpoint]; !ok {
|
|
continue
|
|
}
|
|
|
|
confirmations[outpoint] = confirmationHeight
|
|
}
|
|
|
|
return confirmations
|
|
}
|
|
|
|
// refreshSelectedDeposits reloads the loop-in's selected deposits from the
|
|
// deposit manager/store so recovery does not rely on stale deposit snapshots.
|
|
func (f *FSM) refreshSelectedDeposits(ctx context.Context) error {
|
|
if f.cfg.DepositManager == nil || len(f.loopIn.DepositOutpoints) == 0 {
|
|
return nil
|
|
}
|
|
|
|
err := f.cfg.DepositManager.EnsureDepositsFresh(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to refresh deposit wallet view: %w", err)
|
|
}
|
|
|
|
const ignoreUnknownOutpoints = false
|
|
deposits, err := f.cfg.DepositManager.DepositsForOutpoints(
|
|
ctx, f.loopIn.DepositOutpoints, ignoreUnknownOutpoints,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(deposits) != len(f.loopIn.DepositOutpoints) {
|
|
return fmt.Errorf("expected %d selected deposits, got %d",
|
|
len(f.loopIn.DepositOutpoints), len(deposits))
|
|
}
|
|
|
|
f.loopIn.Deposits = deposits
|
|
|
|
return nil
|
|
}
|
|
|
|
// legacyMinConfsReached returns true once every original deposit is confirmed
|
|
// and the youngest original deposit has reached the legacy confirmation target.
|
|
func legacyMinConfsReached(outpoints []string,
|
|
confirmationHeights map[string]int64, currentHeight int32) bool {
|
|
|
|
if currentHeight <= 0 || len(outpoints) == 0 {
|
|
return false
|
|
}
|
|
|
|
youngestConfirmation := int64(0)
|
|
for _, outpoint := range outpoints {
|
|
confirmationHeight, ok := confirmationHeights[outpoint]
|
|
if !ok || confirmationHeight <= 0 {
|
|
return false
|
|
}
|
|
|
|
if confirmationHeight > youngestConfirmation {
|
|
youngestConfirmation = confirmationHeight
|
|
}
|
|
}
|
|
|
|
return int64(currentHeight) >= youngestConfirmation+deposit.MinConfs-1
|
|
}
|
|
|
|
// shouldStartLegacyConfirmationFallback reports whether the local MinConfs
|
|
// payment deadline fallback should be armed at the current block height.
|
|
//
|
|
// The primary path starts the deadline from a server risk-accepted notification.
|
|
// This fallback preserves the legacy client-side MinConfs behavior when no risk
|
|
// decision has been observed locally: once every original deposit reaches
|
|
// MinConfs, the client treats that as enough confirmation-risk clearance to
|
|
// start the payment window. The selected deposits are refreshed first so
|
|
// recovered swaps do not depend on stale in-memory deposit snapshots.
|
|
func (f *FSM) shouldStartLegacyConfirmationFallback(ctx context.Context,
|
|
currentHeight int32) bool {
|
|
|
|
err := f.refreshSelectedDeposits(ctx)
|
|
if err != nil {
|
|
f.Warnf("unable to refresh selected deposits for legacy "+
|
|
"confirmation fallback: %v", err)
|
|
|
|
return false
|
|
}
|
|
|
|
depositConfirmationHeights := selectedDepositConfirmationHeights(
|
|
f.loopIn,
|
|
)
|
|
|
|
return legacyMinConfsReached(
|
|
f.loopIn.DepositOutpoints, depositConfirmationHeights,
|
|
currentHeight,
|
|
)
|
|
}
|
|
|
|
// originalDepositOutpointUnavailable checks the original selected deposit
|
|
// outpoints against the chain backend's UTXO view.
|
|
func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) (
|
|
bool, error) {
|
|
|
|
if f.cfg.TxOutChecker == nil {
|
|
return false, nil
|
|
}
|
|
|
|
if len(f.loopIn.DepositOutpoints) == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
outpoints := make([]wire.OutPoint, len(f.loopIn.DepositOutpoints))
|
|
for i, outpointStr := range f.loopIn.DepositOutpoints {
|
|
outpoint, err := wire.NewOutPointFromString(outpointStr)
|
|
if err != nil {
|
|
return false, fmt.Errorf("invalid deposit outpoint %q: %w",
|
|
outpointStr, err)
|
|
}
|
|
|
|
outpoints[i] = *outpoint
|
|
}
|
|
|
|
txOuts, err := f.cfg.TxOutChecker.GetTxOuts(ctx, outpoints)
|
|
if err != nil {
|
|
return false, fmt.Errorf("unable to get txouts: %w", err)
|
|
}
|
|
|
|
for _, outpoint := range outpoints {
|
|
if txOuts[outpoint] == nil {
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
// 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
|
|
|
|
outpointUnavailable, err := f.originalDepositOutpointUnavailable(ctx)
|
|
if err != nil {
|
|
return f.HandleError(err)
|
|
}
|
|
if outpointUnavailable {
|
|
err = errors.New("original deposit outpoint no longer available")
|
|
f.Warnf("%v, canceling swap invoice", err)
|
|
if cancelErr := f.cancelSwapInvoice(); cancelErr != nil {
|
|
f.Warnf("unable to cancel invoice for swap %v: %v",
|
|
f.loopIn.SwapHash, cancelErr)
|
|
}
|
|
|
|
return f.HandleError(err)
|
|
}
|
|
|
|
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 {
|
|
|
|
retryMonitor := func(err error) fsm.EventType {
|
|
if ctx.Err() != nil {
|
|
return fsm.NoOp
|
|
}
|
|
|
|
f.Errorf("monitoring failed: %v, retrying", err)
|
|
|
|
invoice, lookupErr := f.cfg.LndClient.LookupInvoice(
|
|
ctx, f.loopIn.SwapHash,
|
|
)
|
|
if lookupErr == nil && invoice.State == invoices.ContractSettled {
|
|
return OnPaymentReceived
|
|
}
|
|
|
|
select {
|
|
case <-time.After(monitorRetryDelay):
|
|
return OnRecover
|
|
|
|
case <-ctx.Done():
|
|
return fsm.NoOp
|
|
}
|
|
}
|
|
|
|
// 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 retryMonitor(err)
|
|
}
|
|
|
|
htlc, err := f.loopIn.getHtlc(f.cfg.ChainParams)
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to get htlc: %w", err)
|
|
|
|
return retryMonitor(err)
|
|
}
|
|
|
|
// Subscribe to htlc tx confirmation.
|
|
reorgChan := make(chan struct{}, 1)
|
|
// registerHtlcConf registers for the HTLC transaction confirmation using
|
|
// the current reorg channel.
|
|
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 retryMonitor(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 retryMonitor(err)
|
|
}
|
|
|
|
// The watcher keeps notification normalization and timestamp restoration
|
|
// outside of the swap-state handling below.
|
|
riskWatcher := newConfirmationRiskWatcher(
|
|
f.cfg, f.loopIn.SwapHash, f.Warnf,
|
|
)
|
|
riskUpdateChan, cancelRiskNotificationSubscriptions :=
|
|
riskWatcher.subscribe(ctx)
|
|
defer cancelRiskNotificationSubscriptions()
|
|
|
|
// 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
|
|
}
|
|
|
|
// A failed lookup leaves the invoice state unknown. The active
|
|
// subscription can still provide an authoritative update, so keep
|
|
// monitoring and, most importantly, keep the deposits locked.
|
|
f.Warnf("unable to look up invoice by swap hash: %v", err)
|
|
invoice = &lndclient.Invoice{}
|
|
}
|
|
|
|
// A settled invoice always takes precedence over a recovered risk
|
|
// rejection or an elapsed payment deadline.
|
|
if invoice.State == invoices.ContractSettled {
|
|
return OnPaymentReceived
|
|
}
|
|
|
|
invoiceCanceledForNonPayment := invoice.State == invoices.ContractCanceled
|
|
if invoiceCanceledForNonPayment {
|
|
// If the invoice was canceled previously we end our
|
|
// subscription to invoice updates.
|
|
cancelInvoiceSubscription()
|
|
}
|
|
|
|
// Create the swap payment timeout timer after the server confirms
|
|
// confirmation risk was accepted. If a server does not support risk
|
|
// notifications, fall back after the legacy deposit confirmation depth.
|
|
var (
|
|
deadlineChan <-chan time.Time
|
|
deadlineTimer *time.Timer
|
|
deadlineStarted bool
|
|
)
|
|
// Stop the payment deadline timer when leaving the monitor action.
|
|
defer func() {
|
|
if deadlineTimer != nil {
|
|
deadlineTimer.Stop()
|
|
}
|
|
}()
|
|
|
|
// depositsInState reports whether all selected deposits are currently
|
|
// in the requested state.
|
|
depositsInState := func(state fsm.StateType) bool {
|
|
if len(f.loopIn.Deposits) == 0 {
|
|
return false
|
|
}
|
|
|
|
for _, d := range f.loopIn.Deposits {
|
|
if d == nil {
|
|
return false
|
|
}
|
|
|
|
if !d.IsInState(state) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// startPaymentDeadline arms the server payment timeout from the decision
|
|
// time when one is available.
|
|
startPaymentDeadline := func(reason string, startedAt time.Time) {
|
|
if deadlineStarted || invoice.State == invoices.ContractCanceled {
|
|
return
|
|
}
|
|
|
|
timeout := f.loopIn.PaymentTimeoutDuration()
|
|
if !startedAt.IsZero() {
|
|
timeout -= time.Since(startedAt)
|
|
if timeout < 0 {
|
|
timeout = 0
|
|
}
|
|
}
|
|
|
|
f.Infof("starting payment deadline after %s", reason)
|
|
deadlineTimer = time.NewTimer(timeout)
|
|
deadlineChan = deadlineTimer.C
|
|
deadlineStarted = true
|
|
}
|
|
|
|
depositsLockedForHtlcTimeout := depositsInState(
|
|
deposit.SweepHtlcTimeout,
|
|
)
|
|
|
|
// transitionDepositsToHtlcTimeout locks deposits into timeout sweeping once
|
|
// the HTLC is confirmed and the invoice cannot be paid.
|
|
transitionDepositsToHtlcTimeout := func(reason string) error {
|
|
if depositsLockedForHtlcTimeout ||
|
|
depositsInState(deposit.SweepHtlcTimeout) {
|
|
|
|
depositsLockedForHtlcTimeout = true
|
|
return nil
|
|
}
|
|
|
|
depositsToTransition := make(
|
|
[]*deposit.Deposit, 0, len(f.loopIn.Deposits),
|
|
)
|
|
for _, d := range f.loopIn.Deposits {
|
|
if d != nil && d.IsInState(deposit.SweepHtlcTimeout) {
|
|
continue
|
|
}
|
|
|
|
depositsToTransition = append(depositsToTransition, d)
|
|
}
|
|
|
|
transitionErr := f.cfg.DepositManager.TransitionDeposits(
|
|
ctx, depositsToTransition,
|
|
deposit.OnSweepingHtlcTimeout,
|
|
deposit.SweepHtlcTimeout,
|
|
)
|
|
if transitionErr != nil {
|
|
// WaitForState can report cancellation after the deposit FSMs
|
|
// already reached the target state. Do not turn that shutdown
|
|
// error into success: the monitor must return NoOp and remain
|
|
// recoverable instead of advancing the loop-in FSM.
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
|
|
// The transition can return an error after every deposit
|
|
// already reached the target state. Treat that as
|
|
// success, but never advance with a partial transition.
|
|
if depositsInState(deposit.SweepHtlcTimeout) {
|
|
depositsLockedForHtlcTimeout = true
|
|
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("unable to transition deposits to the htlc "+
|
|
"timeout sweeping state after %s: %w",
|
|
reason, transitionErr)
|
|
}
|
|
if !depositsInState(deposit.SweepHtlcTimeout) {
|
|
return fmt.Errorf("not all deposits reached the htlc timeout "+
|
|
"sweeping state after %s", reason)
|
|
}
|
|
|
|
depositsLockedForHtlcTimeout = true
|
|
|
|
return nil
|
|
}
|
|
|
|
// startLegacyFallback starts the payment deadline once the old local
|
|
// minimum-confirmation rule has been satisfied.
|
|
startLegacyFallback := func(reason string, currentHeight int32) {
|
|
if deadlineStarted || invoice.State == invoices.ContractCanceled ||
|
|
f.loopIn.ConfirmationRiskDecision !=
|
|
ConfirmationRiskDecisionNone {
|
|
|
|
return
|
|
}
|
|
|
|
if f.shouldStartLegacyConfirmationFallback(ctx, currentHeight) {
|
|
decisionTime, ok := riskWatcher.durableDecisionTime(
|
|
ctx, ConfirmationRiskDecisionAccepted,
|
|
)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
f.loopIn.ConfirmationRiskDecision =
|
|
ConfirmationRiskDecisionAccepted
|
|
f.loopIn.ConfirmationRiskDecisionTime = decisionTime
|
|
startPaymentDeadline(reason, decisionTime)
|
|
}
|
|
}
|
|
|
|
// cancelInvoice only marks the invoice canceled after lnd acknowledges
|
|
// the request or the lookup/subscription already observed that state.
|
|
// Failures recover the monitor state without releasing deposits.
|
|
cancelInvoice := func(reason string) (fsm.EventType, bool) {
|
|
if invoice.State != invoices.ContractCanceled {
|
|
f.Errorf("%s, canceling invoice", reason)
|
|
if err := f.cancelSwapInvoice(); err != nil {
|
|
return retryMonitor(err), false
|
|
}
|
|
}
|
|
|
|
cancelInvoiceSubscription()
|
|
invoice.State = invoices.ContractCanceled
|
|
invoiceCanceledForNonPayment = true
|
|
|
|
return fsm.NoOp, true
|
|
}
|
|
|
|
// handleRiskRejected records a server rejection and only exits through
|
|
// the generic error path once the invoice can no longer settle.
|
|
handleRiskRejected := func(reason string,
|
|
decisionTime time.Time) fsm.EventType {
|
|
|
|
f.loopIn.ConfirmationRiskDecision =
|
|
ConfirmationRiskDecisionRejected
|
|
f.loopIn.ConfirmationRiskDecisionTime = decisionTime
|
|
|
|
event, canceled := cancelInvoice(
|
|
"server rejected confirmation risk wait after " + reason,
|
|
)
|
|
if !canceled {
|
|
return event
|
|
}
|
|
|
|
return f.HandleError(fmt.Errorf(
|
|
"server rejected confirmation risk wait after %s", reason,
|
|
))
|
|
}
|
|
|
|
switch f.loopIn.ConfirmationRiskDecision {
|
|
case ConfirmationRiskDecisionAccepted:
|
|
startPaymentDeadline(
|
|
"recovered risk accepted notification",
|
|
f.loopIn.ConfirmationRiskDecisionTime,
|
|
)
|
|
|
|
case ConfirmationRiskDecisionRejected:
|
|
decisionTime := riskWatcher.decisionTime(
|
|
ctx, ConfirmationRiskDecisionRejected,
|
|
)
|
|
|
|
return handleRiskRejected(
|
|
"recovered risk rejection", decisionTime,
|
|
)
|
|
}
|
|
|
|
info, err := f.cfg.LndClient.GetInfo(ctx)
|
|
if err != nil {
|
|
f.Warnf("unable to query current height for legacy confirmation "+
|
|
"fallback: %v", err)
|
|
} else {
|
|
startLegacyFallback(
|
|
"legacy confirmation fallback", int32(info.BlockHeight),
|
|
)
|
|
}
|
|
|
|
htlcConfirmed := false
|
|
for {
|
|
select {
|
|
case <-htlcConfChan:
|
|
f.Infof("htlc tx confirmed")
|
|
|
|
htlcConfirmed = true
|
|
if invoiceCanceledForNonPayment {
|
|
err = transitionDepositsToHtlcTimeout(
|
|
"htlc confirmation after invoice cancellation",
|
|
)
|
|
if err != nil {
|
|
return retryMonitor(err)
|
|
}
|
|
}
|
|
|
|
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 retryMonitor(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 retryMonitor(err)
|
|
}
|
|
|
|
case <-deadlineChan:
|
|
deadlineChan = nil
|
|
|
|
// If the server didn't pay the invoice on time, we cancel
|
|
// it and keep monitoring the htlc tx. Confirmed HTLC
|
|
// deposits remain locked for timeout sweeping.
|
|
event, canceled := cancelInvoice(
|
|
"timeout waiting for invoice to be paid",
|
|
)
|
|
if !canceled {
|
|
return event
|
|
}
|
|
if htlcConfirmed {
|
|
err = transitionDepositsToHtlcTimeout(
|
|
"payment deadline",
|
|
)
|
|
if err != nil {
|
|
return retryMonitor(err)
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
err = f.unlockDeposits(ctx)
|
|
if err != nil {
|
|
return retryMonitor(fmt.Errorf("unable to unlock deposits "+
|
|
"after payment deadline: %w", err))
|
|
}
|
|
|
|
case riskUpdate, ok := <-riskUpdateChan:
|
|
if !ok {
|
|
riskUpdateChan = nil
|
|
continue
|
|
}
|
|
|
|
decisionTime := riskWatcher.decisionTime(
|
|
ctx, riskUpdate.decision,
|
|
)
|
|
f.loopIn.ConfirmationRiskDecision = riskUpdate.decision
|
|
f.loopIn.ConfirmationRiskDecisionTime = decisionTime
|
|
|
|
switch riskUpdate.decision {
|
|
case ConfirmationRiskDecisionAccepted:
|
|
startPaymentDeadline(
|
|
riskUpdate.reason,
|
|
f.loopIn.ConfirmationRiskDecisionTime,
|
|
)
|
|
|
|
case ConfirmationRiskDecisionRejected:
|
|
return handleRiskRejected(
|
|
riskUpdate.reason, decisionTime,
|
|
)
|
|
}
|
|
|
|
case currentHeight := <-blockChan:
|
|
startLegacyFallback(
|
|
"legacy confirmation fallback", currentHeight,
|
|
)
|
|
|
|
// 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)
|
|
event, canceled := cancelInvoice("htlc timed out")
|
|
if !canceled {
|
|
return event
|
|
}
|
|
|
|
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. Now that the invoice is canceled and
|
|
// the HTLC can no longer confirm, its deposits can be
|
|
// made available again.
|
|
err = f.unlockDeposits(ctx)
|
|
if err != nil {
|
|
return retryMonitor(fmt.Errorf("unable to unlock "+
|
|
"deposits after htlc timeout: %w", err))
|
|
}
|
|
|
|
return OnSwapTimedOut
|
|
}
|
|
|
|
// If the htlc has confirmed and the timeout path has
|
|
// opened up we sweep the funds back to us.
|
|
err = transitionDepositsToHtlcTimeout("htlc timeout")
|
|
if err != nil {
|
|
return retryMonitor(err)
|
|
}
|
|
|
|
return OnSweepHtlcTimeout
|
|
|
|
case err = <-blockChanErr:
|
|
if ctx.Err() != nil {
|
|
return fsm.NoOp
|
|
}
|
|
|
|
f.Errorf("block subscription error: %v", err)
|
|
|
|
return retryMonitor(err)
|
|
|
|
case update, ok := <-invoiceUpdateChan:
|
|
if !ok {
|
|
if !invoiceCanceledForNonPayment {
|
|
return retryMonitor(errors.New(
|
|
"invoice update subscription closed",
|
|
))
|
|
}
|
|
|
|
invoiceUpdateChan = nil
|
|
continue
|
|
}
|
|
|
|
if event, done := f.handleInvoiceUpdate(update); done {
|
|
return event
|
|
}
|
|
|
|
invoice.State = update.State
|
|
if update.State == invoices.ContractCanceled {
|
|
invoiceCanceledForNonPayment = true
|
|
}
|
|
|
|
case err, ok := <-invoiceErrChan:
|
|
if !ok {
|
|
if !invoiceCanceledForNonPayment {
|
|
return retryMonitor(errors.New(
|
|
"invoice error subscription closed",
|
|
))
|
|
}
|
|
|
|
invoiceErrChan = nil
|
|
continue
|
|
}
|
|
|
|
if ctx.Err() != nil {
|
|
return fsm.NoOp
|
|
}
|
|
|
|
return retryMonitor(fmt.Errorf(
|
|
"invoice subscription error: %w", 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 {
|
|
|
|
if err := f.cancelSwapInvoice(); err != nil {
|
|
f.Warnf("unable to cancel invoice for swap %v: %v",
|
|
f.loopIn.SwapHash, err)
|
|
}
|
|
|
|
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
|
|
}
|