diff --git a/instantout/reservation/actions.go b/instantout/reservation/actions.go index 9e62c015..8a805e4d 100644 --- a/instantout/reservation/actions.go +++ b/instantout/reservation/actions.go @@ -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) +} diff --git a/instantout/reservation/actions_test.go b/instantout/reservation/actions_test.go index 2120e02e..e2322a6b 100644 --- a/instantout/reservation/actions_test.go +++ b/instantout/reservation/actions_test.go @@ -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) } } diff --git a/instantout/reservation/fsm.go b/instantout/reservation/fsm.go index f61e5097..46ba40d6 100644 --- a/instantout/reservation/fsm.go +++ b/instantout/reservation/fsm.go @@ -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{ diff --git a/instantout/reservation/reservation.go b/instantout/reservation/reservation.go index 8b83ae33..e450ecb1 100644 --- a/instantout/reservation/reservation.go +++ b/instantout/reservation/reservation.go @@ -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