diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go new file mode 100644 index 00000000..48ff0397 --- /dev/null +++ b/staticaddr/loopin/actions.go @@ -0,0 +1,1069 @@ +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/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/version" + "github.com/lightninglabs/loop/swap" + looprpc "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 +) + +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 { + + // 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 f.HandleError(err) + } + + // Calculate the swap invoice amount. The server needs to pay us the + // sum of all deposits minus the fees that the server charges for the + // swap. + swapInvoiceAmt := f.loopIn.TotalDepositAmount() - 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 f.HandleError(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 f.HandleError(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, + }, + ) + if err != nil { + err = fmt.Errorf("unable to create swap invoice: %w", err) + + return f.HandleError(err) + } + f.loopIn.SwapInvoice = swapInvoice + + f.loopIn.ProtocolVersion = version.AddressProtocolVersion( + version.CurrentRPCProtocolVersion(), + ) + + loopInReq := &looprpc.ServerStaticAddressLoopInRequest{ + SwapHash: f.loopIn.SwapHash[:], + DepositOutpoints: f.loopIn.DepositOutpoints, + HtlcClientPubKey: f.loopIn.ClientPubkey.SerializeCompressed(), + SwapInvoice: f.loopIn.SwapInvoice, + ProtocolVersion: version.CurrentRPCProtocolVersion(), + UserAgent: loop.UserAgent(f.loopIn.Initiator), + PaymentTimeoutSeconds: f.loopIn.PaymentTimeoutSeconds, + } + 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 f.HandleError(err) + } + + // Pushing empty sigs signals the server that we abandoned the swap + // attempt. + pushEmptySigs := func() { + _, err = f.cfg.Server.PushStaticAddressHtlcSigs( + ctx, &looprpc.PushStaticAddressHtlcSigsRequest{ + SwapHash: f.loopIn.SwapHash[:], + }, + ) + if err != nil { + log.Warnf("unable to push htlc tx sigs to server: %w", + err) + } + } + + serverPubkey, err := btcec.ParsePubKey(loopInResp.HtlcServerPubKey) + if err != nil { + pushEmptySigs() + err = fmt.Errorf("unable to parse server pubkey: %w", err) + + return f.HandleError(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 f.HandleError(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 f.HandleError(err) + } + f.htlcServerNoncesHighFee, err = toNonces( + loopInResp.HighFeeHtlcInfo.Nonces, + ) + if err != nil { + pushEmptySigs() + + return f.HandleError(err) + } + f.htlcServerNoncesExtremelyHighFee, err = toNonces( + loopInResp.ExtremeFeeHtlcInfo.Nonces, + ) + if err != nil { + pushEmptySigs() + + return f.HandleError(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(f.loopIn.TotalDepositAmount()) + maxHtlcTxFee := btcutil.Amount(amt * + f.cfg.MaxStaticAddrHtlcFeePercentage) + + maxHtlcTxBackupFee := btcutil.Amount(amt * + f.cfg.MaxStaticAddrHtlcBackupFeePercentage) + + feeRate := chainfee.SatPerKWeight(loopInResp.StandardHtlcInfo.FeeRate) + fee := feeRate.FeeForWeight(f.loopIn.htlcWeight()) + if fee > maxHtlcTxFee { + // Abort the swap by pushing empty sigs to the server. + pushEmptySigs() + + log.Errorf("server htlc tx fee is higher than the configured "+ + "allowed maximum: %v > %v", fee, maxHtlcTxFee) + + return f.HandleError(ErrFeeTooHigh) + } + f.loopIn.HtlcTxFeeRate = feeRate + + highFeeRate := chainfee.SatPerKWeight(loopInResp.HighFeeHtlcInfo.FeeRate) + fee = highFeeRate.FeeForWeight(f.loopIn.htlcWeight()) + if fee > maxHtlcTxBackupFee { + // Abort the swap by pushing empty sigs to the server. + pushEmptySigs() + + log.Errorf("server htlc backup tx fee is higher than the "+ + "configured allowed maximum: %v > %v", fee, + maxHtlcTxBackupFee) + + return f.HandleError(ErrFeeTooHigh) + } + f.loopIn.HtlcTxHighFeeRate = highFeeRate + + extremelyHighFeeRate := chainfee.SatPerKWeight( + loopInResp.ExtremeFeeHtlcInfo.FeeRate, + ) + fee = extremelyHighFeeRate.FeeForWeight(f.loopIn.htlcWeight()) + if fee > maxHtlcTxBackupFee { + // Abort the swap by pushing empty sigs to the server. + pushEmptySigs() + + log.Errorf("server htlc backup tx fee is higher than the "+ + "configured allowed maximum: %v > %v", fee, + maxHtlcTxBackupFee) + + return f.HandleError(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 f.HandleError(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 f.HandleError(err) + } + + return OnHtlcInitiated +} + +// 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) + } + + // Create a musig2 session for each deposit and different htlc tx fee + // rates. + createSession := f.loopIn.createMusig2Sessions + htlcSessions, clientHtlcNonces, err := createSession(ctx, f.cfg.Signer) + 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, + ) + if err != nil { + return f.HandleError(err) + } + defer f.cleanUpSessions(ctx, htlcSessionsHighFee) + + htlcSessionsExtremelyHighFee, extremelyHighNonces, err := createSession( + ctx, f.cfg.Signer, + ) + 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 := &looprpc.PushStaticAddressHtlcSigsRequest{ + SwapHash: f.loopIn.SwapHash[:], + StandardHtlcInfo: &looprpc.ClientHtlcSigningInfo{ + Nonces: clientHtlcNonces, + Sigs: htlcSigs, + }, + HighFeeHtlcInfo: &looprpc.ClientHtlcSigningInfo{ + Nonces: highFeeNonces, + Sigs: htlcSigsHighFee, + }, + ExtremeFeeHtlcInfo: &looprpc.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 +} + +// 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(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 { + 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 { + 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 { + err = fmt.Errorf("unable to subscribe to new blocks: %w", err) + + return f.HandleError(err) + } + + htlcConfirmed := false + + invoice, err := f.cfg.LndClient.LookupInvoice(ctx, f.loopIn.SwapHash) + if err != nil { + 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 set the timeout to 0 to cancel the + // invoice and unlock the deposits immediately. + 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() + + err = f.cfg.InvoicesClient.CancelInvoice(ctx, f.loopIn.SwapHash) + if err != nil { + f.Warnf("unable to cancel invoice "+ + "for swap hash: %v", err) + } + } + + for { + select { + case <-htlcConfChan: + f.Infof("htlc tx confirmed") + + htlcConfirmed = true + + case err = <-htlcErrConfChan: + f.Errorf("htlc tx conf chan error: %v", 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 { + f.Errorf("unable to monitor htlc tx "+ + "confirmation: %v", 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() + + event := f.UnlockDepositsAction(ctx, nil) + if event != fsm.OnError { + f.Errorf("unable to unlock deposits after " + + "payment deadline") + } + + 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: %w", err) + } + + return OnSweepHtlcTimeout + + case err = <-blockChanErr: + f.Errorf("block subscription error: %v", err) + + return f.HandleError(err) + + case update := <-invoiceUpdateChan: + switch update.State { + case invoices.ContractOpen: + case invoices.ContractAccepted: + case invoices.ContractSettled: + f.Debugf("received off-chain payment update "+ + "%v", update.State) + + return OnPaymentReceived + + 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) + + default: + err = fmt.Errorf("unexpected invoice state %v "+ + "for swap hash %v canceled", + update.State, f.loopIn.SwapHash) + + return f.HandleError(err) + } + + case err = <-invoiceErrChan: + f.Errorf("invoice subscription error: %v", err) + + case <-ctx.Done(): + return f.HandleError(ctx.Err()) + } + } +} + +// 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 { + break + } + + f.Errorf("unable to create and publish htlc timeout sweep "+ + "tx: %v, retrying in %v", err, time.Hour.String()) + + select { + case <-ctx.Done(): + f.Errorf(ctx.Err().Error()) + + default: + <-time.After(1 * time.Hour) + } + } + + return OnHtlcTimeoutSweepPublished +} + +// 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 { + err = fmt.Errorf("unable to register to the htlc timeout "+ + "sweep tx: %w", err) + + return f.HandleError(err) + } + + for { + select { + case err := <-errChan: + 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 f.HandleError(ctx.Err()) + } + } +} + +// 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 OnFetchSignPushSweeplessSweepTx +} + +// FetchSignPushSweeplessSweepTxAction requests server nonces, fee rate and +// destination address for the sweepless sweep transaction. It then creates the +// sweep transaction and signs it with the server and client nonces. If signing +// succeeds it pushes the signatures to the server. +func (f *FSM) FetchSignPushSweeplessSweepTxAction(ctx context.Context, + _ fsm.EventContext) fsm.EventType { + + // Fetch the sweepless sweep tx details from server. + fetchReq := &looprpc.FetchSweeplessSweepTxRequest{ + SwapHash: f.loopIn.SwapHash[:], + } + fetchResp, err := f.cfg.Server.FetchSweeplessSweepTx(ctx, fetchReq) + if err != nil { + err = fmt.Errorf("unable to fetch sweepless sweep tx: %w", err) + + return f.HandleError(err) + } + + address, err := btcutil.DecodeAddress( + fetchResp.SweepAddr, f.cfg.ChainParams, + ) + if err != nil { + f.Warnf("unable to decode sweep address: %v", err) + } + + // Standard fee. + feeRate := chainfee.SatPerKWeight(fetchResp.StandardFeeInfo.FeeRate) + serverNonces, err := toNonces(fetchResp.StandardFeeInfo.Nonces) + if err != nil { + err = fmt.Errorf("unable to convert server nonces: %w", err) + + return f.HandleError(err) + } + + // High fee. + highFeeRate := chainfee.SatPerKWeight(fetchResp.HighFeeInfo.FeeRate) + serverHighFeeNonces, err := toNonces(fetchResp.HighFeeInfo.Nonces) + if err != nil { + err = fmt.Errorf("unable to convert high fee server "+ + "nonces: %w", err) + + return f.HandleError(err) + } + + // Extremely high fee. + extremeFeeRate := chainfee.SatPerKWeight( + fetchResp.ExtremeFeeInfo.FeeRate, + ) + serverExtremeNonces, err := toNonces( + fetchResp.ExtremeFeeInfo.Nonces, + ) + if err != nil { + err = fmt.Errorf("unable to convert extremely high fee "+ + "server nonces: %w", err) + + return f.HandleError(err) + } + + // Standard sessions. + sessions, nonces, err := f.loopIn.createMusig2Sessions( + ctx, f.cfg.Signer, + ) + if err != nil { + return f.HandleError(err) + } + clientNonces, err := toNonces(nonces) + if err != nil { + return f.HandleError(err) + } + + // High fee sessions. + highFeeSessions, highFeeClientNonces, err := + f.loopIn.createMusig2Sessions(ctx, f.cfg.Signer) + + if err != nil { + return f.HandleError(err) + } + highClientNonces, err := toNonces(highFeeClientNonces) + if err != nil { + return f.HandleError(err) + } + + // Extremely high sessions. + extremeSessions, extremeClientNonces, err := + f.loopIn.createMusig2Sessions(ctx, f.cfg.Signer) + + if err != nil { + return f.HandleError(err) + } + extremelyHighClientNonces, err := toNonces(extremeClientNonces) + if err != nil { + return f.HandleError(err) + } + + // Create standard fee. + sweepTx, err := f.loopIn.createSweeplessSweepTx(address, feeRate) + if err != nil { + err = fmt.Errorf("unable to create sweepless sweep tx: %w", err) + return f.HandleError(err) + } + + // Create high fee. + highFeeSweepTx, err := f.loopIn.createSweeplessSweepTx( + address, highFeeRate, + ) + if err != nil { + err = fmt.Errorf("unable to create high fee sweepless sweep "+ + "tx: %w", err) + + return f.HandleError(err) + } + + // Create extremely high fee. + extremelyHighFeeSweepTx, err := f.loopIn.createSweeplessSweepTx( + address, extremeFeeRate, + ) + if err != nil { + err = fmt.Errorf("unable to create extremely high fee "+ + "sweepless sweep tx: %w", err) + + return f.HandleError(err) + } + + // Sign standard. + sweeplessClientSigs, err := f.loopIn.signMusig2Tx( + ctx, sweepTx, f.cfg.Signer, sessions, serverNonces, + ) + if err != nil { + err = fmt.Errorf("unable to sign sweepless sweep tx: %w", err) + return f.HandleError(err) + } + + // Sign high fee. + highFeeSigs, err := f.loopIn.signMusig2Tx( + ctx, highFeeSweepTx, f.cfg.Signer, highFeeSessions, + serverHighFeeNonces, + ) + if err != nil { + err = fmt.Errorf("unable to sign high fee sweepless sweep "+ + "tx: %w", err) + + return f.HandleError(err) + } + + // Sign extremely high fee. + extremelyHighSigs, err := f.loopIn.signMusig2Tx( + ctx, extremelyHighFeeSweepTx, f.cfg.Signer, extremeSessions, + serverExtremeNonces, + ) + if err != nil { + err = fmt.Errorf("unable to sign extremely high fee "+ + "sweepless sweep tx: %w", err) + + return f.HandleError(err) + } + + // Push sweepless sigs to the server. + req := &looprpc.PushStaticAddressSweeplessSigsRequest{ + SwapHash: f.loopIn.SwapHash[:], + StandardSigningInfo: &looprpc.ClientSweeplessSigningInfo{ + Nonces: fromNonces(clientNonces), + Sigs: sweeplessClientSigs, + }, + HighFeeSigningInfo: &looprpc.ClientSweeplessSigningInfo{ + Nonces: fromNonces(highClientNonces), + Sigs: highFeeSigs, + }, + ExtremeFeeSigningInfo: &looprpc.ClientSweeplessSigningInfo{ + Nonces: fromNonces(extremelyHighClientNonces), + Sigs: extremelyHighSigs, + }, + } + _, err = f.cfg.Server.PushStaticAddressSweeplessSigs(ctx, req) + if err != nil { + err = fmt.Errorf("unable to push sweepless sweep sigs: %w", err) + + return f.HandleError(err) + } + + return OnSweeplessSweepSigned +} + +// 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 { + + err := f.cfg.DepositManager.TransitionDeposits( + ctx, f.loopIn.Deposits, fsm.OnError, deposit.Deposited, + ) + if err != nil { + err = fmt.Errorf("unable to unlock deposits: %w", err) + + return f.HandleError(err) + } + + return fsm.OnError +} + +// 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 +} + +func fromNonces(nonces [][musig2.PubNonceSize]byte) [][]byte { + result := make([][]byte, 0, len(nonces)) + for _, nonce := range nonces { + temp := make([]byte, musig2.PubNonceSize) + copy(temp, nonce[:]) + result = append(result, temp) + } + + return result +} diff --git a/staticaddr/loopin/fsm.go b/staticaddr/loopin/fsm.go new file mode 100644 index 00000000..40018479 --- /dev/null +++ b/staticaddr/loopin/fsm.go @@ -0,0 +1,365 @@ +package loopin + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/version" +) + +// FSM embeds an FSM and extends it with a static address loop-in and a config. +type FSM struct { + *fsm.StateMachine + + cfg *Config + + // loopIn stores the loop-in details that are relevant during the + // lifetime of the swap. + loopIn *StaticAddressLoopIn + + // MuSig2 data must not be re-used across restarts, hence it is not + // persisted. + // + // htlcServerNonces contains all the nonces that the server generated + // for the htlc musig2 sessions. + htlcServerNonces [][musig2.PubNonceSize]byte + + // htlcServerNoncesHighFee contains all the high fee nonces that the + // server generated for the htlc musig2 sessions. + htlcServerNoncesHighFee [][musig2.PubNonceSize]byte + + // htlcServerNoncesExtremelyHighFee contains all the extremely high fee + // nonces that the server generated for the htlc musig2 sessions. + htlcServerNoncesExtremelyHighFee [][musig2.PubNonceSize]byte +} + +// NewFSM creates a new loop-in state machine. +func NewFSM(ctx context.Context, loopIn *StaticAddressLoopIn, cfg *Config, + recoverStateMachine bool) (*FSM, error) { + + loopInFsm := &FSM{ + cfg: cfg, + loopIn: loopIn, + } + + params, err := cfg.AddressManager.GetStaticAddressParameters(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get static address "+ + "parameters: %w", err) + } + + loopInStates := loopInFsm.LoopInStatesV0() + switch params.ProtocolVersion { + case version.ProtocolVersion_V0: + + default: + return nil, deposit.ErrProtocolVersionNotSupported + } + + if recoverStateMachine { + loopInFsm.StateMachine = fsm.NewStateMachineWithState( + loopInStates, loopIn.GetState(), + deposit.DefaultObserverSize, + ) + } else { + loopInFsm.StateMachine = fsm.NewStateMachine( + loopInStates, deposit.DefaultObserverSize, + ) + } + + loopInFsm.ActionEntryFunc = loopInFsm.updateLoopIn + + return loopInFsm, nil +} + +// States that the loop-in fsm can transition to. +var ( + // InitHtlcTx initiates the htlc tx creation with the server. + InitHtlcTx = fsm.StateType("InitHtlcTx") + + // SignHtlcTx partially signs the htlc transaction with the received + // server nonces. The client doesn't hold a final signature hence can't + // publish the htlc. + SignHtlcTx = fsm.StateType("SignHtlcTx") + + // MonitorInvoiceAndHtlcTx monitors the swap invoice payment and the + // htlc transaction confirmation. + // Since the client provided its partial signature to spend to the htlc + // pkScript, the server could publish the htlc transaction prematurely. + // We need to monitor the htlc transaction to sweep our timeout path in + // this case. + // If the server pays the swap invoice as expected we can stop to + // monitor the htlc timeout path. + MonitorInvoiceAndHtlcTx = fsm.StateType("MonitorInvoiceAndHtlcTx") + + // PaymentReceived is the state where the swap invoice was paid by the + // server. The client can now sign the sweepless sweep transaction. + PaymentReceived = fsm.StateType("PaymentReceived") + + // SweepHtlcTimeout is the state where the htlc timeout path is + // published because the server did not pay the invoice on time. + SweepHtlcTimeout = fsm.StateType("SweepHtlcTimeout") + + // MonitorHtlcTimeoutSweep monitors the htlc timeout sweep transaction + // confirmation. + MonitorHtlcTimeoutSweep = fsm.StateType("MonitorHtlcTimeoutSweep") + + // HtlcTimeoutSwept is the state where the htlc timeout sweep + // transaction was sufficiently confirmed. + HtlcTimeoutSwept = fsm.StateType("HtlcTimeoutSwept") + + // FetchSignPushSweeplessSweepTx is the state where the client fetches, + // signs and pushes the sweepless sweep tx signatures to the server. + FetchSignPushSweeplessSweepTx = fsm.StateType("FetchSignPushSweeplessSweepTx") //nolint:lll + + // Succeeded is the state the swap is in if it was successful. + Succeeded = fsm.StateType("Succeeded") + + // SucceededSweeplessSigFailed is the state the swap is in if the swap + // payment was received but the client failed to sign the sweepless + // sweep transaction. This is considered a successful case from the + // client's perspective. + SucceededSweeplessSigFailed = fsm.StateType("SucceededSweeplessSigFailed") //nolint:lll + + // UnlockDeposits is the state where the deposits are reset. This + // happens when the state machine encountered an error and the swap + // process needs to start from the beginning. + UnlockDeposits = fsm.StateType("UnlockDeposits") + + // Failed is the state the swap is in if it failed. + Failed = fsm.StateType("Failed") +) + +var PendingStates = []fsm.StateType{ + InitHtlcTx, SignHtlcTx, MonitorInvoiceAndHtlcTx, PaymentReceived, + SweepHtlcTimeout, MonitorHtlcTimeoutSweep, FetchSignPushSweeplessSweepTx, + UnlockDeposits, +} + +var FinalStates = []fsm.StateType{ + HtlcTimeoutSwept, Succeeded, SucceededSweeplessSigFailed, Failed, +} + +var AllStates = append(PendingStates, FinalStates...) + +// Events. +var ( + OnInitHtlc = fsm.EventType("OnInitHtlc") + OnHtlcInitiated = fsm.EventType("OnHtlcInitiated") + OnHtlcTxSigned = fsm.EventType("OnHtlcTxSigned") + OnSweepHtlcTimeout = fsm.EventType("OnSweepHtlcTimeout") + OnHtlcTimeoutSweepPublished = fsm.EventType("OnHtlcTimeoutSweepPublished") + OnHtlcTimeoutSwept = fsm.EventType("OnHtlcTimeoutSwept") + OnPaymentReceived = fsm.EventType("OnPaymentReceived") + OnPaymentDeadlineExceeded = fsm.EventType("OnPaymentDeadlineExceeded") + OnSwapTimedOut = fsm.EventType("OnSwapTimedOut") + OnFetchSignPushSweeplessSweepTx = fsm.EventType("OnFetchSignPushSweeplessSweepTx") + OnSweeplessSweepSigned = fsm.EventType("OnSweeplessSweepSigned") + OnRecover = fsm.EventType("OnRecover") +) + +// LoopInStatesV0 returns the state and transition map for the loop-in state +// machine. +func (f *FSM) LoopInStatesV0() fsm.States { + return fsm.States{ + fsm.EmptyState: fsm.State{ + Transitions: fsm.Transitions{ + OnInitHtlc: InitHtlcTx, + }, + Action: fsm.NoOpAction, + }, + InitHtlcTx: fsm.State{ + Transitions: fsm.Transitions{ + OnHtlcInitiated: SignHtlcTx, + OnRecover: UnlockDeposits, + fsm.OnError: UnlockDeposits, + }, + Action: f.InitHtlcAction, + }, + SignHtlcTx: fsm.State{ + Transitions: fsm.Transitions{ + OnHtlcTxSigned: MonitorInvoiceAndHtlcTx, + OnRecover: UnlockDeposits, + fsm.OnError: UnlockDeposits, + }, + Action: f.SignHtlcTxAction, + }, + MonitorInvoiceAndHtlcTx: fsm.State{ + Transitions: fsm.Transitions{ + OnPaymentReceived: PaymentReceived, + OnSweepHtlcTimeout: SweepHtlcTimeout, + OnSwapTimedOut: Failed, + OnRecover: MonitorInvoiceAndHtlcTx, + fsm.OnError: UnlockDeposits, + }, + Action: f.MonitorInvoiceAndHtlcTxAction, + }, + SweepHtlcTimeout: fsm.State{ + Transitions: fsm.Transitions{ + OnHtlcTimeoutSweepPublished: MonitorHtlcTimeoutSweep, + OnRecover: SweepHtlcTimeout, + fsm.OnError: Failed, + }, + Action: f.SweepHtlcTimeoutAction, + }, + MonitorHtlcTimeoutSweep: fsm.State{ + Transitions: fsm.Transitions{ + OnHtlcTimeoutSwept: HtlcTimeoutSwept, + OnRecover: MonitorHtlcTimeoutSweep, + fsm.OnError: Failed, + }, + Action: f.MonitorHtlcTimeoutSweepAction, + }, + PaymentReceived: fsm.State{ + Transitions: fsm.Transitions{ + OnFetchSignPushSweeplessSweepTx: FetchSignPushSweeplessSweepTx, + OnRecover: SucceededSweeplessSigFailed, + fsm.OnError: SucceededSweeplessSigFailed, + }, + Action: f.PaymentReceivedAction, + }, + FetchSignPushSweeplessSweepTx: fsm.State{ + Transitions: fsm.Transitions{ + OnSweeplessSweepSigned: Succeeded, + OnRecover: SucceededSweeplessSigFailed, + fsm.OnError: SucceededSweeplessSigFailed, + }, + Action: f.FetchSignPushSweeplessSweepTxAction, + }, + HtlcTimeoutSwept: fsm.State{ + Action: fsm.NoOpAction, + }, + Succeeded: fsm.State{ + Action: fsm.NoOpAction, + }, + SucceededSweeplessSigFailed: fsm.State{ + Action: fsm.NoOpAction, + }, + UnlockDeposits: fsm.State{ + Transitions: fsm.Transitions{ + OnRecover: UnlockDeposits, + fsm.OnError: Failed, + }, + Action: f.UnlockDepositsAction, + }, + Failed: fsm.State{ + Action: fsm.NoOpAction, + }, + } +} + +// updateLoopIn is called after every action and updates the loop-in in the db. +func (f *FSM) updateLoopIn(ctx context.Context, notification fsm.Notification) { + f.Infof("Current: %v", notification.NextState) + + // Skip the update if the loop-in is not yet initialized. This happens + // on the entry action of the fsm. + if f.loopIn == nil { + return + } + + f.loopIn.SetState(notification.NextState) + + // Check if we can skip updating the loop-in in the database. + if isUpdateSkipped(notification, f.loopIn) { + return + } + + stored, err := f.cfg.Store.IsStored(ctx, f.loopIn.SwapHash) + if err != nil { + f.Errorf("Error checking if loop-in is stored: %v", err) + + return + } + + if !stored { + f.Warnf("Loop-in not stored in db, can't update") + + return + } + + err = f.cfg.Store.UpdateLoopIn(ctx, f.loopIn) + if err != nil { + f.Errorf("Error updating loop-in: %v", err) + + return + } +} + +// isUpdateSkipped returns true if the loop-in should not be updated for the +// given notification. +func isUpdateSkipped(notification fsm.Notification, + l *StaticAddressLoopIn) bool { + + prevState := notification.PreviousState + + // Skip if we are in the empty state because no loop-in has been + // persisted yet. + if l.IsInState(fsm.EmptyState) { + return true + } + + // We don't update in self-loops, e.g. in the case of recovery. + if l.IsInState(prevState) { + return true + } + + // If we transitioned from the empty state to InitHtlcTx there's still + // no loop-in persisted, so we don't need to update it. + if prevState == fsm.EmptyState && l.IsInState(InitHtlcTx) { + return true + } + + return false +} + +// Infof logs an info message with the loop-in swap hash. +func (f *FSM) Infof(format string, args ...interface{}) { + if f.loopIn == nil { + log.Infof(format, args...) + return + } + log.Infof( + "StaticAddr loop-in %s: %s", f.loopIn.SwapHash.String(), + fmt.Sprintf(format, args...), + ) +} + +// Debugf logs a debug message with the loop-in swap hash. +func (f *FSM) Debugf(format string, args ...interface{}) { + if f.loopIn == nil { + log.Infof(format, args...) + return + } + log.Debugf( + "StaticAddr loop-in %s: %s", f.loopIn.SwapHash.String(), + fmt.Sprintf(format, args...), + ) +} + +// Warnf logs a warning message with the loop-in swap hash. +func (f *FSM) Warnf(format string, args ...interface{}) { + if f.loopIn == nil { + log.Warnf(format, args...) + return + } + log.Warnf( + "StaticAddr loop-in %s: %s", f.loopIn.SwapHash.String(), + fmt.Sprintf(format, args...), + ) +} + +// Errorf logs an error message with the loop-in swap hash. +func (f *FSM) Errorf(format string, args ...interface{}) { + if f.loopIn == nil { + log.Errorf(format, args...) + return + } + log.Errorf( + "StaticAddr loop-in %s: %s", f.loopIn.SwapHash.String(), + fmt.Sprintf(format, args...), + ) +} diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go new file mode 100644 index 00000000..88bd543b --- /dev/null +++ b/staticaddr/loopin/interface.go @@ -0,0 +1,72 @@ +package loopin + +import ( + "context" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/lightningnetwork/lnd/zpay32" +) + +type ( + // ValidateLoopInContract validates the contract parameters against our + // request. + ValidateLoopInContract func(height int32, htlcExpiry int32) error +) + +// AddressManager handles fetching of address parameters. +type AddressManager interface { + // GetStaticAddressParameters returns the static address parameters. + GetStaticAddressParameters(ctx context.Context) (*address.Parameters, + error) + + // GetStaticAddress returns the deposit address for the given client and + // server public keys. + GetStaticAddress(ctx context.Context) (*script.StaticAddress, error) +} + +// DepositManager handles the interaction of loop-ins with deposits. +type DepositManager interface { + // AllStringOutpointsActiveDeposits returns all deposits that have the + // given outpoints and are in the given state. If any of the outpoints + // does not correspond to an active deposit, the function returns false. + AllStringOutpointsActiveDeposits(outpoints []string, + stateFilter fsm.StateType) ([]*deposit.Deposit, bool) + + // TransitionDeposits transitions the given deposits to the next state + // based on the given event. It returns an error if the transition is + // invalid. + TransitionDeposits(ctx context.Context, deposits []*deposit.Deposit, + event fsm.EventType, expectedFinalState fsm.StateType) error +} + +// StaticAddressLoopInStore provides access to the static address loop-in DB. +type StaticAddressLoopInStore interface { + // CreateLoopIn creates a loop-in record in the database. + CreateLoopIn(ctx context.Context, loopIn *StaticAddressLoopIn) error + + // UpdateLoopIn updates a loop-in record in the database. + UpdateLoopIn(ctx context.Context, loopIn *StaticAddressLoopIn) error + + // GetStaticAddressLoopInSwapsByStates returns all loop-ins with given + // states. + GetStaticAddressLoopInSwapsByStates(ctx context.Context, + states []fsm.StateType) ([]*StaticAddressLoopIn, error) + + // IsStored checks if the loop-in is already stored in the database. + IsStored(ctx context.Context, swapHash lntypes.Hash) (bool, error) +} + +type QuoteGetter interface { + // GetLoopInQuote returns a quote for a loop-in swap. + GetLoopInQuote(ctx context.Context, amt btcutil.Amount, + pubKey route.Vertex, lastHop *route.Vertex, + routeHints [][]zpay32.HopHint, + initiator string, numDeposits uint32) (*loop.LoopInQuote, error) +} diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go new file mode 100644 index 00000000..35768d17 --- /dev/null +++ b/staticaddr/loopin/manager.go @@ -0,0 +1,458 @@ +package loopin + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/labels" + "github.com/lightninglabs/loop/staticaddr/deposit" + looprpc "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/routing/route" +) + +// Config contains the services required for the loop-in manager. +type Config struct { + // Server is the client that is used to communicate with the static + // address server. + Server looprpc.StaticAddressServerClient + + // AddressManager gives the withdrawal manager access to static address + // parameters. + AddressManager AddressManager + + // DepositManager gives the withdrawal manager access to the deposits + // enabling it to create and manage loop-ins. + DepositManager DepositManager + + // LndClient is used to add invoices and select hop hints. + LndClient lndclient.LightningClient + + // InvoicesClient is used to subscribe to invoice settlements and + // cancel invoices. + InvoicesClient lndclient.InvoicesClient + + // SwapClient is used to get loop in quotes. + QuoteGetter QuoteGetter + + // NodePubKey is used to get a loo-in quote. + NodePubkey route.Vertex + + // WalletKit is the wallet client that is used to derive new keys from + // lnd's wallet. + WalletKit lndclient.WalletKitClient + + // ChainParams is the chain configuration(mainnet, testnet...) this + // manager uses. + ChainParams *chaincfg.Params + + // Chain is the chain notifier that is used to listen for new + // blocks. + ChainNotifier lndclient.ChainNotifierClient + + // Signer is the signer client that is used to sign transactions. + Signer lndclient.SignerClient + + // Store is the database store that is used to store static address + // loop-in related records. + Store StaticAddressLoopInStore + + // ValidateLoopInContract validates the contract parameters against our + // request. + ValidateLoopInContract ValidateLoopInContract + + // MaxStaticAddrHtlcFeePercentage is the percentage of the swap amount + // that we allow the server to charge for the htlc transaction. + // Although highly unlikely, this is a defense against the server + // publishing the htlc without paying the swap invoice, forcing us to + // sweep the timeout path. + MaxStaticAddrHtlcFeePercentage float64 + + // MaxStaticAddrHtlcBackupFeePercentage is the percentage of the swap + // amount that we allow the server to charge for the htlc backup + // transactions. This is a defense against the server publishing the + // htlc backup without paying the swap invoice, forcing us to sweep the + // timeout path. This value is elevated compared to + // MaxStaticAddrHtlcFeePercentage since it serves the server as backup + // transaction in case of fee spikes. + MaxStaticAddrHtlcBackupFeePercentage float64 +} + +// newSwapRequest is used to send a loop-in request to the manager main loop. +type newSwapRequest struct { + loopInRequest *loop.StaticAddressLoopInRequest + respChan chan *newSwapResponse +} + +// newSwapResponse is used to return the loop-in swap and error to the server. +type newSwapResponse struct { + loopIn *StaticAddressLoopIn + err error +} + +// Manager manages the address state machines. +type Manager struct { + cfg *Config + + // initChan signals the daemon that the address manager has completed + // its initialization. + initChan chan struct{} + + // newLoopInChan receives swap requests from the server and initiates + // loop-in swaps. + newLoopInChan chan *newSwapRequest + + // exitChan signals the manager's subroutines that the main looop ctx + // has been canceled. + exitChan chan struct{} + + // errChan forwards errors from the loop-in manager to the server. + errChan chan error + + // currentHeight stores the currently best known block height. + currentHeight atomic.Uint32 + + activeLoopIns map[lntypes.Hash]*FSM +} + +// NewManager creates a new deposit withdrawal manager. +func NewManager(cfg *Config) *Manager { + return &Manager{ + cfg: cfg, + initChan: make(chan struct{}), + newLoopInChan: make(chan *newSwapRequest), + exitChan: make(chan struct{}), + errChan: make(chan error), + activeLoopIns: make(map[lntypes.Hash]*FSM), + } +} + +// Run runs the static address loop-in manager. +func (m *Manager) Run(ctx context.Context, currentHeight uint32) error { + m.currentHeight.Store(currentHeight) + + registerBlockNtfn := m.cfg.ChainNotifier.RegisterBlockEpochNtfn + newBlockChan, newBlockErrChan, err := registerBlockNtfn(ctx) + if err != nil { + return err + } + + // Upon start of the loop-in manager we reinstate all previous loop-ins + // that are not yet completed. + err = m.recoverLoopIns(ctx) + if err != nil { + return err + } + + // Communicate to the caller that the address manager has completed its + // initialization. + close(m.initChan) + + var loopIn *StaticAddressLoopIn + for { + select { + case height := <-newBlockChan: + m.currentHeight.Store(uint32(height)) + + case err = <-newBlockErrChan: + return err + + case request := <-m.newLoopInChan: + loopIn, err = m.initiateLoopIn( + ctx, request.loopInRequest, + ) + if err != nil { + log.Errorf("Error initiating loop-in swap: %v", + err) + } + + // We forward the initialized loop-in and error to + // DeliverLoopInRequest. + resp := &newSwapResponse{ + loopIn: loopIn, + err: err, + } + select { + case request.respChan <- resp: + + case <-ctx.Done(): + // Noify subroutines that the main loop has been + // canceled. + close(m.exitChan) + + return ctx.Err() + } + + case <-ctx.Done(): + return ctx.Err() + } + } +} + +// recover stars a loop-in state machine for each non-final loop-in to pick up +// work where it was left off before the restart. +func (m *Manager) recoverLoopIns(ctx context.Context) error { + log.Infof("Recovering static address loop-ins...") + + // Recover loop-ins. + // Recover pending static address loop-ins. + pendingLoopIns, err := m.cfg.Store.GetStaticAddressLoopInSwapsByStates( + ctx, PendingStates, + ) + if err != nil { + return err + } + + for _, loopIn := range pendingLoopIns { + log.Debugf("Recovering loopIn %x", loopIn.SwapHash[:]) + + // Retrieve all deposits regardless of deposit state. If any of + // the deposits is not active in the in-mem map of the deposits + // manager we log it, but continue to recover the loop-in. + var allActive bool + loopIn.Deposits, allActive = + m.cfg.DepositManager.AllStringOutpointsActiveDeposits( + loopIn.DepositOutpoints, fsm.EmptyState, + ) + + if !allActive { + log.Errorf("one or more deposits are not active") + } + + loopIn.AddressParams, err = + m.cfg.AddressManager.GetStaticAddressParameters(ctx) + + if err != nil { + return err + } + + loopIn.Address, err = m.cfg.AddressManager.GetStaticAddress( + ctx, + ) + if err != nil { + return err + } + + // Create a state machine for a given loop-in. + var ( + recovery = true + fsm *FSM + ) + fsm, err = NewFSM(ctx, loopIn, m.cfg, recovery) + if err != nil { + return err + } + + // Send the OnRecover event to the state machine. + swapHash := loopIn.SwapHash + go func() { + err = fsm.SendEvent(ctx, OnRecover, nil) + if err != nil { + log.Errorf("Error sending OnStart event: %v", + err) + } + + m.activeLoopIns[swapHash] = fsm + }() + } + + return nil +} + +// WaitInitComplete waits until the static address loop-in manager has completed +// its setup. +func (m *Manager) WaitInitComplete() { + defer log.Debugf("Static address loop-in manager initiation complete.") + <-m.initChan +} + +// DeliverLoopInRequest forwards a loop-in request from the server to the +// manager run loop to initiate a new loop-in swap. +func (m *Manager) DeliverLoopInRequest(ctx context.Context, + req *loop.StaticAddressLoopInRequest) (*StaticAddressLoopIn, error) { + + request := &newSwapRequest{ + loopInRequest: req, + respChan: make(chan *newSwapResponse), + } + + // Send the new loop-in request to the manager run loop. + select { + case m.newLoopInChan <- request: + + case <-m.exitChan: + return nil, fmt.Errorf("loop-in manager has been canceled") + + case <-ctx.Done(): + return nil, fmt.Errorf("context canceled while initiating " + + "a loop-in swap") + } + + // Wait for the response from the manager run loop. + select { + case resp := <-request.respChan: + return resp.loopIn, resp.err + + case <-m.exitChan: + return nil, fmt.Errorf("loop-in manager has been canceled") + + case <-ctx.Done(): + return nil, fmt.Errorf("context canceled while waiting for " + + "loop-in swap response") + } +} + +// initiateLoopIn initiates a loop-in swap. It passes the request to the server +// along with all relevant loop-in information. +func (m *Manager) initiateLoopIn(ctx context.Context, + req *loop.StaticAddressLoopInRequest) (*StaticAddressLoopIn, error) { + + // Validate the loop-in request. + if len(req.DepositOutpoints) == 0 { + return nil, fmt.Errorf("no deposit outpoints provided") + } + + // Retrieve all deposits referenced by the outpoints and ensure that + // they are in state Deposited. + deposits, active := m.cfg.DepositManager.AllStringOutpointsActiveDeposits( //nolint:lll + req.DepositOutpoints, deposit.Deposited, + ) + if !active { + return nil, fmt.Errorf("one or more deposits are not in "+ + "state %s", deposit.Deposited) + } + + // Calculate the total deposit amount. + tmp := &StaticAddressLoopIn{ + Deposits: deposits, + } + totalDepositAmount := tmp.TotalDepositAmount() + + // Check that the label is valid. + err := labels.Validate(req.Label) + if err != nil { + return nil, fmt.Errorf("invalid label: %w", err) + } + + // Private and route hints are mutually exclusive as setting private + // means we retrieve our own route hints from the connected node. + if len(req.RouteHints) != 0 && req.Private { + return nil, fmt.Errorf("private and route hints are mutually " + + "exclusive") + } + + // If private is set, we generate route hints. + if req.Private { + // If last_hop is set, we'll only add channels with peers set to + // the last_hop parameter. + includeNodes := make(map[route.Vertex]struct{}) + if req.LastHop != nil { + includeNodes[*req.LastHop] = struct{}{} + } + + // Because the Private flag is set, we'll generate our own set + // of hop hints. + req.RouteHints, err = loop.SelectHopHints( + ctx, m.cfg.LndClient, totalDepositAmount, + loop.DefaultMaxHopHints, includeNodes, + ) + if err != nil { + return nil, fmt.Errorf("unable to generate hop "+ + "hints: %w", err) + } + } + + // Request current server loop in terms and use these to calculate the + // swap fee that we should subtract from the swap amount in the payment + // request that we send to the server. We pass nil as optional route + // hints as hop hint selection when generating invoices with private + // channels is an LND side black box feature. Advanced users will quote + // directly anyway and there they have the option to add specific route + // hints. + // The quote call will also request a probe from the server to ensure + // feasibility of a loop-in for the totalDepositAmount. + numDeposits := uint32(len(deposits)) + quote, err := m.cfg.QuoteGetter.GetLoopInQuote( + ctx, totalDepositAmount, m.cfg.NodePubkey, req.LastHop, + req.RouteHints, req.Initiator, numDeposits, + ) + if err != nil { + return nil, fmt.Errorf("unable to get loop in quote: %w", err) + } + + // If the previously accepted quote fee is lower than what is quoted now + // we abort the swap. + if quote.SwapFee > req.MaxSwapFee { + log.Warnf("Swap fee %v exceeding maximum of %v", + quote.SwapFee, req.MaxSwapFee) + + return nil, loop.ErrSwapFeeTooHigh + } + + paymentTimeoutSeconds := uint32(DefaultPaymentTimeoutSeconds) + if req.PaymentTimeoutSeconds != 0 { + paymentTimeoutSeconds = req.PaymentTimeoutSeconds + } + + swap := &StaticAddressLoopIn{ + DepositOutpoints: req.DepositOutpoints, + Deposits: deposits, + Label: req.Label, + Initiator: req.Initiator, + InitiationTime: time.Now(), + RouteHints: req.RouteHints, + QuotedSwapFee: quote.SwapFee, + MaxSwapFee: req.MaxSwapFee, + PaymentTimeoutSeconds: paymentTimeoutSeconds, + } + if req.LastHop != nil { + swap.LastHop = req.LastHop[:] + } + + swap.InitiationHeight = m.currentHeight.Load() + + return m.startLoopInFsm(ctx, swap) +} + +// startLoopInFsm initiates a loop-in state machine based on the user-provided +// swap information, sends that info to the server and waits for the server to +// return htlc signature information. It then creates the loop-in object in the +// database. +func (m *Manager) startLoopInFsm(ctx context.Context, + loopIn *StaticAddressLoopIn) (*StaticAddressLoopIn, error) { + + // Create a state machine for a given deposit. + recovery := false + loopInFsm, err := NewFSM(ctx, loopIn, m.cfg, recovery) + if err != nil { + return nil, err + } + + // Send the start event to the state machine. + go func() { + err = loopInFsm.SendEvent(ctx, OnInitHtlc, nil) + if err != nil { + log.Errorf("Error sending OnNewRequest event: %v", err) + } + }() + + // If an error occurs before SignHtlcTx is reached we consider the swap + // failed and abort early. + err = loopInFsm.DefaultObserver.WaitForState( + ctx, time.Minute, SignHtlcTx, + fsm.WithAbortEarlyOnErrorOption(), + ) + if err != nil { + return nil, err + } + + m.activeLoopIns[loopIn.SwapHash] = loopInFsm + + return loopIn, nil +}