instantout: add fsm and actions

pull/651/head
sputn1ck 3 months ago
parent 89b5c00cfa
commit 56ed6f7ccb
No known key found for this signature in database
GPG Key ID: 671103D881A5F0E4

@ -0,0 +1,615 @@
package instantout
import (
"context"
"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"
"github.com/lightninglabs/loop/swap"
loop_rpc "github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lntypes"
)
var (
// Define route independent max routing fees. We have currently no way
// to get a reliable estimate of the routing fees. Best we can do is
// the minimum routing fees, which is not very indicative.
maxRoutingFeeBase = btcutil.Amount(10)
maxRoutingFeeRate = int64(20000)
// urgentConfTarget is the target number of blocks for the htlc to be
// confirmed quickly.
urgentConfTarget = int32(3)
// normalConfTarget is the target number of blocks for the sweepless
// sweep to be confirmed.
normalConfTarget = int32(6)
// defaultMaxParts is the default maximum number of parts for the swap.
defaultMaxParts = uint32(5)
// defaultSendpaymentTimeout is the default timeout for the swap invoice.
defaultSendpaymentTimeout = time.Minute * 5
// defaultPollPaymentTime is the default time to poll the server for the
// payment status.
defaultPollPaymentTime = time.Second * 15
)
// InitInstantOutCtx contains the context for the InitInstantOutAction.
type InitInstantOutCtx struct {
cltvExpiry int32
reservations []reservation.ID
initationHeight int32
outgoingChanSet loopdb.ChannelSet
protocolVersion ProtocolVersion
}
// 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 {
resId := reservationId
res, err := f.cfg.ReservationManager.GetReservation(
f.ctx, resId,
)
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, resId[:])
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{
ReceiverKey: keyRes.PubKey.SerializeCompressed(),
SwapHash: swapHash[:],
Expiry: initCtx.cltvExpiry,
HtlcFeeRate: uint64(feeRate),
ReservationIds: reservationIds,
ProtocolVersion: CurrentRpcProtocolVersion(),
},
)
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 swapHash != 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(_ 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: defaultSendpaymentTimeout,
MaxParts: defaultMaxParts,
MaxFee: getMaxRoutingFee(f.InstantOut.value),
},
)
if err != nil {
f.Errorf("error sending payment: %v", err)
return f.handleErrorAndUnlockReservations(err)
}
// We'll continuously poll the server for the payment status.
pollPaymentTries := 0
// We want to poll quickly the first time.
timer := time.NewTimer(time.Second)
for {
select {
case payRes := <-payChan:
f.Debugf("payment result: %v", payRes)
if payRes.State == lnrpc.Payment_FAILED {
return f.handleErrorAndUnlockReservations(
fmt.Errorf("payment failed: %v",
payRes.FailureReason),
)
}
case err := <-paymentErrChan:
f.Errorf("error sending payment: %v", err)
return f.handleErrorAndUnlockReservations(err)
case <-f.ctx.Done():
return f.handleErrorAndUnlockReservations(nil)
case <-timer.C:
res, err := f.cfg.InstantOutClient.PollPaymentAccepted(
f.ctx, &loop_rpc.PollPaymentAcceptedRequest{
SwapHash: f.InstantOut.SwapHash[:],
},
)
if err != nil {
pollPaymentTries++
if pollPaymentTries > 20 {
return f.handleErrorAndUnlockReservations(err)
}
}
if res != nil && res.Accepted {
return OnPaymentAccepted
}
timer.Reset(defaultPollPaymentTime)
}
}
}
// 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 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)
}
txHash := f.InstantOut.finalizedHtlcTx.TxHash()
f.Debugf("published htlc tx: %v", txHash)
// We'll now wait for the htlc to be confirmed.
confChan, confErrChan, err := f.cfg.ChainNotifier.
RegisterConfirmationsNtfn(
f.ctx, &txHash,
f.InstantOut.finalizedHtlcTx.TxOut[0].PkScript,
1, f.InstantOut.initiationHeight,
)
if err != nil {
return f.HandleError(err)
}
for {
select {
case spendErr := <-confErrChan:
return f.HandleError(spendErr)
case <-confChan:
return OnHtlcPublished
}
}
}
// PublishHtlcSweepAction publishes the htlc sweep transaction.
func (f *FSM) PublishHtlcSweepAction(eventCtx fsm.EventContext) fsm.EventType {
// 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)
}
getInfo, err := f.cfg.LndClient.GetInfo(f.ctx)
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, getInfo.BlockHeight,
)
if err != nil {
return f.HandleError(err)
}
label := fmt.Sprintf("htlc-sweep-%v", f.InstantOut.swapPreimage.Hash())
err = f.cfg.Wallet.PublishTransaction(f.ctx, htlcSweepTx, label)
if err != nil {
log.Errorf("error publishing htlc sweep tx: %v", err)
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 {
sweepPkScript, 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, sweepPkScript,
1, f.InstantOut.initiationHeight,
)
if err != nil {
return f.HandleError(err)
}
f.Debugf("waiting for htlc sweep tx %v to be confirmed",
f.InstantOut.SweepTxHash)
for {
select {
case spendErr := <-confErrChan:
return f.HandleError(spendErr)
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 {
// We might get here from a canceled context, we create a new context
// with a timeout to unlock the reservations.
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
// Unlock the reservations.
for _, reservation := range f.InstantOut.reservations {
err := f.cfg.ReservationManager.UnlockReservation(
ctx, reservation.ID,
)
if err != nil {
f.Errorf("error unlocking reservation: %v", err)
return f.HandleError(err)
}
}
// We're also sending the server a cancel message so that it can
// release the reservations. This can be done in a goroutine as we
// wan't to fail the fsm early.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
_, cancelErr := f.cfg.InstantOutClient.CancelInstantSwap(
ctx, &loop_rpc.CancelInstantSwapRequest{
SwapHash: f.InstantOut.SwapHash[:],
},
)
if cancelErr != nil {
// We'll log the error but not return it as we want to return the
// original error.
f.Debugf("error sending cancel message: %v", cancelErr)
}
}()
return f.HandleError(err)
}
func getMaxRoutingFee(amt btcutil.Amount) btcutil.Amount {
return swap.CalcFee(amt, maxRoutingFeeBase, maxRoutingFeeRate)
}

@ -0,0 +1,401 @@
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 is the undefined protocol version.
ProtocolVersionUndefined ProtocolVersion = 0
// ProtocolVersionFullReservation is the protocol version that uses
// the full reservation amount without change.
ProtocolVersionFullReservation ProtocolVersion = 1
)
// CurrentProtocolVersion returns the current protocol version.
func CurrentProtocolVersion() ProtocolVersion {
return ProtocolVersionFullReservation
}
// CurrentRpcProtocolVersion returns the current rpc protocol version.
func CurrentRpcProtocolVersion() loop_rpc.InstantOutProtocolVersion {
return loop_rpc.InstantOutProtocolVersion(CurrentProtocolVersion())
}
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")
// PublishHtlcSweep is the state where the htlc sweep transaction is
// published.
PublishHtlcSweep = fsm.StateType("PublishHtlcSweep")
// 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("InstantOutFailed")
)
// 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")
// OnHtlcPublished is the event that is triggered when the htlc
// transaction is published.
OnHtlcPublished = fsm.EventType("OnHtlcPublished")
// OnHtlcSweepPublished is the event that is triggered when the htlc
// sweep is published.
OnHtlcSweepPublished = fsm.EventType("OnHtlcSweepPublished")
// 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,
OnHtlcPublished: PublishHtlcSweep,
},
Action: f.PublishHtlcAction,
},
PublishHtlcSweep: fsm.State{
Transitions: fsm.Transitions{
OnHtlcSweepPublished: WaitForHtlcSweepConfirmed,
OnRecover: PublishHtlcSweep,
fsm.OnError: FailedHtlcSweep,
},
Action: f.PublishHtlcSweepAction,
},
WaitForHtlcSweepConfirmed: fsm.State{
Transitions: fsm.Transitions{
OnHtlcSwept: FinishedHtlcPreimageSweep,
OnRecover: WaitForHtlcSweepConfirmed,
fsm.OnError: FailedHtlcSweep,
},
Action: f.WaitForHtlcSweepConfirmedAction,
},
FinishedHtlcPreimageSweep: fsm.State{
Transitions: fsm.Transitions{},
Action: fsm.NoOpAction,
},
FailedHtlcSweep: fsm.State{
Action: fsm.NoOpAction,
Transitions: fsm.Transitions{
OnRecover: PublishHtlcSweep,
},
},
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("Previous: %v, Event: %v, Next: %v", notification.PreviousState,
notification.Event, notification.NextState)
// 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 ||
f.InstantOut.State == fsm.EmptyState ||
(notification.PreviousState == Init &&
f.InstantOut.State == Failed) {
return
}
err := f.cfg.Store.UpdateInstantLoopOut(f.ctx, f.InstantOut)
if err != nil {
log.Errorf("Error updating instant out: %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,
FinishedSweeplessSweep:
return true
}
return false
}

@ -0,0 +1,488 @@
package instantout
import (
"context"
"errors"
"fmt"
"reflect"
"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 idx, reservation := range i.reservations {
pkScript, err := reservation.GetPkScript()
if err != nil {
return nil, err
}
inputs[idx] = 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) {
if network == nil {
return nil, errors.New("no network provided")
}
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)
if fee > i.value/5 {
return nil, errors.New("fee is higher than 20% of " +
"sweep value")
}
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)
if fee > i.value/5 {
return nil, errors.New("fee is higher than 20% of " +
"sweep value")
}
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 !reflect.DeepEqual(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
}
// generateHtlcSweepTx creates the htlc sweep transaction for the instant out.
func (i *InstantOut) generateHtlcSweepTx(ctx context.Context,
signer lndclient.SignerClient, feeRate chainfee.SatPerKWeight,
network *chaincfg.Params, blockheight uint32) (
*wire.MsgTx, error) {
if network == nil {
return nil, errors.New("no network provided")
}
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)
sweepTx.LockTime = blockheight
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,
Sequence: htlc.SuccessSequence(),
})
// Add the sweep output.
sweepPkScript, err := txscript.PayToAddrScript(i.sweepAddress)
if err != nil {
return nil, err
}
fee := feeRate.FeeForWeight(int64(weightEstimator.Weight()))
htlcOutValue := i.finalizedHtlcTx.TxOut[0].Value
output := &wire.TxOut{
Value: htlcOutValue - int64(fee),
PkScript: sweepPkScript,
}
sweepTx.AddTxOut(output)
signDesc := lndclient.SignDescriptor{
WitnessScript: htlc.SuccessScript(),
Output: &wire.TxOut{
Value: htlcOutValue,
PkScript: htlc.PkScript,
},
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("sign output error: %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 _, n := range nonces {
n := n
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) ([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
}

@ -0,0 +1,73 @@
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 (
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)
}

@ -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
}

@ -120,7 +120,7 @@ func (f *FSM) SubscribeToConfirmationAction(_ fsm.EventContext) fsm.EventType {
return f.HandleError(err)
case confInfo := <-confChan:
f.Debugf("confirmed in block %v", confInfo.Block)
f.Debugf("confirmed in tx: %v", confInfo.Tx)
outpoint, err := f.reservation.findReservationOutput(
confInfo.Tx,
)

Loading…
Cancel
Save