diff --git a/instantout/actions.go b/instantout/actions.go new file mode 100644 index 0000000..101236c --- /dev/null +++ b/instantout/actions.go @@ -0,0 +1,531 @@ +package instantout + +import ( + "crypto/rand" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/instantout/reservation" + "github.com/lightninglabs/loop/loopdb" + loop_rpc "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/walletrpc" + "github.com/lightningnetwork/lnd/lntypes" +) + +var ( + ErrInsufficientReservationBalance = errors.New( + "insufficient balance in reservations", + ) + + ErrReservationsMustBeSwapAmount = errors.New( + "reservations total amount must be equal swap amount", + ) + + UrgentConfTarget = int32(3) + NormalConfTarget = int32(6) +) + +// InitInstantOutCtx contains the context for the InitInstantOutAction. +type InitInstantOutCtx struct { + cltvExpiry int32 + reservations []reservation.ID + initationHeight int32 + outgoingChanSet loopdb.ChannelSet +} + +// InitInstantOutAction is the first action that is executed when the instant +// out FSM is started. It will send the instant out request to the server. +func (f *FSM) InitInstantOutAction(eventCtx fsm.EventContext) fsm.EventType { + initCtx, ok := eventCtx.(*InitInstantOutCtx) + if !ok { + return f.HandleError(fsm.ErrInvalidContextType) + } + + if len(initCtx.reservations) == 0 { + return f.HandleError(fmt.Errorf("no reservations provided")) + } + + var ( + reservationAmt uint64 + reservationIds = make([][]byte, 0, len(initCtx.reservations)) + reservations = make( + []*reservation.Reservation, 0, len(initCtx.reservations), + ) + ) + + // The requested amount needs to be full reservation amounts. + for _, reservationId := range initCtx.reservations { + res, err := f.cfg.ReservationManager.GetReservation( + f.ctx, reservationId, + ) + if err != nil { + return f.HandleError(err) + } + + // Check if the reservation is locked. + if res.State == reservation.Locked { + return f.HandleError(fmt.Errorf("reservation %v is "+ + "locked", reservationId)) + } + + reservationAmt += uint64(res.Value) + reservationIds = append(reservationIds, reservationId[:]) + reservations = append(reservations, res) + } + + // Create the preimage for the swap. + var preimage lntypes.Preimage + if _, err := rand.Read(preimage[:]); err != nil { + return f.HandleError(err) + } + + // Create the keys for the swap. + keyRes, err := f.cfg.Wallet.DeriveNextKey(f.ctx, KeyFamily) + if err != nil { + return f.HandleError(err) + } + + swapHash := preimage.Hash() + + // Create a high fee rate so that the htlc will be confirmed quickly. + feeRate, err := f.cfg.Wallet.EstimateFeeRate(f.ctx, UrgentConfTarget) + if err != nil { + f.Infof("error estimating fee rate: %v", err) + return f.HandleError(err) + } + + // Send the instantout request to the server. + instantOutResponse, err := f.cfg.InstantOutClient.RequestInstantLoopOut( + f.ctx, + &loop_rpc.InstantLoopOutRequest{ + Amt: reservationAmt, + ReceiverKey: keyRes.PubKey.SerializeCompressed(), + SwapHash: swapHash[:], + Expiry: initCtx.cltvExpiry, + HtlcFeeRate: uint64(feeRate), + ReservationIds: reservationIds, + ProtocolVersion: loop_rpc. + InstantOutProtocolVersion_INSTANTOUT_FULL_RESERVATION, + }, + ) + if err != nil { + return f.HandleError(err) + } + // Decode the invoice to check if the hash is valid. + payReq, err := f.cfg.LndClient.DecodePaymentRequest( + f.ctx, instantOutResponse.SwapInvoice, + ) + if err != nil { + return f.HandleError(err) + } + + if preimage.Hash() != payReq.Hash { + return f.HandleError(fmt.Errorf("invalid swap invoice hash: "+ + "expected %x got %x", preimage.Hash(), payReq.Hash)) + } + serverPubkey, err := btcec.ParsePubKey(instantOutResponse.SenderKey) + if err != nil { + return f.HandleError(err) + } + + // Create the address that we'll send the funds to + sweepAddress, err := f.cfg.Wallet.NextAddr( + f.ctx, "", walletrpc.AddressType_TAPROOT_PUBKEY, false, + ) + if err != nil { + return f.HandleError(err) + } + + // Now we can create the instant out. + instantOut := &InstantOut{ + SwapHash: swapHash, + SwapPreimage: preimage, + ProtocolVersion: ProtocolVersionFullReservation, + InitiationHeight: initCtx.initationHeight, + OutgoingChanSet: initCtx.outgoingChanSet, + CltvExpiry: initCtx.cltvExpiry, + ClientPubkey: keyRes.PubKey, + ServerPubkey: serverPubkey, + Value: btcutil.Amount(reservationAmt), + HtlcFeeRate: feeRate, + SwapInvoice: instantOutResponse.SwapInvoice, + Reservations: reservations, + KeyLocator: keyRes.KeyLocator, + SweepAddress: sweepAddress, + } + + err = f.cfg.Store.CreateInstantLoopOut(f.ctx, instantOut) + if err != nil { + return f.HandleError(err) + } + + f.InstantOut = instantOut + + return OnInit +} + +// PollPaymentAcceptedAction locks the reservations, sends the payment to the +// server and polls the server for the payment status. +func (f *FSM) PollPaymentAcceptedAction( + eventCtx fsm.EventContext) fsm.EventType { + + // Now that we're doing the swap, we first lock the reservations + // so that they can't be used for other swaps. + for _, reservation := range f.InstantOut.Reservations { + err := f.cfg.ReservationManager.LockReservation( + f.ctx, reservation.ID, + ) + if err != nil { + return f.handleErrorAndUnlockReservations(err) + } + } + + // Now we send the payment to the server. + payChan, paymentErrChan, err := f.cfg.RouterClient.SendPayment( + f.ctx, + lndclient.SendPaymentRequest{ + Invoice: f.InstantOut.SwapInvoice, + Timeout: time.Minute * 5, + }, + ) + if err != nil { + return f.handleErrorAndUnlockReservations(err) + } + + // We'll continuously poll the server for the payment status. + for { + select { + case payRes := <-payChan: + f.Debugf("payment result: %v", payRes) + if payRes.FailureReason != + lnrpc.PaymentFailureReason_FAILURE_REASON_NONE { + + return f.handleErrorAndUnlockReservations( + fmt.Errorf("payment failed: %v", + payRes.FailureReason), + ) + } + + case err := <-paymentErrChan: + return f.handleErrorAndUnlockReservations(err) + + case <-f.ctx.Done(): + return fsm.NoOp + + default: + res, err := f.cfg.InstantOutClient.PollPaymentAccepted( + f.ctx, &loop_rpc.PollPaymentAcceptedRequest{ + SwapHash: f.InstantOut.SwapHash[:], + }, + ) + if err != nil { + return f.handleErrorAndUnlockReservations(err) + } + if res.Accepted { + return OnPaymentAccepted + } + + time.Sleep(time.Second) + } + } +} + +// BuildHTLCAction creates the htlc transaction, exchanges nonces with +// the server and sends the htlc signatures to the server. +func (f *FSM) BuildHTLCAction(eventCtx fsm.EventContext) fsm.EventType { + htlcSessions, htlcClientNonces, err := f.InstantOut.createMusig2Session( + f.ctx, f.cfg.Signer, + ) + if err != nil { + return f.handleErrorAndUnlockReservations(err) + } + + f.htlcMusig2Sessions = htlcSessions + + // Send the server the client nonces. + htlcInitRes, err := f.cfg.InstantOutClient.InitHtlcSig( + f.ctx, + &loop_rpc.InitHtlcSigRequest{ + SwapHash: f.InstantOut.SwapHash[:], + HtlcClientNonces: htlcClientNonces, + }, + ) + if err != nil { + return f.handleErrorAndUnlockReservations(err) + } + + if len(htlcInitRes.HtlcServerNonces) != len(f.InstantOut.Reservations) { + return f.handleErrorAndUnlockReservations( + errors.New("invalid number of server nonces"), + ) + } + + htlcServerNonces, err := toNonces(htlcInitRes.HtlcServerNonces) + if err != nil { + return f.handleErrorAndUnlockReservations(err) + } + + // Now that our nonces are set, we can create and sign the htlc + // transaction. + htlcTx, err := f.InstantOut.createHtlcTransaction(f.cfg.Network) + if err != nil { + return f.handleErrorAndUnlockReservations(err) + } + + // Next we'll get our sweep tx signatures. + htlcSigs, err := f.InstantOut.signMusig2Tx( + f.ctx, f.cfg.Signer, htlcTx, f.htlcMusig2Sessions, + htlcServerNonces, + ) + if err != nil { + return f.handleErrorAndUnlockReservations(err) + } + + // Send the server the htlc signatures. + htlcRes, err := f.cfg.InstantOutClient.PushHtlcSig( + f.ctx, + &loop_rpc.PushHtlcSigRequest{ + SwapHash: f.InstantOut.SwapHash[:], + ClientSigs: htlcSigs, + }, + ) + if err != nil { + return f.handleErrorAndUnlockReservations(err) + } + + // We can now finalize the htlc transaction. + htlcTx, err = f.InstantOut.finalizeMusig2Transaction( + f.ctx, f.cfg.Signer, f.htlcMusig2Sessions, htlcTx, + htlcRes.ServerSigs, + ) + if err != nil { + return f.handleErrorAndUnlockReservations(err) + } + + f.InstantOut.FinalizedHtlcTx = htlcTx + + return OnHtlcSigReceived +} + +// PushPreimageAction pushes the preimage to the server. It also creates the +// sweepless sweep transaction and sends the signatures to the server. Finally, +// it publishes the sweepless sweep transaction. If any of the steps after +// pushing the preimage fail, the htlc timeout transaction will be published. +func (f *FSM) PushPreimageAction(eventCtx fsm.EventContext) fsm.EventType { + // First we'll create the musig2 context. + coopSessions, coopClientNonces, err := f.InstantOut.createMusig2Session( + f.ctx, f.cfg.Signer, + ) + if err != nil { + return f.handleErrorAndUnlockReservations(err) + } + + f.sweeplessSweepSessions = coopSessions + + // Get the feerate for the coop sweep. + feeRate, err := f.cfg.Wallet.EstimateFeeRate(f.ctx, NormalConfTarget) + if err != nil { + return f.handleErrorAndUnlockReservations(err) + } + + pushPreImageRes, err := f.cfg.InstantOutClient.PushPreimage( + f.ctx, + &loop_rpc.PushPreimageRequest{ + Preimage: f.InstantOut.SwapPreimage[:], + ClientNonces: coopClientNonces, + ClientSweepAddr: f.InstantOut.SweepAddress.String(), + MusigTxFeeRate: uint64(feeRate), + }, + ) + // Now that we have revealed the preimage, if any following step fail, + // we'll need to publish the htlc timeout tx. + if err != nil { + f.LastActionError = err + return OnErrorPublishHtlc + } + + // Now that we have the sweepless sweep signatures we can build and + // publish the sweepless sweep transaction. + sweepTx, err := f.InstantOut.createSweeplessSweepTx(feeRate) + if err != nil { + f.LastActionError = err + return OnErrorPublishHtlc + } + + coopServerNonces, err := toNonces(pushPreImageRes.ServerNonces) + if err != nil { + f.LastActionError = err + return OnErrorPublishHtlc + } + + // Next we'll get our sweep tx signatures. + _, err = f.InstantOut.signMusig2Tx( + f.ctx, f.cfg.Signer, sweepTx, f.sweeplessSweepSessions, + coopServerNonces, + ) + if err != nil { + f.LastActionError = err + return OnErrorPublishHtlc + } + + // Now we'll finalize the sweepless sweep transaction. + sweepTx, err = f.InstantOut.finalizeMusig2Transaction( + f.ctx, f.cfg.Signer, f.sweeplessSweepSessions, sweepTx, + pushPreImageRes.Musig2SweepSigs, + ) + if err != nil { + f.LastActionError = err + return OnErrorPublishHtlc + } + + txLabel := fmt.Sprintf("sweepless-sweep-%v", + f.InstantOut.SwapPreimage.Hash()) + + // Publish the sweepless sweep transaction. + err = f.cfg.Wallet.PublishTransaction(f.ctx, sweepTx, txLabel) + if err != nil { + f.LastActionError = err + return OnErrorPublishHtlc + } + + f.InstantOut.FinalizedSweeplessSweepTx = sweepTx + txHash := f.InstantOut.FinalizedSweeplessSweepTx.TxHash() + + f.InstantOut.SweepTxHash = &txHash + + return OnSweeplessSweepPublished +} + +// WaitForSweeplessSweepConfirmedAction waits for the sweepless sweep +// transaction to be confirmed. +func (f *FSM) WaitForSweeplessSweepConfirmedAction( + eventCtx fsm.EventContext) fsm.EventType { + + pkscript, err := txscript.PayToAddrScript(f.InstantOut.SweepAddress) + if err != nil { + return f.HandleError(err) + } + + confChan, confErrChan, err := f.cfg.ChainNotifier. + RegisterConfirmationsNtfn( + f.ctx, f.InstantOut.SweepTxHash, pkscript, + 1, f.InstantOut.InitiationHeight, + ) + if err != nil { + return f.HandleError(err) + } + + for { + select { + case spendErr := <-confErrChan: + f.LastActionError = spendErr + f.Errorf("error listening for sweepless sweep "+ + "confirmation: %v", spendErr) + + return OnErrorPublishHtlc + + case conf := <-confChan: + f.InstantOut. + SweepConfirmationHeight = conf.BlockHeight + + return OnSweeplessSweepConfirmed + } + } +} + +// PublishHtlcAction publishes the htlc transaction and the htlc sweep +// transaction. +func (f *FSM) PublishHtlcAction(eventCtx fsm.EventContext) fsm.EventType { + // Publish the htlc transaction. + err := f.cfg.Wallet.PublishTransaction( + f.ctx, f.InstantOut.FinalizedHtlcTx, + fmt.Sprintf("htlc-%v", f.InstantOut.SwapPreimage.Hash()), + ) + if err != nil { + return f.HandleError(err) + } + + // Create a feerate that will confirm the htlc quickly. + feeRate, err := f.cfg.Wallet.EstimateFeeRate(f.ctx, UrgentConfTarget) + if err != nil { + return f.HandleError(err) + } + + // We can immediately publish the htlc sweep transaction + htlcSweepTx, err := f.InstantOut.generateHtlcSweepTx( + f.ctx, f.cfg.Signer, feeRate, f.cfg.Network, + ) + if err != nil { + return f.HandleError(err) + } + + txLabel := fmt.Sprintf("htlc-sweep-%v", + f.InstantOut.SwapPreimage.Hash()) + + err = f.cfg.Wallet.PublishTransaction(f.ctx, htlcSweepTx, txLabel) + if err != nil { + return f.HandleError(err) + } + + sweepTxHash := htlcSweepTx.TxHash() + + f.InstantOut.SweepTxHash = &sweepTxHash + + return OnHtlcSweepPublished +} + +// WaitForHtlcSweepConfirmedAction waits for the htlc sweep transaction to be +// confirmed. +func (f *FSM) WaitForHtlcSweepConfirmedAction( + eventCtx fsm.EventContext) fsm.EventType { + + htlc, err := f.InstantOut.getHtlc(f.cfg.Network) + if err != nil { + return f.HandleError(err) + } + + confChan, confErrChan, err := f.cfg.ChainNotifier.RegisterConfirmationsNtfn( + f.ctx, f.InstantOut.SweepTxHash, htlc.PkScript, + 1, f.InstantOut.InitiationHeight, + ) + if err != nil { + return f.HandleError(err) + } + + for { + select { + case spendErr := <-confErrChan: + f.LastActionError = spendErr + return OnErrorPublishHtlc + + case conf := <-confChan: + f.InstantOut. + SweepConfirmationHeight = conf.BlockHeight + + return OnHtlcSwept + } + } +} + +// handleErrorAndUnlockReservations handles an error and unlocks the +// reservations. +func (f *FSM) handleErrorAndUnlockReservations(err error) fsm.EventType { + // Unlock the reservations. + for _, reservation := range f.InstantOut.Reservations { + err := f.cfg.ReservationManager.UnlockReservation( + f.ctx, reservation.ID, + ) + if err != nil { + return f.HandleError(err) + } + } + + return f.HandleError(err) +} diff --git a/instantout/fsm.go b/instantout/fsm.go new file mode 100644 index 0000000..6a49c7d --- /dev/null +++ b/instantout/fsm.go @@ -0,0 +1,360 @@ +package instantout + +import ( + "context" + "errors" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/fsm" + loop_rpc "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/input" +) + +type ProtocolVersion uint32 + +const ( + ProtocolVersionUndefined ProtocolVersion = 0 + ProtocolVersionFullReservation ProtocolVersion = 1 +) + +const ( + // defaultObserverSize is the size of the fsm observer channel. + defaultObserverSize = 15 +) + +var ( + ErrProtocolVersionNotSupported = errors.New( + "protocol version not supported", + ) +) + +// States. +var ( + // Init is the initial state of the instant out FSM. + Init = fsm.StateType("Init") + + // SendPaymentAndPollAccepted is the state where the payment is sent + // and the server is polled for the accepted state. + SendPaymentAndPollAccepted = fsm.StateType("SendPaymentAndPollAccepted") + + // BuildHtlc is the state where the htlc transaction is built. + BuildHtlc = fsm.StateType("BuildHtlc") + + // PushPreimage is the state where the preimage is pushed to the server. + PushPreimage = fsm.StateType("PushPreimage") + + // WaitForSweeplessSweepConfirmed is the state where we wait for the + // sweepless sweep to be confirmed. + WaitForSweeplessSweepConfirmed = fsm.StateType( + "WaitForSweeplessSweepConfirmed") + + // FinishedSweeplessSweep is the state where the swap is finished by + // publishing the sweepless sweep. + FinishedSweeplessSweep = fsm.StateType("FinishedSweeplessSweep") + + // PublishHtlc is the state where the htlc transaction is published. + PublishHtlc = fsm.StateType("PublishHtlc") + + // FinishedHtlcPreimageSweep is the state where the swap is finished by + // publishing the htlc preimage sweep. + FinishedHtlcPreimageSweep = fsm.StateType("FinishedHtlcPreimageSweep") + + // WaitForHtlcSweepConfirmed is the state where we wait for the htlc + // sweep to be confirmed. + WaitForHtlcSweepConfirmed = fsm.StateType("WaitForHtlcSweepConfirmed") + + // FailedHtlcSweep is the state where the htlc sweep failed. + FailedHtlcSweep = fsm.StateType("FailedHtlcSweep") + + // Failed is the state where the swap failed. + Failed = fsm.StateType("InstantFailedOutFailed") +) + +// Events. +var ( + // OnStart is the event that is sent when the FSM is started. + OnStart = fsm.EventType("OnStart") + + // OnInit is the event that is triggered when the FSM is initialized. + OnInit = fsm.EventType("OnInit") + + // OnPaymentAccepted is the event that is triggered when the payment + // is accepted by the server. + OnPaymentAccepted = fsm.EventType("OnPaymentAccepted") + + // OnHtlcSigReceived is the event that is triggered when the htlc sig + // is received. + OnHtlcSigReceived = fsm.EventType("OnHtlcSigReceived") + + // OnPreimagePushed is the event that is triggered when the preimage + // is pushed to the server. + OnPreimagePushed = fsm.EventType("OnPreimagePushed") + + // OnSweeplessSweepPublished is the event that is triggered when the + // sweepless sweep is published. + OnSweeplessSweepPublished = fsm.EventType("OnSweeplessSweepPublished") + + // OnSweeplessSweepConfirmed is the event that is triggered when the + // sweepless sweep is confirmed. + OnSweeplessSweepConfirmed = fsm.EventType("OnSweeplessSweepConfirmed") + + // OnErrorPublishHtlc is the event that is triggered when the htlc + // sweep is published after an error. + OnErrorPublishHtlc = fsm.EventType("OnErrorPublishHtlc") + + // OnInvalidCoopSweep is the event that is triggered when the coop + // sweep is invalid. + OnInvalidCoopSweep = fsm.EventType("OnInvalidCoopSweep") + + // OnHtlcSweepPublished is the event that is triggered when the htlc + // sweep is published. + OnHtlcSweepPublished = fsm.EventType("OnHtlcBroadcasted") + + // OnHtlcSwept is the event that is triggered when the htlc sweep is + // confirmed. + OnHtlcSwept = fsm.EventType("OnHtlcSwept") + + // OnRecover is the event that is triggered when the FSM recovers from + // a restart. + OnRecover = fsm.EventType("OnRecover") +) + +// Config contains the services required for the instant out FSM. +type Config struct { + // Store is used to store the instant out. + Store InstantLoopOutStore + + // LndClient is used to decode the swap invoice. + LndClient lndclient.LightningClient + + // RouterClient is used to send the offchain payment to the server. + RouterClient lndclient.RouterClient + + // ChainNotifier is used to be notified of on-chain events. + ChainNotifier lndclient.ChainNotifierClient + + // Signer is used to sign transactions. + Signer lndclient.SignerClient + + // Wallet is used to derive keys. + Wallet lndclient.WalletKitClient + + // InstantOutClient is used to communicate with the swap server. + InstantOutClient loop_rpc.InstantSwapServerClient + + // ReservationManager is used to get the reservations and lock them. + ReservationManager ReservationManager + + // Network is the network that is used for the swap. + Network *chaincfg.Params +} + +// FSM is the state machine that handles the instant out. +type FSM struct { + *fsm.StateMachine + + ctx context.Context + + // cfg contains all the services that the reservation manager needs to + // operate. + cfg *Config + + // InstantOut contains all the information about the instant out. + InstantOut *InstantOut + + // htlcMusig2Sessions contains all the reservations input musig2 + // sessions that will be used for the htlc transaction. + htlcMusig2Sessions []*input.MuSig2SessionInfo + + // sweeplessSweepSessions contains all the reservations input musig2 + // sessions that will be used for the sweepless sweep transaction. + sweeplessSweepSessions []*input.MuSig2SessionInfo +} + +// NewFSM creates a new instant out FSM. +func NewFSM(ctx context.Context, cfg *Config, + protocolVersion ProtocolVersion) (*FSM, error) { + + instantOut := &InstantOut{ + State: fsm.EmptyState, + ProtocolVersion: protocolVersion, + } + + return NewFSMFromInstantOut(ctx, cfg, instantOut) +} + +// NewFSMFromInstantOut creates a new instantout FSM from an existing instantout +// recovered from the database. +func NewFSMFromInstantOut(ctx context.Context, cfg *Config, + instantOut *InstantOut) (*FSM, error) { + + instantOutFSM := &FSM{ + ctx: ctx, + cfg: cfg, + InstantOut: instantOut, + } + switch instantOut.ProtocolVersion { + case ProtocolVersionFullReservation: + instantOutFSM.StateMachine = fsm.NewStateMachineWithState( + instantOutFSM.GetV1ReservationStates(), + instantOut.State, defaultObserverSize, + ) + + default: + return nil, ErrProtocolVersionNotSupported + } + + instantOutFSM.ActionEntryFunc = instantOutFSM.updateInstantOut + + return instantOutFSM, nil +} + +// GetV1ReservationStates returns the states for the v1 reservation. +func (f *FSM) GetV1ReservationStates() fsm.States { + return fsm.States{ + fsm.EmptyState: fsm.State{ + Transitions: fsm.Transitions{ + OnStart: Init, + }, + Action: nil, + }, + Init: fsm.State{ + Transitions: fsm.Transitions{ + OnInit: SendPaymentAndPollAccepted, + fsm.OnError: Failed, + OnRecover: Failed, + }, + Action: f.InitInstantOutAction, + }, + SendPaymentAndPollAccepted: fsm.State{ + Transitions: fsm.Transitions{ + OnPaymentAccepted: BuildHtlc, + fsm.OnError: Failed, + OnRecover: Failed, + }, + Action: f.PollPaymentAcceptedAction, + }, + BuildHtlc: fsm.State{ + Transitions: fsm.Transitions{ + OnHtlcSigReceived: PushPreimage, + fsm.OnError: Failed, + OnRecover: Failed, + }, + Action: f.BuildHTLCAction, + }, + PushPreimage: fsm.State{ + Transitions: fsm.Transitions{ + OnSweeplessSweepPublished: WaitForSweeplessSweepConfirmed, + fsm.OnError: Failed, + OnErrorPublishHtlc: PublishHtlc, + OnRecover: PushPreimage, + }, + Action: f.PushPreimageAction, + }, + WaitForSweeplessSweepConfirmed: fsm.State{ + Transitions: fsm.Transitions{ + OnSweeplessSweepConfirmed: FinishedSweeplessSweep, + OnRecover: WaitForSweeplessSweepConfirmed, + fsm.OnError: PublishHtlc, + }, + Action: f.WaitForSweeplessSweepConfirmedAction, + }, + FinishedSweeplessSweep: fsm.State{ + Transitions: fsm.Transitions{}, + Action: fsm.NoOpAction, + }, + PublishHtlc: fsm.State{ + Transitions: fsm.Transitions{ + fsm.OnError: FailedHtlcSweep, + OnRecover: PublishHtlc, + OnHtlcSweepPublished: WaitForHtlcSweepConfirmed, + }, + Action: f.PublishHtlcAction, + }, + WaitForHtlcSweepConfirmed: fsm.State{ + Transitions: fsm.Transitions{ + OnHtlcSwept: FinishedHtlcPreimageSweep, + OnRecover: WaitForHtlcSweepConfirmed, + fsm.OnError: FailedHtlcSweep, + }, + Action: f.WaitForHtlcSweepConfirmedAction, + }, + FailedHtlcSweep: fsm.State{ + Action: fsm.NoOpAction, + }, + Failed: fsm.State{ + Action: fsm.NoOpAction, + }, + } +} + +// updateInstantOut is called after every action and updates the reservation +// in the db. +func (f *FSM) updateInstantOut(notification fsm.Notification) { + f.Infof("Current: %v, Event: %v", notification.NextState, + notification.Event) + + // Skip the update if the reservation is not yet initialized. + if f.InstantOut == nil { + return + } + + f.InstantOut.State = notification.NextState + + // If we're in the early stages we don't have created the reservation + // in the store yet and won't need to update it. + if f.InstantOut.State == Init { + return + } + + err := f.cfg.Store.UpdateInstantLoopOut(f.ctx, f.InstantOut) + if err != nil { + log.Errorf("Error updating reservation: %v", err) + return + } +} + +// Infof logs an info message with the reservation hash as prefix. +func (f *FSM) Infof(format string, args ...interface{}) { + log.Infof( + "InstantOut %v: "+format, + append( + []interface{}{f.InstantOut.SwapPreimage.Hash()}, + args..., + )..., + ) +} + +// Debugf logs a debug message with the reservation hash as prefix. +func (f *FSM) Debugf(format string, args ...interface{}) { + log.Debugf( + "InstantOut %v: "+format, + append( + []interface{}{f.InstantOut.SwapPreimage.Hash()}, + args..., + )..., + ) +} + +// Errorf logs an error message with the reservation hash as prefix. +func (f *FSM) Errorf(format string, args ...interface{}) { + log.Errorf( + "InstantOut %v: "+format, + append( + []interface{}{f.InstantOut.SwapPreimage.Hash()}, + args..., + )..., + ) +} + +// isFinalState returns true if the state is a final state. +func isFinalState(state fsm.StateType) bool { + switch state { + case Failed, FinishedHtlcPreimageSweep, FailedHtlcSweep, + FinishedSweeplessSweep: + + return true + } + return false +} diff --git a/instantout/instantout.go b/instantout/instantout.go new file mode 100644 index 0000000..d5e5d8b --- /dev/null +++ b/instantout/instantout.go @@ -0,0 +1,478 @@ +package instantout + +import ( + "context" + "errors" + "fmt" + + "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/instantout/reservation" + "github.com/lightninglabs/loop/loopdb" + "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" +) + +// InstantOut holds the necessary information to execute an instant out swap. +type InstantOut struct { + // SwapHash is the hash of the swap. + SwapHash lntypes.Hash + + // SwapPreimage is the preimage that is used for the swap. + SwapPreimage lntypes.Preimage + + // State is the current state of the swap. + State fsm.StateType + + // CltvExpiry is the expiry of the swap. + CltvExpiry int32 + + // OutgoingChanSet optionally specifies the short channel ids of the + // channels that may be used to loop out. + OutgoingChanSet loopdb.ChannelSet + + // Reservations are the reservations that are used in as inputs for the + // instant out swap. + Reservations []*reservation.Reservation + + // ProtocolVersion is the version of the protocol that is used for the + // swap. + ProtocolVersion ProtocolVersion + + // InitiationHeight is the height at which the swap was initiated. + InitiationHeight int32 + + // Value is the amount that is swapped. + Value btcutil.Amount + + // KeyLocator is the key locator that is used for the swap. + KeyLocator keychain.KeyLocator + + // 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 + + // SwapInvoice is the invoice that is used for the swap. + SwapInvoice string + + // HtlcFeeRate is the fee rate that is used for the htlc transaction. + HtlcFeeRate chainfee.SatPerKWeight + + // SweepAddress is the address that is used to sweep the funds to. + SweepAddress btcutil.Address + + // FinalizedHtlcTx is the finalized htlc transaction that is used in the + // non-cooperative path for the instant out swap. + FinalizedHtlcTx *wire.MsgTx + + // SweepTxHash is the hash of the sweep transaction. + SweepTxHash *chainhash.Hash + + // FinalizedSweeplessSweepTx is the transaction that is used to sweep + // the funds in the cooperative path. + FinalizedSweeplessSweepTx *wire.MsgTx + + // SweepConfirmationHeight is the height at which the sweep + // transaction was confirmed. + SweepConfirmationHeight uint32 +} + +// getHtlc returns the swap.htlc for the instant out. +func (i *InstantOut) getHtlc(chainParams *chaincfg.Params) (*swap.Htlc, error) { + return swap.NewHtlcV2( + i.CltvExpiry, pubkeyTo33ByteSlice(i.ServerPubkey), + pubkeyTo33ByteSlice(i.ClientPubkey), i.SwapHash, chainParams, + ) +} + +// createMusig2Session creates a musig2 session for the instant out. +func (i *InstantOut) createMusig2Session(ctx context.Context, + signer lndclient.SignerClient) ([]*input.MuSig2SessionInfo, + [][]byte, error) { + + // Create the htlc musig2 context. + musig2Sessions := make([]*input.MuSig2SessionInfo, len(i.Reservations)) + clientNonces := make([][]byte, len(i.Reservations)) + + // Create the sessions and nonces from the reservations. + for idx, reservation := range i.Reservations { + session, err := reservation.Musig2CreateSession( + ctx, signer, + ) + if err != nil { + return nil, nil, err + } + + musig2Sessions[idx] = session + clientNonces[idx] = session.PublicNonce[:] + } + + return musig2Sessions, clientNonces, nil +} + +// getInputReservation returns the input reservation for the instant out. +func (i *InstantOut) getInputReservations() (InputReservations, error) { + if len(i.Reservations) == 0 { + return nil, errors.New("no reservations") + } + + inputs := make(InputReservations, len(i.Reservations)) + for i, reservation := range i.Reservations { + pkScript, err := reservation.GetPkScript() + if err != nil { + return nil, err + } + + inputs[i] = InputReservation{ + Outpoint: *reservation.Outpoint, + Value: reservation.Value, + PkScript: pkScript, + } + } + + return inputs, nil +} + +// createHtlcTransaction creates the htlc transaction for the instant out. +func (i *InstantOut) createHtlcTransaction(network *chaincfg.Params) ( + *wire.MsgTx, error) { + + inputReservations, err := i.getInputReservations() + if err != nil { + return nil, err + } + + // First Create the tx. + msgTx := wire.NewMsgTx(2) + + // add the reservation inputs + for _, reservation := range inputReservations { + msgTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: reservation.Outpoint, + }) + } + + // Estimate the fee + weight := htlcWeight(len(inputReservations)) + fee := i.HtlcFeeRate.FeeForWeight(weight) + + htlc, err := i.getHtlc(network) + if err != nil { + return nil, err + } + + // Create the sweep output + sweepOutput := &wire.TxOut{ + Value: int64(i.Value) - int64(fee), + PkScript: htlc.PkScript, + } + + msgTx.AddTxOut(sweepOutput) + + return msgTx, nil +} + +// createSweeplessSweepTx creates the sweepless sweep transaction for the +// instant out. +func (i *InstantOut) createSweeplessSweepTx(feerate chainfee.SatPerKWeight) ( + *wire.MsgTx, error) { + + inputReservations, err := i.getInputReservations() + if err != nil { + return nil, err + } + + // First Create the tx. + msgTx := wire.NewMsgTx(2) + + // add the reservation inputs + for _, reservation := range inputReservations { + msgTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: reservation.Outpoint, + }) + } + + // Estimate the fee + weight := sweeplessSweepWeight(len(inputReservations)) + fee := feerate.FeeForWeight(weight) + + pkscript, err := txscript.PayToAddrScript(i.SweepAddress) + if err != nil { + return nil, err + } + + // Create the sweep output + sweepOutput := &wire.TxOut{ + Value: int64(i.Value) - int64(fee), + PkScript: pkscript, + } + + msgTx.AddTxOut(sweepOutput) + + return msgTx, nil +} + +// signMusig2Tx adds the server nonces to the musig2 sessions and signs the +// transaction. +func (i *InstantOut) signMusig2Tx(ctx context.Context, + signer lndclient.SignerClient, tx *wire.MsgTx, + musig2sessions []*input.MuSig2SessionInfo, + counterPartyNonces [][66]byte) ([][]byte, error) { + + inputs, err := i.getInputReservations() + if err != nil { + return nil, err + } + + prevOutFetcher := inputs.GetPrevoutFetcher() + sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher) + sigs := make([][]byte, len(inputs)) + + for idx, reservation := range inputs { + if !equalOutpoints(tx.TxIn[idx].PreviousOutPoint, + reservation.Outpoint) { + + return nil, fmt.Errorf("tx input does not match " + + "reservation") + } + + 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 +} + +// finalizeMusig2Transaction creates the finalized transactions for either +// the htlc or the cooperative close. +func (i *InstantOut) finalizeMusig2Transaction(ctx context.Context, + signer lndclient.SignerClient, + musig2Sessions []*input.MuSig2SessionInfo, + tx *wire.MsgTx, serverSigs [][]byte) (*wire.MsgTx, error) { + + inputs, err := i.getInputReservations() + if err != nil { + return nil, err + } + + for idx := range inputs { + haveAllSigs, finalSig, err := signer.MuSig2CombineSig( + ctx, musig2Sessions[idx].SessionID, + [][]byte{serverSigs[idx]}, + ) + if err != nil { + return nil, err + } + + if !haveAllSigs { + return nil, fmt.Errorf("missing sigs") + } + + tx.TxIn[idx].Witness = wire.TxWitness{finalSig} + } + + return tx, nil +} + +// sweeplessSweepOutpoint returns the outpoint of the reservation. +func (i *InstantOut) generateHtlcSweepTx(ctx context.Context, + signer lndclient.SignerClient, + feeRate chainfee.SatPerKWeight, network *chaincfg.Params) ( + *wire.MsgTx, error) { + + if i.FinalizedHtlcTx == nil { + return nil, errors.New("no finalized htlc tx") + } + + htlc, err := i.getHtlc(network) + if err != nil { + return nil, err + } + + // Create the sweep transaction. + sweepTx := wire.NewMsgTx(2) + + var weightEstimator input.TxWeightEstimator + weightEstimator.AddP2TROutput() + + err = htlc.AddSuccessToEstimator(&weightEstimator) + if err != nil { + return nil, err + } + + htlcHash := i.FinalizedHtlcTx.TxHash() + + // Add the htlc input. + sweepTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: htlcHash, + Index: 0, + }, + SignatureScript: htlc.SigScript, + }) + + // Add the sweep output. + sweepPkScript, err := txscript.PayToAddrScript(i.SweepAddress) + if err != nil { + return nil, err + } + + fee := feeRate.FeeForWeight(int64(weightEstimator.Weight())) + + output := &wire.TxOut{ + Value: int64(i.Value) - int64(fee), + PkScript: sweepPkScript, + } + + sweepTx.AddTxOut(output) + + signDesc := lndclient.SignDescriptor{ + WitnessScript: htlc.SuccessScript(), + Output: output, + HashType: htlc.SigHash(), + InputIndex: 0, + KeyDesc: keychain.KeyDescriptor{ + KeyLocator: i.KeyLocator, + }, + } + + rawSigs, err := signer.SignOutputRaw( + ctx, sweepTx, []*lndclient.SignDescriptor{&signDesc}, + nil, + ) + if err != nil { + return nil, fmt.Errorf("signing: %v", err) + } + sig := rawSigs[0] + + // Add witness stack to the tx input. + sweepTx.TxIn[0].Witness, err = htlc.GenSuccessWitness( + sig, i.SwapPreimage, + ) + if err != nil { + return nil, err + } + + return sweepTx, nil +} + +// htlcWeight returns the weight for the htlc transaction. +func htlcWeight(numInputs int) int64 { + var weightEstimator input.TxWeightEstimator + for i := 0; i < numInputs; i++ { + weightEstimator.AddTaprootKeySpendInput( + txscript.SigHashDefault, + ) + } + + weightEstimator.AddP2WSHOutput() + + return int64(weightEstimator.Weight()) +} + +// sweeplessSweepWeight returns the weight for the sweepless sweep transaction. +func sweeplessSweepWeight(numInputs int) int64 { + var weightEstimator input.TxWeightEstimator + for i := 0; i < numInputs; i++ { + weightEstimator.AddTaprootKeySpendInput( + txscript.SigHashDefault, + ) + } + + weightEstimator.AddP2TROutput() + + return int64(weightEstimator.Weight()) +} + +// 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 +} + +// toNonces converts a byte slice to a 66 byte slice. +func toNonces(nonces [][]byte) ([][66]byte, error) { + res := make([][66]byte, 0, len(nonces)) + for _, nonce := range nonces { + nonce, err := byteSliceTo66ByteSlice(nonce) + 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) ([66]byte, error) { + if len(b) != 66 { + return [66]byte{}, fmt.Errorf("invalid byte slice length") + } + + var res [66]byte + copy(res[:], b) + + return res, nil +} + +// equalOutpoints returns true if the outpoints are equal. +func equalOutpoints(txOutpoint, reservationOutpoint wire.OutPoint) bool { + if txOutpoint.Hash != reservationOutpoint.Hash && + txOutpoint.Index != reservationOutpoint.Index { + + return false + } + + return true +} diff --git a/instantout/interfaces.go b/instantout/interfaces.go new file mode 100644 index 0000000..ebd48a7 --- /dev/null +++ b/instantout/interfaces.go @@ -0,0 +1,75 @@ +package instantout + +import ( + "context" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/instantout/reservation" +) + +const ( + // TODO(sputn1ck): decide on actual value. + KeyFamily = int32(42069) +) + +// InstantLoopOutStore is the interface that needs to be implemented by a +// store that wants to be used by the instant loop out manager. +type InstantLoopOutStore interface { + // CreateInstantLoopOut adds a new instant loop out to the store. + CreateInstantLoopOut(ctx context.Context, instantOut *InstantOut) error + + // UpdateInstantLoopOut updates an existing instant loop out in the + // store. + UpdateInstantLoopOut(ctx context.Context, instantOut *InstantOut) error + + // GetInstantLoopOut returns the instant loop out for the given swap + // hash. + GetInstantLoopOut(ctx context.Context, + swapHash []byte) (*InstantOut, error) + + // ListInstantLoopOuts returns all instant loop outs that are in the + // store. + ListInstantLoopOuts(ctx context.Context) ([]*InstantOut, error) +} + +// ReservationManager handles fetching and locking of reservations. +type ReservationManager interface { + // GetReservation returns the reservation for the given id. + GetReservation(ctx context.Context, id reservation.ID) ( + *reservation.Reservation, error) + + // LockReservation locks the reservation for the given id. + LockReservation(ctx context.Context, id reservation.ID) error + + // UnlockReservation unlocks the reservation for the given id. + UnlockReservation(ctx context.Context, id reservation.ID) error +} + +// InputReservations is a helper struct for the input reservations. +type InputReservations []InputReservation + +// InputReservation is a helper struct for the input reservation. +type InputReservation struct { + Outpoint wire.OutPoint + Value btcutil.Amount + PkScript []byte +} + +// Output returns the output for the input reservation. +func (r InputReservation) Output() *wire.TxOut { + return wire.NewTxOut(int64(r.Value), r.PkScript) +} + +// GetPrevoutFetcher returns a prevout fetcher for the input reservations. +func (i InputReservations) GetPrevoutFetcher() txscript.PrevOutputFetcher { + prevOuts := make(map[wire.OutPoint]*wire.TxOut) + + // add the reservation inputs + for _, reservation := range i { + prevOuts[reservation.Outpoint] = reservation.Output() + } + + return txscript.NewMultiPrevOutFetcher(prevOuts) +} diff --git a/instantout/log.go b/instantout/log.go new file mode 100644 index 0000000..75764f5 --- /dev/null +++ b/instantout/log.go @@ -0,0 +1,26 @@ +package instantout + +import ( + "github.com/btcsuite/btclog" + "github.com/lightningnetwork/lnd/build" +) + +// Subsystem defines the sub system name of this package. +const Subsystem = "INST" + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the caller +// requests it. +var log btclog.Logger + +// The default amount of logging is none. +func init() { + UseLogger(build.NewSubLogger(Subsystem, nil)) +} + +// UseLogger uses a specified Logger to output package logging info. +// This should be used in preference to SetLogWriter if the caller is also +// using btclog. +func UseLogger(logger btclog.Logger) { + log = logger +}