mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Store the server's static loop-in confirmation-risk decision and the time it was received. This lets recovered swaps reconstruct whether payment waiting had already started and how much of the payment timeout remains. Wire notification handling to persist accepted and rejected decisions before caching and forwarding them. If the swap row is not present yet, the notification is still cached so the per-swap waiter can replay and store the decision later. Recover accepted decisions by starting the payment deadline from the persisted decision time, and recover rejected decisions by canceling the invoice and failing the swap instead of waiting forever.
570 lines
16 KiB
Go
570 lines
16 KiB
Go
package loopin
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcd/btcec/v2"
|
|
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
|
|
"github.com/btcsuite/btcd/btcutil"
|
|
"github.com/btcsuite/btcd/chaincfg"
|
|
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
|
"github.com/btcsuite/btcd/txscript"
|
|
"github.com/btcsuite/btcd/wire"
|
|
"github.com/lightninglabs/lndclient"
|
|
"github.com/lightninglabs/loop/fsm"
|
|
"github.com/lightninglabs/loop/staticaddr/deposit"
|
|
"github.com/lightninglabs/loop/staticaddr/script"
|
|
"github.com/lightninglabs/loop/staticaddr/staticutil"
|
|
"github.com/lightninglabs/loop/staticaddr/version"
|
|
"github.com/lightninglabs/loop/swap"
|
|
"github.com/lightningnetwork/lnd/input"
|
|
"github.com/lightningnetwork/lnd/keychain"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
|
"github.com/lightningnetwork/lnd/routing/route"
|
|
"github.com/lightningnetwork/lnd/zpay32"
|
|
)
|
|
|
|
// ConfirmationRiskDecision records the server's decision on whether it accepts
|
|
// waiting for low-confirmation deposits before paying a static loop-in invoice.
|
|
type ConfirmationRiskDecision string
|
|
|
|
const (
|
|
// ConfirmationRiskDecisionNone means no risk decision has been received.
|
|
ConfirmationRiskDecisionNone ConfirmationRiskDecision = ""
|
|
|
|
// ConfirmationRiskDecisionAccepted means the server accepted waiting for
|
|
// deposit confirmations and the payment deadline has started.
|
|
ConfirmationRiskDecisionAccepted ConfirmationRiskDecision = "accepted"
|
|
|
|
// ConfirmationRiskDecisionRejected means the server stopped waiting for
|
|
// deposit confirmations before paying the invoice.
|
|
ConfirmationRiskDecisionRejected ConfirmationRiskDecision = "rejected"
|
|
)
|
|
|
|
// StaticAddressLoopIn represents the in-memory loop-in information.
|
|
type StaticAddressLoopIn struct {
|
|
// SwapHash is the hashed preimage of the swap invoice. It represents
|
|
// the primary identifier of the swap.
|
|
SwapHash lntypes.Hash
|
|
|
|
// SwapPreimage is the preimage that is used for the swap.
|
|
SwapPreimage lntypes.Preimage
|
|
|
|
// HtlcCltvExpiry is the expiry of the swap.
|
|
HtlcCltvExpiry int32
|
|
|
|
// MaxSwapFee is the swap fee in sats that the user accepted when
|
|
// initiating the swap. It is the upper limit for the QuotedSwapFee.
|
|
MaxSwapFee btcutil.Amount
|
|
|
|
// InitiationHeight is the height at which the swap was initiated.
|
|
InitiationHeight uint32
|
|
|
|
// InitiationTime is the time at which the swap was initiated.
|
|
InitiationTime time.Time
|
|
|
|
// ProtocolVersion is the protocol version of the static address.
|
|
ProtocolVersion version.AddressProtocolVersion
|
|
|
|
// Label contains an optional label for the swap.
|
|
Label string
|
|
|
|
// Htlc key fields.
|
|
|
|
// ClientPubkey is the pubkey of the client that is used for the swap.
|
|
ClientPubkey *btcec.PublicKey
|
|
|
|
// ServerPubkey is the pubkey of the server that is used for the swap.
|
|
ServerPubkey *btcec.PublicKey
|
|
|
|
// HtlcKeyLocator is the locator of the server's htlc key.
|
|
HtlcKeyLocator keychain.KeyLocator
|
|
|
|
// Static address loop-in fields.
|
|
|
|
// SwapInvoice is the invoice that needs to be paid by the server to
|
|
// complete the loop-in swap.
|
|
SwapInvoice string
|
|
|
|
// LastHop is an optional parameter that specifies the last hop to be
|
|
// used for a loop in swap.
|
|
LastHop []byte
|
|
|
|
// The swap payment timeout allows the user to specify an upper limit
|
|
// for the amount of time the server is allowed to take to fulfill the
|
|
// off-chain swap payment. If the timeout is reached the swap will be
|
|
// aborted on the server side and the client can retry the swap with
|
|
// different parameters.
|
|
PaymentTimeoutSeconds uint32
|
|
|
|
// QuotedSwapFee is the swap fee in sats that the server returned in the
|
|
// swap quote.
|
|
QuotedSwapFee btcutil.Amount
|
|
|
|
// The outpoints in the format txid:vout that are part of the loop-in
|
|
// swap.
|
|
DepositOutpoints []string
|
|
|
|
// SelectedAmount is the amount that the user selected for the swap. If
|
|
// the user did not select an amount, the amount of all deposits is
|
|
// used.
|
|
SelectedAmount btcutil.Amount
|
|
|
|
// Fast indicates whether the client requested fast publication behavior
|
|
// on the server side for this static loop in.
|
|
Fast bool
|
|
|
|
// LastUpdateTime is the timestamp of the latest persisted state update.
|
|
LastUpdateTime time.Time
|
|
|
|
// ConfirmationRiskDecision records the server's persisted decision on
|
|
// low-confirmation deposit risk.
|
|
ConfirmationRiskDecision ConfirmationRiskDecision
|
|
|
|
// ConfirmationRiskDecisionTime is when loopd persisted the server risk
|
|
// decision. It is used to reconstruct payment-deadline timeouts after
|
|
// restart.
|
|
ConfirmationRiskDecisionTime time.Time
|
|
|
|
// state is the current state of the swap.
|
|
state fsm.StateType
|
|
|
|
// Non-persistent convenience fields.
|
|
|
|
// Initiator is an optional identification string that will be appended
|
|
// to the user agent string sent to the server to give information about
|
|
// the usage of loop. This initiator part is meant for user interfaces
|
|
// to add their name to give the full picture of the binary used
|
|
// (loopd, lit) and the method used for triggering the swap
|
|
// (loop cli, autolooper, lit ui, other 3rd party ui).
|
|
Initiator string
|
|
|
|
// Private indicates whether the destination node should be considered
|
|
// private. In which case, loop will generate hop hints to assist with
|
|
// probing and payment.
|
|
Private bool
|
|
|
|
// RouteHints are optional route hints to reach the destination through
|
|
// private channels.
|
|
RouteHints [][]zpay32.HopHint
|
|
|
|
// Deposits are the deposits that are part of the loop-in swap. They
|
|
// implicitly carry the swap amount.
|
|
Deposits []*deposit.Deposit
|
|
|
|
// AddressParams are the parameters of the address that is used for the
|
|
// swap.
|
|
AddressParams *script.Parameters
|
|
|
|
// Address is the address script that is used for the swap.
|
|
Address *script.StaticAddress
|
|
|
|
// HTLC fields.
|
|
|
|
// HtlcTxFeeRate is the fee rate that is used for the htlc transaction.
|
|
HtlcTxFeeRate chainfee.SatPerKWeight
|
|
|
|
// HtlcTxHighFeeRate is the fee rate that is used for the htlc
|
|
// transaction.
|
|
HtlcTxHighFeeRate chainfee.SatPerKWeight
|
|
|
|
// HtlcTxExtremelyHighFeeRate is the fee rate that is used for the htlc
|
|
// transaction.
|
|
HtlcTxExtremelyHighFeeRate chainfee.SatPerKWeight
|
|
|
|
// HtlcTimeoutSweepTxHash is the hash of the htlc timeout sweep tx.
|
|
HtlcTimeoutSweepTxHash *chainhash.Hash
|
|
|
|
// HtlcTimeoutSweepAddress
|
|
HtlcTimeoutSweepAddress btcutil.Address
|
|
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (l *StaticAddressLoopIn) getHtlc(chainParams *chaincfg.Params) (*swap.Htlc,
|
|
error) {
|
|
|
|
return swap.NewHtlcV2(
|
|
l.HtlcCltvExpiry, pubkeyTo33ByteSlice(l.ClientPubkey),
|
|
pubkeyTo33ByteSlice(l.ServerPubkey), l.SwapHash, chainParams,
|
|
)
|
|
}
|
|
|
|
// signMusig2Tx adds the server nonces to the musig2 sessions and signs the
|
|
// transaction.
|
|
func (l *StaticAddressLoopIn) signMusig2Tx(ctx context.Context,
|
|
tx *wire.MsgTx, signer lndclient.SignerClient,
|
|
musig2sessions []*input.MuSig2SessionInfo,
|
|
counterPartyNonces [][musig2.PubNonceSize]byte) ([][]byte, error) {
|
|
|
|
prevOuts, err := staticutil.ToPrevOuts(
|
|
l.Deposits, l.AddressParams.PkScript,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prevOutFetcher := txscript.NewMultiPrevOutFetcher(prevOuts)
|
|
|
|
outpoints := l.Outpoints()
|
|
sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher)
|
|
sigs := make([][]byte, len(outpoints))
|
|
|
|
for idx, outpoint := range outpoints {
|
|
if !reflect.DeepEqual(tx.TxIn[idx].PreviousOutPoint,
|
|
outpoint) {
|
|
|
|
return nil, fmt.Errorf("tx input does not match " +
|
|
"deposits")
|
|
}
|
|
|
|
taprootSigHash, err := txscript.CalcTaprootSignatureHash(
|
|
sigHashes, txscript.SigHashDefault, tx, idx,
|
|
prevOutFetcher,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var digest [32]byte
|
|
copy(digest[:], taprootSigHash)
|
|
|
|
// Register the server's nonce before attempting to create our
|
|
// partial signature.
|
|
haveAllNonces, err := signer.MuSig2RegisterNonces(
|
|
ctx, musig2sessions[idx].SessionID,
|
|
[][musig2.PubNonceSize]byte{counterPartyNonces[idx]},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Sanity check that we have all the nonces.
|
|
if !haveAllNonces {
|
|
return nil, fmt.Errorf("invalid MuSig2 session: " +
|
|
"nonces missing")
|
|
}
|
|
|
|
// Since our MuSig2 session has all nonces, we can now create
|
|
// the local partial signature by signing the sig hash.
|
|
sig, err := signer.MuSig2Sign(
|
|
ctx, musig2sessions[idx].SessionID, digest, false,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sigs[idx] = sig
|
|
}
|
|
|
|
return sigs, nil
|
|
}
|
|
|
|
// createHtlcTx creates the transaction that spend the deposit outpoints into
|
|
// a htlc outpoint.
|
|
func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params,
|
|
feeRate chainfee.SatPerKWeight, maxFeePercentage float64) (*wire.MsgTx,
|
|
error) {
|
|
|
|
// First Create the tx.
|
|
msgTx := wire.NewMsgTx(2)
|
|
|
|
// Add the deposit inputs to the transaction in the order the server
|
|
// signed them.
|
|
outpoints := l.Outpoints()
|
|
for _, outpoint := range outpoints {
|
|
msgTx.AddTxIn(&wire.TxIn{
|
|
PreviousOutPoint: outpoint,
|
|
})
|
|
}
|
|
|
|
// Determine the swap amount. If the user selected a specific amount, we
|
|
// use that and use the difference to the total deposit amount as the
|
|
// change.
|
|
var (
|
|
swapAmt = l.TotalDepositAmount()
|
|
changeAmount btcutil.Amount
|
|
)
|
|
if l.SelectedAmount > 0 {
|
|
swapAmt = l.SelectedAmount
|
|
changeAmount = l.TotalDepositAmount() - l.SelectedAmount
|
|
}
|
|
|
|
// Calculate htlc tx fee for server provided fee rate.
|
|
hasChange := changeAmount > 0
|
|
weight := l.htlcWeight(hasChange)
|
|
fee := feeRate.FeeForWeight(weight)
|
|
|
|
// Check if the server breaches our fee limits.
|
|
feeLimit := btcutil.Amount(float64(swapAmt) * maxFeePercentage)
|
|
if fee > feeLimit {
|
|
return nil, fmt.Errorf("htlc tx fee %v exceeds max fee %v",
|
|
fee, feeLimit)
|
|
}
|
|
|
|
htlc, err := l.getHtlc(chainParams)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pkscript, err := txscript.PayToAddrScript(htlc.Address)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create the sweep output. NOTE: The HTLC output must be added at
|
|
// index 0. createHtlcSweepTx relies on this layout invariant.
|
|
sweepOutput := &wire.TxOut{
|
|
Value: int64(swapAmt - fee),
|
|
PkScript: pkscript,
|
|
}
|
|
|
|
msgTx.AddTxOut(sweepOutput)
|
|
|
|
// We expect change to be sent back to our static address output script.
|
|
if changeAmount > 0 {
|
|
msgTx.AddTxOut(&wire.TxOut{
|
|
Value: int64(changeAmount),
|
|
PkScript: l.AddressParams.PkScript,
|
|
})
|
|
}
|
|
|
|
return msgTx, nil
|
|
}
|
|
|
|
// isHtlcTimedOut returns true if the htlc's cltv expiry is reached. In this
|
|
// case the client is able to broadcast a timeout refund transaction that can be
|
|
// included in blocks greater than height.
|
|
func (l *StaticAddressLoopIn) isHtlcTimedOut(height int32) bool {
|
|
return height >= l.HtlcCltvExpiry
|
|
}
|
|
|
|
// htlcWeight returns the weight for the htlc transaction.
|
|
func (l *StaticAddressLoopIn) htlcWeight(hasChange bool) lntypes.WeightUnit {
|
|
var weightEstimator input.TxWeightEstimator
|
|
for range len(l.Deposits) {
|
|
weightEstimator.AddTaprootKeySpendInput(
|
|
txscript.SigHashDefault,
|
|
)
|
|
}
|
|
|
|
weightEstimator.AddP2WSHOutput()
|
|
|
|
if hasChange {
|
|
weightEstimator.AddP2TROutput()
|
|
}
|
|
|
|
return weightEstimator.Weight()
|
|
}
|
|
|
|
// createHtlcSweepTx creates the htlc sweep transaction for the timeout path of
|
|
// the loop-in htlc.
|
|
func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context,
|
|
signer lndclient.SignerClient, sweepAddress btcutil.Address,
|
|
feeRate chainfee.SatPerKWeight, network *chaincfg.Params,
|
|
blockHeight uint32, maxFeePercentage float64) (*wire.MsgTx, error) {
|
|
|
|
if network == nil {
|
|
return nil, errors.New("no network provided")
|
|
}
|
|
|
|
htlc, err := l.getHtlc(network)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create the sweep transaction.
|
|
sweepTx := wire.NewMsgTx(2)
|
|
sweepTx.LockTime = blockHeight
|
|
|
|
var weightEstimator input.TxWeightEstimator
|
|
weightEstimator.AddP2TROutput()
|
|
|
|
err = htlc.AddSuccessToEstimator(&weightEstimator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
htlcTx, err := l.createHtlcTx(
|
|
network, l.HtlcTxFeeRate, maxFeePercentage,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The HTLC output is always at index 0 (createHtlcTx adds it first).
|
|
// If there is a change output, it is at index 1. Verify this invariant
|
|
// so we fail fast if createHtlcTx's layout ever changes.
|
|
const htlcInputIndex = uint32(0)
|
|
if len(htlcTx.TxOut) == 2 {
|
|
if bytes.Equal(
|
|
htlcTx.TxOut[0].PkScript, l.AddressParams.PkScript,
|
|
) {
|
|
|
|
return nil, fmt.Errorf("htlc tx output layout " +
|
|
"invariant violated: expected HTLC output " +
|
|
"at index 0, got change output")
|
|
}
|
|
}
|
|
|
|
// Add the htlc input.
|
|
sweepTx.AddTxIn(&wire.TxIn{
|
|
PreviousOutPoint: wire.OutPoint{
|
|
Hash: htlcTx.TxHash(),
|
|
Index: htlcInputIndex,
|
|
},
|
|
SignatureScript: htlc.SigScript,
|
|
Sequence: htlc.SuccessSequence(),
|
|
})
|
|
|
|
// Add the sweep output.
|
|
sweepPkScript, err := txscript.PayToAddrScript(sweepAddress)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fee := feeRate.FeeForWeight(weightEstimator.Weight())
|
|
|
|
htlcOutValue := htlcTx.TxOut[htlcInputIndex].Value
|
|
output := &wire.TxOut{
|
|
Value: htlcOutValue - int64(fee),
|
|
PkScript: sweepPkScript,
|
|
}
|
|
|
|
sweepTx.AddTxOut(output)
|
|
|
|
signDesc := lndclient.SignDescriptor{
|
|
WitnessScript: htlc.TimeoutScript(),
|
|
Output: &wire.TxOut{
|
|
Value: htlcOutValue,
|
|
PkScript: htlc.PkScript,
|
|
},
|
|
HashType: htlc.SigHash(),
|
|
InputIndex: 0,
|
|
KeyDesc: keychain.KeyDescriptor{
|
|
KeyLocator: l.HtlcKeyLocator,
|
|
},
|
|
}
|
|
signDesc.SignMethod = input.WitnessV0SignMethod
|
|
|
|
rawSigs, err := signer.SignOutputRaw(
|
|
ctx, sweepTx, []*lndclient.SignDescriptor{&signDesc}, nil,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sign output error: %w", err)
|
|
}
|
|
sig := rawSigs[0]
|
|
|
|
// Add witness stack to the tx input.
|
|
sweepTx.TxIn[0].Witness, err = htlc.GenTimeoutWitness(sig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return sweepTx, nil
|
|
}
|
|
|
|
// pubkeyTo33ByteSlice converts a pubkey to a 33 byte slice.
|
|
func pubkeyTo33ByteSlice(pubkey *btcec.PublicKey) [33]byte {
|
|
var pubkeyBytes [33]byte
|
|
copy(pubkeyBytes[:], pubkey.SerializeCompressed())
|
|
|
|
return pubkeyBytes
|
|
}
|
|
|
|
// TotalDepositAmount returns the total amount of the deposits.
|
|
func (l *StaticAddressLoopIn) TotalDepositAmount() btcutil.Amount {
|
|
var total btcutil.Amount
|
|
if len(l.Deposits) == 0 {
|
|
return total
|
|
}
|
|
|
|
for _, d := range l.Deposits {
|
|
total += d.Value
|
|
}
|
|
return total
|
|
}
|
|
|
|
// RemainingPaymentTimeSeconds returns the remaining time in seconds until the
|
|
// payment timeout is reached. The remaining time is calculated from the
|
|
// initiation time of the swap. If more than the swap's configured payment
|
|
// timeout has passed, the remaining time will be negative.
|
|
func (l *StaticAddressLoopIn) RemainingPaymentTimeSeconds() int64 {
|
|
elapsedSinceInitiation := time.Since(l.InitiationTime).Seconds()
|
|
|
|
return l.paymentTimeoutSeconds() - int64(elapsedSinceInitiation)
|
|
}
|
|
|
|
// PaymentTimeoutDuration returns the configured payment timeout duration,
|
|
// falling back to the default if the swap predates the persisted timeout field.
|
|
func (l *StaticAddressLoopIn) PaymentTimeoutDuration() time.Duration {
|
|
return time.Duration(l.paymentTimeoutSeconds()) * time.Second
|
|
}
|
|
|
|
// paymentTimeoutSeconds returns the configured timeout in seconds.
|
|
func (l *StaticAddressLoopIn) paymentTimeoutSeconds() int64 {
|
|
timeoutSeconds := int64(l.PaymentTimeoutSeconds)
|
|
if timeoutSeconds == 0 {
|
|
timeoutSeconds = int64(DefaultPaymentTimeoutSeconds)
|
|
}
|
|
|
|
return timeoutSeconds
|
|
}
|
|
|
|
// Outpoints returns the wire outpoints of the deposits.
|
|
func (l *StaticAddressLoopIn) Outpoints() []wire.OutPoint {
|
|
outpoints := make([]wire.OutPoint, len(l.Deposits))
|
|
for i, d := range l.Deposits {
|
|
outpoints[i] = wire.OutPoint{
|
|
Hash: d.Hash,
|
|
Index: d.Index,
|
|
}
|
|
}
|
|
|
|
return outpoints
|
|
}
|
|
|
|
// LastHopVertex returns the swap's last hop as a route vertex when the field
|
|
// is present and well formed.
|
|
func (l *StaticAddressLoopIn) LastHopVertex() *route.Vertex {
|
|
if len(l.LastHop) == 0 {
|
|
return nil
|
|
}
|
|
|
|
vertex, err := route.NewVertexFromBytes(l.LastHop)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
return &vertex
|
|
}
|
|
|
|
// GetState returns the current state of the loop-in swap.
|
|
func (l *StaticAddressLoopIn) GetState() fsm.StateType {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
return l.state
|
|
}
|
|
|
|
// SetState sets the current state of the loop-in swap.
|
|
func (l *StaticAddressLoopIn) SetState(state fsm.StateType) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
l.state = state
|
|
}
|
|
|
|
// IsInState returns true if the deposit is in the given state.
|
|
func (l *StaticAddressLoopIn) IsInState(state fsm.StateType) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
return l.state == state
|
|
}
|