Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add simple asset autoloop #886

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
70 changes: 70 additions & 0 deletions assets/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to specify expiry? Is there a default?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sell order expiry is actually unused in tapd. I think we should keep this sane value though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rfq expiry is checked when this function is called on intercepted incoming HTLCs


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")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "quote wasn't accepted", maybe with details?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This actually isn't "quote wasn't accepted" as that would be rfq.GetRejectedQuote(). This would be an internal error in the grpc call so pretty unlikely and a check for the .proto oneof message type.

Copy link
Member Author

@sputn1ck sputn1ck Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

func (x *AddAssetSellOrderResponse) GetAcceptedQuote() *PeerAcceptedSellQuote {
	if x, ok := x.GetResponse().(*AddAssetSellOrderResponse_AcceptedQuote); ok {
		return x.AcceptedQuote
	}
	return nil
}

func (x *AddAssetSellOrderResponse) GetInvalidQuote() *InvalidQuoteResponse {
	if x, ok := x.GetResponse().(*AddAssetSellOrderResponse_InvalidQuote); ok {
		return x.InvalidQuote
	}
	return nil
}

func (x *AddAssetSellOrderResponse) GetRejectedQuote() *RejectedQuoteResponse {
	if x, ok := x.GetResponse().(*AddAssetSellOrderResponse_RejectedQuote); ok {
		return x.RejectedQuote
	}
	return nil
}

These are the 3 possibilities

}

// 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we hold the assumption that an asset unit is at least a millisatoshi. What about fractions? Should we scale both asset units and the rate so we can represent fractions correctly?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you consider this to be assumed?


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) (
Expand Down
40 changes: 40 additions & 0 deletions assets/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
}
19 changes: 9 additions & 10 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ type Client struct {
lndServices *lndclient.LndServices
sweeper *sweep.Sweeper
executor *executor
assetClient *assets.TapdClient

resumeReady chan struct{}
wg sync.WaitGroup
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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{}),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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")
}
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
60 changes: 60 additions & 0 deletions cmd/loop/debug.go
Original file line number Diff line number Diff line change
@@ -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
}
55 changes: 53 additions & 2 deletions cmd/loop/liquidity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
Expand Down
Loading