diff --git a/instantout/reservation/actions.go b/instantout/reservation/actions.go new file mode 100644 index 0000000..3d0965b --- /dev/null +++ b/instantout/reservation/actions.go @@ -0,0 +1,176 @@ +package reservation + +import ( + "context" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop/fsm" + looprpc "github.com/lightninglabs/loop/swapserverrpc" +) + +// InitReservationContext contains the request parameters for a reservation. +type InitReservationContext struct { + reservationID ID + serverPubkey *btcec.PublicKey + value btcutil.Amount + expiry uint32 + heightHint uint32 +} + +// InitAction is the action that is executed when the reservation state machine +// is initialized. It creates the reservation in the database and dispatches the +// payment to the server. +func (r *FSM) InitAction(eventCtx fsm.EventContext) fsm.EventType { + // Check if the context is of the correct type. + reservationRequest, ok := eventCtx.(*InitReservationContext) + if !ok { + return r.HandleError(fsm.ErrInvalidContextType) + } + + keyRes, err := r.cfg.Wallet.DeriveNextKey( + r.ctx, KeyFamily, + ) + if err != nil { + return r.HandleError(err) + } + + // Send the client reservation details to the server. + log.Debugf("Dispatching reservation to server: %x", + reservationRequest.reservationID) + + request := &looprpc.ServerOpenReservationRequest{ + ReservationId: reservationRequest.reservationID[:], + ClientKey: keyRes.PubKey.SerializeCompressed(), + } + + _, err = r.cfg.ReservationClient.OpenReservation(r.ctx, request) + if err != nil { + return r.HandleError(err) + } + + reservation, err := NewReservation( + reservationRequest.reservationID, + reservationRequest.serverPubkey, + keyRes.PubKey, + reservationRequest.value, + reservationRequest.expiry, + reservationRequest.heightHint, + keyRes.KeyLocator, + ) + if err != nil { + return r.HandleError(err) + } + + r.reservation = reservation + + // Create the reservation in the database. + err = r.cfg.Store.CreateReservation(r.ctx, reservation) + if err != nil { + return r.HandleError(err) + } + + return OnBroadcast +} + +// SubscribeToConfirmationAction is the action that is executed when the +// reservation is waiting for confirmation. It subscribes to the confirmation +// of the reservation transaction. +func (r *FSM) SubscribeToConfirmationAction(_ fsm.EventContext) fsm.EventType { + pkscript, err := r.reservation.GetPkScript() + if err != nil { + return r.HandleError(err) + } + + callCtx, cancel := context.WithCancel(r.ctx) + defer cancel() + + // Subscribe to the confirmation of the reservation transaction. + log.Debugf("Subscribing to conf for reservation: %x pkscript: %x, "+ + "initiation height: %v", r.reservation.ID, pkscript, + r.reservation.InitiationHeight) + + confChan, errConfChan, err := r.cfg.ChainNotifier.RegisterConfirmationsNtfn( + callCtx, nil, pkscript, DefaultConfTarget, + r.reservation.InitiationHeight, + ) + if err != nil { + r.Errorf("unable to subscribe to conf notification: %v", err) + return r.HandleError(err) + } + + blockChan, errBlockChan, err := r.cfg.ChainNotifier.RegisterBlockEpochNtfn( + callCtx, + ) + if err != nil { + r.Errorf("unable to subscribe to block notifications: %v", err) + return r.HandleError(err) + } + + // We'll now wait for the confirmation of the reservation transaction. + for { + select { + case err := <-errConfChan: + r.Errorf("conf subscription error: %v", err) + return r.HandleError(err) + + case err := <-errBlockChan: + r.Errorf("block subscription error: %v", err) + return r.HandleError(err) + + case confInfo := <-confChan: + r.Debugf("reservation confirmed: %v", confInfo) + outpoint, err := r.reservation.findReservationOutput( + confInfo.Tx, + ) + if err != nil { + return r.HandleError(err) + } + + r.reservation.ConfirmationHeight = confInfo.BlockHeight + r.reservation.Outpoint = outpoint + + return OnConfirmed + + case block := <-blockChan: + r.Debugf("block received: %v expiry: %v", block, + r.reservation.Expiry) + + if uint32(block) >= r.reservation.Expiry { + return OnTimedOut + } + + case <-r.ctx.Done(): + return fsm.NoOp + } + } +} + +// ReservationConfirmedAction waits for the reservation to be either expired or +// waits for other actions to happen. +func (r *FSM) ReservationConfirmedAction(_ fsm.EventContext) fsm.EventType { + blockHeightChan, errEpochChan, err := r.cfg.ChainNotifier. + RegisterBlockEpochNtfn(r.ctx) + if err != nil { + return r.HandleError(err) + } + + for { + select { + case err := <-errEpochChan: + return r.HandleError(err) + + case blockHeight := <-blockHeightChan: + expired := blockHeight >= int32(r.reservation.Expiry) + if expired { + r.Debugf("Reservation %v expired", + r.reservation.ID) + + return OnTimedOut + } + + case <-r.ctx.Done(): + return fsm.NoOp + } + } +} diff --git a/instantout/reservation/fsm.go b/instantout/reservation/fsm.go new file mode 100644 index 0000000..3bd0820 --- /dev/null +++ b/instantout/reservation/fsm.go @@ -0,0 +1,229 @@ +package reservation + +import ( + "context" + + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/fsm" + looprpc "github.com/lightninglabs/loop/swapserverrpc" +) + +const ( + // defaultObserverSize is the size of the fsm observer channel. + defaultObserverSize = 15 +) + +// Config contains all the services that the reservation FSM needs to operate. +type Config struct { + // Store is the database store for the reservations. + Store Store + + // Wallet handles the key derivation for the reservation. + Wallet lndclient.WalletKitClient + + // ChainNotifier is used to subscribe to block notifications. + ChainNotifier lndclient.ChainNotifierClient + + // ReservationClient is the client used to communicate with the + // swap server. + ReservationClient looprpc.ReservationServiceClient + + // FetchL402 is the function used to fetch the l402 token. + FetchL402 func(context.Context) error +} + +// FSM is the state machine that manages the reservation lifecycle. +type FSM struct { + *fsm.StateMachine + + cfg *Config + + reservation *Reservation + + ctx context.Context +} + +// NewFSM creates a new reservation FSM. +func NewFSM(ctx context.Context, cfg *Config) *FSM { + reservation := &Reservation{ + State: fsm.EmptyState, + } + + return NewFSMFromReservation(ctx, cfg, reservation) +} + +// NewFSMFromReservation creates a new reservation FSM from an existing +// reservation recovered from the database. +func NewFSMFromReservation(ctx context.Context, cfg *Config, + reservation *Reservation) *FSM { + + reservationFsm := &FSM{ + ctx: ctx, + cfg: cfg, + reservation: reservation, + } + + reservationFsm.StateMachine = fsm.NewStateMachineWithState( + reservationFsm.GetReservationStates(), reservation.State, + defaultObserverSize, + ) + reservationFsm.ActionEntryFunc = reservationFsm.updateReservation + + return reservationFsm +} + +// States. +var ( + // Init is the initial state of the reservation. + Init = fsm.StateType("Init") + + // WaitForConfirmation is the state where we wait for the reservation + // tx to be confirmed. + WaitForConfirmation = fsm.StateType("WaitForConfirmation") + + // Confirmed is the state where the reservation tx has been confirmed. + Confirmed = fsm.StateType("Confirmed") + + // TimedOut is the state where the reservation has timed out. + TimedOut = fsm.StateType("TimedOut") + + // Failed is the state where the reservation has failed. + Failed = fsm.StateType("Failed") + + // Spent is the state where a spend tx has been confirmed. + Spent = fsm.StateType("Spent") + + // Locked is the state where the reservation is locked and can't be + // used for instant out swaps. + Locked = fsm.StateType("Locked") +) + +// Events. +var ( + // OnServerRequest is the event that is triggered when the server + // requests a new reservation. + OnServerRequest = fsm.EventType("OnServerRequest") + + // OnBroadcast is the event that is triggered when the reservation tx + // has been broadcast. + OnBroadcast = fsm.EventType("OnBroadcast") + + // OnConfirmed is the event that is triggered when the reservation tx + // has been confirmed. + OnConfirmed = fsm.EventType("OnConfirmed") + + // OnTimedOut is the event that is triggered when the reservation has + // timed out. + OnTimedOut = fsm.EventType("OnTimedOut") + + // OnSwept is the event that is triggered when the reservation has been + // swept by the server. + OnSwept = fsm.EventType("OnSwept") + + // OnRecover is the event that is triggered when the reservation FSM + // recovers from a restart. + OnRecover = fsm.EventType("OnRecover") +) + +// GetReservationStates returns the statemap that defines the reservation +// state machine. +func (f *FSM) GetReservationStates() fsm.States { + return fsm.States{ + fsm.EmptyState: fsm.State{ + Transitions: fsm.Transitions{ + OnServerRequest: Init, + }, + Action: nil, + }, + Init: fsm.State{ + Transitions: fsm.Transitions{ + OnBroadcast: WaitForConfirmation, + OnRecover: Failed, + fsm.OnError: Failed, + }, + Action: f.InitAction, + }, + WaitForConfirmation: fsm.State{ + Transitions: fsm.Transitions{ + OnRecover: WaitForConfirmation, + OnConfirmed: Confirmed, + OnTimedOut: TimedOut, + }, + Action: f.SubscribeToConfirmationAction, + }, + Confirmed: fsm.State{ + Transitions: fsm.Transitions{ + OnTimedOut: TimedOut, + OnRecover: Confirmed, + }, + Action: f.ReservationConfirmedAction, + }, + TimedOut: fsm.State{ + Action: fsm.NoOpAction, + }, + Failed: fsm.State{ + Action: fsm.NoOpAction, + }, + } +} + +// updateReservation updates the reservation in the database. This function +// is called after every new state transition. +func (r *FSM) updateReservation(notification fsm.Notification) { + if r.reservation == nil { + return + } + + r.Debugf( + "NextState: %v, PreviousState: %v, Event: %v", + notification.NextState, notification.PreviousState, + notification.Event, + ) + + r.reservation.State = notification.NextState + + // Don't update the reservation if we are in an initial state or if we + // are transitioning from an initial state to a failed state. + if r.reservation.State == fsm.EmptyState || + r.reservation.State == Init || + (notification.PreviousState == Init && + r.reservation.State == Failed) { + + return + } + + err := r.cfg.Store.UpdateReservation(r.ctx, r.reservation) + if err != nil { + r.Errorf("unable to update reservation: %v", err) + } +} + +func (r *FSM) Infof(format string, args ...interface{}) { + log.Infof( + "Reservation %x: "+format, + append([]interface{}{r.reservation.ID}, args...)..., + ) +} + +func (r *FSM) Debugf(format string, args ...interface{}) { + log.Debugf( + "Reservation %x: "+format, + append([]interface{}{r.reservation.ID}, args...)..., + ) +} + +func (r *FSM) Errorf(format string, args ...interface{}) { + log.Errorf( + "Reservation %x: "+format, + append([]interface{}{r.reservation.ID}, args...)..., + ) +} + +// isFinalState returns true if the state is a final state. +func isFinalState(state fsm.StateType) bool { + switch state { + case Failed, TimedOut, Spent: + return true + } + return false +} diff --git a/instantout/reservation/interfaces.go b/instantout/reservation/interfaces.go new file mode 100644 index 0000000..c999d1b --- /dev/null +++ b/instantout/reservation/interfaces.go @@ -0,0 +1,33 @@ +package reservation + +import ( + "context" + "fmt" +) + +var ( + ErrReservationAlreadyExists = fmt.Errorf("reservation already exists") + ErrReservationNotFound = fmt.Errorf("reservation not found") +) + +const ( + KeyFamily = int32(42068) + DefaultConfTarget = int32(3) + IdLength = 32 +) + +// Store is the interface that stores the reservations. +type Store interface { + // CreateReservation stores the reservation in the database. + CreateReservation(ctx context.Context, reservation *Reservation) error + + // UpdateReservation updates the reservation in the database. + UpdateReservation(ctx context.Context, reservation *Reservation) error + + // GetReservation retrieves the reservation from the database. + GetReservation(ctx context.Context, id ID) (*Reservation, error) + + // ListReservations lists all existing reservations the client has ever + // made. + ListReservations(ctx context.Context) ([]*Reservation, error) +} diff --git a/instantout/reservation/log.go b/instantout/reservation/log.go new file mode 100644 index 0000000..c7f818f --- /dev/null +++ b/instantout/reservation/log.go @@ -0,0 +1,26 @@ +package reservation + +import ( + "github.com/btcsuite/btclog" + "github.com/lightningnetwork/lnd/build" +) + +// Subsystem defines the sub system name of this package. +const Subsystem = "RSRV" + +// 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 +} diff --git a/instantout/reservation/reservation.go b/instantout/reservation/reservation.go new file mode 100644 index 0000000..f220866 --- /dev/null +++ b/instantout/reservation/reservation.go @@ -0,0 +1,137 @@ +package reservation + +import ( + "bytes" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/fsm" + reservation_script "github.com/lightninglabs/loop/instantout/reservation/script" + "github.com/lightningnetwork/lnd/keychain" +) + +// ID is a unique identifier for a reservation. +type ID [IdLength]byte + +// FromByteSlice creates a reservation id from a byte slice. +func (r *ID) FromByteSlice(b []byte) error { + if len(b) != IdLength { + return fmt.Errorf("reservation id must be 32 bytes, got %d, %x", + len(b), b) + } + + copy(r[:], b) + + return nil +} + +// Reservation holds all the necessary information for the 2-of-2 multisig +// reservation utxo. +type Reservation struct { + // ID is the unique identifier of the reservation. + ID ID + + // State is the current state of the reservation. + State fsm.StateType + + // ClientPubkey is the client's pubkey. + ClientPubkey *btcec.PublicKey + + // ServerPubkey is the server's pubkey. + ServerPubkey *btcec.PublicKey + + // Value is the amount of the reservation. + Value btcutil.Amount + + // Expiry is the absolute block height at which the reservation expires. + Expiry uint32 + + // KeyLocator is the key locator of the client's key. + KeyLocator keychain.KeyLocator + + // Outpoint is the outpoint of the reservation. + Outpoint *wire.OutPoint + + // InitiationHeight is the height at which the reservation was + // initiated. + InitiationHeight int32 + + // ConfirmationHeight is the height at which the reservation was + // confirmed. + ConfirmationHeight uint32 +} + +func NewReservation(id ID, serverPubkey, clientPubkey *btcec.PublicKey, + value btcutil.Amount, expiry, heightHint uint32, + keyLocator keychain.KeyLocator) (*Reservation, + error) { + + if id == [32]byte{} { + return nil, errors.New("id is empty") + } + + if clientPubkey == nil { + return nil, errors.New("client pubkey is nil") + } + + if serverPubkey == nil { + return nil, errors.New("server pubkey is nil") + } + + if expiry == 0 { + return nil, errors.New("expiry is 0") + } + + if value == 0 { + return nil, errors.New("value is 0") + } + + if keyLocator.Family == 0 { + return nil, errors.New("key locator family is 0") + } + return &Reservation{ + ID: id, + Value: value, + ClientPubkey: clientPubkey, + ServerPubkey: serverPubkey, + KeyLocator: keyLocator, + Expiry: expiry, + InitiationHeight: int32(heightHint), + }, nil +} + +// GetPkScript returns the pk script of the reservation. +func (r *Reservation) GetPkScript() ([]byte, error) { + // Now that we have all the required data, we can create the pk script. + pkScript, err := reservation_script.ReservationScript( + r.Expiry, r.ServerPubkey, r.ClientPubkey, + ) + if err != nil { + return nil, err + } + + return pkScript, nil +} + +func (r *Reservation) findReservationOutput(tx *wire.MsgTx) (*wire.OutPoint, + error) { + + pkScript, err := r.GetPkScript() + if err != nil { + return nil, err + } + + for i, txOut := range tx.TxOut { + if bytes.Equal(txOut.PkScript, pkScript) { + return &wire.OutPoint{ + Hash: tx.TxHash(), + Index: uint32(i), + }, nil + } + } + + return nil, errors.New("reservation output not found") +}