Browse Source

Merge pull request #295 from carlaKC/205-autoout

liquidity: add budget-limited autoloop
pull/305/head
Carla Kirk-Cohen 3 months ago
committed by GitHub
parent
commit
afc41adab0
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1549 additions and 203 deletions
  1. +44
    -0
      cmd/loop/liquidity.go
  2. +1
    -0
      go.mod
  3. +7
    -0
      go.sum
  4. +13
    -7
      labels/labels.go
  5. +290
    -0
      liquidity/autoloop_test.go
  6. +211
    -0
      liquidity/autoloop_testcontext_test.go
  7. +328
    -10
      liquidity/liquidity.go
  8. +333
    -24
      liquidity/liquidity_test.go
  9. +15
    -2
      loopd/daemon.go
  10. +33
    -1
      loopd/swapclient_server.go
  11. +3
    -0
      loopd/utils.go
  12. +3
    -2
      loopdb/loopin.go
  13. +5
    -0
      loopdb/swapstate.go
  14. +0
    -6
      loopin.go
  15. +0
    -6
      loopout.go
  16. +197
    -145
      looprpc/client.pb.go
  17. +27
    -0
      looprpc/client.proto
  18. +20
    -0
      looprpc/client.swagger.json
  19. +12
    -0
      release_notes.md
  20. +7
    -0
      test/lightning_client_mock.go

+ 44
- 0
cmd/loop/liquidity.go View File

@ -224,6 +224,30 @@ var setParamsCommand = cli.Command{
"previously had a failed swap will be " +
"included in suggestions.",
},
cli.BoolFlag{
Name: "autoout",
Usage: "set to true to enable automated dispatch " +
"of loop out swaps, limited to the budget " +
"set by autobudget",
},
cli.Uint64Flag{
Name: "autobudget",
Usage: "the maximum amount of fees in satoshis that " +
"automatically dispatched loop out swaps may " +
"spend",
},
cli.Uint64Flag{
Name: "budgetstart",
Usage: "the start time for the automated loop " +
"out budget, expressed as a unix timestamp " +
"in seconds",
},
cli.Uint64Flag{
Name: "autoinflight",
Usage: "the maximum number of automatically " +
"dispatched swaps that we allow to be in " +
"flight",
},
},
Action: setParams,
}
@ -304,6 +328,26 @@ func setParams(ctx *cli.Context) error {
flagSet = true
}
if ctx.IsSet("autoout") {
params.AutoLoopOut = ctx.Bool("autoout")
flagSet = true
}
if ctx.IsSet("autobudget") {
params.AutoOutBudgetSat = ctx.Uint64("autobudget")
flagSet = true
}
if ctx.IsSet("budgetstart") {
params.AutoOutBudgetStartSec = ctx.Uint64("budgetstart")
flagSet = true
}
if ctx.IsSet("autoinflight") {
params.AutoMaxInFlight = ctx.Uint64("autoinflight")
flagSet = true
}
if !flagSet {
return fmt.Errorf("at least one flag required to set params")
}

+ 1
- 0
go.mod View File

@ -16,6 +16,7 @@ require (
github.com/lightningnetwork/lnd/cert v1.0.3
github.com/lightningnetwork/lnd/clock v1.0.1
github.com/lightningnetwork/lnd/queue v1.0.4
github.com/lightningnetwork/lnd/ticker v1.0.0
github.com/stretchr/testify v1.5.1
github.com/urfave/cli v1.20.0
golang.org/x/net v0.0.0-20191002035440-2ec189313ef0

+ 7
- 0
go.sum View File

@ -1,10 +1,14 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
git.schwanenlied.me/yawning/bsaes.git v0.0.0-20180720073208-c0276d75487e h1:F2x1bq7RaNCIuqYpswggh1+c1JmwdnkHNC9wy1KDip0=
git.schwanenlied.me/yawning/bsaes.git v0.0.0-20180720073208-c0276d75487e/go.mod h1:BWqTsj8PgcPriQJGl7el20J/7TuT1d/hSyFDXMEpoEo=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e h1:n+DcnTNkQnHlwpsrHoQtkrJIO7CBx029fw6oR4vIob4=
github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e/go.mod h1:Bdzq+51GR4/0DIhaICZEOm+OHvXGwwB2trKZ8B4Y6eQ=
github.com/NebulousLabs/go-upnp v0.0.0-20180202185039-29b680b06c82 h1:MG93+PZYs9PyEsj/n5/haQu2gK0h4tUtSy9ejtMwWa0=
github.com/NebulousLabs/go-upnp v0.0.0-20180202185039-29b680b06c82/go.mod h1:GbuBk21JqF+driLX3XtJYNZjGa45YDoa9IqCTzNSfEc=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/Yawning/aez v0.0.0-20180114000226-4dad034d9db2 h1:2be4ykKKov3M1yISM2E8gnGXZ/N2SsPawfnGiXxaYEU=
github.com/Yawning/aez v0.0.0-20180114000226-4dad034d9db2/go.mod h1:9pIqrY6SXNL8vjRQE5Hd/OL5GyK/9MrGUWs87z/eFfk=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA=
@ -131,7 +135,9 @@ github.com/grpc-ecosystem/grpc-gateway v1.14.3 h1:OCJlWkOUoTnl0neNGlf4fUm3TmbEtg
github.com/grpc-ecosystem/grpc-gateway v1.14.3/go.mod h1:6CwZWGDSPRJidgKAtJVvND6soZe6fT7iteq8wDPdhb0=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jackpal/gateway v1.0.5 h1:qzXWUJfuMdlLMtt0a3Dgt+xkWQiA5itDEITVJtuSwMc=
github.com/jackpal/gateway v1.0.5/go.mod h1:lTpwd4ACLXmpyiCTRtfiNyVnUmqT9RivzCDQetPfnjA=
github.com/jackpal/go-nat-pmp v0.0.0-20170405195558-28a68d0c24ad h1:heFfj7z0pGsNCekUlsFhO2jstxO4b5iQ665LjwM5mDc=
github.com/jackpal/go-nat-pmp v0.0.0-20170405195558-28a68d0c24ad/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
@ -253,6 +259,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5 h1:LnC5Kc/wtumK+WB441p7ynQJzVuNRJiqddSIE3IlSEQ=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tv42/zbase32 v0.0.0-20160707012821-501572607d02 h1:tcJ6OjwOMvExLlzrAVZute09ocAGa7KqOON60++Gz4E=
github.com/tv42/zbase32 v0.0.0-20160707012821-501572607d02/go.mod h1:tHlrkM198S068ZqfrO6S8HsoJq2bF3ETfTL+kt4tInY=
github.com/urfave/cli v1.18.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=

+ 13
- 7
labels/labels.go View File

@ -2,6 +2,8 @@ package labels
import (
"errors"
"fmt"
"strings"
)
const (
@ -11,6 +13,10 @@ const (
// Reserved is used as a prefix to separate labels that are created by
// loopd from those created by users.
Reserved = "[reserved]"
// autoOut is the label used for loop out swaps that are automatically
// dispatched.
autoOut = "autoloop-out"
)
var (
@ -22,6 +28,12 @@ var (
ErrReservedPrefix = errors.New("label contains reserved prefix")
)
// AutoOutLabel returns a label with the reserved prefix that identifies
// automatically dispatched loop outs.
func AutoOutLabel() string {
return fmt.Sprintf("%v: %v", Reserved, autoOut)
}
// Validate checks that a label is of appropriate length and is not in our list
// of reserved labels.
func Validate(label string) error {
@ -29,16 +41,10 @@ func Validate(label string) error {
return ErrLabelTooLong
}
// If the label is shorter than our reserved prefix, it cannot contain
// it.
if len(label) < len(Reserved) {
return nil
}
// Check if our label begins with our reserved prefix. We don't mind if
// it has our reserved prefix in another case, we just need to be able
// to reserve a subset of labels with this prefix.
if label[0:len(Reserved)] == Reserved {
if strings.HasPrefix(label, Reserved) {
return ErrReservedPrefix
}

+ 290
- 0
liquidity/autoloop_test.go View File

@ -0,0 +1,290 @@
package liquidity
import (
"testing"
"time"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwire"
)
// TestAutoLoopDisabled tests the case where we need to perform a swap, but
// autoloop is not enabled.
func TestAutoLoopDisabled(t *testing.T) {
defer test.Guard(t)()
// Set parameters for a channel that will require a swap.
channels := []lndclient.ChannelInfo{
channel1,
}
params := defaultParameters
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{
chanID1: chanRule,
}
c := newAutoloopTestCtx(t, params, channels)
c.start()
// We expect a single quote to be required for our swap on channel 1.
// We set its quote to have acceptable fees for our current limit.
quotes := []quoteRequestResp{
{
request: &loop.LoopOutQuoteRequest{
Amount: chan1Rec.Amount,
SweepConfTarget: chan1Rec.SweepConfTarget,
},
quote: testQuote,
},
}
// Trigger an autoloop attempt for our test context with no existing
// loop in/out swaps. We expect a swap for our channel to be suggested,
// but do not expect any swaps to be executed, since autoloop is
// disabled by default.
c.autoloop(1, chan1Rec.Amount+1, nil, quotes, nil)
// Trigger another autoloop, this time setting our server restrictions
// to have a minimum swap amount greater than the amount that we need
// to swap. In this case we don't even expect to get a quote, because
// our suggested swap is beneath the minimum swap size.
c.autoloop(chan1Rec.Amount+1, chan1Rec.Amount+2, nil, nil, nil)
c.stop()
}
// TestAutoLoopEnabled tests enabling the liquidity manger's autolooper. To keep
// the test simple, we do not update actual lnd channel balances, but rather
// run our mock with two channels that will always require a loop out according
// to our rules. This allows us to test the other restrictions placed on the
// autolooper (such as balance, and in-flight swaps) rather than need to worry
// about calculating swap amounts and thresholds.
func TestAutoLoopEnabled(t *testing.T) {
defer test.Guard(t)()
channels := []lndclient.ChannelInfo{
channel1, channel2,
}
// Create a set of parameters with autoloop enabled. The autoloop budget
// is set to allow exactly 2 swaps at the prices that we set in our
// test quotes.
params := Parameters{
AutoOut: true,
AutoFeeBudget: 40066,
AutoFeeStartDate: testTime,
MaxAutoInFlight: 2,
FailureBackOff: time.Hour,
SweepFeeRateLimit: 20000,
SweepConfTarget: 10,
MaximumPrepay: 20000,
MaximumSwapFeePPM: 1000,
MaximumRoutingFeePPM: 1000,
MaximumPrepayRoutingFeePPM: 1000,
MaximumMinerFee: 20000,
ChannelRules: map[lnwire.ShortChannelID]*ThresholdRule{
chanID1: chanRule,
chanID2: chanRule,
},
}
c := newAutoloopTestCtx(t, params, channels)
c.start()
// Calculate our maximum allowed fees and create quotes that fall within
// our budget.
var (
amt = chan1Rec.Amount
maxSwapFee = ppmToSat(amt, params.MaximumSwapFeePPM)
// Create a quote that is within our limits. We do not set miner
// fee because this value is not actually set by the server.
quote1 = &loop.LoopOutQuote{
SwapFee: maxSwapFee,
PrepayAmount: params.MaximumPrepay - 10,
}
quote2 = &loop.LoopOutQuote{
SwapFee: maxSwapFee,
PrepayAmount: params.MaximumPrepay - 20,
}
quoteRequest = &loop.LoopOutQuoteRequest{
Amount: amt,
SweepConfTarget: params.SweepConfTarget,
}
quotes = []quoteRequestResp{
{
request: quoteRequest,
quote: quote1,
},
{
request: quoteRequest,
quote: quote2,
},
}
maxRouteFee = ppmToSat(amt, params.MaximumRoutingFeePPM)
chan1Swap = &loop.OutRequest{
Amount: amt,
MaxSwapRoutingFee: maxRouteFee,
MaxPrepayRoutingFee: ppmToSat(
quote1.PrepayAmount,
params.MaximumPrepayRoutingFeePPM,
),
MaxSwapFee: quote1.SwapFee,
MaxPrepayAmount: quote1.PrepayAmount,
MaxMinerFee: params.MaximumMinerFee,
SweepConfTarget: params.SweepConfTarget,
OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()},
Label: labels.AutoOutLabel(),
}
chan2Swap = &loop.OutRequest{
Amount: amt,
MaxSwapRoutingFee: maxRouteFee,
MaxPrepayRoutingFee: ppmToSat(
quote2.PrepayAmount,
params.MaximumPrepayRoutingFeePPM,
),
MaxSwapFee: quote2.SwapFee,
MaxPrepayAmount: quote2.PrepayAmount,
MaxMinerFee: params.MaximumMinerFee,
SweepConfTarget: params.SweepConfTarget,
OutgoingChanSet: loopdb.ChannelSet{chanID2.ToUint64()},
Label: labels.AutoOutLabel(),
}
loopOuts = []loopOutRequestResp{
{
request: chan1Swap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{1},
},
},
{
request: chan2Swap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{2},
},
},
}
)
// Tick our autolooper with no existing swaps, we expect a loop out
// swap to be dispatched for each channel.
c.autoloop(1, amt+1, nil, quotes, loopOuts)
// Tick again with both of our swaps in progress. We haven't shifted our
// channel balances at all, so swaps should still be suggested, but we
// have 2 swaps in flight so we do not expect any suggestion.
existing := []*loopdb.LoopOut{
existingSwapFromRequest(chan1Swap, testTime, nil),
existingSwapFromRequest(chan2Swap, testTime, nil),
}
c.autoloop(1, amt+1, existing, nil, nil)
// Now, we update our channel 2 swap to have failed due to off chain
// failure and our first swap to have succeeded.
now := c.testClock.Now()
failedOffChain := []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateFailOffchainPayments,
},
Time: now,
},
}
success := []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateSuccess,
Cost: loopdb.SwapCost{
Server: quote1.SwapFee,
Onchain: params.MaximumMinerFee,
Offchain: maxRouteFee +
chan1Rec.MaxPrepayRoutingFee,
},
},
Time: now,
},
}
quotes = []quoteRequestResp{
{
request: quoteRequest,
quote: quote1,
},
}
loopOuts = []loopOutRequestResp{
{
request: chan1Swap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{3},
},
},
}
existing = []*loopdb.LoopOut{
existingSwapFromRequest(chan1Swap, testTime, success),
existingSwapFromRequest(chan2Swap, testTime, failedOffChain),
}
// We tick again, this time we expect another swap on channel 1 (which
// still has balances which reflect that we need to swap), but nothing
// for channel 2, since it has had a failure.
c.autoloop(1, amt+1, existing, quotes, loopOuts)
// Now, we progress our time so that we have sufficiently backed off
// for channel 2, and could perform another swap.
c.testClock.SetTime(now.Add(params.FailureBackOff))
// Our existing swaps (1 successful, one pending) have used our budget
// so we no longer expect any swaps to automatically dispatch.
existing = []*loopdb.LoopOut{
existingSwapFromRequest(chan1Swap, testTime, success),
existingSwapFromRequest(chan1Swap, c.testClock.Now(), nil),
existingSwapFromRequest(chan2Swap, testTime, failedOffChain),
}
c.autoloop(1, amt+1, existing, quotes, nil)
c.stop()
}
// existingSwapFromRequest is a helper function which returns the db
// representation of a loop out request with the event set provided.
func existingSwapFromRequest(request *loop.OutRequest, initTime time.Time,
events []*loopdb.LoopEvent) *loopdb.LoopOut {
return &loopdb.LoopOut{
Loop: loopdb.Loop{
Events: events,
},
Contract: &loopdb.LoopOutContract{
SwapContract: loopdb.SwapContract{
AmountRequested: request.Amount,
MaxSwapFee: request.MaxSwapFee,
MaxMinerFee: request.MaxMinerFee,
InitiationTime: initTime,
Label: request.Label,
},
SwapInvoice: "",
MaxSwapRoutingFee: request.MaxSwapRoutingFee,
SweepConfTarget: request.SweepConfTarget,
OutgoingChanSet: request.OutgoingChanSet,
MaxPrepayRoutingFee: request.MaxSwapRoutingFee,
},
}
}

+ 211
- 0
liquidity/autoloop_testcontext_test.go View File

@ -0,0 +1,211 @@
package liquidity
import (
"context"
"testing"
"github.com/btcsuite/btcutil"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/ticker"
"github.com/stretchr/testify/assert"
)
type autoloopTestCtx struct {
t *testing.T
manager *Manager
lnd *test.LndMockServices
testClock *clock.TestClock
// quoteRequests is a channel that requests for quotes are pushed into.
quoteRequest chan *loop.LoopOutQuoteRequest
// quotes is a channel that we get loop out quote requests on.
quotes chan *loop.LoopOutQuote
// loopOutRestrictions is a channel that we get the server's
// restrictions on.
loopOutRestrictions chan *Restrictions
// loopOuts is a channel that we get existing loop out swaps on.
loopOuts chan []*loopdb.LoopOut
// loopIns is a channel that we get existing loop in swaps on.
loopIns chan []*loopdb.LoopIn
// restrictions is a channel that we get swap restrictions on.
restrictions chan *Restrictions
// outRequest is a channel that requests to dispatch loop outs are
// pushed into.
outRequest chan *loop.OutRequest
// loopOut is a channel that we return loop out responses on.
loopOut chan *loop.LoopOutSwapInfo
// errChan is a channel that we send run errors into.
errChan chan error
// cancelCtx cancels the context that our liquidity manager is run with.
// This can be used to cleanly shutdown the test. Note that this will be
// nil until the test context has been started.
cancelCtx func()
}
// newAutoloopTestCtx creates a test context with custom liquidity manager
// parameters and lnd channels.
func newAutoloopTestCtx(t *testing.T, parameters Parameters,
channels []lndclient.ChannelInfo) *autoloopTestCtx {
// Create a mock lnd and set our expected fee rate for sweeps to our
// sweep fee rate limit value.
lnd := test.NewMockLnd()
lnd.SetFeeEstimate(
defaultParameters.SweepConfTarget,
defaultParameters.SweepFeeRateLimit,
)
testCtx := &autoloopTestCtx{
t: t,
testClock: clock.NewTestClock(testTime),
lnd: lnd,
quoteRequest: make(chan *loop.LoopOutQuoteRequest),
quotes: make(chan *loop.LoopOutQuote),
loopOutRestrictions: make(chan *Restrictions),
loopOuts: make(chan []*loopdb.LoopOut),
loopIns: make(chan []*loopdb.LoopIn),
restrictions: make(chan *Restrictions),
outRequest: make(chan *loop.OutRequest),
loopOut: make(chan *loop.LoopOutSwapInfo),
errChan: make(chan error, 1),
}
// Set lnd's channels to equal the set of channels we want for our
// test.
testCtx.lnd.Channels = channels
cfg := &Config{
AutoOutTicker: ticker.NewForce(DefaultAutoOutTicker),
LoopOutRestrictions: func(context.Context) (*Restrictions, error) {
return <-testCtx.loopOutRestrictions, nil
},
ListLoopOut: func() ([]*loopdb.LoopOut, error) {
return <-testCtx.loopOuts, nil
},
ListLoopIn: func() ([]*loopdb.LoopIn, error) {
return <-testCtx.loopIns, nil
},
LoopOutQuote: func(_ context.Context,
req *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote,
error) {
testCtx.quoteRequest <- req
return <-testCtx.quotes, nil
},
LoopOut: func(_ context.Context,
req *loop.OutRequest) (*loop.LoopOutSwapInfo,
error) {
testCtx.outRequest <- req
return <-testCtx.loopOut, nil
},
MinimumConfirmations: loop.DefaultSweepConfTarget,
Lnd: &testCtx.lnd.LndServices,
Clock: testCtx.testClock,
}
// Create a manager with our test config and set our starting set of
// parameters.
testCtx.manager = NewManager(cfg)
assert.NoError(t, testCtx.manager.SetParameters(parameters))
return testCtx
}
// start starts our liquidity manager's run loop in a goroutine. Tests should
// be run with test.Guard() to ensure that this does not leak.
func (c *autoloopTestCtx) start() {
ctx := context.Background()
ctx, c.cancelCtx = context.WithCancel(ctx)
go func() {
c.errChan <- c.manager.Run(ctx)
}()
}
// stop shuts down our test context and asserts that we have exited with a
// context-cancelled error.
func (c *autoloopTestCtx) stop() {
c.cancelCtx()
assert.Equal(c.t, context.Canceled, <-c.errChan)
}
// quoteRequestResp pairs an expected swap quote request with the response we
// would like to provide the liquidity manager with.
type quoteRequestResp struct {
request *loop.LoopOutQuoteRequest
quote *loop.LoopOutQuote
}
// loopOutRequestResp pairs an expected loop out request with the response we
// would like the server to respond with.
type loopOutRequestResp struct {
request *loop.OutRequest
response *loop.LoopOutSwapInfo
}
// autoloop walks our test context through the process of triggering our
// autoloop functionality, providing mocked values as required. The set of
// quotes provided indicates that we expect swap suggestions to be made (since
// we will query for a quote for each suggested swap). The set of expected
// swaps indicates whether we expect any of these swap suggestions to actually
// be dispatched by the autolooper.
func (c *autoloopTestCtx) autoloop(minAmt, maxAmt btcutil.Amount,
existingOut []*loopdb.LoopOut, quotes []quoteRequestResp,
expectedSwaps []loopOutRequestResp) {
// Tick our autoloop ticker to force assessing whether we want to loop.
c.manager.cfg.AutoOutTicker.Force <- testTime
// Send a mocked response from the server with the swap size limits.
c.loopOutRestrictions <- NewRestrictions(minAmt, maxAmt)
// Provide the liquidity manager with our desired existing set of swaps.
c.loopOuts <- existingOut
c.loopIns <- nil
// Assert that we query the server for a quote for each of our
// recommended swaps. Note that this differs from our set of expected
// swaps because we may get quotes for suggested swaps but then just
// log them.
for _, expected := range quotes {
request := <-c.quoteRequest
assert.Equal(
c.t, expected.request.Amount, request.Amount,
)
assert.Equal(
c.t, expected.request.SweepConfTarget,
request.SweepConfTarget,
)
c.quotes <- expected.quote
}
// Assert that we dispatch the expected set of swaps.
for _, expected := range expectedSwaps {
actual := <-c.outRequest
// Set our destination address to nil so that we do not need to
// provide the address that is obtained by the mock wallet kit.
actual.DestAddr = nil
assert.Equal(c.t, expected.request, actual)
c.loopOut <- expected.response
}
}

+ 328
- 10
liquidity/liquidity.go View File

@ -36,6 +36,7 @@ import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
@ -43,11 +44,14 @@ import (
"github.com/btcsuite/btcutil"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightningnetwork/lnd"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/ticker"
)
const (
@ -85,12 +89,35 @@ const (
// defaultSweepFeeRateLimit is the default limit we place on estimated
// sweep fees, (750 * 4 /1000 = 3 sat/vByte).
defaultSweepFeeRateLimit = chainfee.SatPerKWeight(750)
// defaultMaxInFlight is the default number of in-flight automatically
// dispatched swaps we allow. Note that this does not enable automated
// swaps itself (because we want non-zero values to be expressed in
// suggestions as a dry-run).
defaultMaxInFlight = 1
// DefaultAutoOutTicker is the default amount of time between automated
// loop out checks.
DefaultAutoOutTicker = time.Minute * 10
)
var (
// defaultBudget is the default autoloop budget we set. This budget will
// only be used for automatically dispatched swaps if autoloop is
// explicitly enabled, so we are happy to set a non-zero value here. The
// amount chosen simply uses the current defaults to provide budget for
// a single swap. We don't have a swap amount to calculate our maximum
// routing fee, so we use 0.16 BTC for now.
defaultBudget = defaultMaximumMinerFee +
ppmToSat(lnd.MaxBtcFundingAmount, defaultSwapFeePPM) +
ppmToSat(defaultMaximumPrepay, defaultPrepayRoutingFeePPM) +
ppmToSat(lnd.MaxBtcFundingAmount, defaultRoutingFeePPM)
// defaultParameters contains the default parameters that we start our
// liquidity manger with.
defaultParameters = Parameters{
AutoFeeBudget: defaultBudget,
MaxAutoInFlight: defaultMaxInFlight,
ChannelRules: make(map[lnwire.ShortChannelID]*ThresholdRule),
FailureBackOff: defaultFailureBackoff,
SweepFeeRateLimit: defaultSweepFeeRateLimit,
@ -125,11 +152,22 @@ var (
// ErrZeroPrepay is returned if a zero maximum prepay is set.
ErrZeroPrepay = errors.New("maximum prepay must be non-zero")
// ErrNegativeBudget is returned if a negative swap budget is set.
ErrNegativeBudget = errors.New("swap budget must be >= 0")
// ErrZeroInFlight is returned is a zero in flight swaps value is set.
ErrZeroInFlight = errors.New("max in flight swaps must be >=0")
)
// Config contains the external functionality required to run the
// liquidity manager.
type Config struct {
// AutoOutTicker determines how often we should check whether we want
// to dispatch an automated loop out. We use a force ticker so that
// we can trigger autoloop in itests.
AutoOutTicker *ticker.Force
// LoopOutRestrictions returns the restrictions that the server applies
// to loop out swaps.
LoopOutRestrictions func(ctx context.Context) (*Restrictions, error)
@ -148,6 +186,10 @@ type Config struct {
LoopOutQuote func(ctx context.Context,
request *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, error)
// LoopOut dispatches a loop out.
LoopOut func(ctx context.Context, request *loop.OutRequest) (
*loop.LoopOutSwapInfo, error)
// Clock allows easy mocking of time in unit tests.
Clock clock.Clock
@ -159,6 +201,23 @@ type Config struct {
// Parameters is a set of parameters provided by the user which guide
// how we assess liquidity.
type Parameters struct {
// AutoOut enables automatic dispatch of loop out swaps.
AutoOut bool
// AutoFeeBudget is the total amount we allow to be spent on
// automatically dispatched swaps. Once this budget has been used, we
// will stop dispatching swaps until the budget is increased or the
// start date is moved.
AutoFeeBudget btcutil.Amount
// AutoFeeStartDate is the date from which we will include automatically
// dispatched swaps in our current budget, inclusive.
AutoFeeStartDate time.Time
// MaxAutoInFlight is the maximum number of in-flight automatically
// dispatched swaps we allow.
MaxAutoInFlight int
// FailureBackOff is the amount of time that we require passes after a
// channel has been part of a failed loop out swap before we suggest
// using it again.
@ -219,12 +278,13 @@ func (p Parameters) String() string {
return fmt.Sprintf("channel rules: %v, failure backoff: %v, sweep "+
"fee rate limit: %v, sweep conf target: %v, maximum prepay: "+
"%v, maximum miner fee: %v, maximum swap fee ppm: %v, maximum "+
"routing fee ppm: %v, maximum prepay routing fee ppm: %v",
"routing fee ppm: %v, maximum prepay routing fee ppm: %v, "+
"auto budget: %v, budget start: %v, max auto in flight: %v",
strings.Join(channelRules, ","), p.FailureBackOff,
p.SweepFeeRateLimit, p.SweepConfTarget, p.MaximumPrepay,
p.MaximumMinerFee, p.MaximumSwapFeePPM,
p.MaximumRoutingFeePPM, p.MaximumPrepayRoutingFeePPM,
)
p.AutoFeeBudget, p.AutoFeeStartDate, p.MaxAutoInFlight)
}
// validate checks whether a set of parameters is valid. It takes the minimum
@ -275,6 +335,14 @@ func (p Parameters) validate(minConfs int32) error {
return ErrZeroMinerFee
}
if p.AutoFeeBudget < 0 {
return ErrNegativeBudget
}
if p.MaxAutoInFlight <= 0 {
return ErrZeroInFlight
}
return nil
}
@ -293,6 +361,26 @@ type Manager struct {
paramsLock sync.Mutex
}
// Run periodically checks whether we should automatically dispatch a loop out.
// We run this loop even if automated swaps are not currently enabled rather
// than managing starting and stopping the ticker as our parameters are updated.
func (m *Manager) Run(ctx context.Context) error {
m.cfg.AutoOutTicker.Resume()
defer m.cfg.AutoOutTicker.Stop()
for {
select {
case <-m.cfg.AutoOutTicker.Ticks():
if err := m.autoloop(ctx); err != nil {
log.Errorf("autoloop failed: %v", err)
}
case <-ctx.Done():
return ctx.Err()
}
}
}
// NewManager creates a liquidity manager which has no rules set.
func NewManager(cfg *Config) *Manager {
return &Manager{
@ -341,10 +429,37 @@ func cloneParameters(params Parameters) Parameters {
return paramCopy
}
// autoloop gets a set of suggested swaps and dispatches them automatically if
// we have automated looping enabled.
func (m *Manager) autoloop(ctx context.Context) error {
swaps, err := m.SuggestSwaps(ctx, true)
if err != nil {
return err
}
for _, swap := range swaps {
// Create a copy of our range var so that we can reference it.
swap := swap
loopOut, err := m.cfg.LoopOut(ctx, &swap)
if err != nil {
return err
}
log.Infof("loop out automatically dispatched: hash: %v, "+
"address: %v", loopOut.SwapHash,
loopOut.HtlcAddressP2WSH)
}
return nil
}
// SuggestSwaps returns a set of swap suggestions based on our current liquidity
// balance for the set of rules configured for the manager, failing if there are
// no rules set.
func (m *Manager) SuggestSwaps(ctx context.Context) (
// no rules set. It takes an autoOut boolean that indicates whether the
// suggestions are being used for our internal autolooper. This boolean is used
// to determine the information we add to our swap suggestion and whether we
// return any suggestions.
func (m *Manager) SuggestSwaps(ctx context.Context, autoOut bool) (
[]loop.OutRequest, error) {
m.paramsLock.Lock()
@ -356,6 +471,16 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
return nil, nil
}
// If our start date is in the future, we interpret this as meaning that
// we should start using our budget at this date. This means that we
// have no budget for the present, so we just return.
if m.params.AutoFeeStartDate.After(m.cfg.Clock.Now()) {
log.Debugf("autoloop fee budget start time: %v is in "+
"the future", m.params.AutoFeeStartDate)
return nil, nil
}
// Before we get any swap suggestions, we check what the current fee
// estimate is to sweep within our target number of confirmations. If
// This fee exceeds the fee limit we have set, we will not suggest any
@ -396,6 +521,32 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
return nil, err
}
// Get a summary of our existing swaps so that we can check our autoloop
// budget.
summary, err := m.checkExistingAutoLoops(ctx, loopOut)
if err != nil {
return nil, err
}
if summary.totalFees() >= m.params.AutoFeeBudget {
log.Debugf("autoloop fee budget: %v exhausted, %v spent on "+
"completed swaps, %v reserved for ongoing swaps "+
"(upper limit)",
m.params.AutoFeeBudget, summary.spentFees,
summary.pendingFees)
return nil, nil
}
// If we have already reached our total allowed number of in flight
// swaps, we do not suggest any more at the moment.
allowedSwaps := m.params.MaxAutoInFlight - summary.inFlightCount
if allowedSwaps <= 0 {
log.Debugf("%v autoloops allowed, %v in flight",
m.params.MaxAutoInFlight, summary.inFlightCount)
return nil, nil
}
eligible, err := m.getEligibleChannels(ctx, loopOut, loopIn)
if err != nil {
return nil, err
@ -445,11 +596,67 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
continue
}
outRequest := m.makeLoopOutRequest(suggestion, quote)
outRequest, err := m.makeLoopOutRequest(
ctx, suggestion, quote, autoOut,
)
if err != nil {
return nil, err
}
suggestions = append(suggestions, outRequest)
}
return suggestions, nil
// If we have no suggestions after we have applied all of our limits,
// just return.
if len(suggestions) == 0 {
return nil, nil
}
// Sort suggestions by amount in descending order.
sort.SliceStable(suggestions, func(i, j int) bool {
return suggestions[i].Amount > suggestions[j].Amount
})
// Run through our suggested swaps in descending order of amount and
// return all of the swaps which will fit within our remaining budget.
var (
available = m.params.AutoFeeBudget - summary.totalFees()
inBudget []loop.OutRequest
)
for _, swap := range suggestions {
fees := worstCaseOutFees(
swap.MaxPrepayRoutingFee, swap.MaxSwapRoutingFee,
swap.MaxSwapFee, swap.MaxMinerFee, swap.MaxPrepayAmount,
)
// If the maximum fee we expect our swap to use is less than the
// amount we have available, we add it to our set of swaps that
// fall within the budget and decrement our available amount.
if fees <= available {
available -= fees
inBudget = append(inBudget, swap)
}
// If we're out of budget, or we have hit the max number of
// swaps that we want to dispatch at one time, exit early.
if available == 0 || allowedSwaps == len(inBudget) {
break
}
}
// If we are getting suggestions for automatically dispatched swaps,
// and they are not enabled in our parameters, we just log the swap
// suggestions and return an empty set of suggestions.
if autoOut && !m.params.AutoOut {
for _, swap := range inBudget {
log.Debugf("recommended autoloop: %v sats over "+
"%v", swap.Amount, swap.OutgoingChanSet)
}
return nil, nil
}
return inBudget, nil
}
// makeLoopOutRequest creates a loop out request from a suggestion. Since we
@ -459,9 +666,13 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
// route-independent, which is a very poor estimation so we don't bother with
// checking against this inaccurate constant. We use the exact prepay amount
// and swap fee given to us by the server, but use our maximum miner fee anyway
// to give us some leeway when performing the swap.
func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation,
quote *loop.LoopOutQuote) loop.OutRequest {
// to give us some leeway when performing the swap. We take an auto-out which
// determines whether we set a label identifying this swap as automatically
// dispatched, and decides whether we set a sweep address (we don't bother for
// non-auto requests, because the client api will set it anyway).
func (m *Manager) makeLoopOutRequest(ctx context.Context,
suggestion *LoopOutRecommendation, quote *loop.LoopOutQuote,
autoOut bool) (loop.OutRequest, error) {
prepayMaxFee := ppmToSat(
quote.PrepayAmount, m.params.MaximumPrepayRoutingFeePPM,
@ -471,7 +682,7 @@ func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation,
suggestion.Amount, m.params.MaximumRoutingFeePPM,
)
return loop.OutRequest{
request := loop.OutRequest{
Amount: suggestion.Amount,
OutgoingChanSet: loopdb.ChannelSet{
suggestion.Channel.ToUint64(),
@ -483,6 +694,109 @@ func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation,
MaxPrepayAmount: quote.PrepayAmount,
SweepConfTarget: m.params.SweepConfTarget,
}
if autoOut {
request.Label = labels.AutoOutLabel()
addr, err := m.cfg.Lnd.WalletKit.NextAddr(ctx)
if err != nil {
return loop.OutRequest{}, err
}
request.DestAddr = addr
}
return request, nil
}
// worstCaseOutFees calculates the largest possible fees for a loop out swap,
// comparing the fees for a successful swap to the cost when the client pays
// the prepay because they failed to sweep the on chain htlc. This is unlikely,
// because we expect clients to be online to sweep, but we want to account for
// every outcome so we include it.
func worstCaseOutFees(prepayRouting, swapRouting, swapFee, minerFee,
prepayAmount btcutil.Amount) btcutil.Amount {
var (
successFees = prepayRouting + minerFee + swapFee + swapRouting
noShowFees = prepayRouting + prepayAmount
)
if noShowFees > successFees {
return noShowFees
}
return successFees
}
// existingAutoLoopSummary provides a summary of the existing autoloops which
// were dispatched during our current budget period.
type existingAutoLoopSummary struct {
// spentFees is the amount we have spent on completed swaps.
spentFees btcutil.Amount
// pendingFees is the worst-case amount of fees we could spend on in
// flight autoloops.
pendingFees btcutil.Amount
// inFlightCount is the total number of automated swaps that are
// currently in flight. Note that this may race with swap completion,
// but not with initiation of new automated swaps, this is ok, because
// it can only lead to dispatching fewer swaps than we could have (not
// too many).
inFlightCount int
}
// totalFees returns the total amount of fees that automatically dispatched
// swaps may consume.
func (e *existingAutoLoopSummary) totalFees() btcutil.Amount {
return e.spentFees + e.pendingFees
}
// checkExistingAutoLoops calculates the total amount that has been spent by
// automatically dispatched swaps that have completed, and the worst-case fee
// total for our set of ongoing, automatically dispatched swaps as well as a
// current in-flight count.
func (m *Manager) checkExistingAutoLoops(ctx context.Context,
loopOuts []*loopdb.LoopOut) (*existingAutoLoopSummary, error) {
var summary existingAutoLoopSummary
for _, out := range loopOuts {
if out.Contract.Label != labels.AutoOutLabel() {
continue
}
// If we have a pending swap, we are uncertain of the fees that
// it will end up paying. We use the worst-case estimate based
// on the maximum values we set for each fee category. This will
// likely over-estimate our fees (because we probably won't
// spend our maximum miner amount). If a swap is not pending,
// it has succeeded or failed so we just record our actual fees
// for the swap provided that the swap completed after our
// budget start date.
if out.State().State.Type() == loopdb.StateTypePending {
summary.inFlightCount++
prepay, err := m.cfg.Lnd.Client.DecodePaymentRequest(
ctx, out.Contract.PrepayInvoice,
)
if err != nil {
return nil, err
}
summary.pendingFees += worstCaseOutFees(
out.Contract.MaxPrepayRoutingFee,
out.Contract.MaxSwapRoutingFee,
out.Contract.MaxSwapFee,
out.Contract.MaxMinerFee,
mSatToSatoshis(prepay.Value),
)
} else if !out.LastUpdateTime().Before(m.params.AutoFeeStartDate) {
summary.spentFees += out.State().Cost.Total()
}
}
return &summary, nil
}
// getEligibleChannels takes lists of our existing loop out and in swaps, and
@ -653,3 +967,7 @@ func satPerKwToSatPerVByte(satPerKw chainfee.SatPerKWeight) int64 {
func ppmToSat(amount btcutil.Amount, ppm int) btcutil.Amount {
return btcutil.Amount(uint64(amount) * uint64(ppm) / FeeBase)
}
func mSatToSatoshis(amount lnwire.MilliSatoshi) btcutil.Amount {
return btcutil.Amount(amount / 1000)
}

+ 333
- 24
liquidity/liquidity_test.go View File

@ -8,6 +8,7 @@ import (
"github.com/btcsuite/btcutil"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/clock"
@ -18,7 +19,8 @@ import (
)
var (
testTime = time.Date(2020, 02, 13, 0, 0, 0, 0, time.UTC)
testTime = time.Date(2020, 02, 13, 0, 0, 0, 0, time.UTC)
testBudgetStart = testTime.Add(time.Hour * -1)
chanID1 = lnwire.NewShortChanIDFromInt(1)
chanID2 = lnwire.NewShortChanIDFromInt(2)
@ -89,6 +91,17 @@ var (
},
),
}
// autoOutContract is a contract for an existing loop out that was
// automatically dispatched. This swap is within our test budget period,
// and restricted to a channel that we do not use in our tests.
autoOutContract = &loopdb.LoopOutContract{
SwapContract: loopdb.SwapContract{
Label: labels.AutoOutLabel(),
InitiationTime: testBudgetStart,
},
OutgoingChanSet: loopdb.ChannelSet{999},
}
)
// newTestConfig creates a default test config.
@ -351,13 +364,16 @@ func TestRestrictedSuggestions(t *testing.T) {
return testCase.loopIn, nil
}
rules := map[lnwire.ShortChannelID]*ThresholdRule{
lnd.Channels = testCase.channels
params := defaultParameters
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{
chanID1: chanRule,
chanID2: chanRule,
}
testSuggestSwaps(
t, cfg, lnd, testCase.channels, rules,
t, newSuggestSwapsSetup(cfg, lnd, params),
testCase.expected,
)
})
@ -397,16 +413,18 @@ func TestSweepFeeLimit(t *testing.T) {
loop.DefaultSweepConfTarget, testCase.feeRate,
)
channels := []lndclient.ChannelInfo{
lnd.Channels = []lndclient.ChannelInfo{
channel1,
}
rules := map[lnwire.ShortChannelID]*ThresholdRule{
params := defaultParameters
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{
chanID1: chanRule,
}
testSuggestSwaps(
t, cfg, lnd, channels, rules, testCase.swaps,
t, newSuggestSwapsSetup(cfg, lnd, params),
testCase.swaps,
)
})
}
@ -448,12 +466,15 @@ func TestSuggestSwaps(t *testing.T) {
t.Run(testCase.name, func(t *testing.T) {
cfg, lnd := newTestConfig()
channels := []lndclient.ChannelInfo{
lnd.Channels = []lndclient.ChannelInfo{
channel1,
}
params := defaultParameters
params.ChannelRules = testCase.rules
testSuggestSwaps(
t, cfg, lnd, channels, testCase.rules,
t, newSuggestSwapsSetup(cfg, lnd, params),
testCase.swaps,
)
})
@ -513,43 +534,331 @@ func TestFeeLimits(t *testing.T) {
return testCase.quote, nil
}
channels := []lndclient.ChannelInfo{
lnd.Channels = []lndclient.ChannelInfo{
channel1,
}
rules := map[lnwire.ShortChannelID]*ThresholdRule{
params := defaultParameters
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{
chanID1: chanRule,
}
testSuggestSwaps(
t, cfg, lnd, channels, rules, testCase.expected,
t, newSuggestSwapsSetup(cfg, lnd, params),
testCase.expected,
)
})
}
}
// testSuggestSwaps tests getting swap suggestions.
func testSuggestSwaps(t *testing.T, cfg *Config, lnd *test.LndMockServices,
channels []lndclient.ChannelInfo,
rules map[lnwire.ShortChannelID]*ThresholdRule,
// TestFeeBudget tests limiting of swap suggestions to a fee budget, with and
// without existing swaps. This test uses example channels and rules which need
// a 7500 sat loop out. With our default parameters, and our test quote with
// a prepay of 500, our total fees are (rounded due to int multiplication):
// swap fee: 1 (as set in test quote)
// route fee: 7500 * 0.005 = 37
// prepay route: 500 * 0.005 = 2 sat
// max miner: set by default params
// Since our routing fees are calculated as a portion of our swap/prepay
// amounts, we use our max miner fee to shift swap cost to values above/below
// our budget, fixing our other fees at 114 sat for simplicity.
func TestFeeBudget(t *testing.T) {
tests := []struct {
name string
// budget is our autoloop budget.
budget btcutil.Amount
// maxMinerFee is the maximum miner fee we will pay for swaps.
maxMinerFee btcutil.Amount
// existingSwaps represents our existing swaps, mapping their
// last update time to their total cost.
existingSwaps map[time.Time]btcutil.Amount
// expectedSwaps is the set of swaps we expect to be suggested.
expectedSwaps []loop.OutRequest
}{
{
// Two swaps will cost (78+5000)*2, set exactly 10156
// budget.
name: "budget for 2 swaps, no existing",
budget: 10156,
maxMinerFee: 5000,
expectedSwaps: []loop.OutRequest{
chan1Rec, chan2Rec,
},
},
{
// Two swaps will cost (78+5000)*2, set 10155 so we can
// only afford one swap.
name: "budget for 1 swaps, no existing",
budget: 10155,
maxMinerFee: 5000,
expectedSwaps: []loop.OutRequest{
chan1Rec,
},
},
{
// Set an existing swap which would limit us to a single
// swap if it were in our period.
name: "existing swaps, before budget period",
budget: 10156,
maxMinerFee: 5000,
existingSwaps: map[time.Time]btcutil.Amount{
testBudgetStart.Add(time.Hour * -1): 200,
},
expectedSwaps: []loop.OutRequest{
chan1Rec, chan2Rec,
},
},
{
// Add an existing swap in our budget period such that
// we only have budget left for one more swap.
name: "existing swaps, in budget period",
budget: 10156,
maxMinerFee: 5000,
existingSwaps: map[time.Time]btcutil.Amount{
testBudgetStart.Add(time.Hour): 500,
},
expectedSwaps: []loop.OutRequest{
chan1Rec,
},
},
{
name: "existing swaps, budget used",
budget: 500,
maxMinerFee: 1000,
existingSwaps: map[time.Time]btcutil.Amount{
testBudgetStart.Add(time.Hour): 500,
},
expectedSwaps: nil,
},
}
for _, testCase := range tests {
testCase := testCase
t.Run(testCase.name, func(t *testing.T) {
cfg, lnd := newTestConfig()
// Create a swap set of existing swaps with our set of
// existing swap timestamps.
swaps := make(
[]*loopdb.LoopOut, 0,
len(testCase.existingSwaps),
)
// Add an event with the timestamp and budget set by
// our test case.
for ts, amt := range testCase.existingSwaps {
event := &loopdb.LoopEvent{
SwapStateData: loopdb.SwapStateData{
Cost: loopdb.SwapCost{
Server: amt,
},
State: loopdb.StateSuccess,
},
Time: ts,
}
swaps = append(swaps, &loopdb.LoopOut{
Loop: loopdb.Loop{
Events: []*loopdb.LoopEvent{
event,
},
},
Contract: autoOutContract,
})
}
cfg.ListLoopOut = func() ([]*loopdb.LoopOut, error) {
return swaps, nil
}
// Set two channels that need swaps.
lnd.Channels = []lndclient.ChannelInfo{
channel1,
channel2,
}
params := defaultParameters
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{
chanID1: chanRule,
chanID2: chanRule,
}
params.AutoFeeStartDate = testBudgetStart
params.AutoFeeBudget = testCase.budget
params.MaximumMinerFee = testCase.maxMinerFee
params.MaxAutoInFlight = 2
// Set our custom max miner fee on each expected swap,
// rather than having to create multiple vars for
// different rates.
for i := range testCase.expectedSwaps {
testCase.expectedSwaps[i].MaxMinerFee =
testCase.maxMinerFee
}
testSuggestSwaps(
t, newSuggestSwapsSetup(cfg, lnd, params),
testCase.expectedSwaps,
)
})
}
}
// TestInFlightLimit tests the limit we place on the number of in-flight swaps
// that are allowed.
func TestInFlightLimit(t *testing.T) {
tests := []struct {
name string
maxInFlight int
existingSwaps []*loopdb.LoopOut
expectedSwaps []loop.OutRequest
}{
{
name: "none in flight, extra space",
maxInFlight: 3,
expectedSwaps: []loop.OutRequest{
chan1Rec, chan2Rec,
},
},
{
name: "none in flight, exact match",
maxInFlight: 2,
expectedSwaps: []loop.OutRequest{
chan1Rec, chan2Rec,
},
},
{
name: "one in flight, one allowed",
maxInFlight: 2,
existingSwaps: []*loopdb.LoopOut{
{
Contract: autoOutContract,
},
},
expectedSwaps: []loop.OutRequest{
chan1Rec,
},
},
{
name: "max in flight",
maxInFlight: 1,
existingSwaps: []*loopdb.LoopOut{
{
Contract: autoOutContract,
},
},
expectedSwaps: nil,
},
{
name: "max swaps exceeded",
maxInFlight: 1,
existingSwaps: []*loopdb.LoopOut{
{
Contract: autoOutContract,
},
{
Contract: autoOutContract,
},
},
expectedSwaps: nil,
},
}
for _, testCase := range tests {
testCase := testCase
t.Run(testCase.name, func(t *testing.T) {
cfg, lnd := newTestConfig()
cfg.ListLoopOut = func() ([]*loopdb.LoopOut, error) {
return testCase.existingSwaps, nil
}
lnd.Channels = []lndclient.ChannelInfo{
channel1, channel2,
}
params := defaultParameters
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{
chanID1: chanRule,
chanID2: chanRule,
}
params.MaxAutoInFlight = testCase.maxInFlight
// By default we only have budget for one swap, increase
// our budget so that we could recommend more than one
// swap at a time.
params.AutoFeeBudget = defaultBudget * 2
testSuggestSwaps(
t, newSuggestSwapsSetup(cfg, lnd, params),
testCase.expectedSwaps,
)
})
}
}
// testSuggestSwapsSetup contains the elements that are used to create a
// suggest swaps test.
type testSuggestSwapsSetup struct {
cfg *Config
lnd *test.LndMockServices
params Parameters
}
// newSuggestSwapsSetup creates a suggest swaps setup struct.
func newSuggestSwapsSetup(cfg *Config, lnd *test.LndMockServices,
params Parameters) *testSuggestSwapsSetup {
return &testSuggestSwapsSetup{
cfg: cfg,
lnd: lnd,
params: params,
}
}
// testSuggestSwaps tests getting swap suggestions. It takes a setup struct
// which contains custom setup for the test. If this struct is nil, it will
// use the default parameters and setup two channels (channel1 + channel2) with
// chanRule set for each.
func testSuggestSwaps(t *testing.T, setup *testSuggestSwapsSetup,
expected []loop.OutRequest) {
t.Parallel()
// Create a mock lnd with the set of channels set in our test case and
// update our test case lnd to use these channels.
lnd.Channels = channels
// If our setup struct is nil, we replace it with our default test
// values.
if setup == nil {
cfg, lnd := newTestConfig()
lnd.Channels = []lndclient.ChannelInfo{
channel1, channel2,
}
params := defaultParameters
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{
chanID1: chanRule,
chanID2: chanRule,
}
setup = &testSuggestSwapsSetup{
cfg: cfg,
lnd: lnd,
params: params,
}
}
// Create a new manager, get our current set of parameters and update
// them to use the rules set by the test.
manager := NewManager(cfg)
currentParams := manager.GetParameters()
currentParams.ChannelRules = rules
manager := NewManager(setup.cfg)
err := manager.SetParameters(currentParams)
err := manager.SetParameters(setup.params)
require.NoError(t, err)
actual, err := manager.SuggestSwaps(context.Background())
actual, err := manager.SuggestSwaps(context.Background(), false)
require.NoError(t, err)
require.Equal(t, expected, actual)
}

+ 15
- 2
loopd/daemon.go View File

@ -98,10 +98,10 @@ func New(config *Config, lisCfg *listenerCfg) *Daemon {
cfg: config,
listenerCfg: lisCfg,
// We have 3 goroutines that could potentially send an error.
// We have 4 goroutines that could potentially send an error.
// We react on the first error but in case more than one exits
// with an error we don't want them to block.
internalErrChan: make(chan error, 3),
internalErrChan: make(chan error, 4),
}
}
@ -408,6 +408,19 @@ func (d *Daemon) initialize() error {
d.processStatusUpdates(d.mainCtx)
}()
d.wg.Add(1)
go func() {
defer d.wg.Done()
log.Info("Starting liquidity manager")
err := d.liquidityMgr.Run(d.mainCtx)
if err != nil && err != context.Canceled {
d.internalErrChan <- err
}
log.Info("Liquidity manager stopped")
}()
// Last, start our internal error handler. This will return exactly one
// error or nil on the main error channel to inform the caller that
// something went wrong or that shutdown is complete. We don't add to

+ 33
- 1
loopd/swapclient_server.go View File

@ -11,6 +11,7 @@ import (
"github.com/btcsuite/btcutil"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/liquidity"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/looprpc"
@ -79,6 +80,11 @@ func (s *swapClientServer) LoopOut(ctx context.Context,
}
}
// Check that the label is valid.
if err := labels.Validate(in.Label); err != nil {
return nil, err
}
req := &loop.OutRequest{
Amount: btcutil.Amount(in.Amt),
DestAddr: sweepAddr,