mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Use each channel's local balance minus its reserve when simulating Loop Out payment bandwidth, clamping the result at zero. This prevents swaps from starting when the gross balance covers the amount and routing fee but the spendable balance does not. Add one-sat boundary tests and a bug-fix release note. Leave a TODO for server-fee and prepay capacity because enforcing their maximum fee caps could reject viable swaps whose actual routing fees are lower.
3209 lines
89 KiB
Go
3209 lines
89 KiB
Go
package loopd
|
|
|
|
import (
|
|
"bytes"
|
|
"cmp"
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcd/btcec/v2"
|
|
"github.com/btcsuite/btcd/btcutil"
|
|
"github.com/btcsuite/btcd/chaincfg"
|
|
"github.com/btcsuite/btcd/wire"
|
|
"github.com/lightninglabs/aperture/l402"
|
|
"github.com/lightninglabs/lndclient"
|
|
"github.com/lightninglabs/loop"
|
|
"github.com/lightninglabs/loop/assets"
|
|
"github.com/lightninglabs/loop/fsm"
|
|
"github.com/lightninglabs/loop/instantout"
|
|
"github.com/lightninglabs/loop/instantout/reservation"
|
|
"github.com/lightninglabs/loop/labels"
|
|
"github.com/lightninglabs/loop/liquidity"
|
|
"github.com/lightninglabs/loop/loopdb"
|
|
"github.com/lightninglabs/loop/looprpc"
|
|
"github.com/lightninglabs/loop/staticaddr/address"
|
|
"github.com/lightninglabs/loop/staticaddr/deposit"
|
|
"github.com/lightninglabs/loop/staticaddr/loopin"
|
|
"github.com/lightninglabs/loop/staticaddr/openchannel"
|
|
"github.com/lightninglabs/loop/staticaddr/staticutil"
|
|
"github.com/lightninglabs/loop/staticaddr/withdraw"
|
|
"github.com/lightninglabs/loop/swap"
|
|
"github.com/lightninglabs/loop/swapserverrpc"
|
|
"github.com/lightninglabs/taproot-assets/rfqmath"
|
|
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"github.com/lightningnetwork/lnd/queue"
|
|
"github.com/lightningnetwork/lnd/routing/route"
|
|
"github.com/lightningnetwork/lnd/zpay32"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
const (
|
|
completedSwapsCount = 5
|
|
|
|
// minConfTarget is the minimum confirmation target we'll allow clients
|
|
// to specify. This is driven by the minimum confirmation target allowed
|
|
// by the backing fee estimator.
|
|
minConfTarget = 2
|
|
|
|
defaultLoopdInitiator = "loopd"
|
|
)
|
|
|
|
var (
|
|
// errIncorrectChain is returned when the format of the
|
|
// destination address provided does not match the active chain.
|
|
errIncorrectChain = errors.New("invalid address format for the " +
|
|
"active chain")
|
|
|
|
// errConfTargetTooLow is returned when the chosen confirmation target
|
|
// is below the allowed minimum.
|
|
errConfTargetTooLow = errors.New("confirmation target too low")
|
|
|
|
// errBalanceTooLow is returned when the loop out amount can't be
|
|
// satisfied given total balance of the selection of channels to loop
|
|
// out on.
|
|
errBalanceTooLow = errors.New(
|
|
"channel balance too low for loop out amount",
|
|
)
|
|
|
|
// errInvalidAddress is returned when the destination address is of
|
|
// an unsupported format such as P2PK or P2TR addresses.
|
|
errInvalidAddress = errors.New(
|
|
"invalid or unsupported address",
|
|
)
|
|
)
|
|
|
|
// swapClientServer implements the grpc service exposed by loopd.
|
|
type swapClientServer struct {
|
|
// Required by the grpc-gateway/v2 library for forward compatibility.
|
|
looprpc.UnimplementedSwapClientServer
|
|
looprpc.UnimplementedDebugServer
|
|
|
|
config *Config
|
|
network lndclient.Network
|
|
impl *loop.Client
|
|
liquidityMgr *liquidity.Manager
|
|
lnd *lndclient.LndServices
|
|
reservationManager *reservation.Manager
|
|
instantOutManager *instantout.Manager
|
|
staticAddressManager *address.Manager
|
|
depositManager staticAddressDepositManager
|
|
withdrawalManager *withdraw.Manager
|
|
staticLoopInManager *loopin.Manager
|
|
openChannelManager *openchannel.Manager
|
|
assetClient *assets.TapdClient
|
|
swaps map[lntypes.Hash]loop.SwapInfo
|
|
subscribers map[int]chan<- any
|
|
statusChan chan loop.SwapInfo
|
|
nextSubscriberID int
|
|
swapsLock sync.Mutex
|
|
mainCtx context.Context
|
|
|
|
// stopDaemon is invoked to trigger a graceful shutdown of the daemon.
|
|
stopDaemon func()
|
|
}
|
|
|
|
// staticAddressDepositManager is the deposit manager behavior required by the
|
|
// RPC server.
|
|
type staticAddressDepositManager interface {
|
|
// EnsureDepositsFresh reconciles tracked deposits with lnd's current
|
|
// wallet view before user-facing deposit selection.
|
|
EnsureDepositsFresh(context.Context) error
|
|
|
|
// GetActiveDepositsInState returns active deposits that are currently in
|
|
// the requested state.
|
|
GetActiveDepositsInState(fsm.StateType) ([]*deposit.Deposit, error)
|
|
|
|
// DepositsForOutpoints returns known deposit records for the requested
|
|
// outpoints, optionally skipping unknown outpoints.
|
|
DepositsForOutpoints(context.Context, []string, bool) (
|
|
[]*deposit.Deposit, error)
|
|
|
|
// GetVisibleDeposits returns deposits that should be shown in normal
|
|
// user-facing views.
|
|
GetVisibleDeposits(context.Context) ([]*deposit.Deposit, error)
|
|
|
|
// GetAllDeposits returns all known deposit records, including historical
|
|
// records that are no longer user-visible.
|
|
GetAllDeposits(context.Context) ([]*deposit.Deposit, error)
|
|
}
|
|
|
|
// LoopOut initiates a loop out swap with the given parameters. The call returns
|
|
// after the swap has been set up with the swap server. From that point onwards,
|
|
// progress can be tracked via the LoopOutStatus stream that is returned from
|
|
// Monitor().
|
|
func (s *swapClientServer) LoopOut(ctx context.Context,
|
|
in *looprpc.LoopOutRequest) (
|
|
*looprpc.SwapResponse, error) {
|
|
|
|
infof("Loop out request received")
|
|
|
|
// Note that LoopOutRequest.PaymentTimeout is unsigned and therefore
|
|
// cannot be negative.
|
|
paymentTimeout := time.Duration(in.PaymentTimeout) * time.Second
|
|
|
|
// Make sure we don't exceed the total allowed payment timeout.
|
|
if paymentTimeout > s.config.TotalPaymentTimeout {
|
|
return nil, fmt.Errorf("payment timeout %v exceeds maximum "+
|
|
"allowed timeout of %v", paymentTimeout,
|
|
s.config.TotalPaymentTimeout)
|
|
}
|
|
|
|
var sweepAddr btcutil.Address
|
|
var isExternalAddr bool
|
|
var err error
|
|
//nolint:lll
|
|
switch {
|
|
case in.Dest != "" && in.Account != "":
|
|
return nil, fmt.Errorf("destination address and external " +
|
|
"account address cannot be set at the same time")
|
|
|
|
case in.Dest != "":
|
|
// Decode the client provided destination address for the loop
|
|
// out sweep.
|
|
sweepAddr, err = btcutil.DecodeAddress(
|
|
in.Dest, s.lnd.ChainParams,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode address: %v", err)
|
|
}
|
|
|
|
isExternalAddr = true
|
|
|
|
case in.Account != "" && in.AccountAddrType == looprpc.AddressType_ADDRESS_TYPE_UNKNOWN:
|
|
return nil, liquidity.ErrAccountAndAddrType
|
|
|
|
case in.Account != "":
|
|
// Derive a new receiving address from the stated account.
|
|
addrType, err := toWalletAddrType(in.AccountAddrType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check if account with address type exists.
|
|
if !s.accountExists(ctx, in.Account, addrType) {
|
|
return nil, fmt.Errorf("the provided account does " +
|
|
"not exist")
|
|
}
|
|
|
|
sweepAddr, err = s.lnd.WalletKit.NextAddr(
|
|
ctx, in.Account, addrType, false,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("NextAddr from account error: "+
|
|
"%v", err)
|
|
}
|
|
|
|
isExternalAddr = true
|
|
|
|
default:
|
|
// Generate sweep address if none specified.
|
|
sweepAddr, err = s.lnd.WalletKit.NextAddr(
|
|
context.Background(), "",
|
|
walletrpc.AddressType_WITNESS_PUBKEY_HASH, false,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("NextAddr error: %v", err)
|
|
}
|
|
}
|
|
|
|
sweepConfTarget, err := validateLoopOutRequest(
|
|
ctx, s.lnd.Client, s.lnd.ChainParams, in, sweepAddr,
|
|
s.impl.LoopOutMaxParts,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Infer if the publication deadline is set in milliseconds.
|
|
publicationDeadline := getPublicationDeadline(in.SwapPublicationDeadline)
|
|
|
|
req := &loop.OutRequest{
|
|
Amount: btcutil.Amount(in.Amt),
|
|
DestAddr: sweepAddr,
|
|
IsExternalAddr: isExternalAddr,
|
|
MaxMinerFee: btcutil.Amount(in.MaxMinerFee),
|
|
MaxPrepayAmount: btcutil.Amount(in.MaxPrepayAmt),
|
|
MaxPrepayRoutingFee: btcutil.Amount(in.MaxPrepayRoutingFee),
|
|
MaxSwapRoutingFee: btcutil.Amount(in.MaxSwapRoutingFee),
|
|
MaxSwapFee: btcutil.Amount(in.MaxSwapFee),
|
|
SweepConfTarget: sweepConfTarget,
|
|
HtlcConfirmations: in.HtlcConfirmations,
|
|
SwapPublicationDeadline: publicationDeadline,
|
|
Label: in.Label,
|
|
Initiator: in.Initiator,
|
|
PaymentTimeout: paymentTimeout,
|
|
}
|
|
|
|
// If the asset id is set, we need to set the asset amount and asset id
|
|
// in the request.
|
|
if in.AssetInfo != nil {
|
|
if len(in.AssetInfo.AssetId) != 0 &&
|
|
len(in.AssetInfo.AssetId) != 32 {
|
|
|
|
return nil, fmt.Errorf(
|
|
"asset id must be set to a 32 byte value",
|
|
)
|
|
}
|
|
|
|
if len(in.AssetRfqInfo.PrepayRfqId) != 0 &&
|
|
len(in.AssetRfqInfo.PrepayRfqId) != 32 {
|
|
|
|
return nil, fmt.Errorf(
|
|
"prepay rfq id must be set to a 32 byte value",
|
|
)
|
|
}
|
|
|
|
if len(in.AssetRfqInfo.SwapRfqId) != 0 &&
|
|
len(in.AssetRfqInfo.SwapRfqId) != 32 {
|
|
|
|
return nil, fmt.Errorf(
|
|
"swap rfq id must be set to a 32 byte value",
|
|
)
|
|
}
|
|
|
|
req.AssetId = in.AssetInfo.AssetId
|
|
req.AssetPrepayRfqId = in.AssetRfqInfo.PrepayRfqId
|
|
req.AssetSwapRfqId = in.AssetRfqInfo.SwapRfqId
|
|
}
|
|
|
|
// Keep accepting the deprecated single-channel field for older clients.
|
|
switch {
|
|
case in.LoopOutChannel != 0 && len(in.OutgoingChanSet) > 0: // nolint:staticcheck
|
|
return nil, errors.New("loop_out_channel and outgoing_" +
|
|
"chan_ids are mutually exclusive")
|
|
|
|
case in.LoopOutChannel != 0: // nolint:staticcheck
|
|
req.OutgoingChanSet = loopdb.ChannelSet{in.LoopOutChannel} // nolint:staticcheck
|
|
|
|
default:
|
|
req.OutgoingChanSet = in.OutgoingChanSet
|
|
}
|
|
|
|
info, err := s.impl.LoopOut(ctx, req)
|
|
if err != nil {
|
|
errorf("LoopOut: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
htlcAddress := info.HtlcAddress.String()
|
|
resp := &looprpc.SwapResponse{
|
|
Id: info.SwapHash.String(),
|
|
IdBytes: info.SwapHash[:],
|
|
HtlcAddress: htlcAddress, //nolint:staticcheck
|
|
ServerMessage: info.ServerMessage,
|
|
}
|
|
|
|
if loopdb.CurrentProtocolVersion() < loopdb.ProtocolVersionHtlcV3 {
|
|
resp.HtlcAddressP2Wsh = htlcAddress
|
|
} else {
|
|
resp.HtlcAddressP2Tr = htlcAddress
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// accountExists returns true if account under the address type exists in the
|
|
// backing lnd instance and false otherwise.
|
|
func (s *swapClientServer) accountExists(ctx context.Context, account string,
|
|
addrType walletrpc.AddressType) bool {
|
|
|
|
accounts, err := s.lnd.WalletKit.ListAccounts(ctx, account, addrType)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
for _, a := range accounts {
|
|
if a.Name == account {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func toWalletAddrType(addrType looprpc.AddressType) (walletrpc.AddressType,
|
|
error) {
|
|
|
|
switch addrType {
|
|
case looprpc.AddressType_TAPROOT_PUBKEY:
|
|
return walletrpc.AddressType_TAPROOT_PUBKEY, nil
|
|
|
|
default:
|
|
return walletrpc.AddressType_UNKNOWN,
|
|
fmt.Errorf("unknown address type")
|
|
}
|
|
}
|
|
|
|
func (s *swapClientServer) marshallSwap(ctx context.Context,
|
|
loopSwap *loop.SwapInfo) (*looprpc.SwapStatus, error) {
|
|
|
|
var (
|
|
state looprpc.SwapState
|
|
failureReason = looprpc.FailureReason_FAILURE_REASON_NONE
|
|
)
|
|
|
|
// Set our state var for non-failure states. If we get a failure, we
|
|
// will update our failure reason. To remain backwards compatible with
|
|
// previous versions where we squashed all failure reasons to a single
|
|
// failure state, we set a failure reason for all our different failure
|
|
// states, and set our failed state for all of them.
|
|
switch loopSwap.State {
|
|
case loopdb.StateInitiated:
|
|
state = looprpc.SwapState_INITIATED
|
|
|
|
case loopdb.StatePreimageRevealed:
|
|
state = looprpc.SwapState_PREIMAGE_REVEALED
|
|
|
|
case loopdb.StateHtlcPublished:
|
|
state = looprpc.SwapState_HTLC_PUBLISHED
|
|
|
|
case loopdb.StateInvoiceSettled:
|
|
state = looprpc.SwapState_INVOICE_SETTLED
|
|
|
|
case loopdb.StateSuccess:
|
|
state = looprpc.SwapState_SUCCESS
|
|
|
|
case loopdb.StateFailOffchainPayments:
|
|
failureReason = looprpc.FailureReason_FAILURE_REASON_OFFCHAIN
|
|
|
|
case loopdb.StateFailTimeout:
|
|
failureReason = looprpc.FailureReason_FAILURE_REASON_TIMEOUT
|
|
|
|
case loopdb.StateFailSweepTimeout:
|
|
failureReason = looprpc.FailureReason_FAILURE_REASON_SWEEP_TIMEOUT
|
|
|
|
case loopdb.StateFailInsufficientValue:
|
|
failureReason = looprpc.FailureReason_FAILURE_REASON_INSUFFICIENT_VALUE
|
|
|
|
case loopdb.StateFailTemporary:
|
|
failureReason = looprpc.FailureReason_FAILURE_REASON_TEMPORARY
|
|
|
|
case loopdb.StateFailIncorrectHtlcAmt:
|
|
failureReason = looprpc.FailureReason_FAILURE_REASON_INCORRECT_AMOUNT
|
|
|
|
case loopdb.StateFailAbandoned:
|
|
failureReason = looprpc.FailureReason_FAILURE_REASON_ABANDONED
|
|
|
|
case loopdb.StateFailInsufficientConfirmedBalance:
|
|
failureReason = looprpc.FailureReason_FAILURE_REASON_INSUFFICIENT_CONFIRMED_BALANCE
|
|
|
|
case loopdb.StateFailIncorrectHtlcAmtSwept:
|
|
failureReason = looprpc.FailureReason_FAILURE_REASON_INCORRECT_HTLC_AMT_SWEPT
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unknown swap state: %v", loopSwap.State)
|
|
}
|
|
|
|
// If we have a failure reason, we have a failure state, so should use
|
|
// our catchall failed state.
|
|
if failureReason != looprpc.FailureReason_FAILURE_REASON_NONE {
|
|
state = looprpc.SwapState_FAILED
|
|
}
|
|
|
|
var swapType looprpc.SwapType
|
|
staticLoopInState := looprpc.
|
|
StaticAddressLoopInSwapState_UNKNOWN_STATIC_ADDRESS_SWAP_STATE
|
|
var (
|
|
htlcAddress string
|
|
htlcAddressP2TR string
|
|
htlcAddressP2WSH string
|
|
)
|
|
var outGoingChanSet []uint64
|
|
var lastHop []byte
|
|
var assetInfo *looprpc.AssetLoopOutInfo
|
|
|
|
switch loopSwap.SwapType {
|
|
case swap.TypeIn:
|
|
swapType = looprpc.SwapType_LOOP_IN
|
|
|
|
if loopSwap.HtlcAddressP2TR != nil {
|
|
htlcAddressP2TR = loopSwap.HtlcAddressP2TR.EncodeAddress()
|
|
htlcAddress = htlcAddressP2TR
|
|
} else {
|
|
htlcAddressP2WSH =
|
|
loopSwap.HtlcAddressP2WSH.EncodeAddress()
|
|
htlcAddress = htlcAddressP2WSH
|
|
}
|
|
|
|
if loopSwap.LastHop != nil {
|
|
lastHop = loopSwap.LastHop[:]
|
|
}
|
|
|
|
case swap.TypeStaticAddressLoopIn:
|
|
// Static loop-ins surface their precise FSM state through the
|
|
// optional oneof and keep the reconstructed HTLC P2WSH address,
|
|
// not the reusable static address.
|
|
swapType = looprpc.SwapType_STATIC_LOOP_IN
|
|
staticLoopInState = toClientStaticAddressLoopInState(
|
|
loopSwap.StaticAddressLoopInState,
|
|
)
|
|
|
|
if loopSwap.HtlcAddressP2WSH == nil {
|
|
return nil, errors.New(
|
|
"missing static address loop-in P2WSH HTLC address",
|
|
)
|
|
}
|
|
htlcAddressP2WSH = loopSwap.HtlcAddressP2WSH.EncodeAddress()
|
|
htlcAddress = htlcAddressP2WSH
|
|
|
|
if loopSwap.LastHop != nil {
|
|
lastHop = loopSwap.LastHop[:]
|
|
}
|
|
|
|
case swap.TypeOut:
|
|
swapType = looprpc.SwapType_LOOP_OUT
|
|
if loopSwap.HtlcAddressP2WSH != nil {
|
|
htlcAddressP2WSH = loopSwap.HtlcAddressP2WSH.EncodeAddress()
|
|
htlcAddress = htlcAddressP2WSH
|
|
} else {
|
|
htlcAddressP2TR = loopSwap.HtlcAddressP2TR.EncodeAddress()
|
|
htlcAddress = htlcAddressP2TR
|
|
}
|
|
|
|
outGoingChanSet = loopSwap.OutgoingChanSet
|
|
|
|
if loopSwap.AssetSwapInfo != nil {
|
|
var (
|
|
// Default the asset name to "N/A" in case we
|
|
// can't fetch it due to the asset client not
|
|
// being set.
|
|
assetName string = "N/A"
|
|
err error
|
|
)
|
|
if s.assetClient != nil {
|
|
assetName, err = s.assetClient.GetAssetName(
|
|
ctx, loopSwap.AssetSwapInfo.AssetId,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
assetInfo = &looprpc.AssetLoopOutInfo{
|
|
AssetId: hex.EncodeToString(loopSwap.AssetSwapInfo.AssetId), // nolint:lll
|
|
AssetCostOffchain: loopSwap.AssetSwapInfo.PrepayPaidAmt +
|
|
loopSwap.AssetSwapInfo.SwapPaidAmt, // nolint:lll
|
|
AssetName: assetName,
|
|
}
|
|
}
|
|
|
|
default:
|
|
return nil, errors.New("unknown swap type")
|
|
}
|
|
|
|
rpcSwap := &looprpc.SwapStatus{
|
|
Amt: int64(loopSwap.AmountRequested),
|
|
Id: loopSwap.SwapHash.String(),
|
|
IdBytes: loopSwap.SwapHash[:],
|
|
State: state,
|
|
FailureReason: failureReason,
|
|
InitiationTime: loopSwap.InitiationTime.UnixNano(),
|
|
LastUpdateTime: loopSwap.LastUpdate.UnixNano(),
|
|
HtlcAddress: htlcAddress,
|
|
HtlcAddressP2Tr: htlcAddressP2TR,
|
|
HtlcAddressP2Wsh: htlcAddressP2WSH,
|
|
Type: swapType,
|
|
CostServer: int64(loopSwap.Cost.Server),
|
|
CostOnchain: int64(loopSwap.Cost.Onchain),
|
|
CostOffchain: int64(loopSwap.Cost.Offchain),
|
|
Label: loopSwap.Label,
|
|
LastHop: lastHop,
|
|
OutgoingChanSet: outGoingChanSet,
|
|
AssetInfo: assetInfo,
|
|
}
|
|
if swapType == looprpc.SwapType_STATIC_LOOP_IN {
|
|
rpcSwap.StaticLoopInStateOptional =
|
|
&looprpc.SwapStatus_StaticLoopInState{
|
|
StaticLoopInState: staticLoopInState,
|
|
}
|
|
}
|
|
|
|
return rpcSwap, nil
|
|
}
|
|
|
|
// Monitor will return a stream of swap updates for currently active swaps.
|
|
func (s *swapClientServer) Monitor(in *looprpc.MonitorRequest,
|
|
server looprpc.SwapClient_MonitorServer) error {
|
|
|
|
infof("Monitor request received")
|
|
|
|
send := func(info loop.SwapInfo) error {
|
|
rpcSwap, err := s.marshallSwap(server.Context(), &info)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return server.Send(rpcSwap)
|
|
}
|
|
|
|
// Start a notification queue for this subscriber.
|
|
queue := queue.NewConcurrentQueue(20)
|
|
queue.Start()
|
|
ctx := server.Context()
|
|
|
|
s.swapsLock.Lock()
|
|
|
|
id := s.nextSubscriberID
|
|
s.nextSubscriberID++
|
|
s.subscribers[id] = queue.ChanIn()
|
|
pendingSwaps, completedSwaps := s.monitorCachedSwaps()
|
|
s.swapsLock.Unlock()
|
|
|
|
err := s.appendStaticAddressLoopInMonitorSnapshot(
|
|
ctx, &pendingSwaps, &completedSwaps,
|
|
)
|
|
if err != nil {
|
|
s.swapsLock.Lock()
|
|
delete(s.subscribers, id)
|
|
s.swapsLock.Unlock()
|
|
queue.Stop()
|
|
|
|
return err
|
|
}
|
|
|
|
defer func() {
|
|
s.swapsLock.Lock()
|
|
delete(s.subscribers, id)
|
|
s.swapsLock.Unlock()
|
|
queue.Stop()
|
|
}()
|
|
|
|
// Sort completed swaps new to old.
|
|
sort.Slice(completedSwaps, func(i, j int) bool {
|
|
return completedSwaps[i].LastUpdate.After(
|
|
completedSwaps[j].LastUpdate,
|
|
)
|
|
})
|
|
|
|
// Discard all but top x latest.
|
|
if len(completedSwaps) > completedSwapsCount {
|
|
completedSwaps = completedSwaps[:completedSwapsCount]
|
|
}
|
|
|
|
// Concatenate both sets.
|
|
filteredSwaps := append(pendingSwaps, completedSwaps...) // nolint: gocritic
|
|
|
|
// Sort again, but this time old to new.
|
|
sort.Slice(filteredSwaps, func(i, j int) bool {
|
|
return filteredSwaps[i].LastUpdate.Before(
|
|
filteredSwaps[j].LastUpdate,
|
|
)
|
|
})
|
|
|
|
// Static-address loop-in updates can arrive from both the initial snapshot
|
|
// and the live queue. Build a high-water mark from the snapshot so we can
|
|
// suppress stale duplicate snapshot items without dropping newer live ones.
|
|
staticSnapshotHighWater := staticAddressLoopInMonitorHighWater(
|
|
filteredSwaps,
|
|
)
|
|
|
|
// Return swaps to caller.
|
|
for _, swap := range filteredSwaps {
|
|
if err := send(swap); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// As long as the client is connected, keep passing through swap
|
|
// updates.
|
|
for {
|
|
select {
|
|
case queueItem, ok := <-queue.ChanOut():
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
swap := queueItem.(loop.SwapInfo)
|
|
if isInitialStaticAddressLoopInStale(
|
|
staticSnapshotHighWater, swap,
|
|
) {
|
|
|
|
continue
|
|
}
|
|
|
|
if err := send(swap); err != nil {
|
|
return err
|
|
}
|
|
|
|
// The client cancels the subscription.
|
|
case <-server.Context().Done():
|
|
return nil
|
|
|
|
// The server is shutting down.
|
|
case <-s.mainCtx.Done():
|
|
return fmt.Errorf("server is shutting down")
|
|
}
|
|
}
|
|
}
|
|
|
|
// staticAddressLoopInMonitorHighWater records the latest snapshot item for each
|
|
// static-address loop-in swap hash.
|
|
func staticAddressLoopInMonitorHighWater(
|
|
swaps []loop.SwapInfo) map[lntypes.Hash]staticAddressLoopInHighWater {
|
|
|
|
highWater := make(map[lntypes.Hash]staticAddressLoopInHighWater)
|
|
for _, swp := range swaps {
|
|
if swp.SwapType != swap.TypeStaticAddressLoopIn {
|
|
continue
|
|
}
|
|
|
|
current, ok := highWater[swp.SwapHash]
|
|
if !ok || swp.LastUpdate.After(current.lastUpdate) {
|
|
highWater[swp.SwapHash] = staticAddressLoopInHighWater{
|
|
lastUpdate: swp.LastUpdate,
|
|
staticState: swp.StaticAddressLoopInState,
|
|
}
|
|
}
|
|
}
|
|
|
|
return highWater
|
|
}
|
|
|
|
// staticAddressLoopInHighWater stores the most recent snapshot timestamp and
|
|
// state for one static-address loop-in swap.
|
|
type staticAddressLoopInHighWater struct {
|
|
lastUpdate time.Time
|
|
staticState fsm.StateType
|
|
}
|
|
|
|
// isInitialStaticAddressLoopInStale reports whether a live static-address
|
|
// loop-in update is older than the snapshot copy already sent, or equal to it
|
|
// with the same static FSM state.
|
|
func isInitialStaticAddressLoopInStale(
|
|
highWater map[lntypes.Hash]staticAddressLoopInHighWater,
|
|
swp loop.SwapInfo) bool {
|
|
|
|
if swp.SwapType != swap.TypeStaticAddressLoopIn {
|
|
return false
|
|
}
|
|
current, ok := highWater[swp.SwapHash]
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
if swp.LastUpdate.Before(current.lastUpdate) {
|
|
return true
|
|
}
|
|
if swp.LastUpdate.After(current.lastUpdate) {
|
|
return false
|
|
}
|
|
|
|
// Equal timestamps can race the initial DB snapshot, so match state too.
|
|
return swp.StaticAddressLoopInState == current.staticState
|
|
}
|
|
|
|
// monitorCachedSwaps returns the current in-memory swaps split into pending and
|
|
// completed slices for monitor snapshot construction.
|
|
func (s *swapClientServer) monitorCachedSwaps() ([]loop.SwapInfo,
|
|
[]loop.SwapInfo) {
|
|
|
|
var pendingSwaps, completedSwaps []loop.SwapInfo
|
|
for _, swap := range s.swaps {
|
|
if swap.State.Type() == loopdb.StateTypePending {
|
|
pendingSwaps = append(pendingSwaps, swap)
|
|
} else {
|
|
completedSwaps = append(completedSwaps, swap)
|
|
}
|
|
}
|
|
|
|
return pendingSwaps, completedSwaps
|
|
}
|
|
|
|
// appendStaticAddressLoopInMonitorSnapshot appends the current static-address
|
|
// loop-in swaps to the monitor snapshot.
|
|
func (s *swapClientServer) appendStaticAddressLoopInMonitorSnapshot(
|
|
ctx context.Context, pendingSwaps, completedSwaps *[]loop.SwapInfo) error {
|
|
|
|
staticSwaps, err := s.staticAddressLoopInSwapInfos(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, swap := range staticSwaps {
|
|
if slices.Contains(
|
|
loopin.FinalStates, swap.StaticAddressLoopInState,
|
|
) {
|
|
|
|
*completedSwaps = append(*completedSwaps, *swap)
|
|
} else {
|
|
*pendingSwaps = append(*pendingSwaps, *swap)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListSwaps returns a list of all currently known swaps and their current
|
|
// status.
|
|
func (s *swapClientServer) ListSwaps(ctx context.Context,
|
|
req *looprpc.ListSwapsRequest) (*looprpc.ListSwapsResponse, error) {
|
|
|
|
var (
|
|
rpcSwaps = []*looprpc.SwapStatus{}
|
|
swapInfos = []*loop.SwapInfo{}
|
|
maxSwaps = int(req.MaxSwaps)
|
|
nextStartTime = int64(0)
|
|
canPage = false
|
|
)
|
|
|
|
s.swapsLock.Lock()
|
|
defer s.swapsLock.Unlock()
|
|
|
|
// We can just use the server's in-memory cache as that contains the
|
|
// most up-to-date state including temporary failures which aren't
|
|
// persisted to disk. The swaps field is a map, that's why we need an
|
|
// additional index.
|
|
for _, swp := range s.swaps {
|
|
// Filter the swap based on the provided filter.
|
|
if !filterSwap(&swp, req.ListSwapFilter) {
|
|
continue
|
|
}
|
|
|
|
swapInfos = append(swapInfos, &swp)
|
|
}
|
|
|
|
// Sort the swaps by initiation time in ascending order (oldest first).
|
|
slices.SortFunc(swapInfos, func(a, b *loop.SwapInfo) int {
|
|
return cmp.Compare(
|
|
a.InitiationTime.UnixNano(),
|
|
b.InitiationTime.UnixNano(),
|
|
)
|
|
})
|
|
|
|
// Apply the maxSwaps limit if specified.
|
|
if maxSwaps > 0 && len(swapInfos) > maxSwaps {
|
|
canPage = true
|
|
swapInfos = swapInfos[:maxSwaps]
|
|
}
|
|
|
|
// Marshal the filtered and limited swaps.
|
|
for _, swp := range swapInfos {
|
|
rpcSwap, err := s.marshallSwap(ctx, swp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rpcSwaps = append(rpcSwaps, rpcSwap)
|
|
}
|
|
|
|
// Set the next start time for pagination if needed.
|
|
if canPage && len(rpcSwaps) > 0 {
|
|
// Use the initiation time of the last swap plus 1 nanosecond.
|
|
nextStartTime = rpcSwaps[len(rpcSwaps)-1].InitiationTime + 1
|
|
}
|
|
|
|
response := looprpc.ListSwapsResponse{
|
|
Swaps: rpcSwaps,
|
|
NextStartTime: nextStartTime,
|
|
}
|
|
return &response, nil
|
|
}
|
|
|
|
// filterSwap filters the given swap based on the provided filter.
|
|
func filterSwap(swapInfo *loop.SwapInfo, filter *looprpc.ListSwapsFilter) bool {
|
|
if filter == nil {
|
|
return true
|
|
}
|
|
|
|
// If the swap type filter is set, we only return swaps that match the
|
|
// filter.
|
|
if filter.SwapType != looprpc.ListSwapsFilter_ANY {
|
|
switch filter.SwapType {
|
|
case looprpc.ListSwapsFilter_LOOP_IN:
|
|
if swapInfo.SwapType != swap.TypeIn {
|
|
return false
|
|
}
|
|
|
|
case looprpc.ListSwapsFilter_LOOP_OUT:
|
|
if swapInfo.SwapType != swap.TypeOut {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// If the pending only filter is set, we only return pending swaps.
|
|
if filter.PendingOnly && !swapInfo.State.IsPending() {
|
|
return false
|
|
}
|
|
|
|
// If timestamp filters are set, only return swaps within the specified time range.
|
|
if filter.StartTimestampNs > 0 &&
|
|
swapInfo.InitiationTime.UnixNano() < filter.StartTimestampNs {
|
|
|
|
return false
|
|
}
|
|
|
|
// If the swap is of type loop out and the outgoing channel filter is
|
|
// set, we only return swaps that match the filter.
|
|
if swapInfo.SwapType == swap.TypeOut && filter.OutgoingChanSet != nil {
|
|
// First we sort both channel sets to make sure we can compare
|
|
// them.
|
|
slices.Sort(swapInfo.OutgoingChanSet)
|
|
slices.Sort(filter.OutgoingChanSet)
|
|
|
|
// Compare the outgoing channel set by using reflect.DeepEqual
|
|
// which compares the underlying arrays.
|
|
if !reflect.DeepEqual(swapInfo.OutgoingChanSet,
|
|
filter.OutgoingChanSet) {
|
|
|
|
return false
|
|
}
|
|
}
|
|
|
|
// If the swap is of type loop in and the last hop filter is set, we
|
|
// only return swaps that match the filter.
|
|
if swapInfo.SwapType == swap.TypeIn && filter.LoopInLastHop != nil {
|
|
// Compare the last hop by using reflect.DeepEqual which
|
|
// compares the underlying arrays.
|
|
if !reflect.DeepEqual(swapInfo.LastHop, filter.LoopInLastHop) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// If a label filter is set, we only return swaps that softly match the
|
|
// filter.
|
|
if filter.Label != "" {
|
|
if !strings.Contains(swapInfo.Label, filter.Label) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// If we only want to return asset swaps, we only return swaps that have
|
|
// an asset id set.
|
|
if filter.AssetSwapOnly && swapInfo.AssetSwapInfo == nil {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// SwapInfo returns all known details about a single swap.
|
|
func (s *swapClientServer) SwapInfo(ctx context.Context,
|
|
req *looprpc.SwapInfoRequest) (*looprpc.SwapStatus, error) {
|
|
|
|
swapHash, err := lntypes.MakeHash(req.Id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error parsing swap hash: %v", err)
|
|
}
|
|
|
|
// Just return the server's in-memory cache here too as we also want to
|
|
// return temporary failures to the client.
|
|
s.swapsLock.Lock()
|
|
swp, ok := s.swaps[swapHash]
|
|
s.swapsLock.Unlock()
|
|
if !ok {
|
|
return nil, fmt.Errorf("swap with hash %s not found", req.Id)
|
|
}
|
|
|
|
return s.marshallSwap(ctx, &swp)
|
|
}
|
|
|
|
// AbandonSwap requests the server to abandon a swap with the given hash.
|
|
func (s *swapClientServer) AbandonSwap(ctx context.Context,
|
|
req *looprpc.AbandonSwapRequest) (*looprpc.AbandonSwapResponse,
|
|
error) {
|
|
|
|
if !req.IKnowWhatIAmDoing {
|
|
return nil, fmt.Errorf("please read the AbandonSwap API " +
|
|
"documentation")
|
|
}
|
|
|
|
swapHash, err := lntypes.MakeHash(req.Id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error parsing swap hash: %v", err)
|
|
}
|
|
|
|
s.swapsLock.Lock()
|
|
swap, ok := s.swaps[swapHash]
|
|
s.swapsLock.Unlock()
|
|
if !ok {
|
|
return nil, fmt.Errorf("swap with hash %s not found", req.Id)
|
|
}
|
|
|
|
if swap.SwapType.IsOut() {
|
|
return nil, fmt.Errorf("abandoning loop out swaps is not " +
|
|
"supported yet")
|
|
}
|
|
|
|
// If the swap is in a final state, we cannot abandon it.
|
|
if swap.State.IsFinal() {
|
|
return nil, fmt.Errorf("cannot abandon swap in final state, "+
|
|
"state = %s, hash = %s", swap.State.String(), swapHash)
|
|
}
|
|
|
|
err = s.impl.AbandonSwap(ctx, &loop.AbandonSwapRequest{
|
|
SwapHash: swapHash,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error abandoning swap: %v", err)
|
|
}
|
|
|
|
return &looprpc.AbandonSwapResponse{}, nil
|
|
}
|
|
|
|
// LoopOutTerms returns the terms that the server enforces for loop out swaps.
|
|
func (s *swapClientServer) LoopOutTerms(ctx context.Context,
|
|
_ *looprpc.TermsRequest) (*looprpc.OutTermsResponse, error) {
|
|
|
|
infof("Loop out terms request received")
|
|
|
|
terms, err := s.impl.LoopOutTerms(ctx, defaultLoopdInitiator)
|
|
if err != nil {
|
|
errorf("Terms request: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
return &looprpc.OutTermsResponse{
|
|
MinSwapAmount: int64(terms.MinSwapAmount),
|
|
MaxSwapAmount: int64(terms.MaxSwapAmount),
|
|
MinCltvDelta: terms.MinCltvDelta,
|
|
MaxCltvDelta: terms.MaxCltvDelta,
|
|
}, nil
|
|
}
|
|
|
|
// LoopOutQuote returns a quote for a loop out swap with the provided
|
|
// parameters.
|
|
func (s *swapClientServer) LoopOutQuote(ctx context.Context,
|
|
req *looprpc.QuoteRequest) (*looprpc.OutQuoteResponse, error) {
|
|
|
|
confTarget, err := validateConfTarget(
|
|
req.ConfTarget, loop.DefaultSweepConfTarget,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
publicactionDeadline := getPublicationDeadline(
|
|
req.SwapPublicationDeadline,
|
|
)
|
|
|
|
loopOutQuoteReq := &loop.LoopOutQuoteRequest{
|
|
Amount: btcutil.Amount(req.Amt),
|
|
SweepConfTarget: confTarget,
|
|
SwapPublicationDeadline: publicactionDeadline,
|
|
Initiator: defaultLoopdInitiator,
|
|
}
|
|
|
|
if req.AssetInfo != nil {
|
|
if req.AssetInfo.AssetId == nil ||
|
|
req.AssetInfo.AssetEdgeNode == nil {
|
|
|
|
return nil, fmt.Errorf(
|
|
"asset id and edge node must both be set")
|
|
}
|
|
loopOutQuoteReq.AssetRFQRequest = &loop.AssetRFQRequest{
|
|
AssetId: req.AssetInfo.AssetId,
|
|
AssetEdgeNode: req.AssetInfo.AssetEdgeNode,
|
|
Expiry: req.AssetInfo.Expiry,
|
|
MaxLimitMultiplier: req.AssetInfo.MaxLimitMultiplier,
|
|
}
|
|
}
|
|
|
|
quote, err := s.impl.LoopOutQuote(ctx, loopOutQuoteReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := &looprpc.OutQuoteResponse{
|
|
HtlcSweepFeeSat: int64(quote.MinerFee),
|
|
PrepayAmtSat: int64(quote.PrepayAmount),
|
|
SwapFeeSat: int64(quote.SwapFee),
|
|
SwapPaymentDest: quote.SwapPaymentDest[:],
|
|
ConfTarget: confTarget,
|
|
}
|
|
|
|
if quote.LoopOutRfq != nil {
|
|
response.AssetRfqInfo = &looprpc.AssetRfqInfo{
|
|
PrepayRfqId: quote.LoopOutRfq.PrepayRfqId,
|
|
MaxPrepayAssetAmt: quote.LoopOutRfq.MaxPrepayAssetAmt,
|
|
PrepayAssetRate: marshalFixedPoint(
|
|
quote.LoopOutRfq.PrepayAssetRate,
|
|
),
|
|
SwapRfqId: quote.LoopOutRfq.SwapRfqId,
|
|
MaxSwapAssetAmt: quote.LoopOutRfq.MaxSwapAssetAmt,
|
|
SwapAssetRate: marshalFixedPoint(
|
|
quote.LoopOutRfq.SwapAssetRate,
|
|
),
|
|
AssetName: quote.LoopOutRfq.AssetName,
|
|
}
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// GetLoopInTerms returns the terms that the server enforces for swaps.
|
|
func (s *swapClientServer) GetLoopInTerms(ctx context.Context,
|
|
_ *looprpc.TermsRequest) (*looprpc.InTermsResponse, error) {
|
|
|
|
infof("Loop in terms request received")
|
|
|
|
terms, err := s.impl.LoopInTerms(ctx, defaultLoopdInitiator)
|
|
if err != nil {
|
|
errorf("Terms request: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
return &looprpc.InTermsResponse{
|
|
MinSwapAmount: int64(terms.MinSwapAmount),
|
|
MaxSwapAmount: int64(terms.MaxSwapAmount),
|
|
}, nil
|
|
}
|
|
|
|
// GetLoopInQuote returns a quote for a swap with the provided parameters.
|
|
func (s *swapClientServer) GetLoopInQuote(ctx context.Context,
|
|
req *looprpc.QuoteRequest) (*looprpc.InQuoteResponse, error) {
|
|
|
|
infof("Loop in quote request received")
|
|
|
|
var (
|
|
selectedAmount = btcutil.Amount(req.Amt)
|
|
totalDepositAmount btcutil.Amount
|
|
autoSelectDeposits = req.AutoSelectDeposits
|
|
err error
|
|
)
|
|
|
|
htlcConfTarget, err := validateLoopInRequest(
|
|
req.ConfTarget, req.ExternalHtlc,
|
|
uint32(len(req.DepositOutpoints)), selectedAmount,
|
|
autoSelectDeposits,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The fast flag is only available for static loop in quote requests.
|
|
if req.Fast {
|
|
if !autoSelectDeposits && len(req.DepositOutpoints) == 0 {
|
|
return nil, fmt.Errorf("fast flag is only " +
|
|
"available for static address requests")
|
|
}
|
|
}
|
|
|
|
// If deposits should be automatically selected, we do so and count the
|
|
// number of deposits to quote for.
|
|
numDeposits := 0
|
|
if autoSelectDeposits {
|
|
err = s.depositManager.EnsureDepositsFresh(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to refresh deposits: %w",
|
|
err)
|
|
}
|
|
|
|
deposits, err := s.depositManager.GetActiveDepositsInState(
|
|
deposit.Deposited,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to retrieve all "+
|
|
"deposits: %w", err)
|
|
}
|
|
|
|
// TODO(hieblmi): add params to deposit for multi-address
|
|
// support.
|
|
params, err := s.staticAddressManager.GetStaticAddressParameters(
|
|
ctx,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to retrieve static "+
|
|
"address parameters: %w", err)
|
|
}
|
|
|
|
info, err := s.lnd.Client.GetInfo(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to get lnd info: %w",
|
|
err)
|
|
}
|
|
selectedDeposits, err := loopin.SelectDeposits(
|
|
selectedAmount, deposits, params.Expiry,
|
|
info.BlockHeight,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to select deposits: %w",
|
|
err)
|
|
}
|
|
|
|
numDeposits = len(selectedDeposits)
|
|
} else if len(req.DepositOutpoints) > 0 {
|
|
err = s.depositManager.EnsureDepositsFresh(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to refresh deposits: %w",
|
|
err)
|
|
}
|
|
|
|
// If deposits are selected, we need to retrieve them to
|
|
// calculate the total value which we request a quote for.
|
|
depositList, err := s.ListStaticAddressDeposits(
|
|
ctx, &looprpc.ListStaticAddressDepositsRequest{
|
|
Outpoints: req.DepositOutpoints,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if depositList == nil {
|
|
return nil, fmt.Errorf("no summary returned for " +
|
|
"deposit outpoints")
|
|
}
|
|
|
|
if len(req.DepositOutpoints) !=
|
|
len(depositList.FilteredDeposits) {
|
|
|
|
return nil, fmt.Errorf("expected %d deposits, got %d",
|
|
len(req.DepositOutpoints),
|
|
len(depositList.FilteredDeposits))
|
|
}
|
|
numDeposits = len(depositList.FilteredDeposits)
|
|
|
|
// In case we quote for deposits, we send the server both the
|
|
// selected value and the number of deposits. This is so the
|
|
// server can probe the selected value and calculate the per
|
|
// input fee.
|
|
for _, deposit := range depositList.FilteredDeposits {
|
|
// ListStaticAddressDeposits only returns deposits that are visible
|
|
// in the manager's live view. For a manual quote we additionally
|
|
// require the current state to be Deposited so stale client-side
|
|
// outpoint selection fails early instead of making it to swap
|
|
// initiation.
|
|
if deposit.State != looprpc.DepositState_DEPOSITED {
|
|
return nil, fmt.Errorf("deposit %s is not "+
|
|
"currently available", deposit.Outpoint)
|
|
}
|
|
|
|
totalDepositAmount += btcutil.Amount(
|
|
deposit.Value,
|
|
)
|
|
}
|
|
|
|
// If a fractional amount is also selected, we check if it
|
|
// leads to a dust change output.
|
|
selectedAmount, err = loopin.DeduceSwapAmount(
|
|
totalDepositAmount, selectedAmount,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error calculating "+
|
|
"swap amount from selected amount: %v",
|
|
err)
|
|
}
|
|
}
|
|
|
|
var (
|
|
routeHints [][]zpay32.HopHint
|
|
lastHop *route.Vertex
|
|
)
|
|
|
|
if req.LoopInLastHop != nil {
|
|
lastHopVertex, err := route.NewVertexFromBytes(
|
|
req.LoopInLastHop,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lastHop = &lastHopVertex
|
|
}
|
|
|
|
if len(req.LoopInRouteHints) != 0 {
|
|
routeHints, err = unmarshallRouteHints(req.LoopInRouteHints)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
quote, err := s.impl.LoopInQuote(ctx, &loop.LoopInQuoteRequest{
|
|
Amount: selectedAmount,
|
|
HtlcConfTarget: htlcConfTarget,
|
|
ExternalHtlc: req.ExternalHtlc,
|
|
LastHop: lastHop,
|
|
RouteHints: routeHints,
|
|
Private: req.Private,
|
|
Initiator: defaultLoopdInitiator,
|
|
NumDeposits: uint32(numDeposits),
|
|
Fast: req.Fast,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &looprpc.InQuoteResponse{
|
|
HtlcPublishFeeSat: int64(quote.MinerFee),
|
|
SwapFeeSat: int64(quote.SwapFee),
|
|
ConfTarget: htlcConfTarget,
|
|
QuotedAmt: int64(selectedAmount),
|
|
}, nil
|
|
}
|
|
|
|
// unmarshallRouteHints unmarshalls a list of route hints.
|
|
func unmarshallRouteHints(rpcRouteHints []*swapserverrpc.RouteHint) (
|
|
[][]zpay32.HopHint, error) {
|
|
|
|
routeHints := make([][]zpay32.HopHint, 0, len(rpcRouteHints))
|
|
for _, rpcRouteHint := range rpcRouteHints {
|
|
routeHint := make(
|
|
[]zpay32.HopHint, 0, len(rpcRouteHint.HopHints),
|
|
)
|
|
for _, rpcHint := range rpcRouteHint.HopHints {
|
|
hint, err := unmarshallHopHint(rpcHint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
routeHint = append(routeHint, hint)
|
|
}
|
|
routeHints = append(routeHints, routeHint)
|
|
}
|
|
|
|
return routeHints, nil
|
|
}
|
|
|
|
// unmarshallHopHint unmarshalls a single hop hint.
|
|
func unmarshallHopHint(rpcHint *swapserverrpc.HopHint) (zpay32.HopHint, error) {
|
|
pubBytes, err := hex.DecodeString(rpcHint.NodeId)
|
|
if err != nil {
|
|
return zpay32.HopHint{}, err
|
|
}
|
|
|
|
pubkey, err := btcec.ParsePubKey(pubBytes)
|
|
if err != nil {
|
|
return zpay32.HopHint{}, err
|
|
}
|
|
|
|
return zpay32.HopHint{
|
|
NodeID: pubkey,
|
|
ChannelID: rpcHint.ChanId,
|
|
FeeBaseMSat: rpcHint.FeeBaseMsat,
|
|
FeeProportionalMillionths: rpcHint.FeeProportionalMillionths,
|
|
CLTVExpiryDelta: uint16(rpcHint.CltvExpiryDelta),
|
|
}, nil
|
|
}
|
|
|
|
// Probe requests the server to probe the client's node to test inbound
|
|
// liquidity.
|
|
func (s *swapClientServer) Probe(ctx context.Context,
|
|
req *looprpc.ProbeRequest) (*looprpc.ProbeResponse, error) {
|
|
|
|
infof("Probe request received")
|
|
|
|
var lastHop *route.Vertex
|
|
if req.LastHop != nil {
|
|
lastHopVertex, err := route.NewVertexFromBytes(req.LastHop)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
lastHop = &lastHopVertex
|
|
}
|
|
|
|
routeHints, err := unmarshallRouteHints(req.RouteHints)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = s.impl.Probe(ctx, &loop.ProbeRequest{
|
|
Amount: btcutil.Amount(req.Amt),
|
|
LastHop: lastHop,
|
|
RouteHints: routeHints,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &looprpc.ProbeResponse{}, nil
|
|
}
|
|
|
|
func (s *swapClientServer) LoopIn(ctx context.Context,
|
|
in *looprpc.LoopInRequest) (*looprpc.SwapResponse, error) {
|
|
|
|
infof("Loop in request received")
|
|
|
|
selectDeposits := false
|
|
numDeposits := uint32(0)
|
|
htlcConfTarget, err := validateLoopInRequest(
|
|
in.HtlcConfTarget, in.ExternalHtlc, numDeposits,
|
|
btcutil.Amount(in.Amt), selectDeposits,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check that the label is valid.
|
|
if err := labels.Validate(in.Label); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
routeHints, err := unmarshallRouteHints(in.RouteHints)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req := &loop.LoopInRequest{
|
|
Amount: btcutil.Amount(in.Amt),
|
|
MaxMinerFee: btcutil.Amount(in.MaxMinerFee),
|
|
MaxSwapFee: btcutil.Amount(in.MaxSwapFee),
|
|
HtlcConfTarget: htlcConfTarget,
|
|
ExternalHtlc: in.ExternalHtlc,
|
|
Label: in.Label,
|
|
Initiator: in.Initiator,
|
|
Private: in.Private,
|
|
RouteHints: routeHints,
|
|
}
|
|
if in.LastHop != nil {
|
|
lastHop, err := route.NewVertexFromBytes(in.LastHop)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.LastHop = &lastHop
|
|
}
|
|
swapInfo, err := s.impl.LoopIn(ctx, req)
|
|
if err != nil {
|
|
errorf("Loop in: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
response := &looprpc.SwapResponse{
|
|
Id: swapInfo.SwapHash.String(),
|
|
IdBytes: swapInfo.SwapHash[:],
|
|
ServerMessage: swapInfo.ServerMessage,
|
|
}
|
|
|
|
if loopdb.CurrentProtocolVersion() < loopdb.ProtocolVersionHtlcV3 {
|
|
p2wshAddr := swapInfo.HtlcAddressP2WSH.String()
|
|
response.HtlcAddress = p2wshAddr //nolint:staticcheck
|
|
response.HtlcAddressP2Wsh = p2wshAddr
|
|
} else {
|
|
p2trAddr := swapInfo.HtlcAddressP2TR.String()
|
|
response.HtlcAddress = p2trAddr //nolint:staticcheck
|
|
response.HtlcAddressP2Tr = p2trAddr
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// GetL402Tokens returns all tokens that are contained in the L402 token store.
|
|
func (s *swapClientServer) GetL402Tokens(ctx context.Context,
|
|
_ *looprpc.TokensRequest) (*looprpc.TokensResponse, error) {
|
|
|
|
infof("Get L402 tokens request received")
|
|
|
|
tokens, err := s.impl.L402Store.AllTokens()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rpcTokens := make([]*looprpc.L402Token, len(tokens))
|
|
idx := 0
|
|
for key, token := range tokens {
|
|
macBytes, err := token.BaseMacaroon().MarshalBinary()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id, err := l402.DecodeIdentifier(
|
|
bytes.NewReader(token.BaseMacaroon().Id()),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rpcTokens[idx] = &looprpc.L402Token{
|
|
BaseMacaroon: macBytes,
|
|
PaymentHash: token.PaymentHash[:],
|
|
PaymentPreimage: token.Preimage[:],
|
|
AmountPaidMsat: int64(token.AmountPaid),
|
|
RoutingFeePaidMsat: int64(token.RoutingFeePaid),
|
|
TimeCreated: token.TimeCreated.Unix(),
|
|
Expired: !token.IsValid(),
|
|
StorageName: key,
|
|
Id: hex.EncodeToString(
|
|
id.TokenID[:],
|
|
),
|
|
}
|
|
idx++
|
|
}
|
|
|
|
return &looprpc.TokensResponse{Tokens: rpcTokens}, nil
|
|
}
|
|
|
|
// GetLsatTokens returns all tokens that are contained in the L402 token store.
|
|
//
|
|
// Deprecated: use GetL402Tokens.
|
|
// This API is provided to maintain backward compatibility with gRPC clients
|
|
// (e.g. `loop listauth`, Terminal Web, RTL).
|
|
// Type LsatToken used by GetLsatTokens in the past was renamed to L402Token,
|
|
// but this does not affect binary encoding, so we can use type L402Token here.
|
|
func (s *swapClientServer) GetLsatTokens(ctx context.Context,
|
|
req *looprpc.TokensRequest) (*looprpc.TokensResponse, error) {
|
|
|
|
warnf("Received deprecated call GetLsatTokens. Please update the " +
|
|
"client software. Calling GetL402Tokens now.")
|
|
|
|
return s.GetL402Tokens(ctx, req)
|
|
}
|
|
|
|
// FetchL402Token fetches a L402 Token from the server. This is required to
|
|
// listen for server notifications such as reservations. If a token is already
|
|
// in the local L402, nothing will happen.
|
|
func (s *swapClientServer) FetchL402Token(ctx context.Context,
|
|
_ *looprpc.FetchL402TokenRequest) (*looprpc.FetchL402TokenResponse,
|
|
error) {
|
|
|
|
err := s.impl.Server.FetchL402(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &looprpc.FetchL402TokenResponse{}, nil
|
|
}
|
|
|
|
// GetInfo returns basic information about the loop daemon and details to swaps
|
|
// from the swap store.
|
|
func (s *swapClientServer) GetInfo(ctx context.Context,
|
|
_ *looprpc.GetInfoRequest) (*looprpc.GetInfoResponse, error) {
|
|
|
|
// Fetch loop-outs from the loop db.
|
|
outSwaps, err := s.impl.Store.FetchLoopOutSwaps(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Collect loop-out stats.
|
|
loopOutStats := &looprpc.LoopStats{}
|
|
for _, out := range outSwaps {
|
|
switch out.State().State.Type() {
|
|
case loopdb.StateTypeSuccess:
|
|
loopOutStats.SuccessCount++
|
|
loopOutStats.SumSucceededAmt += int64(
|
|
out.Contract.AmountRequested,
|
|
)
|
|
|
|
case loopdb.StateTypePending:
|
|
loopOutStats.PendingCount++
|
|
loopOutStats.SumPendingAmt += int64(
|
|
out.Contract.AmountRequested,
|
|
)
|
|
|
|
case loopdb.StateTypeFail:
|
|
loopOutStats.FailCount++
|
|
}
|
|
}
|
|
|
|
// Fetch loop-ins from the loop db.
|
|
inSwaps, err := s.impl.Store.FetchLoopInSwaps(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Collect loop-in stats.
|
|
loopInStats := &looprpc.LoopStats{}
|
|
for _, in := range inSwaps {
|
|
switch in.State().State.Type() {
|
|
case loopdb.StateTypeSuccess:
|
|
loopInStats.SuccessCount++
|
|
loopInStats.SumSucceededAmt += int64(
|
|
in.Contract.AmountRequested,
|
|
)
|
|
|
|
case loopdb.StateTypePending:
|
|
loopInStats.PendingCount++
|
|
loopInStats.SumPendingAmt += int64(
|
|
in.Contract.AmountRequested,
|
|
)
|
|
|
|
case loopdb.StateTypeFail:
|
|
loopInStats.FailCount++
|
|
}
|
|
}
|
|
|
|
commitHash := loop.CommitHash
|
|
if loop.Dirty != "" {
|
|
// If the build was dirty, we add a "-dirty" suffix to the
|
|
// commit hash.
|
|
commitHash += "-" + loop.Dirty
|
|
}
|
|
|
|
return &looprpc.GetInfoResponse{
|
|
Version: loop.Version(),
|
|
CommitHash: commitHash,
|
|
Network: s.config.Network,
|
|
RpcListen: s.config.RPCListen,
|
|
RestListen: s.config.RESTListen,
|
|
MacaroonPath: s.config.MacaroonPath,
|
|
TlsCertPath: s.config.TLSCertPath,
|
|
LoopOutStats: loopOutStats,
|
|
LoopInStats: loopInStats,
|
|
}, nil
|
|
}
|
|
|
|
// StopDaemon triggers a graceful shutdown of the daemon process.
|
|
func (s *swapClientServer) StopDaemon(ctx context.Context,
|
|
_ *looprpc.StopDaemonRequest) (*looprpc.StopDaemonResponse, error) {
|
|
|
|
// Ensure we have a shutdown handler to invoke.
|
|
if s.stopDaemon == nil {
|
|
return nil, status.Error(codes.Unimplemented,
|
|
"stop daemon not supported")
|
|
}
|
|
|
|
// Initiate the shutdown sequence.
|
|
s.stopDaemon()
|
|
|
|
return &looprpc.StopDaemonResponse{}, nil
|
|
}
|
|
|
|
// SweepHtlc spends a Loop HTLC output using the success path and a known
|
|
// preimage.
|
|
func (s *swapClientServer) SweepHtlc(ctx context.Context,
|
|
req *looprpc.SweepHtlcRequest) (*looprpc.SweepHtlcResponse, error) {
|
|
|
|
return sweepHtlc(
|
|
ctx, req, s.lnd.ChainParams, s.impl.Store,
|
|
s.lnd.ChainNotifier, s.lnd.WalletKit, s.lnd.Signer,
|
|
)
|
|
}
|
|
|
|
// GetLiquidityParams gets our current liquidity manager's parameters.
|
|
func (s *swapClientServer) GetLiquidityParams(_ context.Context,
|
|
_ *looprpc.GetLiquidityParamsRequest) (*looprpc.LiquidityParameters,
|
|
error) {
|
|
|
|
cfg := s.liquidityMgr.GetParameters()
|
|
|
|
rpcCfg, err := liquidity.ParametersToRpc(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return rpcCfg, nil
|
|
}
|
|
|
|
// SetLiquidityParams attempts to set our current liquidity manager's
|
|
// parameters.
|
|
func (s *swapClientServer) SetLiquidityParams(ctx context.Context,
|
|
in *looprpc.SetLiquidityParamsRequest) (*looprpc.SetLiquidityParamsResponse,
|
|
error) {
|
|
|
|
enableExperimental := s.config != nil && s.config.EnableExperimental
|
|
params := in.GetParameters()
|
|
if params.GetLoopInSource() ==
|
|
looprpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS &&
|
|
!enableExperimental {
|
|
|
|
return nil, status.Error(
|
|
codes.FailedPrecondition,
|
|
liquidity.ErrStaticAddressAutoloopExperimental.Error(),
|
|
)
|
|
}
|
|
|
|
err := s.liquidityMgr.SetParameters(ctx, in.Parameters)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &looprpc.SetLiquidityParamsResponse{}, nil
|
|
}
|
|
|
|
// SuggestSwaps provides a list of suggested swaps based on lnd's current
|
|
// channel balances and rules set by the liquidity manager.
|
|
func (s *swapClientServer) SuggestSwaps(ctx context.Context,
|
|
_ *looprpc.SuggestSwapsRequest) (*looprpc.SuggestSwapsResponse, error) {
|
|
|
|
suggestions, err := s.liquidityMgr.SuggestSwaps(ctx)
|
|
switch err {
|
|
case liquidity.ErrNoRules:
|
|
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
|
|
|
case nil:
|
|
|
|
default:
|
|
return nil, err
|
|
}
|
|
|
|
resp := &looprpc.SuggestSwapsResponse{
|
|
LoopOut: make(
|
|
[]*looprpc.LoopOutRequest, len(suggestions.OutSwaps),
|
|
),
|
|
LoopIn: make(
|
|
[]*looprpc.LoopInRequest, len(suggestions.InSwaps),
|
|
),
|
|
StaticLoopIn: make(
|
|
[]*looprpc.StaticAddressLoopInRequest,
|
|
len(suggestions.StaticInSwaps),
|
|
),
|
|
}
|
|
|
|
for i, swap := range suggestions.OutSwaps {
|
|
resp.LoopOut[i] = &looprpc.LoopOutRequest{
|
|
Amt: int64(swap.Amount),
|
|
OutgoingChanSet: swap.OutgoingChanSet,
|
|
MaxSwapFee: int64(swap.MaxSwapFee),
|
|
MaxMinerFee: int64(swap.MaxMinerFee),
|
|
MaxPrepayAmt: int64(swap.MaxPrepayAmount),
|
|
MaxSwapRoutingFee: int64(swap.MaxSwapRoutingFee),
|
|
MaxPrepayRoutingFee: int64(swap.MaxPrepayRoutingFee),
|
|
SweepConfTarget: swap.SweepConfTarget,
|
|
}
|
|
}
|
|
|
|
for i, swap := range suggestions.InSwaps {
|
|
loopIn := &looprpc.LoopInRequest{
|
|
Amt: int64(swap.Amount),
|
|
MaxSwapFee: int64(swap.MaxSwapFee),
|
|
MaxMinerFee: int64(swap.MaxMinerFee),
|
|
HtlcConfTarget: swap.HtlcConfTarget,
|
|
}
|
|
|
|
if swap.LastHop != nil {
|
|
loopIn.LastHop = swap.LastHop[:]
|
|
}
|
|
|
|
resp.LoopIn[i] = loopIn
|
|
}
|
|
|
|
for i, swap := range suggestions.StaticInSwaps {
|
|
request := &looprpc.StaticAddressLoopInRequest{
|
|
Outpoints: swap.DepositOutpoints,
|
|
MaxSwapFeeSatoshis: int64(swap.MaxSwapFee),
|
|
Label: swap.Label,
|
|
Initiator: swap.Initiator,
|
|
PaymentTimeoutSeconds: swap.PaymentTimeoutSeconds,
|
|
Amount: int64(swap.SelectedAmount),
|
|
Fast: swap.Fast,
|
|
}
|
|
|
|
if swap.LastHop != nil {
|
|
request.LastHop = swap.LastHop[:]
|
|
}
|
|
|
|
resp.StaticLoopIn[i] = request
|
|
}
|
|
|
|
for id, reason := range suggestions.DisqualifiedChans {
|
|
autoloopReason, err := rpcAutoloopReason(reason)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
exclChan := &looprpc.Disqualified{
|
|
Reason: autoloopReason,
|
|
ChannelId: id.ToUint64(),
|
|
}
|
|
|
|
resp.Disqualified = append(resp.Disqualified, exclChan)
|
|
}
|
|
|
|
for pubkey, reason := range suggestions.DisqualifiedPeers {
|
|
autoloopReason, err := rpcAutoloopReason(reason)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
clonedPubkey := route.Vertex{}
|
|
copy(clonedPubkey[:], pubkey[:])
|
|
|
|
exclChan := &looprpc.Disqualified{
|
|
Reason: autoloopReason,
|
|
Pubkey: clonedPubkey[:],
|
|
}
|
|
|
|
resp.Disqualified = append(resp.Disqualified, exclChan)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// ListReservations lists all existing reservations the client has ever made.
|
|
func (s *swapClientServer) ListReservations(ctx context.Context,
|
|
_ *looprpc.ListReservationsRequest) (
|
|
*looprpc.ListReservationsResponse, error) {
|
|
|
|
if s.reservationManager == nil {
|
|
return nil, status.Error(codes.Unimplemented,
|
|
"Restart loop with --experimental")
|
|
}
|
|
reservations, err := s.reservationManager.GetReservations(
|
|
ctx,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &looprpc.ListReservationsResponse{
|
|
Reservations: ToClientReservations(
|
|
reservations,
|
|
),
|
|
}, nil
|
|
}
|
|
|
|
// InstantOut initiates an instant out swap.
|
|
func (s *swapClientServer) InstantOut(ctx context.Context,
|
|
req *looprpc.InstantOutRequest) (*looprpc.InstantOutResponse,
|
|
error) {
|
|
|
|
if s.instantOutManager == nil {
|
|
return nil, status.Error(codes.Unimplemented,
|
|
"Restart loop with --experimental")
|
|
}
|
|
|
|
reservationIds := make([]reservation.ID, len(req.ReservationIds))
|
|
for i, id := range req.ReservationIds {
|
|
if len(id) != reservation.IdLength {
|
|
return nil, fmt.Errorf("invalid reservation id: "+
|
|
"expected %v bytes, got %d",
|
|
reservation.IdLength, len(id))
|
|
}
|
|
|
|
var resId reservation.ID
|
|
copy(resId[:], id)
|
|
|
|
reservationIds[i] = resId
|
|
}
|
|
|
|
instantOutFsm, err := s.instantOutManager.NewInstantOut(
|
|
ctx, reservationIds, req.DestAddr,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res := &looprpc.InstantOutResponse{
|
|
InstantOutHash: instantOutFsm.InstantOut.SwapHash[:],
|
|
State: string(instantOutFsm.InstantOut.State),
|
|
}
|
|
|
|
if instantOutFsm.InstantOut.SweepTxHash != nil {
|
|
res.SweepTxId = instantOutFsm.InstantOut.SweepTxHash.String()
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// InstantOutQuote returns a quote for an instant out swap with the provided
|
|
// parameters.
|
|
func (s *swapClientServer) InstantOutQuote(ctx context.Context,
|
|
req *looprpc.InstantOutQuoteRequest) (
|
|
*looprpc.InstantOutQuoteResponse, error) {
|
|
|
|
if s.instantOutManager == nil {
|
|
return nil, status.Error(codes.Unimplemented,
|
|
"Restart loop with --experimental")
|
|
}
|
|
|
|
quote, err := s.instantOutManager.GetInstantOutQuote(
|
|
ctx, btcutil.Amount(req.Amt), req.ReservationIds,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &looprpc.InstantOutQuoteResponse{
|
|
ServiceFeeSat: int64(quote.ServiceFee),
|
|
SweepFeeSat: int64(quote.OnChainFee),
|
|
}, nil
|
|
}
|
|
|
|
// ListInstantOuts returns a list of all currently known instant out swaps and
|
|
// their current status.
|
|
func (s *swapClientServer) ListInstantOuts(ctx context.Context,
|
|
_ *looprpc.ListInstantOutsRequest) (
|
|
*looprpc.ListInstantOutsResponse, error) {
|
|
|
|
if s.instantOutManager == nil {
|
|
return nil, status.Error(codes.Unimplemented,
|
|
"Restart loop with --experimental")
|
|
}
|
|
|
|
instantOuts, err := s.instantOutManager.ListInstantOuts(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rpcSwaps := make([]*looprpc.InstantOut, 0, len(instantOuts))
|
|
for _, instantOut := range instantOuts {
|
|
rpcSwaps = append(rpcSwaps, rpcInstantOut(instantOut))
|
|
}
|
|
|
|
return &looprpc.ListInstantOutsResponse{
|
|
Swaps: rpcSwaps,
|
|
}, nil
|
|
}
|
|
|
|
func rpcInstantOut(instantOut *instantout.InstantOut) *looprpc.InstantOut {
|
|
var sweepTxId string
|
|
if instantOut.SweepTxHash != nil {
|
|
sweepTxId = instantOut.SweepTxHash.String()
|
|
}
|
|
|
|
reservations := make([][]byte, len(instantOut.Reservations))
|
|
for i, res := range instantOut.Reservations {
|
|
reservations[i] = res.ID[:]
|
|
}
|
|
|
|
return &looprpc.InstantOut{
|
|
SwapHash: instantOut.SwapHash[:],
|
|
State: string(instantOut.State),
|
|
Amount: uint64(instantOut.Value),
|
|
SweepTxId: sweepTxId,
|
|
ReservationIds: reservations,
|
|
}
|
|
}
|
|
|
|
// NewStaticAddress is the rpc endpoint for loop clients to request a new static
|
|
// address.
|
|
func (s *swapClientServer) NewStaticAddress(ctx context.Context,
|
|
_ *looprpc.NewStaticAddressRequest) (
|
|
*looprpc.NewStaticAddressResponse, error) {
|
|
|
|
staticAddress, expiry, err := s.staticAddressManager.NewAddress(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &looprpc.NewStaticAddressResponse{
|
|
Address: staticAddress.String(),
|
|
Expiry: uint32(expiry),
|
|
}, nil
|
|
}
|
|
|
|
// ListUnspentDeposits returns a list of utxos behind the static address.
|
|
func (s *swapClientServer) ListUnspentDeposits(ctx context.Context,
|
|
req *looprpc.ListUnspentDepositsRequest) (
|
|
*looprpc.ListUnspentDepositsResponse, error) {
|
|
|
|
// List all unspent utxos the wallet sees, regardless of the number of
|
|
// confirmations.
|
|
staticAddress, utxos, err := s.staticAddressManager.ListUnspentRaw(
|
|
ctx, req.MinConfs, req.MaxConfs,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// ListUnspentRaw returns the unspent wallet view of the backing lnd
|
|
// wallet. Static loop-in initiation requires an active deposit record,
|
|
// so only deposits that are both wallet-visible and tracked as
|
|
// Deposited are returned here.
|
|
var (
|
|
outpoints []string
|
|
isUnspent = make(map[wire.OutPoint]struct{})
|
|
)
|
|
|
|
for _, utxo := range utxos {
|
|
outpoints = append(outpoints, utxo.OutPoint.String())
|
|
}
|
|
|
|
err = s.depositManager.EnsureDepositsFresh(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check the spent status of the deposits by looking at their states.
|
|
ignoreUnknownOutpoints := true
|
|
deposits, err := s.depositManager.DepositsForOutpoints(
|
|
ctx, outpoints, ignoreUnknownOutpoints,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, d := range deposits {
|
|
if d == nil {
|
|
continue
|
|
}
|
|
|
|
if d.IsInState(deposit.Deposited) {
|
|
isUnspent[d.OutPoint] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// Prepare the list of unspent deposits for the rpc response.
|
|
var respUtxos []*looprpc.Utxo
|
|
for _, u := range utxos {
|
|
if _, ok := isUnspent[u.OutPoint]; !ok {
|
|
continue
|
|
}
|
|
|
|
utxo := &looprpc.Utxo{
|
|
StaticAddress: staticAddress.String(),
|
|
AmountSat: int64(u.Value),
|
|
Confirmations: u.Confirmations,
|
|
Outpoint: u.OutPoint.String(),
|
|
}
|
|
respUtxos = append(respUtxos, utxo)
|
|
}
|
|
|
|
return &looprpc.ListUnspentDepositsResponse{Utxos: respUtxos}, nil
|
|
}
|
|
|
|
// WithdrawDeposits tries to obtain a partial signature from the server to spend
|
|
// the selected deposits to the client's wallet.
|
|
func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
|
|
req *looprpc.WithdrawDepositsRequest) (
|
|
*looprpc.WithdrawDepositsResponse, error) {
|
|
|
|
var (
|
|
isAllSelected = req.All
|
|
isUtxoSelected = len(req.Outpoints) > 0
|
|
outpoints []wire.OutPoint
|
|
err error
|
|
)
|
|
|
|
switch {
|
|
case isAllSelected == isUtxoSelected:
|
|
return nil, fmt.Errorf("must select either all or some utxos")
|
|
|
|
case isAllSelected:
|
|
err = s.depositManager.EnsureDepositsFresh(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to refresh deposits: %w",
|
|
err)
|
|
}
|
|
|
|
deposits, err := s.depositManager.GetActiveDepositsInState(
|
|
deposit.Deposited,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
outpoints, err = withdrawAllDepositOutpoints(deposits)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case isUtxoSelected:
|
|
outpoints, err = staticutil.ToWireOutpoints(req.Outpoints)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
txhash, address, err := s.withdrawalManager.DeliverWithdrawalRequest(
|
|
ctx, outpoints, req.DestAddr, req.SatPerVbyte, req.Amount,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &looprpc.WithdrawDepositsResponse{
|
|
WithdrawalTxHash: txhash,
|
|
Address: address,
|
|
}, err
|
|
}
|
|
|
|
// withdrawAllDepositOutpoints returns all deposit outpoints for an `all`
|
|
// withdrawal request. The request must fail if any deposited output is still
|
|
// unconfirmed because `all` should not silently downgrade to a subset.
|
|
func withdrawAllDepositOutpoints(deposits []*deposit.Deposit) ([]wire.OutPoint,
|
|
error) {
|
|
|
|
outpoints := make([]wire.OutPoint, 0, len(deposits))
|
|
for _, d := range deposits {
|
|
if d.GetConfirmationHeight() <= 0 {
|
|
return nil, fmt.Errorf("can't withdraw all deposits while " +
|
|
"some deposits are unconfirmed")
|
|
}
|
|
|
|
outpoints = append(outpoints, d.OutPoint)
|
|
}
|
|
|
|
return outpoints, nil
|
|
}
|
|
|
|
// ListStaticAddressDeposits returns a list of all sufficiently confirmed
|
|
// deposits behind the static address and displays properties like value,
|
|
// state or blocks til expiry.
|
|
func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context,
|
|
req *looprpc.ListStaticAddressDepositsRequest) (
|
|
*looprpc.ListStaticAddressDepositsResponse, error) {
|
|
|
|
outpoints := req.Outpoints
|
|
if req.StateFilter != looprpc.DepositState_UNKNOWN_STATE &&
|
|
len(outpoints) > 0 {
|
|
|
|
return nil, fmt.Errorf("can either filter by state or " +
|
|
"outpoints")
|
|
}
|
|
|
|
allDeposits, err := s.depositManager.GetVisibleDeposits(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Deposits filtered by state or outpoints.
|
|
var filteredDeposits []*looprpc.Deposit
|
|
if len(outpoints) > 0 {
|
|
f := func(d *deposit.Deposit) bool {
|
|
return slices.Contains(outpoints, d.OutPoint.String())
|
|
}
|
|
filteredDeposits = filter(allDeposits, f)
|
|
|
|
if len(outpoints) != len(filteredDeposits) {
|
|
return nil, fmt.Errorf("not all outpoints found in " +
|
|
"deposits")
|
|
}
|
|
} else {
|
|
f := func(d *deposit.Deposit) bool {
|
|
if req.StateFilter == looprpc.DepositState_UNKNOWN_STATE {
|
|
// Per default, we return deposits in all
|
|
// states.
|
|
return true
|
|
}
|
|
|
|
return d.IsInState(toServerState(req.StateFilter))
|
|
}
|
|
filteredDeposits = filter(allDeposits, f)
|
|
}
|
|
|
|
// Calculate the blocks until expiry for each deposit.
|
|
err = s.populateBlocksUntilExpiry(ctx, filteredDeposits)
|
|
if err != nil {
|
|
infof("Failed to populate blocks until expiry: %v", err)
|
|
}
|
|
|
|
return &looprpc.ListStaticAddressDepositsResponse{
|
|
FilteredDeposits: filteredDeposits,
|
|
}, nil
|
|
}
|
|
|
|
// ListStaticAddressWithdrawals returns a list of all finalized withdrawal
|
|
// transactions.
|
|
func (s *swapClientServer) ListStaticAddressWithdrawals(ctx context.Context,
|
|
_ *looprpc.ListStaticAddressWithdrawalRequest) (
|
|
*looprpc.ListStaticAddressWithdrawalResponse, error) {
|
|
|
|
withdrawals, err := s.withdrawalManager.GetAllWithdrawals(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(withdrawals) == 0 {
|
|
return &looprpc.ListStaticAddressWithdrawalResponse{}, nil
|
|
}
|
|
|
|
clientWithdrawals := make(
|
|
[]*looprpc.StaticAddressWithdrawal, 0, len(withdrawals),
|
|
)
|
|
for _, w := range withdrawals {
|
|
deposits := make([]*looprpc.Deposit, 0, len(w.Deposits))
|
|
for _, d := range w.Deposits {
|
|
deposits = append(deposits, &looprpc.Deposit{
|
|
Id: d.ID[:],
|
|
Outpoint: d.OutPoint.String(),
|
|
Value: int64(d.Value),
|
|
ConfirmationHeight: d.GetConfirmationHeight(),
|
|
State: toClientDepositState(
|
|
d.GetState(),
|
|
),
|
|
})
|
|
}
|
|
withdrawal := &looprpc.StaticAddressWithdrawal{
|
|
TxId: w.TxID.String(),
|
|
Deposits: deposits,
|
|
TotalDepositAmountSatoshis: int64(w.TotalDepositAmount),
|
|
WithdrawnAmountSatoshis: int64(w.WithdrawnAmount),
|
|
ChangeAmountSatoshis: int64(w.ChangeAmount),
|
|
ConfirmationHeight: uint32(w.ConfirmationHeight),
|
|
}
|
|
clientWithdrawals = append(clientWithdrawals, withdrawal)
|
|
}
|
|
|
|
return &looprpc.ListStaticAddressWithdrawalResponse{
|
|
Withdrawals: clientWithdrawals,
|
|
}, nil
|
|
}
|
|
|
|
// ListStaticAddressSwaps returns a list of all swaps that are currently pending
|
|
// or previously succeeded.
|
|
func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context,
|
|
_ *looprpc.ListStaticAddressSwapsRequest) (
|
|
*looprpc.ListStaticAddressSwapsResponse, error) {
|
|
|
|
swaps, err := s.staticLoopInManager.GetAllSwaps(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(swaps) == 0 {
|
|
return &looprpc.ListStaticAddressSwapsResponse{}, nil
|
|
}
|
|
|
|
// Query lnd's info to get the current block height.
|
|
lndInfo, err := s.lnd.Client.GetInfo(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
addrParams, err := s.staticAddressManager.GetStaticAddressParameters(
|
|
ctx,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Fetch all deposits at once and index them by swap hash for a quick
|
|
// lookup.
|
|
allDeposits, err := s.depositManager.GetAllDeposits(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
depositsBySwap := make(map[lntypes.Hash][]*deposit.Deposit, len(swaps))
|
|
for _, d := range allDeposits {
|
|
if d.SwapHash == nil {
|
|
// This deposit is not associated with a swap, so we
|
|
// skip it.
|
|
continue
|
|
}
|
|
depositsBySwap[*d.SwapHash] = append(
|
|
depositsBySwap[*d.SwapHash], d,
|
|
)
|
|
}
|
|
|
|
var clientSwaps []*looprpc.StaticAddressLoopInSwap
|
|
for _, swp := range swaps {
|
|
if swp == nil {
|
|
continue
|
|
}
|
|
|
|
chainParams, err := s.network.ChainParams()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error getting chain params")
|
|
}
|
|
swapPayReq, err := zpay32.Decode(swp.SwapInvoice, chainParams)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error decoding swap "+
|
|
"invoice: %v", err)
|
|
}
|
|
|
|
// Assemble the deposits associated with this swap, if any.
|
|
var protoDeposits []*looprpc.Deposit
|
|
if ds, ok := depositsBySwap[swp.SwapHash]; ok {
|
|
protoDeposits = make([]*looprpc.Deposit, 0, len(ds))
|
|
for _, d := range ds {
|
|
state := toClientDepositState(d.GetState())
|
|
confirmationHeight := d.GetConfirmationHeight()
|
|
blocksUntilExpiry := depositBlocksUntilExpiry(
|
|
confirmationHeight, addrParams.Expiry,
|
|
int64(lndInfo.BlockHeight),
|
|
)
|
|
|
|
pd := &looprpc.Deposit{
|
|
Id: d.ID[:],
|
|
State: state,
|
|
Outpoint: d.OutPoint.String(),
|
|
Value: int64(d.Value),
|
|
ConfirmationHeight: confirmationHeight,
|
|
SwapHash: d.SwapHash[:],
|
|
BlocksUntilExpiry: blocksUntilExpiry,
|
|
}
|
|
protoDeposits = append(protoDeposits, pd)
|
|
}
|
|
}
|
|
|
|
swapAmount := swp.TotalDepositAmount()
|
|
state := toClientStaticAddressLoopInState(swp.GetState())
|
|
payReqAmount := int64(swapPayReq.MilliSat.ToSatoshis())
|
|
if swp.SelectedAmount > 0 {
|
|
swapAmount = swp.SelectedAmount
|
|
}
|
|
costServer := staticAddressLoopInSwapServerCost(swp)
|
|
initiationTime := staticAddressLoopInTimestamp(swp.InitiationTime)
|
|
lastUpdateTime := staticAddressLoopInTimestamp(swp.LastUpdateTime)
|
|
swap := &looprpc.StaticAddressLoopInSwap{
|
|
SwapHash: swp.SwapHash[:],
|
|
DepositOutpoints: swp.DepositOutpoints,
|
|
State: state,
|
|
SwapAmountSatoshis: int64(swapAmount),
|
|
PaymentRequestAmountSatoshis: payReqAmount,
|
|
Deposits: protoDeposits,
|
|
InitiationTime: initiationTime,
|
|
LastUpdateTime: lastUpdateTime,
|
|
CostServer: costServer,
|
|
}
|
|
|
|
clientSwaps = append(clientSwaps, swap)
|
|
}
|
|
|
|
return &looprpc.ListStaticAddressSwapsResponse{
|
|
Swaps: clientSwaps,
|
|
}, nil
|
|
}
|
|
|
|
// staticAddressLoopInTimestamp converts a non-zero timestamp to Unix nano
|
|
// form and preserves zero timestamps as zero.
|
|
func staticAddressLoopInTimestamp(t time.Time) int64 {
|
|
if t.IsZero() {
|
|
return 0
|
|
}
|
|
|
|
return t.UnixNano()
|
|
}
|
|
|
|
// staticAddressLoopInSwapServerCost returns the paid server cost using the
|
|
// legacy ListSwaps cost semantics. Static loop-ins currently only persist the
|
|
// accepted quote fee, and that fee is paid once the swap invoice settles.
|
|
// Timeout-path miner fees are not persisted, so cost_onchain and cost_offchain
|
|
// remain zero instead of returning an estimate as an actual cost.
|
|
func staticAddressLoopInSwapServerCost(swp *loopin.StaticAddressLoopIn) int64 {
|
|
switch swp.GetState() {
|
|
case loopin.PaymentReceived, loopin.Succeeded,
|
|
loopin.SucceededTransitioningFailed:
|
|
|
|
return int64(swp.QuotedSwapFee)
|
|
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// staticAddressLoopInSwapInfos loads the static-address loop-in manager swaps
|
|
// and converts them to client-facing swap info records.
|
|
func (s *swapClientServer) staticAddressLoopInSwapInfos(
|
|
ctx context.Context) ([]*loop.SwapInfo, error) {
|
|
|
|
if s.staticLoopInManager == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
staticSwaps, err := s.staticLoopInManager.GetAllSwaps(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
swapInfos := make([]*loop.SwapInfo, 0, len(staticSwaps))
|
|
for _, swp := range staticSwaps {
|
|
if swp == nil {
|
|
continue
|
|
}
|
|
|
|
swapInfo, err := s.staticAddressLoopInSwapInfo(ctx, swp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
swapInfos = append(swapInfos, swapInfo)
|
|
}
|
|
|
|
return swapInfos, nil
|
|
}
|
|
|
|
// staticAddressLoopInSwapInfo converts one static-address loop-in into swap
|
|
// info using the daemon's current chain parameters.
|
|
func (s *swapClientServer) staticAddressLoopInSwapInfo(_ context.Context,
|
|
swp *loopin.StaticAddressLoopIn) (*loop.SwapInfo, error) {
|
|
|
|
chainParams, err := s.network.ChainParams()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error getting chain params")
|
|
}
|
|
|
|
return staticAddressLoopInSwapInfoWithChainParams(swp, chainParams)
|
|
}
|
|
|
|
// staticAddressLoopInSwapInfoWithChainParams converts one static-address
|
|
// loop-in into swap info, including its reconstructed V2 P2WSH HTLC address.
|
|
func staticAddressLoopInSwapInfoWithChainParams(
|
|
swp *loopin.StaticAddressLoopIn,
|
|
chainParams *chaincfg.Params) (*loop.SwapInfo, error) {
|
|
|
|
htlcAddress, err := staticAddressLoopInHtlcAddress(swp, chainParams)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var lastHop *route.Vertex
|
|
if len(swp.LastHop) > 0 {
|
|
vertex, err := route.NewVertexFromBytes(swp.LastHop)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lastHop = &vertex
|
|
}
|
|
|
|
amount := swp.TotalDepositAmount()
|
|
if swp.SelectedAmount > 0 {
|
|
amount = swp.SelectedAmount
|
|
}
|
|
|
|
lastUpdate := swp.LastUpdateTime
|
|
if lastUpdate.IsZero() {
|
|
lastUpdate = swp.InitiationTime
|
|
}
|
|
|
|
return &loop.SwapInfo{
|
|
SwapStateData: loopdb.SwapStateData{
|
|
// Mirror ListStaticAddressSwaps by reporting only the persisted
|
|
// client-visible server cost. On-chain and off-chain costs stay
|
|
// zero until static loop-ins persist real fee data.
|
|
Cost: loopdb.SwapCost{
|
|
Server: btcutil.Amount(
|
|
staticAddressLoopInSwapServerCost(swp),
|
|
),
|
|
},
|
|
},
|
|
SwapContract: loopdb.SwapContract{
|
|
AmountRequested: amount,
|
|
CltvExpiry: swp.HtlcCltvExpiry,
|
|
MaxSwapFee: swp.MaxSwapFee,
|
|
InitiationTime: swp.InitiationTime,
|
|
Label: swp.Label,
|
|
ProtocolVersion: loopdb.ProtocolVersion(
|
|
swp.ProtocolVersion,
|
|
),
|
|
},
|
|
LastUpdate: lastUpdate,
|
|
SwapHash: swp.SwapHash,
|
|
SwapType: swap.TypeStaticAddressLoopIn,
|
|
StaticAddressLoopInState: swp.GetState(),
|
|
HtlcAddressP2WSH: htlcAddress,
|
|
LastHop: lastHop,
|
|
}, nil
|
|
}
|
|
|
|
// staticAddressLoopInHtlcAddress reconstructs the V2 P2WSH HTLC address from
|
|
// the static-address loop-in's client and server keys.
|
|
func staticAddressLoopInHtlcAddress(swp *loopin.StaticAddressLoopIn,
|
|
chainParams *chaincfg.Params) (btcutil.Address, error) {
|
|
|
|
if swp.ClientPubkey == nil {
|
|
return nil, errors.New("missing static address loop-in client HTLC key")
|
|
}
|
|
if swp.ServerPubkey == nil {
|
|
return nil, errors.New("missing static address loop-in server HTLC key")
|
|
}
|
|
|
|
htlc, err := swap.NewHtlcV2(
|
|
swp.HtlcCltvExpiry, pubkeyTo33ByteSlice(swp.ClientPubkey),
|
|
pubkeyTo33ByteSlice(swp.ServerPubkey), swp.SwapHash, chainParams,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("construct static address loop-in HTLC: %w", err)
|
|
}
|
|
|
|
return htlc.Address, nil
|
|
}
|
|
|
|
// pubkeyTo33ByteSlice converts a compressed public key to a fixed 33-byte
|
|
// array.
|
|
func pubkeyTo33ByteSlice(pubkey *btcec.PublicKey) [33]byte {
|
|
var pubkeyBytes [33]byte
|
|
copy(pubkeyBytes[:], pubkey.SerializeCompressed())
|
|
|
|
return pubkeyBytes
|
|
}
|
|
|
|
// GetStaticAddressSummary returns a summary of static address-related
|
|
// information. Amongst deposits and withdrawals and their total values, it also
|
|
// includes a list of detailed deposit information filtered by their state.
|
|
func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context,
|
|
_ *looprpc.StaticAddressSummaryRequest) (
|
|
*looprpc.StaticAddressSummaryResponse, error) {
|
|
|
|
allDeposits, err := s.depositManager.GetVisibleDeposits(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var (
|
|
totalNumDeposits = len(allDeposits)
|
|
valueUnconfirmed int64
|
|
valueDeposited int64
|
|
valueExpired int64
|
|
valueWithdrawn int64
|
|
valueLoopedIn int64
|
|
valueChannelsOpened int64
|
|
htlcTimeoutSwept int64
|
|
)
|
|
|
|
// Total values by category.
|
|
for _, d := range allDeposits {
|
|
value := int64(d.Value)
|
|
switch d.GetState() {
|
|
case deposit.Deposited:
|
|
if d.GetConfirmationHeight() <= 0 {
|
|
valueUnconfirmed += value
|
|
} else {
|
|
valueDeposited += value
|
|
}
|
|
|
|
case deposit.Expired:
|
|
valueExpired += value
|
|
|
|
case deposit.Withdrawn:
|
|
valueWithdrawn += value
|
|
|
|
case deposit.LoopedIn:
|
|
valueLoopedIn += value
|
|
|
|
case deposit.HtlcTimeoutSwept:
|
|
htlcTimeoutSwept += value
|
|
|
|
case deposit.ChannelPublished:
|
|
valueChannelsOpened += value
|
|
}
|
|
}
|
|
|
|
params, err := s.staticAddressManager.GetStaticAddressParameters(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
address, err := s.staticAddressManager.GetTaprootAddress(
|
|
params.ClientPubkey, params.ServerPubkey, int64(params.Expiry),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &looprpc.StaticAddressSummaryResponse{
|
|
StaticAddress: address.String(),
|
|
RelativeExpiryBlocks: uint64(params.Expiry),
|
|
TotalNumDeposits: uint32(totalNumDeposits),
|
|
ValueUnconfirmedSatoshis: valueUnconfirmed,
|
|
ValueDepositedSatoshis: valueDeposited,
|
|
ValueExpiredSatoshis: valueExpired,
|
|
ValueWithdrawnSatoshis: valueWithdrawn,
|
|
ValueLoopedInSatoshis: valueLoopedIn,
|
|
ValueChannelsOpened: valueChannelsOpened,
|
|
ValueHtlcTimeoutSweepsSatoshis: htlcTimeoutSwept,
|
|
}, nil
|
|
}
|
|
|
|
// StaticAddressLoopIn initiates a loop-in request using static address
|
|
// deposits.
|
|
func (s *swapClientServer) StaticAddressLoopIn(ctx context.Context,
|
|
in *looprpc.StaticAddressLoopInRequest) (
|
|
*looprpc.StaticAddressLoopInResponse, error) {
|
|
|
|
infof("Static loop-in request received")
|
|
|
|
routeHints, err := unmarshallRouteHints(in.RouteHints)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req := &loop.StaticAddressLoopInRequest{
|
|
SelectedAmount: btcutil.Amount(in.Amount),
|
|
DepositOutpoints: in.Outpoints,
|
|
MaxSwapFee: btcutil.Amount(in.MaxSwapFeeSatoshis),
|
|
Label: in.Label,
|
|
Initiator: in.Initiator,
|
|
Private: in.Private,
|
|
RouteHints: routeHints,
|
|
PaymentTimeoutSeconds: in.PaymentTimeoutSeconds,
|
|
Fast: in.Fast,
|
|
}
|
|
|
|
// External callers must not be able to use reserved autoloop labels.
|
|
// Internal autoloop dispatch bypasses this RPC and can still use the
|
|
// reserved labels needed to attribute automated swaps correctly.
|
|
if err := labels.Validate(req.Label); err != nil {
|
|
return nil, fmt.Errorf("invalid label: %w", err)
|
|
}
|
|
|
|
if in.LastHop != nil {
|
|
lastHop, err := route.NewVertexFromBytes(in.LastHop)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.LastHop = &lastHop
|
|
}
|
|
|
|
loopIn, err := s.staticLoopInManager.DeliverLoopInRequest(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Build a list of used deposits for the response.
|
|
usedDeposits := filter(
|
|
loopIn.Deposits, func(d *deposit.Deposit) bool { return true },
|
|
)
|
|
|
|
err = s.populateBlocksUntilExpiry(ctx, usedDeposits)
|
|
if err != nil {
|
|
infof("Failed to populate blocks until expiry: %v", err)
|
|
}
|
|
|
|
// Determine the actual swap amount and change based on the selected
|
|
// amount and the total value of the selected deposits.
|
|
total := loopIn.TotalDepositAmount()
|
|
swapAmt := total
|
|
var changeAmt btcutil.Amount
|
|
if loopIn.SelectedAmount > 0 {
|
|
amt, err := loopin.DeduceSwapAmount(
|
|
total, loopIn.SelectedAmount,
|
|
)
|
|
if err == nil {
|
|
swapAmt = amt
|
|
changeAmt = total - amt
|
|
}
|
|
}
|
|
|
|
return &looprpc.StaticAddressLoopInResponse{
|
|
SwapHash: loopIn.SwapHash[:],
|
|
State: string(loopIn.GetState()),
|
|
Amount: uint64(total),
|
|
SwapAmount: uint64(swapAmt),
|
|
Change: int64(changeAmt),
|
|
QuotedSwapFeeSatoshis: int64(loopIn.QuotedSwapFee),
|
|
Fast: loopIn.Fast,
|
|
HtlcCltv: loopIn.HtlcCltvExpiry,
|
|
MaxSwapFeeSatoshis: int64(loopIn.MaxSwapFee),
|
|
InitiationHeight: loopIn.InitiationHeight,
|
|
ProtocolVersion: loopIn.ProtocolVersion.String(),
|
|
Initiator: loopIn.Initiator,
|
|
Label: loopIn.Label,
|
|
PaymentTimeoutSeconds: loopIn.PaymentTimeoutSeconds,
|
|
UsedDeposits: usedDeposits,
|
|
}, nil
|
|
}
|
|
|
|
// Calculate the blocks until expiry for each deposit and return the modified
|
|
// StaticAddressLoopInResponse.
|
|
func (s *swapClientServer) populateBlocksUntilExpiry(ctx context.Context,
|
|
deposits []*looprpc.Deposit) error {
|
|
|
|
lndInfo, err := s.lnd.Client.GetInfo(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
bestBlockHeight := int64(lndInfo.BlockHeight)
|
|
params, err := s.staticAddressManager.GetStaticAddressParameters(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range len(deposits) {
|
|
deposits[i].BlocksUntilExpiry = depositBlocksUntilExpiry(
|
|
deposits[i].ConfirmationHeight, params.Expiry,
|
|
bestBlockHeight,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// depositBlocksUntilExpiry returns the remaining blocks until a deposit
|
|
// expires. Unconfirmed deposits return the full CSV value because the timeout
|
|
// has not started yet.
|
|
func depositBlocksUntilExpiry(confirmationHeight int64, expiry uint32,
|
|
bestBlockHeight int64) int64 {
|
|
|
|
if confirmationHeight <= 0 {
|
|
return int64(expiry)
|
|
}
|
|
|
|
return confirmationHeight + int64(expiry) - bestBlockHeight
|
|
}
|
|
|
|
// StaticOpenChannel initiates an open channel request using static address
|
|
// deposits.
|
|
func (s *swapClientServer) StaticOpenChannel(ctx context.Context,
|
|
req *looprpc.StaticOpenChannelRequest) (*looprpc.StaticOpenChannelResponse,
|
|
error) {
|
|
|
|
infof("Static open channel request received")
|
|
|
|
if req == nil || req.OpenChannelRequest == nil {
|
|
return &looprpc.StaticOpenChannelResponse{},
|
|
fmt.Errorf("missing open channel request")
|
|
}
|
|
|
|
chanOutpoint, err := s.openChannelManager.DeliverOpenChannelRequest(
|
|
ctx, req.OpenChannelRequest,
|
|
)
|
|
|
|
var outpointStr string
|
|
if chanOutpoint != nil {
|
|
outpointStr = chanOutpoint.String()
|
|
}
|
|
|
|
return &looprpc.StaticOpenChannelResponse{
|
|
ChannelOpenOutpoint: outpointStr,
|
|
}, err
|
|
}
|
|
|
|
type filterFunc func(deposits *deposit.Deposit) bool
|
|
|
|
func filter(deposits []*deposit.Deposit, f filterFunc) []*looprpc.Deposit {
|
|
var clientDeposits []*looprpc.Deposit
|
|
for _, d := range deposits {
|
|
if !f(d) {
|
|
continue
|
|
}
|
|
|
|
swapHash := make([]byte, 0, len(lntypes.Hash{}))
|
|
if d.SwapHash != nil {
|
|
swapHash = d.SwapHash[:]
|
|
}
|
|
|
|
hash := d.Hash
|
|
outpoint := wire.NewOutPoint(&hash, d.Index).String()
|
|
deposit := &looprpc.Deposit{
|
|
Id: d.ID[:],
|
|
State: toClientDepositState(
|
|
d.GetState(),
|
|
),
|
|
Outpoint: outpoint,
|
|
Value: int64(d.Value),
|
|
ConfirmationHeight: d.GetConfirmationHeight(),
|
|
SwapHash: swapHash,
|
|
}
|
|
|
|
clientDeposits = append(clientDeposits, deposit)
|
|
}
|
|
|
|
return clientDeposits
|
|
}
|
|
|
|
func toClientDepositState(state fsm.StateType) looprpc.DepositState {
|
|
switch state {
|
|
case deposit.Deposited:
|
|
return looprpc.DepositState_DEPOSITED
|
|
|
|
case deposit.Withdrawing:
|
|
return looprpc.DepositState_WITHDRAWING
|
|
|
|
case deposit.Withdrawn:
|
|
return looprpc.DepositState_WITHDRAWN
|
|
|
|
case deposit.PublishExpirySweep:
|
|
return looprpc.DepositState_PUBLISH_EXPIRED
|
|
|
|
case deposit.LoopingIn:
|
|
return looprpc.DepositState_LOOPING_IN
|
|
|
|
case deposit.LoopedIn:
|
|
return looprpc.DepositState_LOOPED_IN
|
|
|
|
case deposit.OpeningChannel:
|
|
return looprpc.DepositState_OPENING_CHANNEL
|
|
|
|
case deposit.ChannelPublished:
|
|
return looprpc.DepositState_CHANNEL_PUBLISHED
|
|
|
|
case deposit.SweepHtlcTimeout:
|
|
return looprpc.DepositState_SWEEP_HTLC_TIMEOUT
|
|
|
|
case deposit.HtlcTimeoutSwept:
|
|
return looprpc.DepositState_HTLC_TIMEOUT_SWEPT
|
|
|
|
case deposit.WaitForExpirySweep:
|
|
return looprpc.DepositState_WAIT_FOR_EXPIRY_SWEEP
|
|
|
|
case deposit.Expired:
|
|
return looprpc.DepositState_EXPIRED
|
|
|
|
default:
|
|
return looprpc.DepositState_UNKNOWN_STATE
|
|
}
|
|
}
|
|
|
|
// toClientStaticAddressLoopInState maps the static-address loop-in FSM state
|
|
// to the RPC enum exposed to clients.
|
|
func toClientStaticAddressLoopInState(
|
|
state fsm.StateType) looprpc.StaticAddressLoopInSwapState {
|
|
|
|
switch state {
|
|
case loopin.InitHtlcTx:
|
|
return looprpc.StaticAddressLoopInSwapState_INIT_HTLC
|
|
|
|
case loopin.SignHtlcTx:
|
|
return looprpc.StaticAddressLoopInSwapState_SIGN_HTLC_TX
|
|
|
|
case loopin.MonitorInvoiceAndHtlcTx:
|
|
return looprpc.StaticAddressLoopInSwapState_MONITOR_INVOICE_HTLC_TX
|
|
|
|
case loopin.PaymentReceived:
|
|
return looprpc.StaticAddressLoopInSwapState_PAYMENT_RECEIVED
|
|
|
|
case loopin.SweepHtlcTimeout:
|
|
return looprpc.StaticAddressLoopInSwapState_SWEEP_STATIC_ADDRESS_HTLC_TIMEOUT
|
|
|
|
case loopin.MonitorHtlcTimeoutSweep:
|
|
return looprpc.StaticAddressLoopInSwapState_MONITOR_HTLC_TIMEOUT_SWEEP
|
|
|
|
case loopin.HtlcTimeoutSwept:
|
|
return looprpc.StaticAddressLoopInSwapState_HTLC_STATIC_ADDRESS_TIMEOUT_SWEPT
|
|
|
|
case loopin.Succeeded:
|
|
return looprpc.StaticAddressLoopInSwapState_SUCCEEDED
|
|
|
|
case loopin.SucceededTransitioningFailed:
|
|
return looprpc.StaticAddressLoopInSwapState_SUCCEEDED_TRANSITIONING_FAILED
|
|
|
|
case loopin.UnlockDeposits:
|
|
return looprpc.StaticAddressLoopInSwapState_UNLOCK_DEPOSITS
|
|
|
|
case loopin.Failed:
|
|
return looprpc.StaticAddressLoopInSwapState_FAILED_STATIC_ADDRESS_SWAP
|
|
|
|
default:
|
|
return looprpc.StaticAddressLoopInSwapState_UNKNOWN_STATIC_ADDRESS_SWAP_STATE
|
|
}
|
|
}
|
|
|
|
func toServerState(state looprpc.DepositState) fsm.StateType {
|
|
switch state {
|
|
case looprpc.DepositState_DEPOSITED:
|
|
return deposit.Deposited
|
|
|
|
case looprpc.DepositState_WITHDRAWING:
|
|
return deposit.Withdrawing
|
|
|
|
case looprpc.DepositState_WITHDRAWN:
|
|
return deposit.Withdrawn
|
|
|
|
case looprpc.DepositState_PUBLISH_EXPIRED:
|
|
return deposit.PublishExpirySweep
|
|
|
|
case looprpc.DepositState_LOOPING_IN:
|
|
return deposit.LoopingIn
|
|
|
|
case looprpc.DepositState_LOOPED_IN:
|
|
return deposit.LoopedIn
|
|
|
|
case looprpc.DepositState_OPENING_CHANNEL:
|
|
return deposit.OpeningChannel
|
|
|
|
case looprpc.DepositState_CHANNEL_PUBLISHED:
|
|
return deposit.ChannelPublished
|
|
|
|
case looprpc.DepositState_SWEEP_HTLC_TIMEOUT:
|
|
return deposit.SweepHtlcTimeout
|
|
|
|
case looprpc.DepositState_HTLC_TIMEOUT_SWEPT:
|
|
return deposit.HtlcTimeoutSwept
|
|
|
|
case looprpc.DepositState_WAIT_FOR_EXPIRY_SWEEP:
|
|
return deposit.WaitForExpirySweep
|
|
|
|
case looprpc.DepositState_EXPIRED:
|
|
return deposit.Expired
|
|
|
|
default:
|
|
return fsm.EmptyState
|
|
}
|
|
}
|
|
|
|
func rpcAutoloopReason(reason liquidity.Reason) (looprpc.AutoReason, error) {
|
|
switch reason {
|
|
case liquidity.ReasonNone:
|
|
return looprpc.AutoReason_AUTO_REASON_UNKNOWN, nil
|
|
|
|
case liquidity.ReasonBudgetNotStarted:
|
|
return looprpc.AutoReason_AUTO_REASON_BUDGET_NOT_STARTED, nil
|
|
|
|
case liquidity.ReasonSweepFees:
|
|
return looprpc.AutoReason_AUTO_REASON_SWEEP_FEES, nil
|
|
|
|
case liquidity.ReasonBudgetElapsed:
|
|
return looprpc.AutoReason_AUTO_REASON_BUDGET_ELAPSED, nil
|
|
|
|
case liquidity.ReasonInFlight:
|
|
return looprpc.AutoReason_AUTO_REASON_IN_FLIGHT, nil
|
|
|
|
case liquidity.ReasonSwapFee:
|
|
return looprpc.AutoReason_AUTO_REASON_SWAP_FEE, nil
|
|
|
|
case liquidity.ReasonMinerFee:
|
|
return looprpc.AutoReason_AUTO_REASON_MINER_FEE, nil
|
|
|
|
case liquidity.ReasonPrepay:
|
|
return looprpc.AutoReason_AUTO_REASON_PREPAY, nil
|
|
|
|
case liquidity.ReasonFailureBackoff:
|
|
return looprpc.AutoReason_AUTO_REASON_FAILURE_BACKOFF, nil
|
|
|
|
case liquidity.ReasonLoopOut:
|
|
return looprpc.AutoReason_AUTO_REASON_LOOP_OUT, nil
|
|
|
|
case liquidity.ReasonLoopIn:
|
|
return looprpc.AutoReason_AUTO_REASON_LOOP_IN, nil
|
|
|
|
case liquidity.ReasonLiquidityOk:
|
|
return looprpc.AutoReason_AUTO_REASON_LIQUIDITY_OK, nil
|
|
|
|
case liquidity.ReasonBudgetInsufficient:
|
|
return looprpc.AutoReason_AUTO_REASON_BUDGET_INSUFFICIENT, nil
|
|
|
|
case liquidity.ReasonFeePPMInsufficient:
|
|
return looprpc.AutoReason_AUTO_REASON_SWAP_FEE, nil
|
|
|
|
case liquidity.ReasonStaticLoopInNoCandidate:
|
|
return looprpc.AutoReason_AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE,
|
|
nil
|
|
|
|
case liquidity.ReasonCustomChannelData:
|
|
return looprpc.AutoReason_AUTO_REASON_CUSTOM_CHANNEL_DATA, nil
|
|
|
|
default:
|
|
return 0, fmt.Errorf("unknown autoloop reason: %v", reason)
|
|
}
|
|
}
|
|
|
|
// processStatusUpdates reads updates on the status channel and processes them.
|
|
//
|
|
// NOTE: This must run inside a goroutine as it blocks until the main context
|
|
// shuts down.
|
|
func (s *swapClientServer) processStatusUpdates(mainCtx context.Context) {
|
|
for {
|
|
select {
|
|
// On updates, refresh the server's in-memory state and inform
|
|
// subscribers about the changes.
|
|
case swp := <-s.statusChan:
|
|
s.swapsLock.Lock()
|
|
// Static loop-ins are broadcast to monitor subscribers, but they
|
|
// stay out of the legacy swap cache so ListSwaps and SwapInfo remain
|
|
// traditional-swap views.
|
|
if swp.SwapType != swap.TypeStaticAddressLoopIn {
|
|
s.swaps[swp.SwapHash] = swp
|
|
}
|
|
|
|
for _, subscriber := range s.subscribers {
|
|
select {
|
|
case subscriber <- swp:
|
|
case <-mainCtx.Done():
|
|
s.swapsLock.Unlock()
|
|
return
|
|
}
|
|
}
|
|
|
|
s.swapsLock.Unlock()
|
|
|
|
// Server is shutting down.
|
|
case <-mainCtx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// validateConfTarget ensures the given confirmation target is valid. If one
|
|
// isn't specified (0 value), then the default target is used.
|
|
func validateConfTarget(target, defaultTarget int32) (int32, error) {
|
|
switch {
|
|
case target == 0:
|
|
return defaultTarget, nil
|
|
|
|
// Ensure the target respects our minimum threshold.
|
|
case target < minConfTarget:
|
|
return 0, fmt.Errorf("%w: A confirmation target of at "+
|
|
"least %v must be provided", errConfTargetTooLow,
|
|
minConfTarget)
|
|
|
|
default:
|
|
return target, nil
|
|
}
|
|
}
|
|
|
|
// validateLoopInRequest fails if the mutually exclusive conf target and
|
|
// external parameters are both set. It returns the confirmation target of the
|
|
// legacy loop-in.
|
|
func validateLoopInRequest(htlcConfTarget int32, external bool,
|
|
numDeposits uint32, amount btcutil.Amount,
|
|
autoSelectDeposits bool) (int32, error) {
|
|
|
|
if amount < 0 {
|
|
return 0, errors.New("amount cannot be negative")
|
|
}
|
|
|
|
if amount == 0 && numDeposits == 0 {
|
|
return 0, errors.New("either amount, or deposits or both " +
|
|
"must be set")
|
|
}
|
|
|
|
if autoSelectDeposits && numDeposits > 0 {
|
|
return 0, errors.New("cannot auto-select deposits while " +
|
|
"providing deposits at the same time")
|
|
}
|
|
|
|
// If the htlc is going to be externally set, the htlcConfTarget should
|
|
// not be set, because it has no relevance when the htlc is external.
|
|
if external && htlcConfTarget != 0 {
|
|
return 0, errors.New("external and htlc conf target cannot " +
|
|
"both be set")
|
|
}
|
|
|
|
// If the htlc is being externally published, we do not need to set a
|
|
// confirmation target.
|
|
if external {
|
|
return 0, nil
|
|
}
|
|
|
|
// If the loop in uses static address deposits, we do not need to set a
|
|
// confirmation target since the HTLC won't be published by the client.
|
|
if numDeposits > 0 || autoSelectDeposits {
|
|
return 0, nil
|
|
}
|
|
|
|
return validateConfTarget(htlcConfTarget, loop.DefaultHtlcConfTarget)
|
|
}
|
|
|
|
// validateLoopOutRequest validates the confirmation target, destination
|
|
// address and label of the loop out request. It also checks that the requested
|
|
// loop amount is valid given the available balance.
|
|
func validateLoopOutRequest(ctx context.Context, lnd lndclient.LightningClient,
|
|
chainParams *chaincfg.Params, req *looprpc.LoopOutRequest,
|
|
sweepAddr btcutil.Address, maxParts uint32) (int32, error) {
|
|
|
|
// Check that the provided destination address has the correct format
|
|
// for the active network.
|
|
if !sweepAddr.IsForNet(chainParams) {
|
|
return 0, fmt.Errorf("%w: Current active network is %s",
|
|
errIncorrectChain, chainParams.Name)
|
|
}
|
|
|
|
// Check that the provided destination address is a supported
|
|
// address format.
|
|
switch sweepAddr.(type) {
|
|
case *btcutil.AddressTaproot,
|
|
*btcutil.AddressWitnessScriptHash,
|
|
*btcutil.AddressWitnessPubKeyHash,
|
|
*btcutil.AddressScriptHash,
|
|
*btcutil.AddressPubKeyHash:
|
|
|
|
default:
|
|
return 0, errInvalidAddress
|
|
}
|
|
|
|
// If this is an asset payment, we'll check that we have the necessary
|
|
// outbound asset capacaity to fulfill the request.
|
|
if req.AssetInfo != nil {
|
|
// Todo(sputn1ck) actually check outbound capacity.
|
|
return validateConfTarget(
|
|
req.SweepConfTarget, loop.DefaultSweepConfTarget,
|
|
)
|
|
}
|
|
|
|
// Check that the label is valid.
|
|
if err := labels.Validate(req.Label); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
channels, err := lnd.ListChannels(ctx, false, false)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
unlimitedChannels := len(req.OutgoingChanSet) == 0
|
|
outgoingChanSetMap := make(map[uint64]bool)
|
|
for _, chanID := range req.OutgoingChanSet {
|
|
outgoingChanSetMap[chanID] = true
|
|
}
|
|
|
|
var activeChannelSet []lndclient.ChannelInfo
|
|
for _, c := range channels {
|
|
// Don't bother looking at inactive channels.
|
|
if !c.Active {
|
|
continue
|
|
}
|
|
|
|
// If no outgoing channel set was specified then all active
|
|
// channels are considered. However, if a channel set was
|
|
// specified then only the specified channels are considered.
|
|
if unlimitedChannels || outgoingChanSetMap[c.ChannelID] {
|
|
activeChannelSet = append(activeChannelSet, c)
|
|
}
|
|
}
|
|
|
|
// Determine if the loop out request is theoretically possible given
|
|
// the amount requested, the maximum possible routing fees,
|
|
// the available channel set and the fact that equal splitting is
|
|
// used for MPP.
|
|
//
|
|
// TODO: Also account for the quoted server fee and the concurrent prepay
|
|
// payment. The CLI and autoloop obtain the fee and prepay amounts from a
|
|
// quote, but this RPC only carries maximum limits, which direct callers may
|
|
// set higher than the quoted amounts. Treating those limits as exact can
|
|
// reject a viable swap, while the actual routing fees are only known when
|
|
// the invoices are paid.
|
|
requiredBalance := btcutil.Amount(req.Amt + req.MaxSwapRoutingFee)
|
|
isRoutable, _ := hasBandwidth(activeChannelSet, requiredBalance,
|
|
int(maxParts))
|
|
|
|
if !isRoutable {
|
|
return 0, fmt.Errorf("%w: Requested swap amount of %d "+
|
|
"sats along with the maximum routing fee of %d sats "+
|
|
"is more than what can be routed given current state "+
|
|
"of the channel set", errBalanceTooLow, req.Amt,
|
|
req.MaxSwapRoutingFee)
|
|
}
|
|
|
|
return validateConfTarget(
|
|
req.SweepConfTarget, loop.DefaultSweepConfTarget,
|
|
)
|
|
}
|
|
|
|
// hasBandwidth simulates the MPP splitting logic that will be used by LND when
|
|
// attempting to route the payment. This function is used to evaluate if a
|
|
// payment will be routable given the splitting logic used by LND.
|
|
// It returns true if the amount is routable given the channel set and the
|
|
// maximum number of shards allowed. If the amount is routable then the number
|
|
// of shards used is also returned. This function makes an assumption that the
|
|
// minimum loop amount divided by max parts will not be less than the minimum
|
|
// shard amount. If the MPP logic changes, then this function should be updated.
|
|
func hasBandwidth(channels []lndclient.ChannelInfo, amt btcutil.Amount,
|
|
maxParts int) (bool, int) {
|
|
|
|
tracef("Checking if %v sats can be routed with %v parts over "+
|
|
"channel set of length %v", amt, maxParts, len(channels))
|
|
|
|
localBalances := make([]btcutil.Amount, len(channels))
|
|
var totalBandwidth btcutil.Amount
|
|
for i, channel := range channels {
|
|
localBalance := channel.LocalBalance
|
|
var reserve btcutil.Amount
|
|
if channel.LocalConstraints != nil {
|
|
reserve = channel.LocalConstraints.Reserve
|
|
}
|
|
|
|
if reserve >= localBalance {
|
|
localBalance = 0
|
|
} else {
|
|
localBalance -= reserve
|
|
}
|
|
|
|
tracef("Channel %v: local=%v reserve=%v available=%v "+
|
|
"remote=%v", channel.ChannelID, channel.LocalBalance,
|
|
reserve, localBalance, channel.RemoteBalance)
|
|
|
|
localBalances[i] = localBalance
|
|
totalBandwidth += localBalance
|
|
}
|
|
|
|
tracef("Total bandwidth: %v", totalBandwidth)
|
|
if totalBandwidth < amt {
|
|
return false, 0
|
|
}
|
|
|
|
logLocalBalances := func(shard int) {
|
|
tracef("Local balances for %v shards:", shard)
|
|
for i, balance := range localBalances {
|
|
tracef("Channel %v: localBalances[%v]=%v",
|
|
channels[i].ChannelID, i, balance)
|
|
}
|
|
}
|
|
|
|
split := amt
|
|
for shard := 0; shard <= maxParts; {
|
|
tracef("Trying to split %v sats into %v parts", amt, shard)
|
|
|
|
paid := false
|
|
for i := range len(localBalances) {
|
|
if localBalances[i] >= split {
|
|
tracef("len(shards)=%v: Local channel "+
|
|
"balance %v can pay %v sats",
|
|
shard, localBalances[i], split)
|
|
|
|
localBalances[i] -= split
|
|
tracef("len(shards)=%v: Subtracted "+
|
|
"%v sats from localBalance[%v]=%v",
|
|
shard, split, i, localBalances[i])
|
|
|
|
amt -= split
|
|
tracef("len(shards)=%v: Remaining total "+
|
|
"amount amt=%v", shard, amt)
|
|
|
|
paid = true
|
|
shard++
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
logLocalBalances(shard)
|
|
|
|
if amt == 0 {
|
|
tracef("Payment is routable with %v part(s)", shard)
|
|
|
|
return true, shard
|
|
}
|
|
|
|
if !paid {
|
|
tracef("len(shards)=%v: No channel could pay %v "+
|
|
"sats, halving payment to %v and trying again",
|
|
split/2)
|
|
|
|
split /= 2
|
|
} else {
|
|
tracef("len(shards)=%v: Payment was made, trying "+
|
|
"to pay remaining sats %v", shard, amt)
|
|
|
|
split = amt
|
|
}
|
|
}
|
|
|
|
tracef("Payment is not routable, remaining amount that can't be "+
|
|
"sent: %v sats", amt)
|
|
|
|
logLocalBalances(maxParts)
|
|
|
|
return false, 0
|
|
}
|
|
|
|
// getPublicationDeadline returns the publication deadline for a swap given the
|
|
// unix timestamp. If the timestamp is believed to be in milliseconds, then it
|
|
// is converted to seconds.
|
|
func getPublicationDeadline(unixTimestamp uint64) time.Time {
|
|
length := len(fmt.Sprintf("%d", unixTimestamp))
|
|
if length >= 13 {
|
|
// Likely a millisecond timestamp
|
|
secs := unixTimestamp / 1000
|
|
nsecs := (unixTimestamp % 1000) * 1e6
|
|
return time.Unix(int64(secs), int64(nsecs))
|
|
} else {
|
|
// Likely a second timestamp
|
|
return time.Unix(int64(unixTimestamp), 0)
|
|
}
|
|
}
|
|
|
|
// ToClientReservations converts a slice of server
|
|
// reservations to a slice of client reservations.
|
|
func ToClientReservations(
|
|
res []*reservation.Reservation) []*looprpc.ClientReservation {
|
|
|
|
var result []*looprpc.ClientReservation
|
|
for _, r := range res {
|
|
result = append(result, toClientReservation(r))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// toClientReservation converts a server reservation to a
|
|
// client reservation.
|
|
func toClientReservation(
|
|
res *reservation.Reservation) *looprpc.ClientReservation {
|
|
|
|
var (
|
|
txid string
|
|
vout uint32
|
|
)
|
|
if res.Outpoint != nil {
|
|
txid = res.Outpoint.Hash.String()
|
|
vout = res.Outpoint.Index
|
|
}
|
|
|
|
return &looprpc.ClientReservation{
|
|
ReservationId: res.ID[:],
|
|
State: string(res.State),
|
|
Amount: uint64(res.Value),
|
|
TxId: txid,
|
|
Vout: vout,
|
|
Expiry: res.Expiry,
|
|
}
|
|
}
|
|
|
|
// marshalFixedPoint marshals a fixed point from the tap rfqmath package to the
|
|
// looprpc package.
|
|
func marshalFixedPoint(bigIntFixedPoint *rfqmath.BigIntFixedPoint,
|
|
) *looprpc.FixedPoint {
|
|
|
|
return &looprpc.FixedPoint{
|
|
Coefficient: bigIntFixedPoint.Coefficient.String(),
|
|
Scale: uint32(bigIntFixedPoint.Scale),
|
|
}
|
|
}
|