This commit is contained in:
Slyghtning 2026-08-11 13:05:14 +00:00 committed by GitHub
commit b02df896b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2442 additions and 312 deletions

View file

@ -190,6 +190,7 @@ func instantOut(ctx context.Context, cmd *cli.Command) error {
ReservationIds: selectedReservations,
OutgoingChanSet: outgoingChanSet,
DestAddr: cmd.String("addr"),
MaxSwapFeeSat: quote.ServiceFeeSat,
},
)
if err != nil {

View file

@ -41,6 +41,8 @@ var (
defaultSwapWaitTime = 30 * time.Minute
defaultRpcTimeout = 30 * time.Second
// maxMsgRecvSize is the largest message our client will receive. We
// set this to 200MiB atm.
maxMsgRecvSize = grpc.MaxCallRecvMsgSize(1 * 1024 * 1024 * 200)

View file

@ -2,13 +2,27 @@ package main
import (
"context"
"errors"
"fmt"
"strings"
"github.com/lightninglabs/loop/looprpc"
"github.com/urfave/cli/v3"
)
var reservationsCommands = &cli.Command{
var (
reservationAmountFlag = &cli.Uint64Flag{
Name: "amt",
Usage: "the amount in satoshis for the reservation",
}
reservationExpiryFlag = &cli.UintFlag{
Name: "expiry",
Usage: "the relative block height at which the reservation" +
" expires",
}
)
var reservationsCommands = &cli.Command{
Name: "reservations",
Aliases: []string{"r"},
Usage: "manage reservations",
@ -20,6 +34,7 @@ var reservationsCommands = &cli.Command{
`,
Commands: []*cli.Command{
listReservationsCommand,
newReservationCommand,
},
}
@ -34,8 +49,76 @@ var (
`,
Action: listReservations,
}
newReservationCommand = &cli.Command{
Name: "new",
Aliases: []string{"n"},
Usage: "create a new reservation",
Description: `
Create a new reservation with the given value and expiry.
`,
Action: newReservation,
Flags: []cli.Flag{
reservationAmountFlag,
reservationExpiryFlag,
},
}
)
func newReservation(ctx context.Context, cmd *cli.Command) error {
client, cleanup, err := getClient(cmd)
if err != nil {
return err
}
defer cleanup()
rpcCtx, cancel := context.WithTimeout(ctx, defaultRpcTimeout)
defer cancel()
if !cmd.IsSet(reservationAmountFlag.Name) {
return errors.New("amt flag missing")
}
if !cmd.IsSet(reservationExpiryFlag.Name) {
return errors.New("expiry flag missing")
}
quoteReq, err := client.ReservationQuote(
rpcCtx, &looprpc.ReservationQuoteRequest{
Amt: cmd.Uint64(reservationAmountFlag.Name),
Expiry: uint32(cmd.Uint(reservationExpiryFlag.Name)),
},
)
if err != nil {
return err
}
fmt.Printf(satAmtFmt, "Reservation Cost: ", quoteReq.PrepayAmt)
fmt.Printf("CONTINUE RESERVATION? (y/n): ")
var answer string
if _, err := fmt.Scanln(&answer); err != nil ||
!strings.EqualFold(answer, "y") {
return nil
}
reservationRes, err := client.ReservationRequest(
rpcCtx, &looprpc.ReservationRequestRequest{
Amt: cmd.Uint64(reservationAmountFlag.Name),
Expiry: uint32(cmd.Uint(reservationExpiryFlag.Name)),
MaxPrepayAmt: quoteReq.PrepayAmt,
},
)
if err != nil {
return err
}
printRespJSON(reservationRes)
return nil
}
func listReservations(ctx context.Context, cmd *cli.Command) error {
client, cleanup, err := getClient(cmd)
if err != nil {

View file

@ -139,7 +139,8 @@
"Mu65fbhayEtRzougKLBnoeRN8f+tEM1+O9QuNvUIfbI="
],
"outgoing_chan_set": [],
"dest_addr": ""
"dest_addr": "",
"max_swap_fee_sat": "4800"
}
}
},

View file

@ -148,7 +148,8 @@
"outgoing_chan_set": [
"125344325763072"
],
"dest_addr": ""
"dest_addr": "",
"max_swap_fee_sat": "3200"
}
}
},

View file

@ -162,7 +162,8 @@
"cSfKVONNmsK9+p4Uc5nc3ZtE+37uOODHeq1vprhh/x4="
],
"outgoing_chan_set": [],
"dest_addr": ""
"dest_addr": "",
"max_swap_fee_sat": "1600"
}
}
},

View file

@ -388,6 +388,18 @@ list all reservations
.PP
\fB--help, -h\fP: show help
.SS new, n
create a new reservation
.PP
\fB--amt\fP="": the amount in satoshis for the reservation (default: 0)
.PP
\fB--expiry\fP="": the relative block height at which the reservation expires (default: 0)
.PP
\fB--help, -h\fP: show help
.SH instantout
perform an instant off-chain to on-chain swap (looping out)

View file

@ -455,6 +455,26 @@ The following flags are supported:
|-----------------|-------------|------|:-------------:|
| `--help` (`-h`) | show help | bool | `false` |
### `reservations new` subcommand (aliases: `n`)
create a new reservation.
Create a new reservation with the given value and expiry.
Usage:
```bash
$ loop [GLOBAL FLAGS] reservations new [COMMAND FLAGS] [ARGUMENTS...]
```
The following flags are supported:
| Name | Description | Type | Default value |
|-----------------|------------------------------------------------------------|------|:-------------:|
| `--amt="…"` | the amount in satoshis for the reservation | uint | `0` |
| `--expiry="…"` | the relative block height at which the reservation expires | uint | `0` |
| `--help` (`-h`) | show help | bool | `false` |
### `instantout` command
perform an instant off-chain to on-chain swap (looping out).

View file

@ -6,6 +6,10 @@
#### Bug Fixes
* Hardened Instant Out and reservation handling against malformed server
responses, invalid signatures and reservation outputs, resource exhaustion,
unsafe recovery, excessive swap fees, and under-scoped macaroon permissions.
* Taproot Asset Loop Out handling now validates RFQ timeouts and asset rates,
keeps cached asset-name lookups responsive during slow `tapd` queries, and
closes `tapd` connections cleanly during shutdown and startup failures.

View file

@ -20,6 +20,7 @@ import (
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwire"
)
const (
@ -61,6 +62,13 @@ type InitInstantOutCtx struct {
outgoingChanSet loopdb.ChannelSet
protocolVersion ProtocolVersion
sweepAddress btcutil.Address
maxSwapFee btcutil.Amount
}
// RecoverInstantOutCtx contains the chain height at which an instant out is
// resumed after restart.
type RecoverInstantOutCtx struct {
currentHeight int32
}
// InitInstantOutAction is the first action that is executed when the instant
@ -78,7 +86,7 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
}
var (
reservationAmt uint64
reservationAmt btcutil.Amount
reservationIds = make([][]byte, 0, len(initCtx.reservations))
reservations = make(
[]*reservation.Reservation, 0, len(initCtx.reservations),
@ -99,7 +107,7 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
"locked", reservationId))
}
reservationAmt += uint64(res.Value)
reservationAmt += res.Value
reservationIds = append(reservationIds, resId[:])
reservations = append(reservations, res)
@ -161,6 +169,11 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
return f.HandleError(fmt.Errorf("invalid swap invoice hash: "+
"expected %x got %x", preimage.Hash(), payReq.Hash))
}
if err := validateInstantOutInvoiceAmount(
payReq.Value, reservationAmt, initCtx.maxSwapFee,
); err != nil {
return f.HandleError(err)
}
serverPubkey, err := btcec.ParsePubKey(instantOutResponse.SenderKey)
if err != nil {
return f.HandleError(err)
@ -188,7 +201,8 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
CltvExpiry: initCtx.cltvExpiry,
clientPubkey: keyRes.PubKey,
serverPubkey: serverPubkey,
Value: btcutil.Amount(reservationAmt),
Value: reservationAmt,
MaxSwapFee: initCtx.maxSwapFee,
htlcFeeRate: feeRate,
swapInvoice: instantOutResponse.SwapInvoice,
Reservations: reservations,
@ -206,6 +220,31 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
return OnInit
}
// validateInstantOutInvoiceAmount verifies that the server invoice doesn't
// charge more than the client-approved swap fee. Sub-satoshi fees are rounded
// up so the cap cannot be bypassed with millisatoshi precision.
func validateInstantOutInvoiceAmount(invoiceAmount lnwire.MilliSatoshi,
swapAmount, maxSwapFee btcutil.Amount) error {
if maxSwapFee < 0 {
return fmt.Errorf("maximum swap fee must not be negative")
}
swapAmountMsat := lnwire.NewMSatFromSatoshis(swapAmount)
if invoiceAmount <= swapAmountMsat {
return nil
}
swapFeeMsat := invoiceAmount - swapAmountMsat
swapFeeSat := btcutil.Amount((int64(swapFeeMsat)-1)/1000 + 1)
if swapFeeSat > maxSwapFee {
return fmt.Errorf("instant out swap fee %d exceeds maximum %d",
swapFeeSat, maxSwapFee)
}
return nil
}
// PollPaymentAcceptedAction locks the reservations, sends the payment to the
// server and polls the server for the payment status.
func (f *FSM) PollPaymentAcceptedAction(ctx context.Context,
@ -293,6 +332,15 @@ func (f *FSM) BuildHTLCAction(ctx context.Context,
}
f.htlcMusig2Sessions = htlcSessions
defer func() {
err := cleanupMuSig2Sessions(
ctx, f.cfg.Signer, f.htlcMusig2Sessions,
)
if err != nil {
f.Errorf("unable to clean up HTLC MuSig2 sessions: %v", err)
}
f.htlcMusig2Sessions = nil
}()
// Send the server the client nonces.
htlcInitRes, err := f.cfg.InstantOutClient.InitHtlcSig(
@ -373,6 +421,26 @@ func (f *FSM) BuildHTLCAction(ctx context.Context,
func (f *FSM) PushPreimageAction(ctx context.Context,
eventCtx fsm.EventContext) fsm.EventType {
// A recovered swap may have been offline long enough that the server's
// reservation timeout is now close. Fall back to the already finalized
// HTLC instead of revealing the preimage without enough time to publish
// that safety transaction.
if recoverCtx, ok := eventCtx.(*RecoverInstantOutCtx); ok {
minReservationExpiry := int64(recoverCtx.currentHeight) +
int64(htlcExpiryDelta)
for _, res := range f.InstantOut.Reservations {
if int64(res.Expiry) >= minReservationExpiry {
continue
}
f.LastActionError = fmt.Errorf("reservation %x expires at "+
"height %d, before recovery safety height %d",
res.ID, res.Expiry, minReservationExpiry)
return OnErrorPublishHtlc
}
}
// First we'll create the musig2 context.
coopSessions, coopClientNonces, err := f.InstantOut.createMusig2Session(
ctx, f.cfg.Signer,
@ -382,6 +450,15 @@ func (f *FSM) PushPreimageAction(ctx context.Context,
}
f.sweeplessSweepSessions = coopSessions
defer func() {
err := cleanupMuSig2Sessions(
ctx, f.cfg.Signer, f.sweeplessSweepSessions,
)
if err != nil {
f.Errorf("unable to clean up sweep MuSig2 sessions: %v", err)
}
f.sweeplessSweepSessions = nil
}()
// Get the feerate for the coop sweep.
feeRate, err := f.cfg.Wallet.EstimateFeeRate(ctx, normalConfTarget)
@ -614,13 +691,34 @@ func (f *FSM) WaitForHtlcSweepConfirmedAction(ctx context.Context,
}
}
// unlockReservationsOnRecoverAction is the action of the
// UnlockReservationsOnRecover state. It is entered via OnRecover from any
// in-flight state where the reservations are already locked, and it unlocks
// them before routing to Failed. Without this, a crash between
// PollPaymentAcceptedAction's LockReservation and the swap reaching a
// terminal state would leave the reservations permanently Locked in the
// local store, blocking any future InstantOut that wants to spend them.
func (f *FSM) unlockReservationsOnRecoverAction(ctx context.Context,
_ fsm.EventContext) fsm.EventType {
return f.handleErrorAndUnlockReservations(
ctx, errors.New("instant out recovered from in-flight state"),
)
}
// handleErrorAndUnlockReservations handles an error and unlocks the
// reservations.
func (f *FSM) handleErrorAndUnlockReservations(ctx context.Context,
func (f *FSM) handleErrorAndUnlockReservations(_ context.Context,
err error) fsm.EventType {
// We might get here from a canceled context, we create a new context
// with a timeout to unlock the reservations.
ctx, cancel := context.WithTimeout(ctx, time.Second*30)
// We very likely got here from a canceled parent context (caller
// timeout, daemon shutdown, etc.). Deriving with timeout from a
// canceled parent yields an already-done context, so neither the
// local UnlockReservation calls nor the server-side CancelInstantSwap
// RPC would ever get a chance to run. Detach from the caller's
// context entirely.
ctx, cancel := context.WithTimeout(
context.Background(), time.Second*30,
)
defer cancel()
// Unlock the reservations.
@ -635,19 +733,23 @@ func (f *FSM) handleErrorAndUnlockReservations(ctx context.Context,
}
// We're also sending the server a cancel message so that it can
// release the reservations. This can be done in a goroutine as we
// wan't to fail the fsm early.
// release the reservations. This runs in a goroutine because we
// want to fail the FSM early -- but it must use its OWN background
// context with timeout, not derive from the cancel above (which
// fires the moment this function returns).
go func() {
ctx, cancel := context.WithTimeout(ctx, time.Second*30)
cancelCtx, cancel := context.WithTimeout(
context.Background(), time.Second*30,
)
defer cancel()
_, cancelErr := f.cfg.InstantOutClient.CancelInstantSwap(
ctx, &swapserverrpc.CancelInstantSwapRequest{
cancelCtx, &swapserverrpc.CancelInstantSwapRequest{
SwapHash: f.InstantOut.SwapHash[:],
},
)
if cancelErr != nil {
// We'll log the error but not return it as we want to return the
// original error.
// We'll log the error but not return it as we want
// to return the original error.
f.Debugf("error sending cancel message: %v", cancelErr)
}
}()

View file

@ -85,6 +85,14 @@ var (
// FailedHtlcSweep is the state where the htlc sweep failed.
FailedHtlcSweep = fsm.StateType("FailedHtlcSweep")
// UnlockReservationsOnRecover is a transient state entered via
// OnRecover from any in-flight state that had already locked the
// underlying reservations. Its action unlocks them and routes the
// FSM to Failed, so a crash mid-swap does not leave reservations
// stuck Locked in the local store.
UnlockReservationsOnRecover = fsm.StateType(
"UnlockReservationsOnRecover")
// Failed is the state where the swap failed.
Failed = fsm.StateType("InstantOutFailed")
)
@ -246,7 +254,11 @@ func (f *FSM) GetV1ReservationStates() fsm.States {
Transitions: fsm.Transitions{
OnPaymentAccepted: BuildHtlc,
fsm.OnError: Failed,
OnRecover: Failed,
// OnRecover must go through cleanup since
// PollPaymentAcceptedAction has already locked
// the reservations by the time the FSM can
// crash here.
OnRecover: UnlockReservationsOnRecover,
},
Action: f.PollPaymentAcceptedAction,
},
@ -254,10 +266,18 @@ func (f *FSM) GetV1ReservationStates() fsm.States {
Transitions: fsm.Transitions{
OnHtlcSigReceived: PushPreimage,
fsm.OnError: Failed,
OnRecover: Failed,
// Same as SendPaymentAndPollAccepted -- the
// reservations are still locked at this point.
OnRecover: UnlockReservationsOnRecover,
},
Action: f.BuildHTLCAction,
},
UnlockReservationsOnRecover: fsm.State{
Transitions: fsm.Transitions{
fsm.OnError: Failed,
},
Action: f.unlockReservationsOnRecoverAction,
},
PushPreimage: fsm.State{
Transitions: fsm.Transitions{
OnSweeplessSweepPublished: WaitForSweeplessSweepConfirmed,

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"reflect"
"time"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
@ -25,6 +26,8 @@ import (
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
)
const muSig2CleanupTimeout = 5 * time.Second
// InstantOut holds the necessary information to execute an instant out swap.
type InstantOut struct {
// SwapHash is the hash of the swap.
@ -57,6 +60,9 @@ type InstantOut struct {
// Value is the amount that is swapped.
Value btcutil.Amount
// MaxSwapFee is the maximum off-chain swap fee accepted by the client.
MaxSwapFee btcutil.Amount
// keyLocator is the key locator that is used for the swap.
keyLocator keychain.KeyLocator
@ -112,7 +118,10 @@ func (i *InstantOut) createMusig2Session(ctx context.Context,
for idx, reservation := range i.Reservations {
session, err := reservation.Musig2CreateSession(ctx, signer)
if err != nil {
return nil, nil, err
cleanupErr := cleanupMuSig2Sessions(
ctx, signer, musig2Sessions[:idx],
)
return nil, nil, errors.Join(err, cleanupErr)
}
musig2Sessions[idx] = session
@ -122,6 +131,32 @@ func (i *InstantOut) createMusig2Session(ctx context.Context,
return musig2Sessions, clientNonces, nil
}
// cleanupMuSig2Sessions removes completed or abandoned MuSig2 sessions from
// lnd. Cleanup uses a bounded context that survives cancellation of the swap
// action that created the sessions.
func cleanupMuSig2Sessions(ctx context.Context, signer lndclient.SignerClient,
sessions []*input.MuSig2SessionInfo) error {
cleanupCtx, cancel := context.WithTimeout(
context.WithoutCancel(ctx), muSig2CleanupTimeout,
)
defer cancel()
var cleanupErr error
for _, session := range sessions {
if session == nil {
continue
}
err := signer.MuSig2Cleanup(cleanupCtx, session.SessionID)
if err != nil {
cleanupErr = errors.Join(cleanupErr, err)
}
}
return cleanupErr
}
// getInputReservations returns the input reservations for the instant out.
func (i *InstantOut) getInputReservations() (InputReservations, error) {
if len(i.Reservations) == 0 {
@ -263,12 +298,31 @@ func (i *InstantOut) signMusig2Tx(ctx context.Context,
if err != nil {
return nil, err
}
if tx == nil {
return nil, errors.New("transaction is nil")
}
if len(tx.TxIn) != len(inputs) {
return nil, fmt.Errorf("invalid number of transaction inputs: "+
"expected %d, got %d", len(inputs), len(tx.TxIn))
}
if len(musig2sessions) != len(inputs) {
return nil, fmt.Errorf("invalid number of MuSig2 sessions: "+
"expected %d, got %d", len(inputs), len(musig2sessions))
}
if len(counterPartyNonces) != len(inputs) {
return nil, fmt.Errorf("invalid number of server nonces: "+
"expected %d, got %d", len(inputs), len(counterPartyNonces))
}
prevOutFetcher := inputs.GetPrevoutFetcher()
sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher)
sigs := make([][]byte, len(inputs))
for idx, reservation := range inputs {
if musig2sessions[idx] == nil {
return nil, fmt.Errorf("MuSig2 session %d is nil", idx)
}
if !reflect.DeepEqual(tx.TxIn[idx].PreviousOutPoint,
reservation.Outpoint) {
@ -329,8 +383,30 @@ func (i *InstantOut) finalizeMusig2Transaction(ctx context.Context,
if err != nil {
return nil, err
}
if tx == nil {
return nil, errors.New("transaction is nil")
}
if len(tx.TxIn) != len(inputs) {
return nil, fmt.Errorf("invalid number of transaction inputs: "+
"expected %d, got %d", len(inputs), len(tx.TxIn))
}
if len(musig2Sessions) != len(inputs) {
return nil, fmt.Errorf("invalid number of MuSig2 sessions: "+
"expected %d, got %d", len(inputs), len(musig2Sessions))
}
if len(serverSigs) != len(inputs) {
return nil, fmt.Errorf("invalid number of server signatures: "+
"expected %d, got %d", len(inputs), len(serverSigs))
}
prevOutFetcher := inputs.GetPrevoutFetcher()
sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher)
for idx := range inputs {
if musig2Sessions[idx] == nil {
return nil, fmt.Errorf("MuSig2 session %d is nil", idx)
}
haveAllSigs, finalSig, err := signer.MuSig2CombineSig(
ctx, musig2Sessions[idx].SessionID,
[][]byte{serverSigs[idx]},
@ -343,7 +419,26 @@ func (i *InstantOut) finalizeMusig2Transaction(ctx context.Context,
return nil, fmt.Errorf("missing sigs")
}
// lnd removes a MuSig2 session automatically once all signatures
// have been combined. Clear the local entry so the caller's deferred
// cleanup only targets sessions abandoned on an error path.
musig2Sessions[idx] = nil
tx.TxIn[idx].Witness = wire.TxWitness{finalSig}
vm, err := txscript.NewEngine(
inputs[idx].PkScript, tx, idx,
txscript.StandardVerifyFlags, nil, sigHashes,
int64(inputs[idx].Value), prevOutFetcher,
)
if err != nil {
return nil, fmt.Errorf("unable to verify final MuSig2 "+
"signature for input %d: %w", idx, err)
}
if err := vm.Execute(); err != nil {
return nil, fmt.Errorf("invalid final MuSig2 signature "+
"for input %d: %w", idx, err)
}
}
return tx, nil

View file

@ -0,0 +1,203 @@
package instantout
import (
"context"
"testing"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/instantout/reservation"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
)
type invalidFinalSigSigner struct {
lndclient.SignerClient
}
func (s *invalidFinalSigSigner) MuSig2CombineSig(context.Context, [32]byte,
[][]byte) (bool, []byte, error) {
return true, make([]byte, 64), nil
}
type cleanupTrackingSigner struct {
lndclient.SignerClient
cleaned [][32]byte
}
func (s *cleanupTrackingSigner) MuSig2Cleanup(_ context.Context,
sessionID [32]byte) error {
s.cleaned = append(s.cleaned, sessionID)
return nil
}
// TestMuSig2VectorLengthValidation verifies that malformed server-controlled
// vectors are rejected before they can be indexed.
func TestMuSig2VectorLengthValidation(t *testing.T) {
_, pubKey := btcec.PrivKeyFromBytes([]byte{1})
instantOut := &InstantOut{
Reservations: []*reservation.Reservation{
{
ClientPubkey: pubKey,
ServerPubkey: pubKey,
Value: btcutil.Amount(100_000),
Expiry: 200,
Outpoint: &wire.OutPoint{},
},
},
}
tx := wire.NewMsgTx(2)
tx.AddTxIn(&wire.TxIn{})
sessions := []*input.MuSig2SessionInfo{{}}
require.NotPanics(t, func() {
_, err := instantOut.signMusig2Tx(
context.Background(), nil, tx, sessions, nil,
)
require.ErrorContains(t, err, "server nonces")
})
require.NotPanics(t, func() {
_, err := instantOut.finalizeMusig2Transaction(
context.Background(), nil, sessions, tx, nil,
)
require.ErrorContains(t, err, "server signatures")
})
}
// TestFinalizeMuSig2TransactionVerifiesSignature verifies that a combined
// signature is validated locally before the transaction can be used as the
// instant-out safety net.
func TestFinalizeMuSig2TransactionVerifiesSignature(t *testing.T) {
_, pubKey := btcec.PrivKeyFromBytes([]byte{1})
res := &reservation.Reservation{
ClientPubkey: pubKey,
ServerPubkey: pubKey,
Value: btcutil.Amount(100_000),
Expiry: 200,
Outpoint: &wire.OutPoint{},
}
instantOut := &InstantOut{
Reservations: []*reservation.Reservation{res},
}
tx := wire.NewMsgTx(2)
tx.AddTxIn(&wire.TxIn{PreviousOutPoint: *res.Outpoint})
tx.AddTxOut(&wire.TxOut{Value: 90_000})
sessions := []*input.MuSig2SessionInfo{{}}
_, err := instantOut.finalizeMusig2Transaction(
context.Background(), &invalidFinalSigSigner{},
sessions, tx, [][]byte{{1}},
)
require.ErrorContains(t, err, "invalid final MuSig2 signature")
require.Nil(t, sessions[0])
}
// TestCleanupMuSig2Sessions verifies that all allocated sessions are released
// while nil entries from partial session creation are skipped.
func TestCleanupMuSig2Sessions(t *testing.T) {
firstID := [32]byte{1}
secondID := [32]byte{2}
signer := &cleanupTrackingSigner{}
err := cleanupMuSig2Sessions(
t.Context(), signer, []*input.MuSig2SessionInfo{
{SessionID: firstID}, nil, {SessionID: secondID},
},
)
require.NoError(t, err)
require.Equal(t, [][32]byte{firstID, secondID}, signer.cleaned)
}
// TestPushPreimageRejectsExpiringReservation verifies that recovery takes the
// on-chain fallback before revealing the preimage when a reservation is too
// close to its server-controlled timeout.
func TestPushPreimageRejectsExpiringReservation(t *testing.T) {
instantOutFSM := &FSM{
StateMachine: &fsm.StateMachine{},
InstantOut: &InstantOut{
Reservations: []*reservation.Reservation{
{
ID: reservation.ID{1},
Expiry: 139,
},
},
},
}
event := instantOutFSM.PushPreimageAction(
t.Context(), &RecoverInstantOutCtx{currentHeight: 100},
)
require.Equal(t, OnErrorPublishHtlc, event)
require.ErrorContains(
t, instantOutFSM.LastActionError, "before recovery safety height",
)
}
// TestValidateInstantOutInvoiceAmount verifies enforcement of the fee cap at
// millisatoshi precision.
func TestValidateInstantOutInvoiceAmount(t *testing.T) {
const (
swapAmount = btcutil.Amount(100_000)
maxSwapFee = btcutil.Amount(200)
)
tests := []struct {
name string
invoiceAmount lnwire.MilliSatoshi
maxSwapFee btcutil.Amount
expectErr bool
}{
{
name: "exact fee cap",
invoiceAmount: lnwire.NewMSatFromSatoshis(
swapAmount + maxSwapFee,
),
maxSwapFee: maxSwapFee,
},
{
name: "one millisatoshi over fee cap",
invoiceAmount: lnwire.NewMSatFromSatoshis(
swapAmount+maxSwapFee,
) + 1,
maxSwapFee: maxSwapFee,
expectErr: true,
},
{
name: "discounted invoice",
invoiceAmount: lnwire.NewMSatFromSatoshis(
swapAmount - 1,
),
maxSwapFee: 0,
},
{
name: "negative cap",
invoiceAmount: lnwire.NewMSatFromSatoshis(
swapAmount,
),
maxSwapFee: -1,
expectErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateInstantOutInvoiceAmount(
tc.invoiceAmount, swapAmount, tc.maxSwapFee,
)
if tc.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}

View file

@ -119,8 +119,11 @@ func (m *Manager) recoverInstantOuts(ctx context.Context) error {
// As SendEvent can block, we'll start a goroutine to process
// the event.
recoverCtx := &RecoverInstantOutCtx{
currentHeight: m.currentHeight,
}
go func() {
err := instantOutFSM.SendEvent(ctx, OnRecover, nil)
err := instantOutFSM.SendEvent(ctx, OnRecover, recoverCtx)
if err != nil {
log.Errorf("FSM %v Error sending recover "+
"event %v, state: %v",
@ -135,7 +138,12 @@ func (m *Manager) recoverInstantOuts(ctx context.Context) error {
// NewInstantOut creates a new instantout.
func (m *Manager) NewInstantOut(ctx context.Context,
reservations []reservation.ID, sweepAddress string) (*FSM, error) {
reservations []reservation.ID, sweepAddress string,
maxSwapFee btcutil.Amount) (*FSM, error) {
if maxSwapFee < 0 {
return nil, fmt.Errorf("maximum swap fee must not be negative")
}
var (
sweepAddr btcutil.Address
@ -148,6 +156,24 @@ func (m *Manager) NewInstantOut(ctx context.Context,
if err != nil {
return nil, err
}
if !sweepAddr.IsForNet(m.cfg.Network) {
return nil, fmt.Errorf("sweep address %s is not "+
"valid for network %s", sweepAddress,
m.cfg.Network.Name)
}
switch sweepAddr.(type) {
case *btcutil.AddressTaproot,
*btcutil.AddressWitnessScriptHash,
*btcutil.AddressWitnessPubKeyHash,
*btcutil.AddressScriptHash,
*btcutil.AddressPubKeyHash:
default:
return nil, fmt.Errorf("unsupported sweep address "+
"type %T", sweepAddr)
}
}
m.Lock()
@ -158,6 +184,7 @@ func (m *Manager) NewInstantOut(ctx context.Context,
initationHeight: m.currentHeight,
protocolVersion: CurrentProtocolVersion(),
sweepAddress: sweepAddr,
maxSwapFee: maxSwapFee,
}
instantOut, err := NewFSM(m.cfg, ProtocolVersionFullReservation)

View file

@ -2,16 +2,189 @@ 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. Compare as int64 so the
// lower bound stays meaningful when expectedExpiry < expiryDelta
// (which would otherwise underflow the uint32 and accept any
// response below the upper bound).
if int64(requestResponse.Expiry) < int64(expectedExpiry)-int64(expiryDelta) ||
int64(requestResponse.Expiry) > int64(expectedExpiry)+int64(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
// Persist the row with state = Init so a crash before the next
// state transition leaves a recoverable row. Without this, NewReservation
// produces State = fsm.EmptyState (zero value), which has no OnRecover
// transition in the client-initiated state map, and recovery would
// silently leave the row stuck forever.
reservation.State = Init
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 +192,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 +413,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,
@ -80,6 +80,26 @@ func (m *mockReservationClient) FetchL402(ctx context.Context,
args.Error(1)
}
func (m *mockReservationClient) QuoteReservation(ctx context.Context,
in *swapserverrpc.QuoteReservationRequest, opts ...grpc.CallOption) (
*swapserverrpc.QuoteReservationResponse, error) {
args := m.Called(ctx, in, opts)
return args.Get(0).(*swapserverrpc.QuoteReservationResponse),
args.Error(1)
}
func (m *mockReservationClient) RequestReservation(ctx context.Context,
in *swapserverrpc.RequestReservationRequest, opts ...grpc.CallOption) (
*swapserverrpc.RequestReservationResponse, error) {
args := m.Called(ctx, in, opts)
return args.Get(0).(*swapserverrpc.RequestReservationResponse),
args.Error(1)
}
type mockStore struct {
mock.Mock
@ -154,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)
}
}
@ -203,6 +223,7 @@ func TestSubscribeToConfirmationAction(t *testing.T) {
blockHeight int32
blockErr error
sendTxConf bool
outputValue btcutil.Amount
confErr error
expectedEvent fsm.EventType
}{
@ -210,8 +231,15 @@ func TestSubscribeToConfirmationAction(t *testing.T) {
name: "success",
blockHeight: 0,
sendTxConf: true,
outputValue: defaultValue,
expectedEvent: OnConfirmed,
},
{
name: "reservation value mismatch",
sendTxConf: true,
outputValue: defaultValue - 1,
expectedEvent: fsm.OnError,
},
{
name: "expired",
blockHeight: 100,
@ -273,7 +301,7 @@ func TestSubscribeToConfirmationAction(t *testing.T) {
TxIn: []*wire.TxIn{},
TxOut: []*wire.TxOut{
{
Value: int64(defaultValue),
Value: int64(tc.outputValue),
PkScript: pkScript,
},
},

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
@ -60,10 +70,10 @@ type FSM struct {
}
// NewFSM creates a new reservation FSM.
func NewFSM(cfg *Config) *FSM {
func NewFSM(cfg *Config, protocolVersion ProtocolVersion) *FSM {
reservation := &Reservation{
State: fsm.EmptyState,
ProtocolVersion: CurrentProtocolVersion,
ProtocolVersion: protocolVersion,
}
return NewFSMFromReservation(cfg, reservation)
@ -82,6 +92,9 @@ func NewFSMFromReservation(cfg *Config, reservation *Reservation) *FSM {
case ProtocolVersionServerInitiated:
states = reservationFsm.GetServerInitiatedReservationStates()
case ProtocolVersionClientInitiated:
states = reservationFsm.GetClientInitiatedReservationStates()
default:
states = make(fsm.States)
}
@ -100,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")
@ -127,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")
@ -160,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 {
@ -176,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

@ -8,14 +8,18 @@ import (
)
var (
ErrReservationAlreadyExists = fmt.Errorf("reservation already exists")
ErrReservationNotFound = fmt.Errorf("reservation not found")
ErrReservationAlreadyExists = fmt.Errorf("reservation already exists")
ErrReservationNotFound = fmt.Errorf("reservation not found")
ErrTooManyActiveReservations = fmt.Errorf(
"too many active reservations",
)
)
const (
KeyFamily = int32(42068)
DefaultConfTarget = int32(3)
IdLength = 32
KeyFamily = int32(42068)
DefaultConfTarget = int32(3)
IdLength = 32
maxActiveReservations = 1000
)
// Store is the interface that stores the reservations.

View file

@ -2,6 +2,7 @@ package reservation
import (
"context"
"errors"
"fmt"
"strings"
"sync"
@ -10,9 +11,31 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/swapserverrpc"
reservationrpc "github.com/lightninglabs/loop/swapserverrpc"
)
var (
// defaultWaitForStateTime is how long RequestReservationFromServer
// blocks waiting for the FSM to advance to SendPrepaymentPayment.
// The action that drives that transition performs a server RPC
// round-trip that itself creates an lnd hold invoice on the swap
// server side, plus an lnd DecodePaymentRequest, plus a local
// CreateReservation. Under load any one of these can take a few
// seconds, so 15s is too tight: when the RPC times out the FSM
// continues running in the background and may still pay the
// prepay invoice after the caller has been told the request
// failed. 60s gives realistic head-room.
defaultWaitForStateTime = time.Second * 60
)
// FSMSendEventReq contains the information needed to send an event to the FSM.
type FSMSendEventReq struct {
fsm *FSM
event fsm.EventType
eventCtx fsm.EventContext
}
// Manager manages the reservation state machines.
type Manager struct {
sync.Mutex
@ -23,6 +46,32 @@ type Manager struct {
// activeReservations contains all the active reservationsFSMs.
activeReservations map[ID]*FSM
currentHeight int32
reqChan chan *FSMSendEventReq
}
// finalStateObserver removes a reservation FSM from the active set once it
// reaches a terminal state.
type finalStateObserver struct {
manager *Manager
id ID
fsm *FSM
}
// Notify implements the fsm.Observer interface.
func (o *finalStateObserver) Notify(notification fsm.Notification) {
if !isFinalState(notification.NextState) {
return
}
o.manager.Lock()
defer o.manager.Unlock()
if o.manager.activeReservations[o.id] == o.fsm {
delete(o.manager.activeReservations, o.id)
}
}
// NewManager creates a new reservation manager.
@ -30,6 +79,7 @@ func NewManager(cfg *Config) *Manager {
return &Manager{
cfg: cfg,
activeReservations: make(map[ID]*FSM),
reqChan: make(chan *FSMSendEventReq),
}
}
@ -42,7 +92,13 @@ func (m *Manager) Run(ctx context.Context, height int32,
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
currentHeight := height
// Take the lock for the initial write so the race detector sees a
// consistent synchronisation rule (later writes in the new-block
// case already lock; concurrent reads in RequestReservationFromServer
// already lock).
m.Lock()
m.currentHeight = height
m.Unlock()
err := m.RecoverReservations(runCtx)
if err != nil {
@ -64,7 +120,9 @@ func (m *Manager) Run(ctx context.Context, height int32,
select {
case height := <-newBlockChan:
log.Debugf("Received block %v", height)
currentHeight = height
m.Lock()
m.currentHeight = height
m.Unlock()
case reservationRes, ok := <-ntfnChan:
if !ok {
@ -76,13 +134,27 @@ func (m *Manager) Run(ctx context.Context, height int32,
log.Debugf("Received reservation %x",
reservationRes.ReservationId)
_, err := m.newReservation(
runCtx, uint32(currentHeight), reservationRes,
_, err := m.newReservationFromNtfn(
runCtx, uint32(m.currentHeight), reservationRes,
)
if err != nil {
return err
log.Errorf("Unable to create reservation %x: %v",
reservationRes.ReservationId, err)
}
case req := <-m.reqChan:
// We'll send the event in a goroutine to avoid blocking
// the main loop.
go func() {
err := req.fsm.SendEvent(
runCtx, req.event, req.eventCtx,
)
if err != nil {
log.Errorf("Error sending event: %v",
err)
}
}()
case err := <-newBlockErrChan:
return err
@ -93,9 +165,11 @@ func (m *Manager) Run(ctx context.Context, height int32,
}
}
// newReservation creates a new reservation from the reservation request.
func (m *Manager) newReservation(ctx context.Context, currentHeight uint32,
req *reservationrpc.ServerReservationNotification) (*FSM, error) {
// newReservationFromNtfn creates a new reservation from the reservation
// notification.
func (m *Manager) newReservationFromNtfn(ctx context.Context,
currentHeight uint32, req *reservationrpc.ServerReservationNotification,
) (*FSM, error) {
var reservationID ID
err := reservationID.FromByteSlice(
@ -110,17 +184,42 @@ func (m *Manager) newReservation(ctx context.Context, currentHeight uint32,
return nil, err
}
_, err = m.cfg.Store.GetReservation(ctx, reservationID)
switch {
case err == nil:
return nil, ErrReservationAlreadyExists
case !errors.Is(err, ErrReservationNotFound):
return nil, err
}
// Create the reservation state machine. We need to pass in the runCtx
// of the reservation manager so that the state machine will keep on
// running even if the grpc conte
reservationFSM := NewFSM(m.cfg)
reservationFSM := NewFSM(m.cfg, ProtocolVersionServerInitiated)
// Add the reservation to the active reservations map.
// Add the reservation to the active reservations map. Check the map while
// holding the lock as concurrent callers may both have completed the store
// lookup above.
m.Lock()
if _, ok := m.activeReservations[reservationID]; ok {
m.Unlock()
return nil, ErrReservationAlreadyExists
}
if len(m.activeReservations) >= maxActiveReservations {
m.Unlock()
return nil, ErrTooManyActiveReservations
}
m.activeReservations[reservationID] = reservationFSM
m.Unlock()
initContext := &InitReservationContext{
reservationFSM.RegisterObserver(&finalStateObserver{
manager: m,
id: reservationID,
fsm: reservationFSM,
})
initContext := &ServerRequestedInitContext{
reservationID: reservationID,
serverPubkey: serverKey,
value: btcutil.Amount(req.Value),
@ -130,9 +229,11 @@ func (m *Manager) newReservation(ctx context.Context, currentHeight uint32,
// Send the init event to the state machine.
go func() {
err = reservationFSM.SendEvent(ctx, OnServerRequest, initContext)
if err != nil {
log.Errorf("Error sending init event: %v", err)
sendErr := reservationFSM.SendEvent(
ctx, OnServerRequest, initContext,
)
if sendErr != nil {
log.Errorf("Error sending init event: %v", sendErr)
}
}()
@ -143,6 +244,12 @@ func (m *Manager) newReservation(ctx context.Context, currentHeight uint32,
fsm.WithWaitForStateOption(time.Second),
)
if err != nil {
m.Lock()
if m.activeReservations[reservationID] == reservationFSM {
delete(m.activeReservations, reservationID)
}
m.Unlock()
if reservationFSM.LastActionError != nil {
return nil, fmt.Errorf("error waiting for "+
"state: %v, last action error: %v",
@ -154,6 +261,73 @@ func (m *Manager) newReservation(ctx context.Context, currentHeight uint32,
return reservationFSM, nil
}
// RequestReservationFromServer sends a request to the server to create a new
// reservation.
func (m *Manager) RequestReservationFromServer(ctx context.Context,
value btcutil.Amount, expiry uint32, maxPrepaymentAmt btcutil.Amount) (
*Reservation, error) {
m.Lock()
currentHeight := m.currentHeight
m.Unlock()
// Create a new reservation req.
req := &ClientRequestedInitContext{
value: value,
relativeExpiry: expiry,
heightHint: uint32(currentHeight),
maxPrepaymentAmt: maxPrepaymentAmt,
}
reservationFSM := NewFSM(m.cfg, ProtocolVersionClientInitiated)
// Send the event to the main loop. reqChan is unbuffered so the
// raw send blocks until Run picks it up; if Run has already exited
// or the caller has cancelled, fall through with an error instead
// of hanging the RPC indefinitely.
select {
case m.reqChan <- &FSMSendEventReq{
fsm: reservationFSM,
event: OnClientInitialized,
eventCtx: req,
}:
case <-ctx.Done():
return nil, ctx.Err()
}
// We'll now wait for the reservation to be in the state where we are
// sending the prepayment.
err := reservationFSM.DefaultObserver.WaitForState(
ctx, defaultWaitForStateTime, SendPrepaymentPayment,
fsm.WithAbortEarlyOnErrorOption(),
)
if err != nil {
return nil, err
}
// Now we can add the reservation to our active fsm.
m.Lock()
m.activeReservations[reservationFSM.reservation.ID] = reservationFSM
m.Unlock()
return reservationFSM.reservation, nil
}
// QuoteReservation quotes the server for a new reservation.
func (m *Manager) QuoteReservation(ctx context.Context, value btcutil.Amount,
expiry uint32) (btcutil.Amount, error) {
quoteReq := &swapserverrpc.QuoteReservationRequest{
Value: uint64(value),
Expiry: expiry,
}
req, err := m.cfg.ReservationClient.QuoteReservation(ctx, quoteReq)
if err != nil {
return 0, err
}
return btcutil.Amount(req.PrepayCost), nil
}
// RecoverReservations tries to recover all reservations that are still active
// from the database.
func (m *Manager) RecoverReservations(ctx context.Context) error {
@ -162,6 +336,16 @@ func (m *Manager) RecoverReservations(ctx context.Context) error {
return err
}
activeCount := 0
for _, reservation := range reservations {
if !isFinalState(reservation.State) {
activeCount++
}
}
if activeCount > maxActiveReservations {
return ErrTooManyActiveReservations
}
for _, reservation := range reservations {
if isFinalState(reservation.State) {
continue
@ -174,6 +358,11 @@ func (m *Manager) RecoverReservations(ctx context.Context) error {
reservationFSM := NewFSMFromReservation(m.cfg, reservation)
m.activeReservations[reservation.ID] = reservationFSM
reservationFSM.RegisterObserver(&finalStateObserver{
manager: m,
id: reservation.ID,
fsm: reservationFSM,
})
// As SendEvent can block, we'll start a goroutine to process
// the event.

View file

@ -36,7 +36,7 @@ func TestManager(t *testing.T) {
<-initChan
// Create a new reservation.
reservationFSM, err := testContext.manager.newReservation(
reservationFSM, err := testContext.manager.newReservationFromNtfn(
ctxb, uint32(testContext.mockLnd.Height),
&swapserverrpc.ServerReservationNotification{
ReservationId: defaultReservationId[:],
@ -57,6 +57,7 @@ func TestManager(t *testing.T) {
confTx := &wire.MsgTx{
TxOut: []*wire.TxOut{
{
Value: int64(defaultValue),
PkScript: pkScript,
},
},
@ -97,6 +98,117 @@ func TestManager(t *testing.T) {
// We'll now expect the reservation to be expired.
err = reservationFSM.DefaultObserver.WaitForState(ctxb, 5*time.Second, Spent)
require.NoError(t, err)
testContext.manager.Lock()
_, ok := testContext.manager.activeReservations[defaultReservationId]
testContext.manager.Unlock()
require.False(t, ok)
}
// TestManagerContinuesAfterInvalidNotification verifies that a malformed
// server notification doesn't stop the reservation manager from processing
// later notifications.
func TestManagerContinuesAfterInvalidNotification(t *testing.T) {
testContext := newManagerTestContext(t)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
initChan := make(chan struct{})
errChan := make(chan error, 1)
go func() {
errChan <- testContext.manager.Run(
ctx, testContext.mockLnd.Height, initChan,
)
}()
<-initChan
// A malformed ID is rejected by newReservationFromNtfn. The manager
// should log the error and continue processing the stream.
testContext.reservationNotificationChan <- &swapserverrpc.ServerReservationNotification{
ReservationId: []byte{1},
}
testContext.reservationNotificationChan <- &swapserverrpc.ServerReservationNotification{
ReservationId: defaultReservationId[:],
Value: uint64(defaultValue),
ServerKey: defaultPubkeyBytes,
Expiry: uint32(testContext.mockLnd.Height) +
defaultExpiry,
}
select {
case <-testContext.mockLnd.RegisterConfChannel:
case err := <-errChan:
require.NoError(t, err)
t.Fatal("reservation manager stopped after malformed notification")
case <-time.After(5 * time.Second):
t.Fatal("valid reservation notification was not processed")
}
cancel()
require.NoError(t, <-errChan)
}
// TestManagerRejectsDuplicateReservation verifies that a duplicate server
// notification cannot replace the active FSM for an existing reservation.
func TestManagerRejectsDuplicateReservation(t *testing.T) {
testContext := newManagerTestContext(t)
ctx := t.Context()
req := &swapserverrpc.ServerReservationNotification{
ReservationId: defaultReservationId[:],
Value: uint64(defaultValue),
ServerKey: defaultPubkeyBytes,
Expiry: uint32(testContext.mockLnd.Height) +
defaultExpiry,
}
firstFSM, err := testContext.manager.newReservationFromNtfn(
ctx, uint32(testContext.mockLnd.Height), req,
)
require.NoError(t, err)
secondFSM, err := testContext.manager.newReservationFromNtfn(
ctx, uint32(testContext.mockLnd.Height), req,
)
require.ErrorIs(t, err, ErrReservationAlreadyExists)
require.Nil(t, secondFSM)
require.Same(
t, firstFSM,
testContext.manager.activeReservations[defaultReservationId],
)
}
// TestManagerLimitsActiveReservations verifies that server notifications
// cannot grow the active FSM set without bound.
func TestManagerLimitsActiveReservations(t *testing.T) {
testContext := newManagerTestContext(t)
for i := range maxActiveReservations {
var id ID
id[0] = byte(i)
id[1] = byte(i >> 8)
testContext.manager.activeReservations[id] = NewFSM(
testContext.manager.cfg, ProtocolVersionServerInitiated,
)
}
reservationFSM, err := testContext.manager.newReservationFromNtfn(
t.Context(), uint32(testContext.mockLnd.Height),
&swapserverrpc.ServerReservationNotification{
ReservationId: defaultReservationId[:],
Value: uint64(defaultValue),
ServerKey: defaultPubkeyBytes,
Expiry: uint32(testContext.mockLnd.Height) +
defaultExpiry,
},
)
require.ErrorIs(t, err, ErrTooManyActiveReservations)
require.Nil(t, reservationFSM)
require.Len(
t, testContext.manager.activeReservations,
maxActiveReservations,
)
}
// ManagerTestContext is a helper struct that contains all the necessary

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
@ -142,8 +145,14 @@ func (r *Reservation) findReservationOutput(tx *wire.MsgTx) (*wire.OutPoint,
return nil, err
}
var foundScript bool
for i, txOut := range tx.TxOut {
if bytes.Equal(txOut.PkScript, pkScript) {
foundScript = true
if txOut.Value != int64(r.Value) {
continue
}
return &wire.OutPoint{
Hash: tx.TxHash(),
Index: uint32(i),
@ -151,6 +160,11 @@ func (r *Reservation) findReservationOutput(tx *wire.MsgTx) (*wire.OutPoint,
}
}
if foundScript {
return nil, fmt.Errorf("reservation output value mismatch: "+
"expected %d", r.Value)
}
return nil, errors.New("reservation output not found")
}

View file

@ -84,6 +84,7 @@ func (r *SQLStore) CreateReservation(ctx context.Context,
ClientKeyIndex: int32(reservation.KeyLocator.Index),
InitiationHeight: reservation.InitiationHeight,
ProtocolVersion: int32(reservation.ProtocolVersion),
PrepayInvoice: reservation.PrepayInvoice,
}
updateArgs := sqlc.InsertReservationUpdateParams{
@ -180,6 +181,10 @@ func (r *SQLStore) GetReservation(ctx context.Context,
return nil
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrReservationNotFound
}
return nil, err
}
@ -289,6 +294,7 @@ func sqlReservationToReservation(row sqlc.Reservation,
InitiationHeight: row.InitiationHeight,
State: fsm.StateType(lastUpdate.UpdateState),
ProtocolVersion: ProtocolVersion(row.ProtocolVersion),
PrepayInvoice: row.PrepayInvoice,
}, nil
}

View file

@ -104,7 +104,7 @@ func (s *SQLStore) CreateInstantLoopOut(ctx context.Context,
AmountRequested: int64(instantOut.Value),
CltvExpiry: instantOut.CltvExpiry,
MaxMinerFee: 0,
MaxSwapFee: 0,
MaxSwapFee: int64(instantOut.MaxSwapFee),
InitiationHeight: instantOut.initiationHeight,
ProtocolVersion: int32(instantOut.protocolVersion),
Label: "",
@ -368,6 +368,7 @@ func (s *SQLStore) sqlInstantOutToInstantOut(ctx context.Context,
protocolVersion: ProtocolVersion(row.ProtocolVersion),
initiationHeight: row.InitiationHeight,
Value: btcutil.Amount(row.AmountRequested),
MaxSwapFee: btcutil.Amount(row.MaxSwapFee),
keyLocator: keychain.KeyLocator{
Family: keychain.KeyFamily(row.ClientKeyFamily),
Index: uint32(row.ClientKeyIndex),

View file

@ -761,6 +761,8 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
ChainNotifier: d.lnd.ChainNotifier,
ReservationClient: reservationClient,
NotificationManager: notificationManager,
LightningClient: d.lnd.Client,
RouterClient: d.lnd.Router,
}
reservationManager = reservation.NewManager(

View file

@ -1739,6 +1739,49 @@ func (s *swapClientServer) ListReservations(ctx context.Context,
}, nil
}
func (s *swapClientServer) ReservationRequest(ctx context.Context,
req *looprpc.ReservationRequestRequest) (
*looprpc.ReservationRequestResponse, error) {
if s.reservationManager == nil {
return nil, status.Error(codes.Unimplemented,
"Restart loop with --experimental")
}
reservation, err := s.reservationManager.RequestReservationFromServer(
ctx, btcutil.Amount(req.Amt), req.Expiry,
btcutil.Amount(req.MaxPrepayAmt),
)
if err != nil {
return nil, err
}
return &looprpc.ReservationRequestResponse{
Reservation: toClientReservation(reservation),
}, nil
}
func (s *swapClientServer) ReservationQuote(ctx context.Context,
req *looprpc.ReservationQuoteRequest) (
*looprpc.ReservationQuoteResponse, error) {
if s.reservationManager == nil {
return nil, status.Error(codes.Unimplemented,
"Restart loop with --experimental")
}
quote, err := s.reservationManager.QuoteReservation(
ctx, btcutil.Amount(req.Amt), req.Expiry,
)
if err != nil {
return nil, err
}
return &looprpc.ReservationQuoteResponse{
PrepayAmt: uint64(quote),
}, nil
}
// InstantOut initiates an instant out swap.
func (s *swapClientServer) InstantOut(ctx context.Context,
req *looprpc.InstantOutRequest) (*looprpc.InstantOutResponse,
@ -1765,6 +1808,7 @@ func (s *swapClientServer) InstantOut(ctx context.Context,
instantOutFsm, err := s.instantOutManager.NewInstantOut(
ctx, reservationIds, req.DestAddr,
btcutil.Amount(req.MaxSwapFeeSat),
)
if err != nil {
return nil, err

View file

@ -1,3 +1,3 @@
-- protocol_version is used to determine the version of the reservation protocol
-- that was used to create the reservation.
ALTER TABLE reservations DROP COLUMN protocol_Version;
ALTER TABLE reservations DROP COLUMN protocol_version;

View file

@ -1,3 +1,3 @@
-- protocol_version is used to determine the version of the reservation protocol
-- that was used to create the reservation.
ALTER TABLE reservations ADD COLUMN protocol_Version INTEGER NOT NULL DEFAULT 0;
ALTER TABLE reservations ADD COLUMN protocol_version INTEGER NOT NULL DEFAULT 0;

View file

@ -0,0 +1 @@
ALTER TABLE reservations DROP COLUMN prepay_invoice;

View file

@ -0,0 +1,3 @@
-- prepay_invoice is a field that will store the invoice of the prepay payment
-- that pays for the reservation.
ALTER TABLE reservations ADD COLUMN prepay_invoice TEXT NOT NULL DEFAULT '';

View file

@ -115,6 +115,7 @@ type Reservation struct {
OutIndex sql.NullInt32
ConfirmationHeight sql.NullInt32
ProtocolVersion int32
PrepayInvoice string
}
type ReservationUpdate struct {

View file

@ -8,7 +8,8 @@ INSERT INTO reservations (
client_key_family,
client_key_index,
initiation_height,
protocol_version
protocol_version,
prepay_invoice
) VALUES (
$1,
$2,
@ -18,7 +19,8 @@ INSERT INTO reservations (
$6,
$7,
$8,
$9
$9,
$10
);
-- name: UpdateReservation :exec

View file

@ -21,7 +21,8 @@ INSERT INTO reservations (
client_key_family,
client_key_index,
initiation_height,
protocol_version
protocol_version,
prepay_invoice
) VALUES (
$1,
$2,
@ -31,7 +32,8 @@ INSERT INTO reservations (
$6,
$7,
$8,
$9
$9,
$10
)
`
@ -45,6 +47,7 @@ type CreateReservationParams struct {
ClientKeyIndex int32
InitiationHeight int32
ProtocolVersion int32
PrepayInvoice string
}
func (q *Queries) CreateReservation(ctx context.Context, arg CreateReservationParams) error {
@ -58,13 +61,14 @@ func (q *Queries) CreateReservation(ctx context.Context, arg CreateReservationPa
arg.ClientKeyIndex,
arg.InitiationHeight,
arg.ProtocolVersion,
arg.PrepayInvoice,
)
return err
}
const getReservation = `-- name: GetReservation :one
SELECT
id, reservation_id, client_pubkey, server_pubkey, expiry, value, client_key_family, client_key_index, initiation_height, tx_hash, out_index, confirmation_height, protocol_version
id, reservation_id, client_pubkey, server_pubkey, expiry, value, client_key_family, client_key_index, initiation_height, tx_hash, out_index, confirmation_height, protocol_version, prepay_invoice
FROM
reservations
WHERE
@ -88,6 +92,7 @@ func (q *Queries) GetReservation(ctx context.Context, reservationID []byte) (Res
&i.OutIndex,
&i.ConfirmationHeight,
&i.ProtocolVersion,
&i.PrepayInvoice,
)
return i, err
}
@ -133,7 +138,7 @@ func (q *Queries) GetReservationUpdates(ctx context.Context, reservationID []byt
const getReservations = `-- name: GetReservations :many
SELECT
id, reservation_id, client_pubkey, server_pubkey, expiry, value, client_key_family, client_key_index, initiation_height, tx_hash, out_index, confirmation_height, protocol_version
id, reservation_id, client_pubkey, server_pubkey, expiry, value, client_key_family, client_key_index, initiation_height, tx_hash, out_index, confirmation_height, protocol_version, prepay_invoice
FROM
reservations
ORDER BY
@ -163,6 +168,7 @@ func (q *Queries) GetReservations(ctx context.Context) ([]Reservation, error) {
&i.OutIndex,
&i.ConfirmationHeight,
&i.ProtocolVersion,
&i.PrepayInvoice,
); err != nil {
return nil, err
}

File diff suppressed because it is too large Load diff

View file

@ -137,12 +137,25 @@ service SwapClient {
*/
rpc SuggestSwaps (SuggestSwapsRequest) returns (SuggestSwapsResponse);
/* loop: `listreservations`
/* loop: `reservations list`
ListReservations returns a list of all reservations the server opened to us.
*/
rpc ListReservations (ListReservationsRequest)
returns (ListReservationsResponse);
/* loop:`reservation request`
ReservationRequest requests a reservation from the server.
*/
rpc ReservationRequest (ReservationRequestRequest)
returns (ReservationRequestResponse);
/* loop:`reservation quote`
ReservationQuote returns a quote for a reservation with the provided
parameters.
*/
rpc ReservationQuote (ReservationQuoteRequest)
returns (ReservationQuoteResponse);
/* loop: `instantout`
InstantOut initiates an instant out swap with the given parameters.
*/
@ -1667,6 +1680,46 @@ message ClientReservation {
uint32 expiry = 6;
}
message ReservationRequestRequest {
/*
The amount to reserve in satoshis.
*/
uint64 amt = 1;
/*
The relative expiry of the reservation in blocks.
*/
uint32 expiry = 2;
/*
The maximum amt in satoshis we allow for the prepayment.
*/
uint64 max_prepay_amt = 3;
}
message ReservationRequestResponse {
ClientReservation reservation = 1;
}
message ReservationQuoteRequest {
/*
The amount to reserve in satoshis.
*/
uint64 amt = 1;
/*
The relative expiry of the reservation in blocks.
*/
uint32 expiry = 2;
}
message ReservationQuoteResponse {
/*
The prepay fee that will be charged for the reservation.
*/
uint64 prepay_amt = 1;
}
message InstantOutRequest {
/*
The reservations to use for the swap.
@ -1685,6 +1738,11 @@ message InstantOutRequest {
will be swept to the wallet's internal address.
*/
string dest_addr = 3;
/*
The maximum off-chain swap fee that may be charged for the swap.
*/
int64 max_swap_fee_sat = 4;
}
message InstantOutResponse {

View file

@ -130,7 +130,7 @@
},
"/v1/instantout/reservations": {
"get": {
"summary": "loop: `listreservations`\nListReservations returns a list of all reservations the server opened to us.",
"summary": "loop: `reservations list`\nListReservations returns a list of all reservations the server opened to us.",
"operationId": "SwapClient_ListReservations",
"responses": {
"200": {
@ -1917,6 +1917,11 @@
"dest_addr": {
"type": "string",
"description": "An optional address to sweep the onchain funds to. If not set, the funds\nwill be swept to the wallet's internal address."
},
"max_swap_fee_sat": {
"type": "string",
"format": "int64",
"description": "The maximum off-chain swap fee that may be charged for the swap."
}
}
},
@ -2616,6 +2621,24 @@
"type": "object",
"description": "PublishSucceeded is returned by SweepHtlc if publishing was requested in\nSweepHtlcRequest and it succeeded."
},
"looprpcReservationQuoteResponse": {
"type": "object",
"properties": {
"prepay_amt": {
"type": "string",
"format": "uint64",
"description": "The prepay fee that will be charged for the reservation."
}
}
},
"looprpcReservationRequestResponse": {
"type": "object",
"properties": {
"reservation": {
"$ref": "#/definitions/looprpcClientReservation"
}
}
},
"looprpcRouteHint": {
"type": "object",
"properties": {

View file

@ -99,9 +99,16 @@ type SwapClientClient interface {
// Note that only loop out suggestions are currently supported.
// [EXPERIMENTAL]: endpoint is subject to change.
SuggestSwaps(ctx context.Context, in *SuggestSwapsRequest, opts ...grpc.CallOption) (*SuggestSwapsResponse, error)
// loop: `listreservations`
// loop: `reservations list`
// ListReservations returns a list of all reservations the server opened to us.
ListReservations(ctx context.Context, in *ListReservationsRequest, opts ...grpc.CallOption) (*ListReservationsResponse, error)
// loop:`reservation request`
// ReservationRequest requests a reservation from the server.
ReservationRequest(ctx context.Context, in *ReservationRequestRequest, opts ...grpc.CallOption) (*ReservationRequestResponse, error)
// loop:`reservation quote`
// ReservationQuote returns a quote for a reservation with the provided
// parameters.
ReservationQuote(ctx context.Context, in *ReservationQuoteRequest, opts ...grpc.CallOption) (*ReservationQuoteResponse, error)
// loop: `instantout`
// InstantOut initiates an instant out swap with the given parameters.
InstantOut(ctx context.Context, in *InstantOutRequest, opts ...grpc.CallOption) (*InstantOutResponse, error)
@ -366,6 +373,24 @@ func (c *swapClientClient) ListReservations(ctx context.Context, in *ListReserva
return out, nil
}
func (c *swapClientClient) ReservationRequest(ctx context.Context, in *ReservationRequestRequest, opts ...grpc.CallOption) (*ReservationRequestResponse, error) {
out := new(ReservationRequestResponse)
err := c.cc.Invoke(ctx, "/looprpc.SwapClient/ReservationRequest", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *swapClientClient) ReservationQuote(ctx context.Context, in *ReservationQuoteRequest, opts ...grpc.CallOption) (*ReservationQuoteResponse, error) {
out := new(ReservationQuoteResponse)
err := c.cc.Invoke(ctx, "/looprpc.SwapClient/ReservationQuote", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *swapClientClient) InstantOut(ctx context.Context, in *InstantOutRequest, opts ...grpc.CallOption) (*InstantOutResponse, error) {
out := new(InstantOutResponse)
err := c.cc.Invoke(ctx, "/looprpc.SwapClient/InstantOut", in, out, opts...)
@ -559,9 +584,16 @@ type SwapClientServer interface {
// Note that only loop out suggestions are currently supported.
// [EXPERIMENTAL]: endpoint is subject to change.
SuggestSwaps(context.Context, *SuggestSwapsRequest) (*SuggestSwapsResponse, error)
// loop: `listreservations`
// loop: `reservations list`
// ListReservations returns a list of all reservations the server opened to us.
ListReservations(context.Context, *ListReservationsRequest) (*ListReservationsResponse, error)
// loop:`reservation request`
// ReservationRequest requests a reservation from the server.
ReservationRequest(context.Context, *ReservationRequestRequest) (*ReservationRequestResponse, error)
// loop:`reservation quote`
// ReservationQuote returns a quote for a reservation with the provided
// parameters.
ReservationQuote(context.Context, *ReservationQuoteRequest) (*ReservationQuoteResponse, error)
// loop: `instantout`
// InstantOut initiates an instant out swap with the given parameters.
InstantOut(context.Context, *InstantOutRequest) (*InstantOutResponse, error)
@ -674,6 +706,12 @@ func (UnimplementedSwapClientServer) SuggestSwaps(context.Context, *SuggestSwaps
func (UnimplementedSwapClientServer) ListReservations(context.Context, *ListReservationsRequest) (*ListReservationsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListReservations not implemented")
}
func (UnimplementedSwapClientServer) ReservationRequest(context.Context, *ReservationRequestRequest) (*ReservationRequestResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReservationRequest not implemented")
}
func (UnimplementedSwapClientServer) ReservationQuote(context.Context, *ReservationQuoteRequest) (*ReservationQuoteResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReservationQuote not implemented")
}
func (UnimplementedSwapClientServer) InstantOut(context.Context, *InstantOutRequest) (*InstantOutResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method InstantOut not implemented")
}
@ -1104,6 +1142,42 @@ func _SwapClient_ListReservations_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
func _SwapClient_ReservationRequest_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReservationRequestRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SwapClientServer).ReservationRequest(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/looprpc.SwapClient/ReservationRequest",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SwapClientServer).ReservationRequest(ctx, req.(*ReservationRequestRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SwapClient_ReservationQuote_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReservationQuoteRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SwapClientServer).ReservationQuote(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/looprpc.SwapClient/ReservationQuote",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SwapClientServer).ReservationQuote(ctx, req.(*ReservationQuoteRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SwapClient_InstantOut_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(InstantOutRequest)
if err := dec(in); err != nil {
@ -1407,6 +1481,14 @@ var SwapClient_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListReservations",
Handler: _SwapClient_ListReservations_Handler,
},
{
MethodName: "ReservationRequest",
Handler: _SwapClient_ReservationRequest_Handler,
},
{
MethodName: "ReservationQuote",
Handler: _SwapClient_ReservationQuote_Handler,
},
{
MethodName: "InstantOut",
Handler: _SwapClient_InstantOut_Handler,

View file

@ -177,18 +177,41 @@ var RequiredPermissions = map[string][]bakery.Op{
"/looprpc.SwapClient/ListReservations": {{
Entity: "swap",
Action: "read",
}, {
Entity: "loop",
Action: "out",
}},
"/looprpc.SwapClient/ReservationRequest": {{
Entity: "swap",
Action: "execute",
}, {
Entity: "loop",
Action: "out",
}},
"/looprpc.SwapClient/ReservationQuote": {{
Entity: "swap",
Action: "read",
}},
"/looprpc.SwapClient/InstantOut": {{
Entity: "swap",
Action: "execute",
}, {
Entity: "loop",
Action: "out",
}},
"/looprpc.SwapClient/InstantOutQuote": {{
Entity: "swap",
Action: "read",
}, {
Entity: "loop",
Action: "out",
}},
"/looprpc.SwapClient/ListInstantOuts": {{
Entity: "swap",
Action: "read",
}, {
Entity: "loop",
Action: "out",
}},
"/looprpc.SwapClient/StopDaemon": {{
Entity: "loop",

View file

@ -563,6 +563,56 @@ func RegisterSwapClientJSONCallbacks(registry map[string]func(ctx context.Contex
callback(string(respBytes), nil)
}
registry["looprpc.SwapClient.ReservationRequest"] = func(ctx context.Context,
conn *grpc.ClientConn, reqJSON string, callback func(string, error)) {
req := &ReservationRequestRequest{}
err := marshaler.Unmarshal([]byte(reqJSON), req)
if err != nil {
callback("", err)
return
}
client := NewSwapClientClient(conn)
resp, err := client.ReservationRequest(ctx, req)
if err != nil {
callback("", err)
return
}
respBytes, err := marshaler.Marshal(resp)
if err != nil {
callback("", err)
return
}
callback(string(respBytes), nil)
}
registry["looprpc.SwapClient.ReservationQuote"] = func(ctx context.Context,
conn *grpc.ClientConn, reqJSON string, callback func(string, error)) {
req := &ReservationQuoteRequest{}
err := marshaler.Unmarshal([]byte(reqJSON), req)
if err != nil {
callback("", err)
return
}
client := NewSwapClientClient(conn)
resp, err := client.ReservationQuote(ctx, req)
if err != nil {
callback("", err)
return
}
respBytes, err := marshaler.Marshal(resp)
if err != nil {
callback("", err)
return
}
callback(string(respBytes), nil)
}
registry["looprpc.SwapClient.InstantOut"] = func(ctx context.Context,
conn *grpc.ClientConn, reqJSON string, callback func(string, error)) {

View file

@ -301,6 +301,248 @@ func (*ServerOpenReservationResponse) Descriptor() ([]byte, []int) {
return file_reservation_proto_rawDescGZIP(), []int{3}
}
// RequestReservationRequest is a request sent from the client to the server to
// request a new reservation UTXO.
type RequestReservationRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// value is the value of the reservation in satoshis.
Value uint64 `protobuf:"varint,1,opt,name=value,proto3" json:"value,omitempty"`
// expiry is the relative expiry of the reservation.
Expiry uint32 `protobuf:"varint,2,opt,name=expiry,proto3" json:"expiry,omitempty"`
// client_key is the public key of the client.
ClientKey []byte `protobuf:"bytes,3,opt,name=client_key,json=clientKey,proto3" json:"client_key,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RequestReservationRequest) Reset() {
*x = RequestReservationRequest{}
mi := &file_reservation_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RequestReservationRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RequestReservationRequest) ProtoMessage() {}
func (x *RequestReservationRequest) ProtoReflect() protoreflect.Message {
mi := &file_reservation_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RequestReservationRequest.ProtoReflect.Descriptor instead.
func (*RequestReservationRequest) Descriptor() ([]byte, []int) {
return file_reservation_proto_rawDescGZIP(), []int{4}
}
func (x *RequestReservationRequest) GetValue() uint64 {
if x != nil {
return x.Value
}
return 0
}
func (x *RequestReservationRequest) GetExpiry() uint32 {
if x != nil {
return x.Expiry
}
return 0
}
func (x *RequestReservationRequest) GetClientKey() []byte {
if x != nil {
return x.ClientKey
}
return nil
}
// RequestReservationResponse is a response sent from the server to the client
// to confirm a reservation request.
type RequestReservationResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// reservation_id is the id of the reservation.
ReservationId []byte `protobuf:"bytes,1,opt,name=reservation_id,json=reservationId,proto3" json:"reservation_id,omitempty"`
// server_key is the public key of the server.
ServerKey []byte `protobuf:"bytes,2,opt,name=server_key,json=serverKey,proto3" json:"server_key,omitempty"`
// invoice is the invoice for the reservation that the client should pay.
Invoice string `protobuf:"bytes,3,opt,name=invoice,proto3" json:"invoice,omitempty"`
// expiry is the absolute expiry of the reservation.
Expiry uint32 `protobuf:"varint,4,opt,name=expiry,proto3" json:"expiry,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RequestReservationResponse) Reset() {
*x = RequestReservationResponse{}
mi := &file_reservation_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RequestReservationResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RequestReservationResponse) ProtoMessage() {}
func (x *RequestReservationResponse) ProtoReflect() protoreflect.Message {
mi := &file_reservation_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RequestReservationResponse.ProtoReflect.Descriptor instead.
func (*RequestReservationResponse) Descriptor() ([]byte, []int) {
return file_reservation_proto_rawDescGZIP(), []int{5}
}
func (x *RequestReservationResponse) GetReservationId() []byte {
if x != nil {
return x.ReservationId
}
return nil
}
func (x *RequestReservationResponse) GetServerKey() []byte {
if x != nil {
return x.ServerKey
}
return nil
}
func (x *RequestReservationResponse) GetInvoice() string {
if x != nil {
return x.Invoice
}
return ""
}
func (x *RequestReservationResponse) GetExpiry() uint32 {
if x != nil {
return x.Expiry
}
return 0
}
// QuoteReservationRequest is a request sent from the client to the server to
// request a quote for a reservation UTXO.
type QuoteReservationRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// value is the value of the reservation in satoshis.
Value uint64 `protobuf:"varint,1,opt,name=value,proto3" json:"value,omitempty"`
// expiry is the relative expiry of the reservation.
Expiry uint32 `protobuf:"varint,2,opt,name=expiry,proto3" json:"expiry,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *QuoteReservationRequest) Reset() {
*x = QuoteReservationRequest{}
mi := &file_reservation_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *QuoteReservationRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*QuoteReservationRequest) ProtoMessage() {}
func (x *QuoteReservationRequest) ProtoReflect() protoreflect.Message {
mi := &file_reservation_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use QuoteReservationRequest.ProtoReflect.Descriptor instead.
func (*QuoteReservationRequest) Descriptor() ([]byte, []int) {
return file_reservation_proto_rawDescGZIP(), []int{6}
}
func (x *QuoteReservationRequest) GetValue() uint64 {
if x != nil {
return x.Value
}
return 0
}
func (x *QuoteReservationRequest) GetExpiry() uint32 {
if x != nil {
return x.Expiry
}
return 0
}
// QuoteReservationResponse is a response sent from the server to the client to
// confirm a reservation quote request.
type QuoteReservationResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// prepay_cost is the cost of the prepay.
PrepayCost uint64 `protobuf:"varint,1,opt,name=prepay_cost,json=prepayCost,proto3" json:"prepay_cost,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *QuoteReservationResponse) Reset() {
*x = QuoteReservationResponse{}
mi := &file_reservation_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *QuoteReservationResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*QuoteReservationResponse) ProtoMessage() {}
func (x *QuoteReservationResponse) ProtoReflect() protoreflect.Message {
mi := &file_reservation_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use QuoteReservationResponse.ProtoReflect.Descriptor instead.
func (*QuoteReservationResponse) Descriptor() ([]byte, []int) {
return file_reservation_proto_rawDescGZIP(), []int{7}
}
func (x *QuoteReservationResponse) GetPrepayCost() uint64 {
if x != nil {
return x.PrepayCost
}
return 0
}
var File_reservation_proto protoreflect.FileDescriptor
const file_reservation_proto_rawDesc = "" +
@ -319,13 +561,32 @@ const file_reservation_proto_rawDesc = "" +
"\x0ereservation_id\x18\x01 \x01(\fR\rreservationId\x12\x1d\n" +
"\n" +
"client_key\x18\x02 \x01(\fR\tclientKey\"\x1f\n" +
"\x1dServerOpenReservationResponse*Q\n" +
"\x1dServerOpenReservationResponse\"h\n" +
"\x19RequestReservationRequest\x12\x14\n" +
"\x05value\x18\x01 \x01(\x04R\x05value\x12\x16\n" +
"\x06expiry\x18\x02 \x01(\rR\x06expiry\x12\x1d\n" +
"\n" +
"client_key\x18\x03 \x01(\fR\tclientKey\"\x94\x01\n" +
"\x1aRequestReservationResponse\x12%\n" +
"\x0ereservation_id\x18\x01 \x01(\fR\rreservationId\x12\x1d\n" +
"\n" +
"server_key\x18\x02 \x01(\fR\tserverKey\x12\x18\n" +
"\ainvoice\x18\x03 \x01(\tR\ainvoice\x12\x16\n" +
"\x06expiry\x18\x04 \x01(\rR\x06expiry\"G\n" +
"\x17QuoteReservationRequest\x12\x14\n" +
"\x05value\x18\x01 \x01(\x04R\x05value\x12\x16\n" +
"\x06expiry\x18\x02 \x01(\rR\x06expiry\";\n" +
"\x18QuoteReservationResponse\x12\x1f\n" +
"\vprepay_cost\x18\x01 \x01(\x04R\n" +
"prepayCost*Q\n" +
"\x1aReservationProtocolVersion\x12\x14\n" +
"\x10RESERVATION_NONE\x10\x00\x12\x1d\n" +
"\x19RESERVATION_SERVER_NOTIFY\x10\x012\xef\x01\n" +
"\x19RESERVATION_SERVER_NOTIFY\x10\x012\xa7\x03\n" +
"\x12ReservationService\x12w\n" +
"\x1dReservationNotificationStream\x12'.looprpc.ReservationNotificationRequest\x1a&.looprpc.ServerReservationNotification\"\x03\x88\x02\x010\x01\x12`\n" +
"\x0fOpenReservation\x12%.looprpc.ServerOpenReservationRequest\x1a&.looprpc.ServerOpenReservationResponseB-Z+github.com/lightninglabs/loop/swapserverrpcb\x06proto3"
"\x0fOpenReservation\x12%.looprpc.ServerOpenReservationRequest\x1a&.looprpc.ServerOpenReservationResponse\x12]\n" +
"\x12RequestReservation\x12\".looprpc.RequestReservationRequest\x1a#.looprpc.RequestReservationResponse\x12W\n" +
"\x10QuoteReservation\x12 .looprpc.QuoteReservationRequest\x1a!.looprpc.QuoteReservationResponseB-Z+github.com/lightninglabs/loop/swapserverrpcb\x06proto3"
var (
file_reservation_proto_rawDescOnce sync.Once
@ -340,23 +601,31 @@ func file_reservation_proto_rawDescGZIP() []byte {
}
var file_reservation_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_reservation_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_reservation_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
var file_reservation_proto_goTypes = []any{
(ReservationProtocolVersion)(0), // 0: looprpc.ReservationProtocolVersion
(*ReservationNotificationRequest)(nil), // 1: looprpc.ReservationNotificationRequest
(*ServerReservationNotification)(nil), // 2: looprpc.ServerReservationNotification
(*ServerOpenReservationRequest)(nil), // 3: looprpc.ServerOpenReservationRequest
(*ServerOpenReservationResponse)(nil), // 4: looprpc.ServerOpenReservationResponse
(*RequestReservationRequest)(nil), // 5: looprpc.RequestReservationRequest
(*RequestReservationResponse)(nil), // 6: looprpc.RequestReservationResponse
(*QuoteReservationRequest)(nil), // 7: looprpc.QuoteReservationRequest
(*QuoteReservationResponse)(nil), // 8: looprpc.QuoteReservationResponse
}
var file_reservation_proto_depIdxs = []int32{
0, // 0: looprpc.ReservationNotificationRequest.protocol_version:type_name -> looprpc.ReservationProtocolVersion
0, // 1: looprpc.ServerReservationNotification.protocol_version:type_name -> looprpc.ReservationProtocolVersion
1, // 2: looprpc.ReservationService.ReservationNotificationStream:input_type -> looprpc.ReservationNotificationRequest
3, // 3: looprpc.ReservationService.OpenReservation:input_type -> looprpc.ServerOpenReservationRequest
2, // 4: looprpc.ReservationService.ReservationNotificationStream:output_type -> looprpc.ServerReservationNotification
4, // 5: looprpc.ReservationService.OpenReservation:output_type -> looprpc.ServerOpenReservationResponse
4, // [4:6] is the sub-list for method output_type
2, // [2:4] is the sub-list for method input_type
5, // 4: looprpc.ReservationService.RequestReservation:input_type -> looprpc.RequestReservationRequest
7, // 5: looprpc.ReservationService.QuoteReservation:input_type -> looprpc.QuoteReservationRequest
2, // 6: looprpc.ReservationService.ReservationNotificationStream:output_type -> looprpc.ServerReservationNotification
4, // 7: looprpc.ReservationService.OpenReservation:output_type -> looprpc.ServerOpenReservationResponse
6, // 8: looprpc.ReservationService.RequestReservation:output_type -> looprpc.RequestReservationResponse
8, // 9: looprpc.ReservationService.QuoteReservation:output_type -> looprpc.QuoteReservationResponse
6, // [6:10] is the sub-list for method output_type
2, // [2:6] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
@ -373,7 +642,7 @@ func file_reservation_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_reservation_proto_rawDesc), len(file_reservation_proto_rawDesc)),
NumEnums: 1,
NumMessages: 4,
NumMessages: 8,
NumExtensions: 0,
NumServices: 1,
},

View file

@ -19,6 +19,14 @@ service ReservationService {
// OpenReservation requests a new reservation UTXO from the server.
rpc OpenReservation (ServerOpenReservationRequest)
returns (ServerOpenReservationResponse);
// RequestReservation requests a new reservation UTXO from the server.
rpc RequestReservation (RequestReservationRequest)
returns (RequestReservationResponse);
// QuoteReservation requests a quote for a reservation UTXO from the server.
rpc QuoteReservation (QuoteReservationRequest)
returns (QuoteReservationResponse);
}
// ReservationNotificationRequest is an empty request sent from the client to
@ -62,6 +70,51 @@ message ServerOpenReservationRequest {
message ServerOpenReservationResponse {
}
// RequestReservationRequest is a request sent from the client to the server to
// request a new reservation UTXO.
message RequestReservationRequest {
// value is the value of the reservation in satoshis.
uint64 value = 1;
// expiry is the relative expiry of the reservation.
uint32 expiry = 2;
// client_key is the public key of the client.
bytes client_key = 3;
}
// RequestReservationResponse is a response sent from the server to the client
// to confirm a reservation request.
message RequestReservationResponse {
// reservation_id is the id of the reservation.
bytes reservation_id = 1;
// server_key is the public key of the server.
bytes server_key = 2;
// invoice is the invoice for the reservation that the client should pay.
string invoice = 3;
// expiry is the absolute expiry of the reservation.
uint32 expiry = 4;
}
// QuoteReservationRequest is a request sent from the client to the server to
// request a quote for a reservation UTXO.
message QuoteReservationRequest {
// value is the value of the reservation in satoshis.
uint64 value = 1;
// expiry is the relative expiry of the reservation.
uint32 expiry = 2;
}
// QuoteReservationResponse is a response sent from the server to the client to
// confirm a reservation quote request.
message QuoteReservationResponse {
// prepay_cost is the cost of the prepay.
uint64 prepay_cost = 1;
}
// ReservationProtocolVersion is the version of the reservation protocol.
enum ReservationProtocolVersion {
// RESERVATION_NONE is the default value and means that the reservation

View file

@ -24,6 +24,10 @@ type ReservationServiceClient interface {
ReservationNotificationStream(ctx context.Context, in *ReservationNotificationRequest, opts ...grpc.CallOption) (ReservationService_ReservationNotificationStreamClient, error)
// OpenReservation requests a new reservation UTXO from the server.
OpenReservation(ctx context.Context, in *ServerOpenReservationRequest, opts ...grpc.CallOption) (*ServerOpenReservationResponse, error)
// RequestReservation requests a new reservation UTXO from the server.
RequestReservation(ctx context.Context, in *RequestReservationRequest, opts ...grpc.CallOption) (*RequestReservationResponse, error)
// QuoteReservation requests a quote for a reservation UTXO from the server.
QuoteReservation(ctx context.Context, in *QuoteReservationRequest, opts ...grpc.CallOption) (*QuoteReservationResponse, error)
}
type reservationServiceClient struct {
@ -76,6 +80,24 @@ func (c *reservationServiceClient) OpenReservation(ctx context.Context, in *Serv
return out, nil
}
func (c *reservationServiceClient) RequestReservation(ctx context.Context, in *RequestReservationRequest, opts ...grpc.CallOption) (*RequestReservationResponse, error) {
out := new(RequestReservationResponse)
err := c.cc.Invoke(ctx, "/looprpc.ReservationService/RequestReservation", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *reservationServiceClient) QuoteReservation(ctx context.Context, in *QuoteReservationRequest, opts ...grpc.CallOption) (*QuoteReservationResponse, error) {
out := new(QuoteReservationResponse)
err := c.cc.Invoke(ctx, "/looprpc.ReservationService/QuoteReservation", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// ReservationServiceServer is the server API for ReservationService service.
// All implementations must embed UnimplementedReservationServiceServer
// for forward compatibility
@ -86,6 +108,10 @@ type ReservationServiceServer interface {
ReservationNotificationStream(*ReservationNotificationRequest, ReservationService_ReservationNotificationStreamServer) error
// OpenReservation requests a new reservation UTXO from the server.
OpenReservation(context.Context, *ServerOpenReservationRequest) (*ServerOpenReservationResponse, error)
// RequestReservation requests a new reservation UTXO from the server.
RequestReservation(context.Context, *RequestReservationRequest) (*RequestReservationResponse, error)
// QuoteReservation requests a quote for a reservation UTXO from the server.
QuoteReservation(context.Context, *QuoteReservationRequest) (*QuoteReservationResponse, error)
mustEmbedUnimplementedReservationServiceServer()
}
@ -99,6 +125,12 @@ func (UnimplementedReservationServiceServer) ReservationNotificationStream(*Rese
func (UnimplementedReservationServiceServer) OpenReservation(context.Context, *ServerOpenReservationRequest) (*ServerOpenReservationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method OpenReservation not implemented")
}
func (UnimplementedReservationServiceServer) RequestReservation(context.Context, *RequestReservationRequest) (*RequestReservationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RequestReservation not implemented")
}
func (UnimplementedReservationServiceServer) QuoteReservation(context.Context, *QuoteReservationRequest) (*QuoteReservationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method QuoteReservation not implemented")
}
func (UnimplementedReservationServiceServer) mustEmbedUnimplementedReservationServiceServer() {}
// UnsafeReservationServiceServer may be embedded to opt out of forward compatibility for this service.
@ -151,6 +183,42 @@ func _ReservationService_OpenReservation_Handler(srv interface{}, ctx context.Co
return interceptor(ctx, in, info, handler)
}
func _ReservationService_RequestReservation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RequestReservationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ReservationServiceServer).RequestReservation(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/looprpc.ReservationService/RequestReservation",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ReservationServiceServer).RequestReservation(ctx, req.(*RequestReservationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ReservationService_QuoteReservation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(QuoteReservationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ReservationServiceServer).QuoteReservation(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/looprpc.ReservationService/QuoteReservation",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ReservationServiceServer).QuoteReservation(ctx, req.(*QuoteReservationRequest))
}
return interceptor(ctx, in, info, handler)
}
// ReservationService_ServiceDesc is the grpc.ServiceDesc for ReservationService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -162,6 +230,14 @@ var ReservationService_ServiceDesc = grpc.ServiceDesc{
MethodName: "OpenReservation",
Handler: _ReservationService_OpenReservation_Handler,
},
{
MethodName: "RequestReservation",
Handler: _ReservationService_RequestReservation_Handler,
},
{
MethodName: "QuoteReservation",
Handler: _ReservationService_QuoteReservation_Handler,
},
},
Streams: []grpc.StreamDesc{
{