Merge pull request #886 from sputn1ck/asset_autoloop_1

Add simple asset autoloop
This commit is contained in:
Konstantin Nick 2025-03-25 15:58:39 +01:00 committed by GitHub
commit 612c7047f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2013 additions and 915 deletions

View file

@ -9,6 +9,7 @@ import (
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/taproot-assets/rfqmath"
"github.com/lightninglabs/taproot-assets/tapcfg"
"github.com/lightninglabs/taproot-assets/taprpc"
"github.com/lightninglabs/taproot-assets/taprpc/priceoraclerpc"
@ -184,6 +185,75 @@ func (c *TapdClient) GetAssetName(ctx context.Context,
return assetName, nil
}
// GetAssetPrice returns the price of an asset in satoshis. NOTE: this currently
// uses the rfq process for the asset price. A future implementation should
// use a price oracle to not spam a peer.
func (c *TapdClient) GetAssetPrice(ctx context.Context, assetID string,
peerPubkey []byte, assetAmt uint64, paymentMaxAmt btcutil.Amount) (
btcutil.Amount, error) {
// We'll allow a short rfq expiry as we'll only use this rfq to
// gauge a price.
rfqExpiry := time.Now().Add(time.Minute).Unix()
msatAmt := lnwire.NewMSatFromSatoshis(paymentMaxAmt)
// First we'll rfq a random peer for the asset.
rfq, err := c.RfqClient.AddAssetSellOrder(
ctx, &rfqrpc.AddAssetSellOrderRequest{
AssetSpecifier: &rfqrpc.AssetSpecifier{
Id: &rfqrpc.AssetSpecifier_AssetIdStr{
AssetIdStr: assetID,
},
},
PaymentMaxAmt: uint64(msatAmt),
Expiry: uint64(rfqExpiry),
TimeoutSeconds: uint32(c.cfg.RFQtimeout.Seconds()),
PeerPubKey: peerPubkey,
})
if err != nil {
return 0, err
}
if rfq == nil {
return 0, fmt.Errorf("no RFQ response")
}
if rfq.GetInvalidQuote() != nil {
return 0, fmt.Errorf("peer %v sent an invalid quote response %v for "+
"asset %v", peerPubkey, rfq.GetInvalidQuote(), assetID)
}
if rfq.GetRejectedQuote() != nil {
return 0, fmt.Errorf("peer %v rejected the quote request for "+
"asset %v, %v", peerPubkey, assetID, rfq.GetRejectedQuote())
}
acceptedRes := rfq.GetAcceptedQuote()
if acceptedRes == nil {
return 0, fmt.Errorf("no accepted quote")
}
// We'll use the accepted quote to calculate the price.
return getSatsFromAssetAmt(assetAmt, acceptedRes.BidAssetRate)
}
// getSatsFromAssetAmt returns the amount in satoshis for the given asset amount
// and asset rate.
func getSatsFromAssetAmt(assetAmt uint64, assetRate *rfqrpc.FixedPoint) (
btcutil.Amount, error) {
rateFP, err := rfqrpc.UnmarshalFixedPoint(assetRate)
if err != nil {
return 0, fmt.Errorf("cannot unmarshal asset rate: %w", err)
}
assetUnits := rfqmath.NewBigIntFixedPoint(assetAmt, 0)
msatAmt := rfqmath.UnitsToMilliSatoshi(assetUnits, *rateFP)
return msatAmt.ToSatoshis(), nil
}
// getPaymentMaxAmount returns the milisat amount we are willing to pay for the
// payment.
func getPaymentMaxAmount(satAmount btcutil.Amount, feeLimitMultiplier float64) (

View file

@ -4,7 +4,9 @@ import (
"testing"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/taproot-assets/taprpc/rfqrpc"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
)
func TestGetPaymentMaxAmount(t *testing.T) {
@ -65,3 +67,41 @@ func TestGetPaymentMaxAmount(t *testing.T) {
}
}
}
func TestGetSatsFromAssetAmt(t *testing.T) {
tests := []struct {
assetAmt uint64
assetRate *rfqrpc.FixedPoint
expected btcutil.Amount
expectError bool
}{
{
assetAmt: 1000,
assetRate: &rfqrpc.FixedPoint{Coefficient: "100000", Scale: 0},
expected: btcutil.Amount(1000000),
expectError: false,
},
{
assetAmt: 500000,
assetRate: &rfqrpc.FixedPoint{Coefficient: "200000000", Scale: 0},
expected: btcutil.Amount(250000),
expectError: false,
},
{
assetAmt: 0,
assetRate: &rfqrpc.FixedPoint{Coefficient: "100000000", Scale: 0},
expected: btcutil.Amount(0),
expectError: false,
},
}
for _, test := range tests {
result, err := getSatsFromAssetAmt(test.assetAmt, test.assetRate)
if test.expectError {
require.NotNil(t, err)
} else {
require.Nil(t, err)
require.Equal(t, test.expected, result)
}
}
}

View file

@ -103,7 +103,6 @@ type Client struct {
lndServices *lndclient.LndServices
sweeper *sweep.Sweeper
executor *executor
assetClient *assets.TapdClient
resumeReady chan struct{}
wg sync.WaitGroup
@ -196,6 +195,7 @@ func NewClient(dbDir string, loopDB loopdb.SwapStore,
CreateExpiryTimer: func(d time.Duration) <-chan time.Time {
return time.NewTimer(d).C
},
AssetClient: cfg.AssetClient,
LoopOutMaxParts: cfg.LoopOutMaxParts,
}
@ -286,7 +286,6 @@ func NewClient(dbDir string, loopDB loopdb.SwapStore,
errChan: make(chan error),
clientConfig: *config,
lndServices: cfg.Lnd,
assetClient: cfg.AssetClient,
sweeper: sweeper,
executor: executor,
resumeReady: make(chan struct{}),
@ -467,7 +466,7 @@ func (s *Client) Run(ctx context.Context, statusChan chan<- SwapInfo) error {
func (s *Client) resumeSwaps(ctx context.Context,
loopOutSwaps []*loopdb.LoopOut, loopInSwaps []*loopdb.LoopIn) {
swapCfg := newSwapConfig(s.lndServices, s.Store, s.Server, s.assetClient)
swapCfg := newSwapConfig(s.lndServices, s.Store, s.Server, s.AssetClient)
for _, pend := range loopOutSwaps {
if pend.State().State.Type() != loopdb.StateTypePending {
@ -524,7 +523,7 @@ func (s *Client) LoopOut(globalCtx context.Context,
// Verify that if we have an asset id set, we have a valid asset
// client to use.
if s.assetClient == nil {
if s.AssetClient == nil {
return nil, errors.New("asset client must be set " +
"when using an asset id")
}
@ -559,7 +558,7 @@ func (s *Client) LoopOut(globalCtx context.Context,
// Create a new swap object for this swap.
swapCfg := newSwapConfig(
s.lndServices, s.Store, s.Server, s.assetClient,
s.lndServices, s.Store, s.Server, s.AssetClient,
)
initResult, err := newLoopOutSwap(
@ -741,7 +740,7 @@ func (s *Client) LoopIn(globalCtx context.Context,
// Create a new swap object for this swap.
initiationHeight := s.executor.height()
swapCfg := newSwapConfig(s.lndServices, s.Store, s.Server, s.assetClient)
swapCfg := newSwapConfig(s.lndServices, s.Store, s.Server, s.AssetClient)
initResult, err := newLoopInSwap(
globalCtx, swapCfg, initiationHeight, request,
)
@ -960,7 +959,7 @@ func (s *Client) AbandonSwap(ctx context.Context,
func (s *Client) getAssetRfq(ctx context.Context, quote *LoopOutQuote,
request *LoopOutQuoteRequest) (*LoopOutRfq, error) {
if s.assetClient == nil {
if s.AssetClient == nil {
return nil, errors.New("asset client must be set " +
"when trying to loop out with an asset")
}
@ -974,7 +973,7 @@ func (s *Client) getAssetRfq(ctx context.Context, quote *LoopOutQuote,
}
// First we'll get the prepay rfq.
prepayRfq, err := s.assetClient.GetRfqForAsset(
prepayRfq, err := s.AssetClient.GetRfqForAsset(
ctx, quote.PrepayAmount, rfqReq.AssetId,
rfqReq.AssetEdgeNode, rfqReq.Expiry,
rfqReq.MaxLimitMultiplier,
@ -995,7 +994,7 @@ func (s *Client) getAssetRfq(ctx context.Context, quote *LoopOutQuote,
invoiceAmt := request.Amount + quote.SwapFee -
quote.PrepayAmount
swapRfq, err := s.assetClient.GetRfqForAsset(
swapRfq, err := s.AssetClient.GetRfqForAsset(
ctx, invoiceAmt, rfqReq.AssetId,
rfqReq.AssetEdgeNode, rfqReq.Expiry,
rfqReq.MaxLimitMultiplier,
@ -1012,7 +1011,7 @@ func (s *Client) getAssetRfq(ctx context.Context, quote *LoopOutQuote,
}
// We'll also want the asset name to verify for the client.
assetName, err := s.assetClient.GetAssetName(
assetName, err := s.AssetClient.GetAssetName(
ctx, rfqReq.AssetId,
)
if err != nil {

60
cmd/loop/debug.go Normal file
View file

@ -0,0 +1,60 @@
//go:build dev
// +build dev
package main
import (
"context"
"github.com/lightninglabs/loop/looprpc"
"github.com/urfave/cli"
)
func init() {
// Register the debug command.
commands = append(commands, forceAutoloopCmd)
}
var forceAutoloopCmd = cli.Command{
Name: "forceautoloop",
Usage: `
Forces to trigger an autoloop step, regardless of the current internal
autoloop timer. THIS MUST NOT BE USED IN A PROD ENVIRONMENT.
`,
Action: forceAutoloop,
}
func forceAutoloop(ctx *cli.Context) error {
client, cleanup, err := getDebugClient(ctx)
if err != nil {
return err
}
defer cleanup()
cfg, err := client.ForceAutoLoop(
context.Background(), &looprpc.ForceAutoLoopRequest{},
)
if err != nil {
return err
}
printRespJSON(cfg)
return nil
}
func getDebugClient(ctx *cli.Context) (looprpc.DebugClient, func(), error) {
rpcServer := ctx.GlobalString("rpcserver")
tlsCertPath, macaroonPath, err := extractPathArgs(ctx)
if err != nil {
return nil, nil, err
}
conn, err := getClientConn(rpcServer, tlsCertPath, macaroonPath)
if err != nil {
return nil, nil, err
}
cleanup := func() { conn.Close() }
debugClient := looprpc.NewDebugClient(conn)
return debugClient, cleanup, nil
}

View file

@ -350,6 +350,23 @@ var setParamsCommand = cli.Command{
Usage: "the target size of total local balance in " +
"satoshis, used by easy autoloop.",
},
cli.BoolFlag{
Name: "asset_easyautoloop",
Usage: "set to true to enable asset easy autoloop, which " +
"will automatically dispatch asset swaps in order " +
"to meet the target local balance.",
},
cli.StringFlag{
Name: "asset_id",
Usage: "If set to a valid asset ID, the easyautoloop " +
"and localbalancesat flags will be set for the " +
"specified asset.",
},
cli.Uint64Flag{
Name: "asset_localbalance",
Usage: "the target size of total local balance in " +
"asset units, used by asset easy autoloop.",
},
},
Action: setParams,
}
@ -515,14 +532,48 @@ func setParams(ctx *cli.Context) error {
flagSet = true
}
// If we are setting easy autoloop parameters, we need to ensure that
// the asset ID is set, and that we have a valid entry in our params
// map.
if ctx.IsSet("asset_id") {
if params.EasyAssetParams == nil {
params.EasyAssetParams = make(
map[string]*looprpc.EasyAssetAutoloopParams,
)
}
if _, ok := params.EasyAssetParams[ctx.String("asset_id")]; !ok { //nolint:lll
params.EasyAssetParams[ctx.String("asset_id")] =
&looprpc.EasyAssetAutoloopParams{}
}
}
if ctx.IsSet("easyautoloop") {
params.EasyAutoloop = ctx.Bool("easyautoloop")
flagSet = true
}
if ctx.IsSet("localbalancesat") {
params.EasyAutoloopLocalTargetSat =
ctx.Uint64("localbalancesat")
params.EasyAutoloopLocalTargetSat = ctx.Uint64("localbalancesat")
flagSet = true
}
if ctx.IsSet("asset_easyautoloop") {
if !ctx.IsSet("asset_id") {
return fmt.Errorf("asset_id must be set to use " +
"asset_easyautoloop")
}
params.EasyAssetParams[ctx.String("asset_id")].
Enabled = ctx.Bool("asset_easyautoloop")
flagSet = true
}
if ctx.IsSet("asset_localbalance") {
if !ctx.IsSet("asset_id") {
return fmt.Errorf("asset_id must be set to use " +
"asset_localbalance")
}
params.EasyAssetParams[ctx.String("asset_id")].
LocalTargetAssetAmt = ctx.Uint64("asset_localbalance")
flagSet = true
}

View file

@ -5,6 +5,7 @@ import (
"github.com/lightninglabs/aperture/l402"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/assets"
"github.com/lightninglabs/loop/loopdb"
"google.golang.org/grpc"
)
@ -15,6 +16,7 @@ type clientConfig struct {
Server swapServerClient
Conn *grpc.ClientConn
Store loopdb.SwapStore
AssetClient *assets.TapdClient
L402Store l402.Store
CreateExpiryTimer func(expiry time.Duration) <-chan time.Time
LoopOutMaxParts uint32

View file

@ -1,6 +1,9 @@
package liquidity
import (
"context"
"encoding/hex"
"encoding/json"
"testing"
"time"
@ -11,12 +14,15 @@ import (
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/test"
"github.com/lightninglabs/taproot-assets/rfqmsg"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
)
const p2wkhAddr = "bcrt1qq68r6ff4k4pjx39efs44gcyccf7unqnu5qtjjz"
// TestAutoLoopDisabled tests the case where we need to perform a swap, but
// autoloop is not enabled.
func TestAutoLoopDisabled(t *testing.T) {
@ -335,7 +341,6 @@ func TestAutoloopAddress(t *testing.T) {
// Decode a dummy p2wkh address to use as the destination address for
// the swaps.
p2wkhAddr := "bcrt1qq68r6ff4k4pjx39efs44gcyccf7unqnu5qtjjz"
addr, err := btcutil.DecodeAddress(p2wkhAddr, nil)
if err != nil {
t.Error(err)
@ -1260,7 +1265,6 @@ func TestEasyAutoloop(t *testing.T) {
// Decode a dummy p2wkh address to use as the destination address for
// the swaps.
p2wkhAddr := "bcrt1qq68r6ff4k4pjx39efs44gcyccf7unqnu5qtjjz"
addr, err := btcutil.DecodeAddress(p2wkhAddr, nil)
if err != nil {
t.Error(err)
@ -1520,3 +1524,356 @@ func existingInFromRequest(in *loop.LoopInRequest, initTime time.Time,
},
}
}
// TestEasyAssetAutoloop tests that the easy asset autoloop logic works as
// expected. This involves testing that channels are correctly selected and
// that the balance target is successfully met.
func TestEasyAssetAutoloop(t *testing.T) {
defer test.Guard(t)
// Common variables for asset tests.
assetId := [32]byte{0x01}
assetStr := hex.EncodeToString(assetId[:])
addr, err := btcutil.DecodeAddress(p2wkhAddr, nil)
require.NoError(t, err)
// Sub-test 1: Single asset channel.
t.Run("single asset channel", func(t *testing.T) {
// Prepare a channel with asset custom data.
customChanData := rfqmsg.JsonAssetChannel{
Assets: []rfqmsg.JsonAssetChanInfo{
{
AssetInfo: rfqmsg.JsonAssetUtxo{
AssetGenesis: rfqmsg.JsonAssetGenesis{
AssetID: assetStr,
},
},
LocalBalance: 950000,
RemoteBalance: 0,
Capacity: 100000,
},
},
}
customChanDataBytes, err := json.Marshal(customChanData)
require.NoError(t, err)
assetChan := lndclient.ChannelInfo{
Active: true,
ChannelID: chanID1.ToUint64(),
PubKeyBytes: peer1,
LocalBalance: 95000,
RemoteBalance: 0,
Capacity: 100000,
CustomChannelData: customChanDataBytes,
}
channels := []lndclient.ChannelInfo{assetChan}
params := Parameters{
Autoloop: true,
DestAddr: addr,
AutoFeeBudget: 36000,
AutoFeeRefreshPeriod: time.Hour * 3,
AutoloopBudgetLastRefresh: testBudgetStart,
MaxAutoInFlight: 2,
FailureBackOff: time.Hour,
SweepConfTarget: 10,
HtlcConfTarget: defaultHtlcConfTarget,
FeeLimit: defaultFeePortion(),
AssetAutoloopParams: map[string]AssetParams{
assetStr: {
EnableEasyOut: true,
LocalTargetAssetAmount: 75000,
},
},
}
c := newAutoloopTestCtx(t, params, channels, testRestrictions)
// For testing, simply return asset units 1:1 to satoshis.
assetPriceFunc := func(ctx context.Context, assetId string,
peerPubkey []byte, assetAmt uint64, minSatAmt btcutil.Amount) (
btcutil.Amount, error) {
return btcutil.Amount(assetAmt), nil
}
c.manager.cfg.GetAssetPrice = assetPriceFunc
c.start()
// In this scenario we expect a swap of maxAmt (here chosen as 50000)
// on our single asset channel.
maxAmt := 50000
chanSwap := &loop.OutRequest{
Amount: btcutil.Amount(maxAmt),
DestAddr: addr,
OutgoingChanSet: loopdb.ChannelSet{assetChan.ChannelID},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
quotesOut := []quoteRequestResp{
{
request: &loop.LoopOutQuoteRequest{
Amount: btcutil.Amount(maxAmt),
AssetRFQRequest: &loop.AssetRFQRequest{
AssetId: assetId[:],
AssetEdgeNode: []byte("edge"),
},
},
quote: &loop.LoopOutQuote{
SwapFee: 1,
PrepayAmount: 1,
MinerFee: 1,
LoopOutRfq: &loop.LoopOutRfq{
PrepayRfqId: []byte("prepay"),
SwapRfqId: []byte("swap"),
},
},
},
}
expectedOut := []loopOutRequestResp{
{
request: chanSwap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{1},
},
},
}
step := &easyAutoloopStep{
minAmt: 1,
maxAmt: btcutil.Amount(maxAmt),
quotesOut: quotesOut,
expectedOut: expectedOut,
}
c.easyautoloop(step, false)
c.stop()
})
// Sub-test 2: Two asset channels.
t.Run("two asset channels", func(t *testing.T) {
// Reuse the same custom channel data for both channels.
customChanData := rfqmsg.JsonAssetChannel{
Assets: []rfqmsg.JsonAssetChanInfo{
{
AssetInfo: rfqmsg.JsonAssetUtxo{
AssetGenesis: rfqmsg.JsonAssetGenesis{
AssetID: assetStr,
},
},
LocalBalance: 950000,
RemoteBalance: 0,
Capacity: 100000,
},
},
}
customChanDataBytes1, err := json.Marshal(customChanData)
require.NoError(t, err)
customChanData.Assets[0].LocalBalance = 1050000
customChanDataBytes2, err := json.Marshal(customChanData)
require.NoError(t, err)
// Create two asset channels with different local balances.
assetChan1 := lndclient.ChannelInfo{
Active: true,
ChannelID: chanID1.ToUint64(),
PubKeyBytes: peer1,
CustomChannelData: customChanDataBytes1,
}
assetChan2 := lndclient.ChannelInfo{
Active: true,
ChannelID: chanID2.ToUint64(), // different channel ID
PubKeyBytes: peer2,
CustomChannelData: customChanDataBytes2,
}
channels := []lndclient.ChannelInfo{assetChan1, assetChan2}
params := Parameters{
Autoloop: true,
DestAddr: addr,
AutoFeeBudget: 36000,
AutoFeeRefreshPeriod: time.Hour * 3,
AutoloopBudgetLastRefresh: testBudgetStart,
MaxAutoInFlight: 2,
FailureBackOff: time.Hour,
SweepConfTarget: 10,
HtlcConfTarget: defaultHtlcConfTarget,
FeeLimit: defaultFeePortion(),
AssetAutoloopParams: map[string]AssetParams{
assetStr: {
EnableEasyOut: true,
LocalTargetAssetAmount: 75000,
},
},
}
c := newAutoloopTestCtx(t, params, channels, testRestrictions)
assetPriceFunc := func(ctx context.Context, assetId string,
peerPubkey []byte, assetAmt uint64, minSatAmt btcutil.Amount) (
btcutil.Amount, error) {
return btcutil.Amount(assetAmt), nil
}
c.manager.cfg.GetAssetPrice = assetPriceFunc
c.start()
// Expect a swap on the channel with the higher local balance (assetChan2).
maxAmt := 40000
chanSwap := &loop.OutRequest{
Amount: btcutil.Amount(maxAmt),
DestAddr: addr,
OutgoingChanSet: loopdb.ChannelSet{assetChan2.ChannelID},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
quotesOut := []quoteRequestResp{
{
request: &loop.LoopOutQuoteRequest{
Amount: btcutil.Amount(maxAmt),
AssetRFQRequest: &loop.AssetRFQRequest{
AssetId: assetId[:],
AssetEdgeNode: []byte("edge"),
},
},
quote: &loop.LoopOutQuote{
SwapFee: 1,
PrepayAmount: 1,
MinerFee: 1,
LoopOutRfq: &loop.LoopOutRfq{
PrepayRfqId: []byte("prepay"),
SwapRfqId: []byte("swap"),
},
},
},
}
expectedOut := []loopOutRequestResp{
{
request: chanSwap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{1},
},
},
}
step := &easyAutoloopStep{
minAmt: 1,
maxAmt: btcutil.Amount(maxAmt),
quotesOut: quotesOut,
expectedOut: expectedOut,
}
c.easyautoloop(step, false)
c.stop()
})
// Sub-test 3: Mixed asset and non-asset channels.
t.Run("non asset and normal channel", func(t *testing.T) {
// Create an asset channel with custom asset data.
customChanData := rfqmsg.JsonAssetChannel{
Assets: []rfqmsg.JsonAssetChanInfo{
{
AssetInfo: rfqmsg.JsonAssetUtxo{
AssetGenesis: rfqmsg.JsonAssetGenesis{
AssetID: assetStr,
},
},
LocalBalance: 950000,
RemoteBalance: 0,
Capacity: 100000,
},
},
}
customChanDataBytes, err := json.Marshal(customChanData)
require.NoError(t, err)
assetChan := lndclient.ChannelInfo{
Active: true,
ChannelID: chanID1.ToUint64(),
PubKeyBytes: peer1,
LocalBalance: 95000,
RemoteBalance: 0,
Capacity: 100000,
CustomChannelData: customChanDataBytes,
}
// Create a normal channel (no custom channel data).
normalChan := lndclient.ChannelInfo{
Active: true,
ChannelID: chanID2.ToUint64(),
PubKeyBytes: peer1,
LocalBalance: 100000,
RemoteBalance: 0,
Capacity: 100000,
}
channels := []lndclient.ChannelInfo{assetChan, normalChan}
params := Parameters{
Autoloop: true,
DestAddr: addr,
AutoFeeBudget: 36000,
AutoFeeRefreshPeriod: time.Hour * 3,
AutoloopBudgetLastRefresh: testBudgetStart,
MaxAutoInFlight: 2,
FailureBackOff: time.Hour,
SweepConfTarget: 10,
HtlcConfTarget: defaultHtlcConfTarget,
FeeLimit: defaultFeePortion(),
AssetAutoloopParams: map[string]AssetParams{
assetStr: {
EnableEasyOut: true,
LocalTargetAssetAmount: 75000,
},
},
}
c := newAutoloopTestCtx(t, params, channels, testRestrictions)
assetPriceFunc := func(ctx context.Context, assetId string,
peerPubkey []byte, assetAmt uint64, minSatAmt btcutil.Amount) (
btcutil.Amount, error) {
return btcutil.Amount(assetAmt), nil
}
c.manager.cfg.GetAssetPrice = assetPriceFunc
c.start()
maxAmtAsset := 50000
assetSwap := &loop.OutRequest{
Amount: btcutil.Amount(maxAmtAsset),
DestAddr: addr,
OutgoingChanSet: loopdb.ChannelSet{assetChan.ChannelID},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
quotesOut := []quoteRequestResp{
{
request: &loop.LoopOutQuoteRequest{
Amount: btcutil.Amount(maxAmtAsset),
AssetRFQRequest: &loop.AssetRFQRequest{
AssetId: assetId[:],
AssetEdgeNode: []byte("edge"),
},
},
quote: &loop.LoopOutQuote{
SwapFee: 1,
PrepayAmount: 1,
MinerFee: 1,
LoopOutRfq: &loop.LoopOutRfq{
PrepayRfqId: []byte("prepay"),
SwapRfqId: []byte("swap"),
},
},
},
}
expectedOut := []loopOutRequestResp{
{
request: assetSwap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{1},
},
},
}
step := &easyAutoloopStep{
minAmt: 1,
maxAmt: 50000,
quotesOut: quotesOut,
expectedOut: expectedOut,
}
c.easyautoloop(step, false)
c.stop()
})
}

View file

@ -62,7 +62,8 @@ type swapBuilder interface {
// is just for a dry run.
buildSwap(ctx context.Context, peer route.Vertex,
channels []lnwire.ShortChannelID, amount btcutil.Amount,
params Parameters) (swapSuggestion, error)
params Parameters, swapOpts ...buildSwapOption) (swapSuggestion,
error)
}
// swapSuggestion is an interface implemented by suggested swaps for our

View file

@ -34,6 +34,8 @@ package liquidity
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"math"
@ -48,6 +50,7 @@ import (
"github.com/lightninglabs/loop/loopdb"
clientrpc "github.com/lightninglabs/loop/looprpc"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/taproot-assets/rfqmsg"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/funding"
"github.com/lightningnetwork/lnd/lntypes"
@ -218,6 +221,11 @@ type Config struct {
LoopOutTerms func(ctx context.Context,
initiator string) (*loop.LoopOutTerms, error)
// GetAssetPrice returns the price of an asset in satoshis.
GetAssetPrice func(ctx context.Context, assetId string,
peerPubkey []byte, assetAmt uint64,
maxPaymentAmt btcutil.Amount) (btcutil.Amount, error)
// Clock allows easy mocking of time in unit tests.
Clock clock.Clock
@ -305,6 +313,16 @@ func (m *Manager) Run(ctx context.Context) error {
}
}
// Try to automatically dispach an asset auto-loop.
for assetID := range m.params.AssetAutoloopParams {
err = m.easyAssetAutoloop(ctx, assetID)
if err != nil {
log.Errorf("easy asset autoloop "+
"failed: id: %v, err: %v",
assetID, err)
}
}
case <-ctx.Done():
return ctx.Err()
}
@ -505,6 +523,32 @@ func (m *Manager) easyAutoLoop(ctx context.Context) error {
return nil
}
// easyAssetAutoloop is the main entry point for the easy auto loop functionality
// for assets. This function will try to dispatch a swap in order to meet the
// easy autoloop requirements for the given asset. For easyAutoloop to work
// there needs to be an EasyAutoloopTarget defined in the parameters. Easy
// autoloop also uses the configured max inflight swaps and budget rules defined
// in the parameters.
func (m *Manager) easyAssetAutoloop(ctx context.Context, assetID string) error {
if !m.params.Autoloop {
return nil
}
assetParams, ok := m.params.AssetAutoloopParams[assetID]
if !ok && !assetParams.EnableEasyOut {
return nil
}
// First check if we should refresh our budget before calculating any
// swaps for autoloop.
m.refreshAutoloopBudget(ctx)
// Dispatch the best easy autoloop swap.
targetAmt := assetParams.LocalTargetAssetAmount
return m.dispatchBestAssetEasyAutoloopSwap(ctx, assetID, targetAmt)
}
// ForceAutoLoop force-ticks our auto-out ticker.
func (m *Manager) ForceAutoLoop(ctx context.Context) error {
select {
@ -550,12 +594,14 @@ func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error {
return err
}
usableChannels := make([]lndclient.ChannelInfo, 0, len(channels))
localTotal := btcutil.Amount(0)
for _, channel := range channels {
if channelIsCustom(channel) {
continue
}
localTotal += channel.LocalBalance
usableChannels = append(usableChannels, channel)
}
// Since we're only autolooping-out we need to check if we are below
@ -597,7 +643,7 @@ func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error {
builder := newLoopOutBuilder(m.cfg)
channel := m.pickEasyAutoloopChannel(
channels, restrictions, loopOut, loopIn,
usableChannels, restrictions, loopOut, loopIn, 0,
)
if channel == nil {
return fmt.Errorf("no eligible channel for easy autoloop")
@ -657,6 +703,196 @@ func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error {
return nil
}
// dispatchBestAssetEasyAutoloopSwap tries to dispatch a swap to bring the total
// local balance back to the target for the given asset.
func (m *Manager) dispatchBestAssetEasyAutoloopSwap(ctx context.Context,
assetID string, localTarget uint64) error {
if len(assetID) != sha256.Size*2 {
return fmt.Errorf("invalid asset id: %v", assetID)
}
// Retrieve existing swaps.
loopOut, err := m.cfg.ListLoopOut(ctx)
if err != nil {
return err
}
loopIn, err := m.cfg.ListLoopIn(ctx)
if err != nil {
return err
}
// Get a summary of our existing swaps so that we can check our autoloop
// budget.
summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn)
err = m.checkSummaryBudget(summary)
if err != nil {
return err
}
_, err = m.checkSummaryInflight(summary)
if err != nil {
return err
}
// Get all channels in order to calculate current total local balance.
channels, err := m.cfg.Lnd.Client.ListChannels(ctx, false, false)
if err != nil {
return err
}
// If we are running a custom asset, we'll need to get a random asset
// peer pubkey in order to rfq the asset price.
var assetPeerPubkey []byte
usableChannels := []lndclient.ChannelInfo{}
localTotal := uint64(0)
for _, channel := range channels {
// We are only interested in custom asset channels.
if !channelIsCustom(channel) {
continue
}
assetData := getCustomAssetData(channel, assetID)
if assetData == nil {
continue
}
// We'll overwrite the channel local balance to be
// the custom asset balance. This allows us to make
// use of existing logic.
channel.LocalBalance = btcutil.Amount(assetData.LocalBalance)
usableChannels = append(usableChannels, channel)
// We'll use a random peer pubkey in order to get a rfq for the asset
// to get a rough amount of sats to swap amount.
assetPeerPubkey = channel.PubKeyBytes[:]
localTotal += assetData.LocalBalance
}
// Since we're only autolooping-out we need to check if we are below
// the target, meaning that we already meet the requirements.
if localTotal <= localTarget {
log.Debugf("Asset: %v... total local balance %v below target %v",
assetID[:8], localTotal, localTarget)
return nil
}
restrictions, err := m.cfg.Restrictions(
ctx, swap.TypeOut, getInitiator(m.params),
)
if err != nil {
return err
}
// Calculate the assetAmount that we want to loop out. If it exceeds the
// max allowed clamp it to max.
assetAmount := localTotal - localTarget
// We need a request sat amount for the asset price request. We'll use
// the average of the min and max restrictions.
assetPriceRequestSatAmt := (restrictions.Minimum + restrictions.Maximum) / 2
// If we run a custom asset, we'll need to convert the asset amount
// we want to swap to the satoshi amount.
satAmount, err := m.cfg.GetAssetPrice(
ctx, assetID, assetPeerPubkey, assetAmount,
assetPriceRequestSatAmt,
)
if err != nil {
return err
}
if satAmount > restrictions.Maximum {
log.Debugf("Asset %v easy autoloop: using maximum allowed "+
"swap amount, maximum=%v, need to swap %v",
assetID[:8], restrictions.Maximum, satAmount)
satAmount = restrictions.Maximum
}
// If the amount we want to loop out is less than the minimum we can't
// proceed with a swap, so we return early.
if satAmount < restrictions.Minimum {
log.Debugf("Asset %v easy autoloop: swap amount is below"+
" minimum swap size, minimum=%v, need to swap %v",
assetID[:8], restrictions.Minimum, satAmount)
return nil
}
satsPerAsset := float64(satAmount) / float64(assetAmount)
log.Debugf("Asset %v easy autoloop: local_total=%v, target=%v, "+
"attempting to loop out %v assets corresponding to %v sats",
assetID[:8], localTotal, localTarget, assetAmount, satAmount)
// Start building that swap.
builder := newLoopOutBuilder(m.cfg)
channel := m.pickEasyAutoloopChannel(
usableChannels, restrictions, loopOut, loopIn, satsPerAsset,
)
if channel == nil {
return fmt.Errorf("no eligible channel for easy autoloop")
}
log.Debugf("Asset %v easy autoloop: picked channel %v with local "+
"balance %v", assetID[:8], channel.ChannelID,
int(channel.LocalBalance))
// If no fee is set, override our current parameters in order to use the
// default percent limit of easy-autoloop.
easyParams := m.params
switch feeLimit := easyParams.FeeLimit.(type) {
case *FeePortion:
if feeLimit.PartsPerMillion == 0 {
easyParams.FeeLimit = &FeePortion{
PartsPerMillion: defaultFeePPM,
}
}
default:
easyParams.FeeLimit = &FeePortion{
PartsPerMillion: defaultFeePPM,
}
}
// Set the swap outgoing channel to the chosen channel.
outgoing := []lnwire.ShortChannelID{
lnwire.NewShortChanIDFromInt(channel.ChannelID),
}
assetSwap := &assetSwapInfo{
assetID: assetID,
peerPubkey: channel.PubKeyBytes[:],
}
suggestion, err := builder.buildSwap(
ctx, channel.PubKeyBytes, outgoing, satAmount, easyParams,
withAssetSwapInfo(assetSwap),
)
if err != nil {
return err
}
var swp loop.OutRequest
if t, ok := suggestion.(*loopOutSwapSuggestion); ok {
swp = t.OutRequest
} else {
return fmt.Errorf("unexpected swap suggestion type: %T", t)
}
// Dispatch a sticky loop out.
go m.dispatchStickyLoopOut(
ctx, swp, defaultAmountBackoffRetry, defaultAmountBackoff,
)
return nil
}
// Suggestions provides a set of suggested swaps, and the set of channels that
// were excluded from consideration.
type Suggestions struct {
@ -1419,7 +1655,7 @@ func (m *Manager) waitForSwapPayment(ctx context.Context, swapHash lntypes.Hash,
// swap conflicts.
func (m *Manager) pickEasyAutoloopChannel(channels []lndclient.ChannelInfo,
restrictions *Restrictions, loopOut []*loopdb.LoopOut,
loopIn []*loopdb.LoopIn) *lndclient.ChannelInfo {
loopIn []*loopdb.LoopIn, satsPerAsset float64) *lndclient.ChannelInfo {
traffic := m.currentSwapTraffic(loopOut, loopIn)
@ -1433,10 +1669,6 @@ func (m *Manager) pickEasyAutoloopChannel(channels []lndclient.ChannelInfo,
// Check each channel, since channels are already sorted we return the
// first channel that passes all checks.
for _, channel := range channels {
if channelIsCustom(channel) {
continue
}
shortChanID := lnwire.NewShortChanIDFromInt(channel.ChannelID)
if !channel.Active {
@ -1459,7 +1691,16 @@ func (m *Manager) pickEasyAutoloopChannel(channels []lndclient.ChannelInfo,
continue
}
if channel.LocalBalance < restrictions.Minimum {
localBalance := channel.LocalBalance
// If we're running a custom asset, the local balance is
// denominated in the asset's unit, so we convert it to
// back to sats to check the minimum.
if channelIsCustom(channel) {
localBalance = localBalance.MulF64(satsPerAsset)
}
if localBalance < restrictions.Minimum {
log.Debugf("Channel %v cannot be used for easy "+
"autoloop: insufficient local balance %v,"+
"minimum is %v, skipping remaining channels",
@ -1570,3 +1811,28 @@ func channelIsCustom(channel lndclient.ChannelInfo) bool {
// don't want to consider it for swaps.
return channel.CustomChannelData != nil
}
// getCustomAssetData returns the asset data for a custom channel.
func getCustomAssetData(channel lndclient.ChannelInfo, assetID string,
) *rfqmsg.JsonAssetChanInfo {
if channel.CustomChannelData == nil {
return nil
}
var assetData rfqmsg.JsonAssetChannel
err := json.Unmarshal(channel.CustomChannelData, &assetData)
if err != nil {
log.Errorf("Error unmarshalling custom channel %v data: %v",
channel.ChannelID, err)
return nil
}
for _, asset := range assetData.Assets {
if asset.AssetInfo.AssetGenesis.AssetID == assetID {
return &asset
}
}
return nil
}

View file

@ -84,7 +84,8 @@ func (b *loopInBuilder) inUse(traffic *swapTraffic, peer route.Vertex,
// For loop in, we do not add the autoloop label for dry runs.
func (b *loopInBuilder) buildSwap(ctx context.Context, pubkey route.Vertex,
_ []lnwire.ShortChannelID, amount btcutil.Amount,
params Parameters) (swapSuggestion, error) {
params Parameters, swapOpts ...buildSwapOption) (swapSuggestion,
error) {
quote, err := b.cfg.LoopInQuote(ctx, &loop.LoopInQuoteRequest{
Amount: amount,

View file

@ -184,7 +184,7 @@ func TestLoopinBuildSwap(t *testing.T) {
swap, err := builder.buildSwap(
context.Background(), peer1, []lnwire.ShortChannelID{
chan1,
}, swapAmt, params,
}, swapAmt, params, nil,
)
assert.Equal(t, testCase.expectedSwap, swap)
assert.Equal(t, testCase.expectedErr, err)

View file

@ -2,6 +2,7 @@ package liquidity
import (
"context"
"encoding/hex"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/loop"
@ -84,6 +85,33 @@ func (b *loopOutBuilder) inUse(traffic *swapTraffic, peer route.Vertex,
return nil
}
type assetSwapInfo struct {
assetID string
peerPubkey []byte
}
// buildSwapOpts contains the options for building a swap.
type buildSwapOpts struct {
assetSwap *assetSwapInfo
}
// defaultBuildSwapOpts returns the default options for building a swap.
func defaultBuildSwapOpts() *buildSwapOpts {
return &buildSwapOpts{}
}
// buildSwapOption is a functional option that can be passed to the buildSwap
// method.
type buildSwapOption func(*buildSwapOpts)
// withAssetSwapInfo is an option to provide asset swap information to the
// builder.
func withAssetSwapInfo(assetSwapInfo *assetSwapInfo) buildSwapOption {
return func(o *buildSwapOpts) {
o.assetSwap = assetSwapInfo
}
}
// buildSwap creates a swap for the target peer/channels provided. The autoloop
// boolean indicates whether this swap will actually be executed, because there
// are some calls we can leave out if this swap is just for a dry run (ie, when
@ -94,14 +122,42 @@ func (b *loopOutBuilder) inUse(traffic *swapTraffic, peer route.Vertex,
// dry-run, and we do not add the autoloop label to the recommended swap.
func (b *loopOutBuilder) buildSwap(ctx context.Context, pubkey route.Vertex,
channels []lnwire.ShortChannelID, amount btcutil.Amount,
params Parameters) (swapSuggestion, error) {
params Parameters, swapOpts ...buildSwapOption) (swapSuggestion,
error) {
var (
assetRfqRequest *loop.AssetRFQRequest
assetIDBytes []byte
err error
)
opts := defaultBuildSwapOpts()
for _, opt := range swapOpts {
opt(opts)
}
initiator := getInitiator(params)
if opts.assetSwap != nil {
assetSwap := opts.assetSwap
assetIDBytes, err = hex.DecodeString(assetSwap.assetID)
if err != nil {
return nil, err
}
assetRfqRequest = &loop.AssetRFQRequest{
AssetId: assetIDBytes,
AssetEdgeNode: assetSwap.peerPubkey,
}
initiator += "-" + assetSwap.assetID
}
quote, err := b.cfg.LoopOutQuote(
ctx, &loop.LoopOutQuoteRequest{
Amount: amount,
SweepConfTarget: params.SweepConfTarget,
SwapPublicationDeadline: b.cfg.Clock.Now(),
Initiator: getInitiator(params),
Initiator: initiator,
AssetRFQRequest: assetRfqRequest,
},
)
if err != nil {
@ -146,7 +202,13 @@ func (b *loopOutBuilder) buildSwap(ctx context.Context, pubkey route.Vertex,
MaxSwapFee: quote.SwapFee,
MaxPrepayAmount: quote.PrepayAmount,
SweepConfTarget: params.SweepConfTarget,
Initiator: getInitiator(params),
Initiator: initiator,
}
if opts.assetSwap != nil {
request.AssetId = assetIDBytes
request.AssetPrepayRfqId = quote.LoopOutRfq.PrepayRfqId
request.AssetSwapRfqId = quote.LoopOutRfq.SwapRfqId
}
if params.Autoloop {

View file

@ -114,6 +114,21 @@ type Parameters struct {
// EasyAutoloopTarget is the target amount of liquidity that we want to
// maintain in our channels.
EasyAutoloopTarget btcutil.Amount
// AssetAutoloopParams maps an asset id hex encoded string to its
// easy autoloop parameters.
AssetAutoloopParams map[string]AssetParams
}
// AssetParams define the asset specific autoloop parameters.
type AssetParams struct {
// EnableEasyOut is a boolean that indicates whether we should use the
// easy autoloop feature for this asset.
EnableEasyOut bool
// LocalTargetAssetAmount is the target amount of liquidity that we
// want to maintain in our channels.
LocalTargetAssetAmount uint64
}
// String returns the string representation of our parameters.
@ -413,6 +428,14 @@ func RpcToParameters(req *clientrpc.LiquidityParameters) (*Parameters,
addrType = walletrpc.AddressType_TAPROOT_PUBKEY
}
easyAssetParams := make(map[string]AssetParams)
for asset, params := range req.EasyAssetParams {
easyAssetParams[asset] = AssetParams{
EnableEasyOut: params.Enabled,
LocalTargetAssetAmount: params.LocalTargetAssetAmt,
}
}
params := &Parameters{
FeeLimit: feeLimit,
SweepConfTarget: req.SweepConfTarget,
@ -437,9 +460,10 @@ func RpcToParameters(req *clientrpc.LiquidityParameters) (*Parameters,
Minimum: btcutil.Amount(req.MinSwapAmount),
Maximum: btcutil.Amount(req.MaxSwapAmount),
},
HtlcConfTarget: req.HtlcConfTarget,
EasyAutoloop: req.EasyAutoloop,
EasyAutoloopTarget: btcutil.Amount(req.EasyAutoloopLocalTargetSat),
HtlcConfTarget: req.HtlcConfTarget,
EasyAutoloop: req.EasyAutoloop,
EasyAutoloopTarget: btcutil.Amount(req.EasyAutoloopLocalTargetSat),
AssetAutoloopParams: easyAssetParams,
}
if req.AutoloopBudgetRefreshPeriodSec != 0 {
@ -528,6 +552,18 @@ func ParametersToRpc(cfg Parameters) (*clientrpc.LiquidityParameters,
addrType = clientrpc.AddressType_ADDRESS_TYPE_UNKNOWN
}
easyAssetMap := make(
map[string]*clientrpc.EasyAssetAutoloopParams,
len(cfg.AssetAutoloopParams),
)
for asset, params := range cfg.AssetAutoloopParams {
easyAssetMap[asset] = &clientrpc.EasyAssetAutoloopParams{
Enabled: params.EnableEasyOut,
LocalTargetAssetAmt: params.LocalTargetAssetAmount,
}
}
rpcCfg := &clientrpc.LiquidityParameters{
SweepConfTarget: cfg.SweepConfTarget,
FailureBackoffSec: uint64(cfg.FailureBackOff.Seconds()),
@ -555,6 +591,7 @@ func ParametersToRpc(cfg Parameters) (*clientrpc.LiquidityParameters,
EasyAutoloopLocalTargetSat: uint64(cfg.EasyAutoloopTarget),
Account: cfg.Account,
AccountAddrType: addrType,
EasyAssetParams: easyAssetMap,
}
switch f := cfg.FeeLimit.(type) {

View file

@ -151,6 +151,7 @@ func getLiquidityManager(client *loop.Client) *liquidity.Manager {
ListLoopIn: client.Store.FetchLoopInSwaps,
LoopInTerms: client.LoopInTerms,
LoopOutTerms: client.LoopOutTerms,
GetAssetPrice: client.AssetClient.GetAssetPrice,
MinimumConfirmations: minConfTarget,
PutLiquidityParams: client.Store.PutLiquidityParams,
FetchLiquidityParams: client.Store.FetchLiquidityParams,

File diff suppressed because it is too large Load diff

View file

@ -1207,6 +1207,29 @@ message LiquidityParameters {
The address type of the account specified in the account field.
*/
AddressType account_addr_type = 24;
/*
A map of asset parameters to use for swaps. The key is the asset id and the
value is the parameters to use for swaps in that asset.
*/
map<string, EasyAssetAutoloopParams> easy_asset_params = 25;
}
message EasyAssetAutoloopParams {
/*
Set to true to enable easy autoloop for this asset. If set the client will
automatically dispatch swaps in order to meet the configured local balance
target size. Currently only loop out is supported, meaning that easy
autoloop can only reduce the funds that are held as balance in channels.
*/
bool enabled = 1;
/*
The local balance target size, expressed in the asset's base units. This is
used by easy autoloop to determine how much liquidity should be maintained
in channels.
*/
uint64 local_target_asset_amt = 2;
}
enum LiquidityRuleType {

View file

@ -884,6 +884,20 @@
}
}
},
"looprpcEasyAssetAutoloopParams": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Set to true to enable easy autoloop for this asset. If set the client will\nautomatically dispatch swaps in order to meet the configured local balance\ntarget size. Currently only loop out is supported, meaning that easy\nautoloop can only reduce the funds that are held as balance in channels."
},
"local_target_asset_amt": {
"type": "string",
"format": "uint64",
"description": "The local balance target size, expressed in the asset's base units. This is\nused by easy autoloop to determine how much liquidity should be maintained\nin channels."
}
}
},
"looprpcFailureReason": {
"type": "string",
"enum": [
@ -1255,6 +1269,13 @@
"account_addr_type": {
"$ref": "#/definitions/looprpcAddressType",
"description": "The address type of the account specified in the account field."
},
"easy_asset_params": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/looprpcEasyAssetAutoloopParams"
},
"description": "A map of asset parameters to use for swaps. The key is the asset id and the\nvalue is the parameters to use for swaps in that asset."
}
}
},