reservation: add reservation fsm and actions

This commit adds the reservation state machine and actions to the
reservation package.
pull/632/head
sputn1ck 8 months ago
parent ad7d80a878
commit 228bf6a941
No known key found for this signature in database
GPG Key ID: 671103D881A5F0E4

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

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

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

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

@ -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")
}
Loading…
Cancel
Save