reservations: add client requested fsm

This commit is contained in:
sputn1ck 2025-02-03 14:21:18 +01:00 committed by Slyghtning
parent af017413a3
commit 23e533eebc
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
4 changed files with 278 additions and 12 deletions

View file

@ -2,16 +2,179 @@ package reservation
import (
"context"
"errors"
"fmt"
"time"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/lnrpc"
)
// InitReservationContext contains the request parameters for a reservation.
type InitReservationContext struct {
const (
// 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)
)
var (
// The allowed delta between what we accept as the expiry height and
// the actual expiry height.
expiryDelta = uint32(3)
// defaultPrepayTimeout is the default timeout for the prepayment.
DefaultPrepayTimeout = time.Minute * 120
)
// ClientRequestedInitContext contains the request parameters for a reservation.
type ClientRequestedInitContext struct {
value btcutil.Amount
relativeExpiry uint32
heightHint uint32
maxPrepaymentAmt btcutil.Amount
}
// InitFromClientRequestAction is the action that is executed when the
// reservation state machine is initialized from a client request. It creates
// the reservation in the database and sends the reservation request to the
// server.
func (f *FSM) InitFromClientRequestAction(ctx context.Context,
eventCtx fsm.EventContext) fsm.EventType {
// Check if the context is of the correct type.
reservationRequest, ok := eventCtx.(*ClientRequestedInitContext)
if !ok {
return f.HandleError(fsm.ErrInvalidContextType)
}
// Create the reservation in the database.
keyRes, err := f.cfg.Wallet.DeriveNextKey(ctx, KeyFamily)
if err != nil {
return f.HandleError(err)
}
// Send the request to the server.
requestResponse, err := f.cfg.ReservationClient.RequestReservation(
ctx, &swapserverrpc.RequestReservationRequest{
Value: uint64(reservationRequest.value),
Expiry: reservationRequest.relativeExpiry,
ClientKey: keyRes.PubKey.SerializeCompressed(),
},
)
if err != nil {
return f.HandleError(err)
}
expectedExpiry := reservationRequest.relativeExpiry +
reservationRequest.heightHint
// Check that the expiry is in the delta.
if requestResponse.Expiry < expectedExpiry-expiryDelta ||
requestResponse.Expiry > expectedExpiry+expiryDelta {
return f.HandleError(
fmt.Errorf("unexpected expiry height: %v, expected %v",
requestResponse.Expiry, expectedExpiry))
}
prepayment, err := f.cfg.LightningClient.DecodePaymentRequest(
ctx, requestResponse.Invoice,
)
if err != nil {
return f.HandleError(err)
}
if prepayment.Value.ToSatoshis() > reservationRequest.maxPrepaymentAmt {
return f.HandleError(
errors.New("prepayment amount too high"))
}
serverKey, err := btcec.ParsePubKey(requestResponse.ServerKey)
if err != nil {
return f.HandleError(err)
}
var Id ID
copy(Id[:], requestResponse.ReservationId)
reservation, err := NewReservation(
Id, serverKey, keyRes.PubKey, reservationRequest.value,
requestResponse.Expiry, reservationRequest.heightHint,
keyRes.KeyLocator, ProtocolVersionClientInitiated,
)
if err != nil {
return f.HandleError(err)
}
reservation.PrepayInvoice = requestResponse.Invoice
f.reservation = reservation
// Create the reservation in the database.
err = f.cfg.Store.CreateReservation(ctx, reservation)
if err != nil {
return f.HandleError(err)
}
return OnClientInitialized
}
// SendPrepayment is the action that is executed when the reservation
// is initialized from a client request. It dispatches the prepayment to the
// server and wait for it to be settled, signaling confirmation of the
// reservation.
func (f *FSM) SendPrepayment(ctx context.Context,
_ fsm.EventContext) fsm.EventType {
prepayment, err := f.cfg.LightningClient.DecodePaymentRequest(
ctx, f.reservation.PrepayInvoice,
)
if err != nil {
return f.HandleError(err)
}
payReq := lndclient.SendPaymentRequest{
Invoice: f.reservation.PrepayInvoice,
Timeout: DefaultPrepayTimeout,
MaxFee: getMaxRoutingFee(prepayment.Value.ToSatoshis()),
}
// Send the prepayment to the server.
payChan, errChan, err := f.cfg.RouterClient.SendPayment(
ctx, payReq,
)
if err != nil {
return f.HandleError(err)
}
for {
select {
case <-ctx.Done():
return fsm.NoOp
case err := <-errChan:
return f.HandleError(err)
case prepayResp := <-payChan:
if prepayResp.State == lnrpc.Payment_FAILED {
return f.HandleError(
fmt.Errorf("prepayment failed: %v",
prepayResp.FailureReason))
}
if prepayResp.State == lnrpc.Payment_SUCCEEDED {
return OnBroadcast
}
}
}
}
// ServerRequestedInitContext contains the request parameters for a reservation.
type ServerRequestedInitContext struct {
reservationID ID
serverPubkey *btcec.PublicKey
value btcutil.Amount
@ -19,14 +182,14 @@ type InitReservationContext struct {
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 (f *FSM) InitAction(ctx context.Context,
// InitFromServerRequestAction is the action that is executed when the
// reservation state machine is initialized from a server request. It creates
// the reservation in the database and dispatches the payment to the server.
func (f *FSM) InitFromServerRequestAction(ctx context.Context,
eventCtx fsm.EventContext) fsm.EventType {
// Check if the context is of the correct type.
reservationRequest, ok := eventCtx.(*InitReservationContext)
reservationRequest, ok := eventCtx.(*ServerRequestedInitContext)
if !ok {
return f.HandleError(fsm.ErrInvalidContextType)
}
@ -240,3 +403,7 @@ func (f *FSM) handleAsyncError(ctx context.Context, err error) {
f.Errorf("Error sending event: %v", err2)
}
}
func getMaxRoutingFee(amt btcutil.Amount) btcutil.Amount {
return swap.CalcFee(amt, maxRoutingFeeBase, maxRoutingFeeRate)
}

View file

@ -31,8 +31,8 @@ var (
defaultExpiry = uint32(100)
)
func newValidInitReservationContext() *InitReservationContext {
return &InitReservationContext{
func newValidInitReservationContext() *ServerRequestedInitContext {
return &ServerRequestedInitContext{
reservationID: ID{0x01},
serverPubkey: defaultPubkey,
value: defaultValue,
@ -174,7 +174,7 @@ func TestInitReservationAction(t *testing.T) {
StateMachine: &fsm.StateMachine{},
}
event := reservationFSM.InitAction(ctxb, tc.eventCtx)
event := reservationFSM.InitFromServerRequestAction(ctxb, tc.eventCtx)
require.Equal(t, tc.expectedEvent, event)
}
}

View file

@ -22,7 +22,11 @@ const (
// ProtocolVersionServerInitiated is the protocol version where the
// server initiates the reservation.
ProtocolVersionServerInitiated ProtocolVersion = 0
ProtocolVersionServerInitiated ProtocolVersion = 1
// ProtocolVersionClientInitiated is the protocol version where the
// client initiates the reservation.
ProtocolVersionClientInitiated ProtocolVersion = 2
)
const (
@ -45,6 +49,12 @@ type Config struct {
// swap server.
ReservationClient swapserverrpc.ReservationServiceClient
// LightningClient is the lnd client used to handle invoices decoding.
LightningClient lndclient.LightningClient
// RouterClient is used to send the offchain payments.
RouterClient lndclient.RouterClient
// NotificationManager is the manager that handles the notification
// subscriptions.
NotificationManager NotificationManager
@ -81,6 +91,10 @@ func NewFSMFromReservation(cfg *Config, reservation *Reservation) *FSM {
switch reservation.ProtocolVersion {
case ProtocolVersionServerInitiated:
states = reservationFsm.GetServerInitiatedReservationStates()
case ProtocolVersionClientInitiated:
states = reservationFsm.GetClientInitiatedReservationStates()
default:
states = make(fsm.States)
}
@ -99,6 +113,10 @@ var (
// Init is the initial state of the reservation.
Init = fsm.StateType("Init")
// SendPrepaymentPayment is the state where the client sends the payment to the
// server.
SendPrepaymentPayment = fsm.StateType("SendPayment")
// WaitForConfirmation is the state where we wait for the reservation
// tx to be confirmed.
WaitForConfirmation = fsm.StateType("WaitForConfirmation")
@ -126,6 +144,10 @@ var (
// requests a new reservation.
OnServerRequest = fsm.EventType("OnServerRequest")
// OnClientInitialized is the event that is triggered when the client
// has initialized the reservation.
OnClientInitialized = fsm.EventType("OnClientInitialized")
// OnBroadcast is the event that is triggered when the reservation tx
// has been broadcast.
OnBroadcast = fsm.EventType("OnBroadcast")
@ -159,6 +181,80 @@ var (
OnUnlocked = fsm.EventType("OnUnlocked")
)
// GetClientInitiatedReservationStates returns the statemap that defines the
// reservation state machine, where the client initiates the reservation.
func (f *FSM) GetClientInitiatedReservationStates() fsm.States {
return fsm.States{
fsm.EmptyState: fsm.State{
Transitions: fsm.Transitions{
OnClientInitialized: Init,
},
Action: nil,
},
Init: fsm.State{
Transitions: fsm.Transitions{
OnClientInitialized: SendPrepaymentPayment,
OnRecover: Failed,
fsm.OnError: Failed,
},
Action: f.InitFromClientRequestAction,
},
SendPrepaymentPayment: fsm.State{
Transitions: fsm.Transitions{
OnBroadcast: WaitForConfirmation,
OnRecover: SendPrepaymentPayment,
fsm.OnError: Failed,
},
Action: f.SendPrepayment,
},
WaitForConfirmation: fsm.State{
Transitions: fsm.Transitions{
OnRecover: WaitForConfirmation,
OnConfirmed: Confirmed,
OnTimedOut: TimedOut,
},
Action: f.SubscribeToConfirmationAction,
},
Confirmed: fsm.State{
Transitions: fsm.Transitions{
OnSpent: Spent,
OnTimedOut: TimedOut,
OnRecover: Confirmed,
OnLocked: Locked,
fsm.OnError: Confirmed,
},
Action: f.AsyncWaitForExpiredOrSweptAction,
},
Locked: fsm.State{
Transitions: fsm.Transitions{
OnUnlocked: Confirmed,
OnTimedOut: TimedOut,
OnRecover: Locked,
OnSpent: Spent,
fsm.OnError: Locked,
},
Action: f.AsyncWaitForExpiredOrSweptAction,
},
TimedOut: fsm.State{
Transitions: fsm.Transitions{
OnTimedOut: TimedOut,
},
Action: fsm.NoOpAction,
},
Spent: fsm.State{
Transitions: fsm.Transitions{
OnSpent: Spent,
},
Action: fsm.NoOpAction,
},
Failed: fsm.State{
Action: fsm.NoOpAction,
},
}
}
// GetServerInitiatedReservationStates returns the statemap that defines the
// reservation state machine, where the server initiates the reservation.
func (f *FSM) GetServerInitiatedReservationStates() fsm.States {
@ -175,7 +271,7 @@ func (f *FSM) GetServerInitiatedReservationStates() fsm.States {
OnRecover: Failed,
fsm.OnError: Failed,
},
Action: f.InitAction,
Action: f.InitFromServerRequestAction,
},
WaitForConfirmation: fsm.State{
Transitions: fsm.Transitions{

View file

@ -62,6 +62,9 @@ type Reservation struct {
// Outpoint is the outpoint of the reservation.
Outpoint *wire.OutPoint
// PrepayInvoice is the invoice that the client paid as a prepayment.
PrepayInvoice string
// InitiationHeight is the height at which the reservation was
// initiated.
InitiationHeight int32