Skip to content

Commit e76c4da

Browse files
committed
liquidity: add easy asset autoloop out
1 parent e3f049a commit e76c4da

File tree

1 file changed

+268
-6
lines changed

1 file changed

+268
-6
lines changed

liquidity/liquidity.go

Lines changed: 268 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ package liquidity
3434

3535
import (
3636
"context"
37+
"crypto/sha256"
38+
"encoding/json"
3739
"errors"
3840
"fmt"
3941
"math"
@@ -48,6 +50,7 @@ import (
4850
"github.com/lightninglabs/loop/loopdb"
4951
clientrpc "github.com/lightninglabs/loop/looprpc"
5052
"github.com/lightninglabs/loop/swap"
53+
"github.com/lightninglabs/taproot-assets/rfqmsg"
5154
"github.com/lightningnetwork/lnd/clock"
5255
"github.com/lightningnetwork/lnd/funding"
5356
"github.com/lightningnetwork/lnd/lntypes"
@@ -218,6 +221,11 @@ type Config struct {
218221
LoopOutTerms func(ctx context.Context,
219222
initiator string) (*loop.LoopOutTerms, error)
220223

224+
// GetAssetPrice returns the price of an asset in satoshis.
225+
GetAssetPrice func(ctx context.Context, assetId string,
226+
peerPubkey []byte, assetAmt uint64,
227+
maxPaymentAmt btcutil.Amount) (btcutil.Amount, error)
228+
221229
// Clock allows easy mocking of time in unit tests.
222230
Clock clock.Clock
223231

@@ -305,6 +313,15 @@ func (m *Manager) Run(ctx context.Context) error {
305313
}
306314
}
307315

316+
for assetID := range m.params.AssetAutoloopParams {
317+
err = m.easyAssetAutoloop(ctx, assetID)
318+
if err != nil {
319+
log.Errorf("easy asset autoloop "+
320+
"failed: id: %v, err: %v",
321+
assetID, err)
322+
}
323+
}
324+
308325
case <-ctx.Done():
309326
return ctx.Err()
310327
}
@@ -505,6 +522,32 @@ func (m *Manager) easyAutoLoop(ctx context.Context) error {
505522
return nil
506523
}
507524

525+
// easyAssetAutoloop is the main entry point for the easy auto loop functionality
526+
// for assets. This function will try to dispatch a swap in order to meet the
527+
// easy autoloop requirements for the given asset. For easyAutoloop to work
528+
// there needs to be an EasyAutoloopTarget defined in the parameters. Easy
529+
// autoloop also uses the configured max inflight swaps and budget rules defined
530+
// in the parameters.
531+
func (m *Manager) easyAssetAutoloop(ctx context.Context, assetID string) error {
532+
if !m.params.Autoloop {
533+
return nil
534+
}
535+
536+
assetParams, ok := m.params.AssetAutoloopParams[assetID]
537+
if !ok && !assetParams.EnableEasyOut {
538+
return nil
539+
}
540+
541+
// First check if we should refresh our budget before calculating any
542+
// swaps for autoloop.
543+
m.refreshAutoloopBudget(ctx)
544+
545+
// Dispatch the best easy autoloop swap.
546+
targetAmt := assetParams.LocalTargetAssetAmount
547+
548+
return m.dispatchBestAssetEasyAutoloopSwap(ctx, assetID, targetAmt)
549+
}
550+
508551
// ForceAutoLoop force-ticks our auto-out ticker.
509552
func (m *Manager) ForceAutoLoop(ctx context.Context) error {
510553
select {
@@ -637,7 +680,195 @@ func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error {
637680

638681
suggestion, err := builder.buildSwap(
639682
ctx, channel.PubKeyBytes, outgoing, swapAmt, easyParams,
640-
nil,
683+
)
684+
if err != nil {
685+
return err
686+
}
687+
688+
var swp loop.OutRequest
689+
if t, ok := suggestion.(*loopOutSwapSuggestion); ok {
690+
swp = t.OutRequest
691+
} else {
692+
return fmt.Errorf("unexpected swap suggestion type: %T", t)
693+
}
694+
695+
// Dispatch a sticky loop out.
696+
go m.dispatchStickyLoopOut(
697+
ctx, swp, defaultAmountBackoffRetry, defaultAmountBackoff,
698+
)
699+
700+
return nil
701+
}
702+
703+
// dispatchBestAssetEasyAutoloopSwap tries to dispatch a swap to bring the total
704+
// local balance back to the target for the given asset.
705+
func (m *Manager) dispatchBestAssetEasyAutoloopSwap(ctx context.Context,
706+
assetID string, localTarget uint64) error {
707+
708+
if len(assetID) != sha256.Size*2 {
709+
return fmt.Errorf("invalid asset id: %v", assetID)
710+
}
711+
712+
// Retrieve existing swaps.
713+
loopOut, err := m.cfg.ListLoopOut(ctx)
714+
if err != nil {
715+
return err
716+
}
717+
718+
loopIn, err := m.cfg.ListLoopIn(ctx)
719+
if err != nil {
720+
return err
721+
}
722+
723+
// Get a summary of our existing swaps so that we can check our autoloop
724+
// budget.
725+
summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn)
726+
727+
err = m.checkSummaryBudget(summary)
728+
if err != nil {
729+
return err
730+
}
731+
732+
_, err = m.checkSummaryInflight(summary)
733+
if err != nil {
734+
return err
735+
}
736+
737+
// Get all channels in order to calculate current total local balance.
738+
channels, err := m.cfg.Lnd.Client.ListChannels(ctx, false, false)
739+
if err != nil {
740+
return err
741+
}
742+
743+
// If we are running a custom asset, we'll need to get a random asset
744+
// peer pubkey in order to rfq the asset price.
745+
var assetPeerPubkey []byte
746+
747+
usableChannels := []lndclient.ChannelInfo{}
748+
localTotal := uint64(0)
749+
for _, channel := range channels {
750+
// We are only interested in custom asset channels.
751+
if !channelIsCustom(channel) {
752+
continue
753+
}
754+
755+
assetData := getCustomAssetData(channel, assetID)
756+
if assetData == nil {
757+
continue
758+
}
759+
760+
// We'll overwrite the channel local balance to be
761+
// the custom asset balance. This allows us to make
762+
// use of existing logic.
763+
channel.LocalBalance = btcutil.Amount(assetData.LocalBalance)
764+
usableChannels = append(usableChannels, channel)
765+
766+
// We'll use a random peer pubkey in order to get a rfq for the asset
767+
// to get a rough amount of sats to swap amount.
768+
assetPeerPubkey = channel.PubKeyBytes[:]
769+
770+
localTotal += assetData.LocalBalance
771+
}
772+
773+
// Since we're only autolooping-out we need to check if we are below
774+
// the target, meaning that we already meet the requirements.
775+
if localTotal <= localTarget {
776+
log.Debugf("Asset: %v... total local balance %v below target %v",
777+
assetID[:8], localTotal, localTarget)
778+
return nil
779+
}
780+
781+
restrictions, err := m.cfg.Restrictions(
782+
ctx, swap.TypeOut, getInitiator(m.params),
783+
)
784+
if err != nil {
785+
return err
786+
}
787+
788+
// Calculate the assetAmount that we want to loop out. If it exceeds the
789+
// max allowed clamp it to max.
790+
assetAmount := localTotal - localTarget
791+
792+
// We need a request sat amount for the asset price request. We'll use
793+
// the average of the min and max restrictions.
794+
assetPriceRequestSatAmt := (restrictions.Minimum + restrictions.Maximum) / 2
795+
796+
// If we run a custom asset, we'll need to convert the asset amount
797+
// we want to swap to the satoshi amount.
798+
satAmount, err := m.cfg.GetAssetPrice(
799+
ctx, assetID, assetPeerPubkey, assetAmount,
800+
assetPriceRequestSatAmt,
801+
)
802+
if err != nil {
803+
return err
804+
}
805+
806+
if satAmount > restrictions.Maximum {
807+
log.Debugf("Asset %v easy autoloop: using maximum allowed "+
808+
"swap amount, maximum=%v, need to swap %v",
809+
assetID[:8], restrictions.Maximum, satAmount)
810+
satAmount = restrictions.Maximum
811+
}
812+
813+
// If the amount we want to loop out is less than the minimum we can't
814+
// proceed with a swap, so we return early.
815+
if satAmount < restrictions.Minimum {
816+
log.Debugf("Asset %v easy autoloop: swap amount is below"+
817+
" minimum swap size, minimum=%v, need to swap %v",
818+
assetID[:8], restrictions.Minimum, satAmount)
819+
return nil
820+
}
821+
822+
satsPerAsset := float64(satAmount) / float64(assetAmount)
823+
824+
log.Debugf("Asset %v easy autoloop: local_total=%v, target=%v, "+
825+
"attempting to loop out %v assets corresponding to %v sats",
826+
assetID[:8], localTotal, localTarget, assetAmount, satAmount)
827+
828+
// Start building that swap.
829+
builder := newLoopOutBuilder(m.cfg)
830+
831+
channel := m.pickEasyAutoloopChannel(
832+
usableChannels, restrictions, loopOut, loopIn, satsPerAsset,
833+
)
834+
if channel == nil {
835+
return fmt.Errorf("no eligible channel for easy autoloop")
836+
}
837+
838+
log.Debugf("Asset %v easy autoloop: picked channel %v with local"+
839+
" balance %v", assetID[:8], channel.ChannelID,
840+
int(channel.LocalBalance))
841+
842+
// If no fee is set, override our current parameters in order to use the
843+
// default percent limit of easy-autoloop.
844+
easyParams := m.params
845+
846+
switch feeLimit := easyParams.FeeLimit.(type) {
847+
case *FeePortion:
848+
if feeLimit.PartsPerMillion == 0 {
849+
easyParams.FeeLimit = &FeePortion{
850+
PartsPerMillion: defaultFeePPM,
851+
}
852+
}
853+
default:
854+
easyParams.FeeLimit = &FeePortion{
855+
PartsPerMillion: defaultFeePPM,
856+
}
857+
}
858+
859+
// Set the swap outgoing channel to the chosen channel.
860+
outgoing := []lnwire.ShortChannelID{
861+
lnwire.NewShortChanIDFromInt(channel.ChannelID),
862+
}
863+
864+
assetSwap := &assetSwapInfo{
865+
assetID: assetID,
866+
peerPubkey: channel.PubKeyBytes[:],
867+
}
868+
869+
suggestion, err := builder.buildSwap(
870+
ctx, channel.PubKeyBytes, outgoing, satAmount, easyParams,
871+
withAssetSwapInfo(assetSwap),
641872
)
642873
if err != nil {
643874
return err
@@ -1434,10 +1665,6 @@ func (m *Manager) pickEasyAutoloopChannel(channels []lndclient.ChannelInfo,
14341665
// Check each channel, since channels are already sorted we return the
14351666
// first channel that passes all checks.
14361667
for _, channel := range channels {
1437-
if channelIsCustom(channel) {
1438-
continue
1439-
}
1440-
14411668
shortChanID := lnwire.NewShortChanIDFromInt(channel.ChannelID)
14421669

14431670
if !channel.Active {
@@ -1460,7 +1687,17 @@ func (m *Manager) pickEasyAutoloopChannel(channels []lndclient.ChannelInfo,
14601687
continue
14611688
}
14621689

1463-
if channel.LocalBalance < restrictions.Minimum {
1690+
localBalance := channel.LocalBalance
1691+
1692+
// If we're running a custom asset, the local balance is
1693+
// denominated in the asset's unit, so we convert it to
1694+
// back to sats to check the minimum.
1695+
if channelIsCustom(channel) {
1696+
localBalance = localBalance.MulF64(satsPerAsset)
1697+
}
1698+
1699+
if localBalance < restrictions.Minimum {
1700+
14641701
log.Debugf("Channel %v cannot be used for easy "+
14651702
"autoloop: insufficient local balance %v,"+
14661703
"minimum is %v, skipping remaining channels",
@@ -1571,3 +1808,28 @@ func channelIsCustom(channel lndclient.ChannelInfo) bool {
15711808
// don't want to consider it for swaps.
15721809
return channel.CustomChannelData != nil
15731810
}
1811+
1812+
// getCustomAssetData returns the asset data for a custom channel.
1813+
func getCustomAssetData(channel lndclient.ChannelInfo, assetID string,
1814+
) *rfqmsg.JsonAssetChanInfo {
1815+
1816+
if channel.CustomChannelData == nil {
1817+
return nil
1818+
}
1819+
1820+
var assetData rfqmsg.JsonAssetChannel
1821+
err := json.Unmarshal(channel.CustomChannelData, &assetData)
1822+
if err != nil {
1823+
log.Errorf("Error unmarshalling custom channel %v data: %v",
1824+
channel.ChannelID, err)
1825+
return nil
1826+
}
1827+
1828+
for _, asset := range assetData.Assets {
1829+
if asset.AssetInfo.AssetGenesis.AssetID == assetID {
1830+
return &asset
1831+
}
1832+
}
1833+
1834+
return nil
1835+
}

0 commit comments

Comments
 (0)