mirror of https://github.com/lightninglabs/loop
reservation: add reservation fsm and actions
This commit adds the reservation state machine and actions to the reservation package.pull/632/head
parent
ad7d80a878
commit
228bf6a941
@ -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…
Reference in New Issue