From 157c4df24f0211d721a5c20d9c84dbd066795bf1 Mon Sep 17 00:00:00 2001 From: Chris Malec Date: Tue, 25 Jun 2024 10:45:57 -0700 Subject: [PATCH] LM-24 Integrate minimum liquidities rebalancing algorithm (#1037) ## Motivation ## Solution --------- Co-authored-by: amirylm <83904651+amirylm@users.noreply.github.com> --- .../liquiditymanager/discoverer/evm.go | 4 +- .../ocr2/plugins/liquiditymanager/factory.go | 2 + .../plugins/liquiditymanager/graph/data.go | 6 + .../plugins/liquiditymanager/graph/graph.go | 27 + .../plugins/liquiditymanager/graph/reader.go | 41 ++ .../plugins/liquiditymanager/graph/writer.go | 1 + .../liquidityrebalancer/common.go | 138 ++++ .../{targetbalance.go => minimum_balance.go} | 121 +--- ...alance_test.go => minimum_balance_test.go} | 71 +- .../target_balance_with_minimum.go | 320 +++++++++ .../target_balance_with_minimum_test.go | 634 ++++++++++++++++++ .../plugins/liquiditymanager/models/config.go | 1 + .../plugins/liquiditymanager/models/models.go | 40 +- 13 files changed, 1251 insertions(+), 155 deletions(-) create mode 100644 core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/common.go rename core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/{targetbalance.go => minimum_balance.go} (73%) rename core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/{targetbalance_test.go => minimum_balance_test.go} (83%) create mode 100644 core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/target_balance_with_minimum.go create mode 100644 core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/target_balance_with_minimum_test.go diff --git a/core/services/ocr2/plugins/liquiditymanager/discoverer/evm.go b/core/services/ocr2/plugins/liquiditymanager/discoverer/evm.go index 85f1167ffe..6c43ffd475 100644 --- a/core/services/ocr2/plugins/liquiditymanager/discoverer/evm.go +++ b/core/services/ocr2/plugins/liquiditymanager/discoverer/evm.go @@ -147,9 +147,11 @@ func (e *evmDiscoverer) getVertexData(ctx context.Context, v graph.Vertex) (grap minimumLiquidity, err := lm.GetMinimumLiquidity(&bind.CallOpts{Context: ctx}) if err != nil { - return graph.Data{}, nil, fmt.Errorf("get target balance: %w", err) + return graph.Data{}, nil, fmt.Errorf("get minimum liquidity balance: %w", err) } + //Do we want to add TargetLiquidity to the contract? + data := graph.Data{ Liquidity: liquidity, TokenAddress: models.Address(token), diff --git a/core/services/ocr2/plugins/liquiditymanager/factory.go b/core/services/ocr2/plugins/liquiditymanager/factory.go index 1fe4988b6d..f44052b431 100644 --- a/core/services/ocr2/plugins/liquiditymanager/factory.go +++ b/core/services/ocr2/plugins/liquiditymanager/factory.go @@ -66,6 +66,8 @@ func (p PluginFactory) buildRebalancer() (liquidityrebalancer.Rebalancer, error) return liquidityrebalancer.NewPingPong(), nil case models.RebalancerTypeMinLiquidity: return liquidityrebalancer.NewMinLiquidityRebalancer(p.lggr), nil + case models.RebalancerTypeTargetAndMin: + return liquidityrebalancer.NewTargetMinBalancer(p.lggr), nil default: return nil, fmt.Errorf("invalid rebalancer type %s", p.config.RebalancerConfig.Type) } diff --git a/core/services/ocr2/plugins/liquiditymanager/graph/data.go b/core/services/ocr2/plugins/liquiditymanager/graph/data.go index 65d035468a..0234e29110 100644 --- a/core/services/ocr2/plugins/liquiditymanager/graph/data.go +++ b/core/services/ocr2/plugins/liquiditymanager/graph/data.go @@ -32,6 +32,7 @@ type Data struct { ConfigDigest models.ConfigDigest NetworkSelector models.NetworkSelector MinimumLiquidity *big.Int + TargetLiquidity *big.Int } func (d Data) Equals(other Data) bool { @@ -60,6 +61,10 @@ func (d Data) Clone() Data { if minLiq == nil { minLiq = big.NewInt(0) } + targetLiq := d.TargetLiquidity + if targetLiq == nil { + targetLiq = big.NewInt(0) + } return Data{ Liquidity: big.NewInt(0).Set(liq), TokenAddress: tokenAddr, @@ -68,5 +73,6 @@ func (d Data) Clone() Data { ConfigDigest: d.ConfigDigest.Clone(), NetworkSelector: d.NetworkSelector, MinimumLiquidity: big.NewInt(0).Set(minLiq), + TargetLiquidity: big.NewInt(0).Set(targetLiq), } } diff --git a/core/services/ocr2/plugins/liquiditymanager/graph/graph.go b/core/services/ocr2/plugins/liquiditymanager/graph/graph.go index 625c8ac81e..287272a9d1 100644 --- a/core/services/ocr2/plugins/liquiditymanager/graph/graph.go +++ b/core/services/ocr2/plugins/liquiditymanager/graph/graph.go @@ -44,6 +44,9 @@ type GraphReader interface { IsEmpty() bool // Len returns the number of vertices in the graph. Len() int + // FindPath returns the path from the source to the destination network. + // The iterator function is called for each node in the path with the data of the node. + FindPath(from, to models.NetworkSelector, maxEdgesTraversed int, iterator func(nodes ...Data) bool) []models.NetworkSelector } // Graph contains graphs functionality for networks and liquidity @@ -57,6 +60,8 @@ type Graph interface { String() string // Reset resets the graph to it's empty state. Reset() + // Clone creates a deep copy of the graph. + Clone() Graph } // GraphTest provides testing functionality for the graph. @@ -159,3 +164,25 @@ func (g *liquidityGraph) Reset() { g.adj = make(map[models.NetworkSelector][]models.NetworkSelector) g.data = make(map[models.NetworkSelector]Data) } + +func (g *liquidityGraph) Clone() Graph { + g.lock.RLock() + defer g.lock.RUnlock() + + clone := &liquidityGraph{ + adj: make(map[models.NetworkSelector][]models.NetworkSelector, len(g.adj)), + data: make(map[models.NetworkSelector]Data, len(g.data)), + } + + for k, v := range g.adj { + adjCopy := make([]models.NetworkSelector, len(v)) + copy(adjCopy, v) + clone.adj[k] = adjCopy + } + + for k, v := range g.data { + clone.data[k] = v.Clone() + } + + return clone +} diff --git a/core/services/ocr2/plugins/liquiditymanager/graph/reader.go b/core/services/ocr2/plugins/liquiditymanager/graph/reader.go index 5e5678f9f2..625c08783e 100644 --- a/core/services/ocr2/plugins/liquiditymanager/graph/reader.go +++ b/core/services/ocr2/plugins/liquiditymanager/graph/reader.go @@ -135,6 +135,47 @@ func (g *liquidityGraph) Len() int { return g.len() } +// FindPath finds a path from the source network to the destination network with the given number of edges that are allow to be traversed. +// It calls the iterator function with each individual node in the path. +// It returns the list of network selectors representing the path from source to destination (including the destination node). +func (g *liquidityGraph) FindPath(from, to models.NetworkSelector, maxEdgesTraversed int, iterator func(nodes ...Data) bool) []models.NetworkSelector { + g.lock.RLock() + defer g.lock.RUnlock() + + return g.findPath(from, to, maxEdgesTraversed, iterator) +} + +func (g *liquidityGraph) findPath(from, to models.NetworkSelector, maxEdgesTraversed int, iterator func(nodes ...Data) bool) []models.NetworkSelector { + if maxEdgesTraversed == 0 { + return []models.NetworkSelector{} + } + neibs, exist := g.adj[from] + if !exist { + return []models.NetworkSelector{} + } + for _, n := range neibs { + if n == to { + if !iterator(g.data[to]) { + continue + } + return []models.NetworkSelector{n} + } + } + for _, n := range neibs { + if p := g.findPath(n, to, maxEdgesTraversed-1, iterator); len(p) > 0 { + data := []Data{g.data[n]} + for _, d := range p { + data = append(data, g.data[d]) + } + if !iterator(data...) { + continue + } + return append([]models.NetworkSelector{n}, p...) + } + } + return []models.NetworkSelector{} +} + func (g *liquidityGraph) getData(n models.NetworkSelector) (Data, bool) { data, exists := g.data[n] return data, exists diff --git a/core/services/ocr2/plugins/liquiditymanager/graph/writer.go b/core/services/ocr2/plugins/liquiditymanager/graph/writer.go index 74b660b658..2da8207d0d 100644 --- a/core/services/ocr2/plugins/liquiditymanager/graph/writer.go +++ b/core/services/ocr2/plugins/liquiditymanager/graph/writer.go @@ -48,6 +48,7 @@ func (g *liquidityGraph) SetLiquidity(n models.NetworkSelector, liquidity *big.I ConfigDigest: prev.ConfigDigest, NetworkSelector: prev.NetworkSelector, MinimumLiquidity: prev.MinimumLiquidity, + TargetLiquidity: prev.TargetLiquidity, } return true } diff --git a/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/common.go b/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/common.go new file mode 100644 index 0000000000..3165d59384 --- /dev/null +++ b/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/common.go @@ -0,0 +1,138 @@ +package liquidityrebalancer + +import ( + "fmt" + "math/big" + + big2 "github.com/smartcontractkit/chainlink/v2/core/chains/evm/utils/big" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/liquiditymanager/graph" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/liquiditymanager/models" +) + +type Funds struct { + AvailableAmount *big.Int +} + +// getExpectedGraph returns a copy of the graph instance with all the non-executed transfers applied. +func getExpectedGraph(g graph.Graph, nonExecutedTransfers []UnexecutedTransfer) (graph.Graph, error) { + expG := g.Clone() + + for _, tr := range nonExecutedTransfers { + liqTo, err := expG.GetLiquidity(tr.ToNetwork()) + if err != nil { + return nil, err + } + expG.SetLiquidity(tr.ToNetwork(), big.NewInt(0).Add(liqTo, tr.TransferAmount())) + + // we only subtract from the sender if the transfer is still in progress, otherwise the source value would have already been updated + switch tr.TransferStatus() { + case models.TransferStatusProposed, models.TransferStatusInflight: + liqFrom, err := expG.GetLiquidity(tr.FromNetwork()) + if err != nil { + return nil, err + } + expG.SetLiquidity(tr.FromNetwork(), big.NewInt(0).Sub(liqFrom, tr.TransferAmount())) + } + } + + return expG, nil +} + +// mergeProposedTransfers merges multiple transfers with the same sender and recipient into a single transfer. +func mergeProposedTransfers(transfers []models.ProposedTransfer) []models.ProposedTransfer { + sums := make(map[[2]models.NetworkSelector]*big.Int) + for _, tr := range transfers { + k := [2]models.NetworkSelector{tr.From, tr.To} + if _, exists := sums[k]; !exists { + sums[k] = tr.TransferAmount() + continue + } + sums[k] = big.NewInt(0).Add(sums[k], tr.TransferAmount()) + } + + merged := make([]models.ProposedTransfer, 0, len(transfers)) + for k, v := range sums { + merged = append(merged, models.ProposedTransfer{From: k[0], To: k[1], Amount: big2.New(v)}) + } + return merged +} + +func minBigInt(a, b *big.Int) *big.Int { + switch a.Cmp(b) { + case -1: // a < b + return a + case 0: // a == b + return a + case 1: // a > b + return b + } + return nil +} + +// availableTransferableAmount calculates the available transferable amount of liquidity for a given network +// at two different time points (graphNow and graphLater). +// It takes a graph.Graph instance for the current time point (graphNow), a graph.Graph instance for the future time point (graphLater), +// and a models.NetworkSelector that represents the network for which to calculate the transferable amount. +// It returns the minimum of the available transferable amounts calculated from graphNow and graphLater as a *big.Int +func availableTransferableAmount(graphNow, graphLater graph.Graph, net models.NetworkSelector) (*big.Int, error) { + nowData, err := graphNow.GetData(net) + if err != nil { + return nil, fmt.Errorf("error during GetData for %d in graphNow: %v", net, err) + } + availableAmountNow := big.NewInt(0).Sub(nowData.Liquidity, nowData.MinimumLiquidity) + laterData, err := graphLater.GetData(net) + if err != nil { + return nil, fmt.Errorf("error during GetData for %d in graphLater: %v", net, err) + } + availableAmountLater := big.NewInt(0).Sub(laterData.Liquidity, laterData.MinimumLiquidity) + return minBigInt(availableAmountNow, availableAmountLater), nil +} + +// getTargetLiquidityDifferences calculates the liquidity differences between two graph instances. +// It returns two maps, liqDiffsNow and liqDiffsLater, where each map contains the liquidity differences for each network. +// The function iterates over the networks in graphNow and graphLater and compares their target liquidity and liquidity values. +// If the target liquidity is set to 0, automated rebalancing is disabled for that network. +// The liquidity differences are calculated by subtracting the liquidity from the target liquidity. +// The function uses the models.NetworkSelector type to identify networks. +func getTargetLiquidityDifferences(graphNow, graphLater graph.Graph) (liqDiffsNow, liqDiffsLater map[models.NetworkSelector]*big.Int, err error) { + liqDiffsNow = make(map[models.NetworkSelector]*big.Int) + liqDiffsLater = make(map[models.NetworkSelector]*big.Int) + + for _, net := range graphNow.GetNetworks() { + dataNow, err := graphNow.GetData(net) + if err != nil { + return nil, nil, fmt.Errorf("get data now of net %v: %w", net, err) + } + + dataLater, err := graphLater.GetData(net) + if err != nil { + return nil, nil, fmt.Errorf("get data later of net %v: %w", net, err) + } + + if dataNow.TargetLiquidity == nil { + return nil, nil, fmt.Errorf("target liquidity is nil for network %v", net) + } + if dataNow.TargetLiquidity.Cmp(big.NewInt(0)) == 0 { + // automated rebalancing is disabled if target is set to 0 + liqDiffsNow[net] = big.NewInt(0) + liqDiffsLater[net] = big.NewInt(0) + continue + } + + liqDiffsNow[net] = big.NewInt(0).Sub(dataNow.TargetLiquidity, dataNow.Liquidity) + liqDiffsLater[net] = big.NewInt(0).Sub(dataLater.TargetLiquidity, dataLater.Liquidity) + } + + return liqDiffsNow, liqDiffsLater, nil +} + +// filterUnexecutedTransfers filters out transfers that have already been executed. +func filterUnexecutedTransfers(nonExecutedTransfers []UnexecutedTransfer) []UnexecutedTransfer { + filtered := make([]UnexecutedTransfer, 0, len(nonExecutedTransfers)) + for _, tr := range nonExecutedTransfers { + if tr.TransferStatus() != models.TransferStatusExecuted { + filtered = append(filtered, tr) + } + } + return filtered +} diff --git a/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/targetbalance.go b/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/minimum_balance.go similarity index 73% rename from core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/targetbalance.go rename to core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/minimum_balance.go index 833ac626ef..143b073e4f 100644 --- a/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/targetbalance.go +++ b/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/minimum_balance.go @@ -28,10 +28,10 @@ func (r *MinLiquidityRebalancer) ComputeTransfersToBalance( graphNow graph.Graph, nonExecutedTransfers []UnexecutedTransfer, ) ([]models.ProposedTransfer, error) { - nonExecutedTransfers = r.filterUnexecutedTransfers(nonExecutedTransfers) + nonExecutedTransfers = filterUnexecutedTransfers(nonExecutedTransfers) r.lggr.Debugf("computing the expected graph after non executed transfers get applied") - graphLater, err := r.getExpectedGraph(graphNow, nonExecutedTransfers) + graphLater, err := getExpectedGraph(graphNow, nonExecutedTransfers) if err != nil { return nil, fmt.Errorf("copy graph: %w", err) } @@ -75,7 +75,7 @@ func (r *MinLiquidityRebalancer) ComputeTransfersToBalance( proposedTransfers = append(proposedTransfers, netProposedTransfers...) } - proposedTransfers = r.mergeProposedTransfers(proposedTransfers) + proposedTransfers = mergeProposedTransfers(proposedTransfers) r.lggr.Debugf("sorting proposed transfers for determinism") sort.Slice(proposedTransfers, func(i, j int) bool { @@ -93,7 +93,7 @@ func (r *MinLiquidityRebalancer) findNetworksRequiringFunding(graphNow, graphLat liqDiffsNow, liqDiffsLater map[models.NetworkSelector]*big.Int, err error, ) { - liqDiffsNow, liqDiffsLater, err = r.getTargetLiquidityDifferences(graphNow, graphLater) + liqDiffsNow, liqDiffsLater, err = getTargetLiquidityDifferences(graphNow, graphLater) if err != nil { return nil, nil, nil, fmt.Errorf("compute tokens funding requirements: %w", err) } @@ -116,54 +116,6 @@ func (r *MinLiquidityRebalancer) findNetworksRequiringFunding(graphNow, graphLat return res, liqDiffsNow, liqDiffsLater, nil } -func (r *MinLiquidityRebalancer) filterUnexecutedTransfers(nonExecutedTransfers []UnexecutedTransfer) []UnexecutedTransfer { - r.lggr.Debugf("filtering out executed transfers") - filtered := make([]UnexecutedTransfer, 0, len(nonExecutedTransfers)) - for _, tr := range nonExecutedTransfers { - if tr.TransferStatus() != models.TransferStatusExecuted { - filtered = append(filtered, tr) - } - } - return filtered -} - -// getTargetLiquidityDifferences computes for each network the difference between -// the target liquidity of the network and the actual liquidity. -// It does this on both the current liquidity graph (graphNow) and the liquidity graph -// after all pending transfers have been successfully executed (graphLater). -// A negative number indicates that there is a liquidity shortage for the network, -// while a positive number indicates a liquidity surplus for the network. -func (r *MinLiquidityRebalancer) getTargetLiquidityDifferences( - graphNow, graphLater graph.Graph, -) (liqDiffsNow, liqDiffsLater map[models.NetworkSelector]*big.Int, err error) { - liqDiffsNow = make(map[models.NetworkSelector]*big.Int) - liqDiffsLater = make(map[models.NetworkSelector]*big.Int) - - for _, net := range graphNow.GetNetworks() { - dataNow, err := graphNow.GetData(net) - if err != nil { - return nil, nil, fmt.Errorf("get data now of net %d: %w", net, err) - } - - dataLater, err := graphLater.GetData(net) - if err != nil { - return nil, nil, fmt.Errorf("get data later of net %d: %w", net, err) - } - - if dataNow.MinimumLiquidity.Cmp(big.NewInt(0)) == 0 { - // automated rebalancing is disabled if target is set to 0 - liqDiffsNow[net] = big.NewInt(0) - liqDiffsLater[net] = big.NewInt(0) - continue - } - - liqDiffsNow[net] = big.NewInt(0).Sub(dataNow.MinimumLiquidity, dataNow.Liquidity) - liqDiffsLater[net] = big.NewInt(0).Sub(dataLater.MinimumLiquidity, dataLater.Liquidity) - } - - return liqDiffsNow, liqDiffsLater, nil -} - func (r *MinLiquidityRebalancer) oneHopTransfers( graphLater graph.Graph, // the networks graph state after all transfers are applied targetNetwork models.NetworkSelector, @@ -353,71 +305,6 @@ func (r *MinLiquidityRebalancer) acceptTransfers(graphLater graph.Graph, potenti return proposedTransfers, nil } -// getExpectedGraph returns the a copy of the graph instance with all the non executed transfers applied. -func (r *MinLiquidityRebalancer) getExpectedGraph( - g graph.Graph, - nonExecutedTransfers []UnexecutedTransfer, -) (graph.Graph, error) { - edges, err := g.GetEdges() - if err != nil { - return nil, err - } - - expG := graph.NewGraph() - for _, edge := range edges { - sourceData, err := g.GetData(edge.Source) - if err != nil { - return nil, err - } - - destData, err := g.GetData(edge.Dest) - if err != nil { - return nil, err - } - if err := expG.Add(sourceData, destData); err != nil { - return nil, err - } - } - - for _, tr := range nonExecutedTransfers { - liqTo, err := expG.GetLiquidity(tr.ToNetwork()) - if err != nil { - return nil, err - } - expG.SetLiquidity(tr.ToNetwork(), big.NewInt(0).Add(liqTo, tr.TransferAmount())) - - switch tr.TransferStatus() { - case models.TransferStatusProposed, models.TransferStatusInflight: - liqFrom, err := expG.GetLiquidity(tr.FromNetwork()) - if err != nil { - return nil, err - } - expG.SetLiquidity(tr.FromNetwork(), big.NewInt(0).Sub(liqFrom, tr.TransferAmount())) - } - } - - return expG, nil -} - -// mergeProposedTransfers merges multiple transfers with same sender and recipient into a single transfer. -func (r *MinLiquidityRebalancer) mergeProposedTransfers(transfers []models.ProposedTransfer) []models.ProposedTransfer { - sums := make(map[[2]models.NetworkSelector]*big.Int) - for _, tr := range transfers { - k := [2]models.NetworkSelector{tr.From, tr.To} - if _, exists := sums[k]; !exists { - sums[k] = tr.TransferAmount() - continue - } - sums[k] = big.NewInt(0).Add(sums[k], tr.TransferAmount()) - } - - merged := make([]models.ProposedTransfer, 0, len(transfers)) - for k, v := range sums { - merged = append(merged, models.ProposedTransfer{From: k[0], To: k[1], Amount: ubig.New(v)}) - } - return merged -} - func newTransfer(from, to models.NetworkSelector, amount *big.Int) models.ProposedTransfer { return models.ProposedTransfer{ From: from, diff --git a/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/targetbalance_test.go b/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/minimum_balance_test.go similarity index 83% rename from core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/targetbalance_test.go rename to core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/minimum_balance_test.go index 4d31a67206..491b6e7370 100644 --- a/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/targetbalance_test.go +++ b/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/minimum_balance_test.go @@ -13,9 +13,11 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/liquiditymanager/models" ) -var eth = models.NetworkSelector(1) -var opt = models.NetworkSelector(2) -var arb = models.NetworkSelector(3) +var eth = models.NetworkSelector(5009297550715157269) +var opt = models.NetworkSelector(3734403246176062136) +var arb = models.NetworkSelector(4949039107694359620) +var base = models.NetworkSelector(15971525489660198786) +var celo = models.NetworkSelector(1346049177634351622) func TestTargetBalanceRebalancer_ComputeTransfersToBalance_arb_eth_opt(t *testing.T) { type transfer struct { @@ -28,77 +30,77 @@ func TestTargetBalanceRebalancer_ComputeTransfersToBalance_arb_eth_opt(t *testin testCases := []struct { name string balances map[models.NetworkSelector]int64 - targets map[models.NetworkSelector]int64 + minimums map[models.NetworkSelector]int64 pendingTransfers []transfer expTransfers []transfer }{ { name: "all above target", balances: map[models.NetworkSelector]int64{eth: 1400, arb: 1000, opt: 1100}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{}, }, { name: "arb below target", balances: map[models.NetworkSelector]int64{eth: 1400, arb: 800, opt: 1100}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{{from: eth, to: arb, am: 200}}, }, { name: "opt below target", balances: map[models.NetworkSelector]int64{eth: 1400, arb: 1000, opt: 900}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{{from: eth, to: opt, am: 100}}, }, { name: "eth below target", balances: map[models.NetworkSelector]int64{eth: 900, arb: 1000, opt: 1300}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{{from: opt, to: eth, am: 100}}, }, { name: "both opt and arb below target", balances: map[models.NetworkSelector]int64{eth: 1500, arb: 800, opt: 900}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{{from: eth, to: opt, am: 100}, {from: eth, to: arb, am: 200}}, }, { name: "eth below target and requires two transfers to reach target", balances: map[models.NetworkSelector]int64{eth: 800, arb: 1100, opt: 1150}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{{from: opt, to: eth, am: 150}, {from: arb, to: eth, am: 50}}, }, { name: "eth below with two sources to reach target the highest one is selected", balances: map[models.NetworkSelector]int64{eth: 900, arb: 2000, opt: 1800}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{{from: arb, to: eth, am: 100}}, }, { name: "eth below with two sources to reach target the highest one is selected - reversed", balances: map[models.NetworkSelector]int64{eth: 900, arb: 1800, opt: 2000}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{{from: opt, to: eth, am: 100}}, }, { name: "eth below with two sources to reach target should be deterministic", balances: map[models.NetworkSelector]int64{eth: 700, arb: 1400, opt: 1400}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{{from: opt, to: eth, am: 300}}, }, { name: "arb is below target but there is an inflight transfer", balances: map[models.NetworkSelector]int64{eth: 1000, arb: 800, opt: 1000}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{{from: eth, to: arb, am: 250}}, expTransfers: []transfer{}, }, @@ -106,35 +108,35 @@ func TestTargetBalanceRebalancer_ComputeTransfersToBalance_arb_eth_opt(t *testin name: "arb is below target but there is inflight transfer that isn't onchain yet", // since it's not on-chain yet the source balance should be expected to be lower than the current value balances: map[models.NetworkSelector]int64{eth: 1250, arb: 800, opt: 1000}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{{from: eth, to: arb, am: 250, status: models.TransferStatusProposed}}, expTransfers: []transfer{}, }, { name: "eth is below target there are two sources of funding but one is already inflight", balances: map[models.NetworkSelector]int64{eth: 800, arb: 2000, opt: 2200}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{{from: arb, to: eth, am: 250}}, expTransfers: []transfer{}, }, { name: "eth is below target there are two sources of funding but one is already inflight but will not cover target", balances: map[models.NetworkSelector]int64{eth: 800, arb: 2000, opt: 2200}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{{from: arb, to: eth, am: 100}}, expTransfers: []transfer{{from: opt, to: eth, am: 100}}, }, { name: "eth is below target there are two sources of funding but one is already inflight that will not cover target, both sources are used", balances: map[models.NetworkSelector]int64{eth: 100, arb: 1100, opt: 1200}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{{from: arb, to: eth, am: 100}}, expTransfers: []transfer{{from: opt, to: eth, am: 200}, {from: arb, to: eth, am: 100}}, }, { name: "eth is below target there are multiple inflight transfers", balances: map[models.NetworkSelector]int64{eth: 100, arb: 2000, opt: 2000}, - targets: map[models.NetworkSelector]int64{eth: 2000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 2000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{ {from: arb, to: eth, am: 50}, {from: arb, to: eth, am: 100}, @@ -150,7 +152,7 @@ func TestTargetBalanceRebalancer_ComputeTransfersToBalance_arb_eth_opt(t *testin { name: "eth is below target there are multiple inflight transfers 2", balances: map[models.NetworkSelector]int64{eth: 100, arb: 1100, opt: 2000}, - targets: map[models.NetworkSelector]int64{eth: 2000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 2000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{ {from: arb, to: eth, am: 50}, {from: arb, to: eth, am: 100}, @@ -166,7 +168,7 @@ func TestTargetBalanceRebalancer_ComputeTransfersToBalance_arb_eth_opt(t *testin { name: "eth is below target there are multiple inflight transfers 3", balances: map[models.NetworkSelector]int64{eth: 100, arb: 4000, opt: 2000}, - targets: map[models.NetworkSelector]int64{eth: 2000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 2000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{ {from: arb, to: eth, am: 50}, {from: arb, to: eth, am: 100}, @@ -181,17 +183,17 @@ func TestTargetBalanceRebalancer_ComputeTransfersToBalance_arb_eth_opt(t *testin { name: "arb below target but there is no full funding to reach target", balances: map[models.NetworkSelector]int64{eth: 1100, arb: 800, opt: 1050}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{ - {from: eth, to: arb, am: 100}, {from: opt, to: eth, am: 50}, // 2hop transfer + {from: eth, to: arb, am: 100}, }, // transfer is made but without reaching target }, { name: "opt is below target and arb can fund it with a transfer to eth", balances: map[models.NetworkSelector]int64{eth: 1000, arb: 1300, opt: 800}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{ {from: arb, to: eth, am: 200}, @@ -200,7 +202,7 @@ func TestTargetBalanceRebalancer_ComputeTransfersToBalance_arb_eth_opt(t *testin { name: "both opt and eth are below target one transfer should be made", balances: map[models.NetworkSelector]int64{eth: 900, arb: 1300, opt: 900}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{ {from: arb, to: eth, am: 200}, @@ -209,7 +211,7 @@ func TestTargetBalanceRebalancer_ComputeTransfersToBalance_arb_eth_opt(t *testin { name: "both opt and eth are below target arb cannot fully fund both", balances: map[models.NetworkSelector]int64{eth: 900, arb: 1150, opt: 900}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{ {from: arb, to: eth, am: 150}, @@ -218,17 +220,17 @@ func TestTargetBalanceRebalancer_ComputeTransfersToBalance_arb_eth_opt(t *testin { name: "arb is below target requires transfers from both eth and opt", balances: map[models.NetworkSelector]int64{eth: 1100, arb: 800, opt: 1400}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{ - {from: eth, to: arb, am: 100}, {from: opt, to: eth, am: 100}, + {from: eth, to: arb, am: 100}, }, }, { name: "arb rebalancing is disabled and eth is below target", balances: map[models.NetworkSelector]int64{eth: 800, arb: 1000, opt: 2000}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 0, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 0, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{ {from: opt, to: eth, am: 200}, @@ -236,9 +238,9 @@ func TestTargetBalanceRebalancer_ComputeTransfersToBalance_arb_eth_opt(t *testin }, { name: "both arb and opt are below target and balance cannot cover both", - // in this case owner should reconsider targets or increase liquidity + // in this case owner should reconsider minimums or increase liquidity balances: map[models.NetworkSelector]int64{eth: 1200, arb: 900, opt: 800}, - targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, pendingTransfers: []transfer{}, expTransfers: []transfer{ {from: eth, to: opt, am: 200}, @@ -256,7 +258,8 @@ func TestTargetBalanceRebalancer_ComputeTransfersToBalance_arb_eth_opt(t *testin g.(graph.GraphTest).AddNetwork(net, graph.Data{ Liquidity: big.NewInt(b), NetworkSelector: net, - MinimumLiquidity: big.NewInt(tc.targets[net]), + MinimumLiquidity: big.NewInt(tc.minimums[net]), + TargetLiquidity: big.NewInt(tc.minimums[net]), }) } assert.NoError(t, g.(graph.GraphTest).AddConnection(eth, arb)) @@ -281,10 +284,12 @@ func TestTargetBalanceRebalancer_ComputeTransfersToBalance_arb_eth_opt(t *testin assert.NoError(t, err) for _, tr := range transfersToBalance { - t.Logf("actual transfer: %d->%d %s", tr.From, tr.To, tr.Amount) + t.Logf("actual transfer: %v->%v %s", tr.From, tr.To, tr.Amount) } + assert.Len(t, transfersToBalance, len(tc.expTransfers)) for i, tr := range tc.expTransfers { + t.Logf("expected transfer: %v->%v %d", tr.from, tr.to, tr.am) assert.Equal(t, tr.from, transfersToBalance[i].From) assert.Equal(t, tr.to, transfersToBalance[i].To) assert.Equal(t, tr.am, transfersToBalance[i].Amount.Int64()) diff --git a/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/target_balance_with_minimum.go b/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/target_balance_with_minimum.go new file mode 100644 index 0000000000..326de9fcc2 --- /dev/null +++ b/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/target_balance_with_minimum.go @@ -0,0 +1,320 @@ +package liquidityrebalancer + +import ( + "fmt" + "math/big" + "sort" + + big2 "github.com/smartcontractkit/chainlink/v2/core/chains/evm/utils/big" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/liquiditymanager/graph" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/liquiditymanager/models" +) + +// TargetMinBalancer tries to reach balance using a target and minimum liquidity that is configured on each network. +type TargetMinBalancer struct { + lggr logger.Logger +} + +func NewTargetMinBalancer(lggr logger.Logger) *TargetMinBalancer { + return &TargetMinBalancer{ + lggr: lggr.With("service", "TargetMinBalancer"), + } +} + +type determineTransfersFunc func(graphLater graph.Graph, targetNetwork models.NetworkSelector, networkFunds map[models.NetworkSelector]*Funds) ([]models.ProposedTransfer, error) + +func (r *TargetMinBalancer) ComputeTransfersToBalance(graphNow graph.Graph, nonExecutedTransfers []UnexecutedTransfer) ([]models.ProposedTransfer, error) { + nonExecutedTransfers = filterUnexecutedTransfers(nonExecutedTransfers) + + var proposedTransfers []models.ProposedTransfer + // 4 rounds of rebalancing alternate between 1 hop and 2 hop transfers. + // this allows us to have multistep transaction initiated at the same time. + for i := 0; i < 5; i++ { + r.lggr.Debugf("Round %d: nonExecutedTransfers: %v", i, nonExecutedTransfers) + var currentProposed []models.ProposedTransfer + transfersFunc := r.oneHopTransfers + if i%2 != 0 { + transfersFunc = r.twoHopTransfers + } + currentProposed, err := r.rebalancingRound(graphNow, nonExecutedTransfers, transfersFunc) + if err != nil { + return nil, err + } + r.lggr.Debugf("Round %d: proposed transfers: %v", i, currentProposed) + for _, t := range currentProposed { + // put proposed in nonExecutedTransfers to carryover to next round + nonExecutedTransfers = append(nonExecutedTransfers, t) + } + proposedTransfers = append(proposedTransfers, currentProposed...) + } + + r.lggr.Debugf("merging proposed transfers") + proposedTransfers = mergeProposedTransfers(proposedTransfers) + r.lggr.Debugf("sorting proposed transfers for determinism") + sort.Sort(models.ProposedTransfers(proposedTransfers)) + + return proposedTransfers, nil +} + +func (r *TargetMinBalancer) rebalancingRound(graphNow graph.Graph, nonExecutedTransfers []UnexecutedTransfer, transfersFunc determineTransfersFunc) ([]models.ProposedTransfer, error) { + var err error + graphLater, err := getExpectedGraph(graphNow, nonExecutedTransfers) + if err != nil { + return nil, fmt.Errorf("get expected graph: %w", err) + } + + r.lggr.Debugf("finding networks that require funding") + networksRequiringFunding, networkFunds, err := r.findNetworksRequiringFunding(graphNow, graphLater) + if err != nil { + return nil, fmt.Errorf("find networks that require funding: %w", err) + } + r.lggr.Debugf("networks requiring funding: %v", networksRequiringFunding) + r.lggr.Debugf("network funds: %+v", networkFunds) + + proposedTransfers := make([]models.ProposedTransfer, 0) + for _, net := range networksRequiringFunding { + r.lggr.Debugf("finding transfers for network %v", net) + potentialTransfers, err1 := transfersFunc(graphLater, net, networkFunds) + if err1 != nil { + return nil, fmt.Errorf("finding transfers for network %v: %w", net, err1) + } + + dataLater, err2 := graphLater.GetData(net) + if err2 != nil { + return nil, fmt.Errorf("get data later of net %v: %w", net, err2) + } + liqDiffLater := new(big.Int).Sub(dataLater.TargetLiquidity, dataLater.Liquidity) + netProposedTransfers, err3 := r.applyProposedTransfers(graphLater, potentialTransfers, liqDiffLater) + if err3 != nil { + return nil, fmt.Errorf("applying transfers: %w", err3) + } + proposedTransfers = append(proposedTransfers, netProposedTransfers...) + } + + return proposedTransfers, nil +} + +func (r *TargetMinBalancer) findNetworksRequiringFunding(graphNow, graphLater graph.Graph) ([]models.NetworkSelector, map[models.NetworkSelector]*Funds, error) { + mapNetworkFunds := make(map[models.NetworkSelector]*Funds) + liqDiffsLater := make(map[models.NetworkSelector]*big.Int) + + //TODO: LM-23 Create minTokenTransfer config to filter-out small rebalance txs + // check that the transfer is not tiny, we should only transfer if it is significant. What is too tiny? + // we could prevent this by only making a network requiring funding if its below X% of the target + + res := make([]models.NetworkSelector, 0) + for _, net := range graphNow.GetNetworks() { + //use min here for transferable. because we don't know when the transfers will complete and want to avoid issues + transferableAmount, ataErr := availableTransferableAmount(graphNow, graphLater, net) + if ataErr != nil { + return nil, nil, fmt.Errorf("getting available transferrable amount for net %d: %v", net, ataErr) + } + dataLater, err := graphLater.GetData(net) + if err != nil { + return nil, nil, fmt.Errorf("get data later of net %v: %w", net, err) + } + liqDiffLater := new(big.Int).Sub(dataLater.TargetLiquidity, dataLater.Liquidity) + mapNetworkFunds[net] = &Funds{ + AvailableAmount: transferableAmount, + } + if liqDiffLater.Cmp(big.NewInt(0)) <= 0 { + continue + } + liqDiffsLater[net] = liqDiffLater + res = append(res, net) + } + + sort.Slice(res, func(i, j int) bool { return liqDiffsLater[res[i]].Cmp(liqDiffsLater[res[j]]) > 0 }) + return res, mapNetworkFunds, nil +} + +func (r *TargetMinBalancer) oneHopTransfers(graphLater graph.Graph, targetNetwork models.NetworkSelector, networkFunds map[models.NetworkSelector]*Funds) ([]models.ProposedTransfer, error) { + zero := big.NewInt(0) + potentialTransfers := make([]models.ProposedTransfer, 0) + + neighbors, exist := graphLater.GetNeighbors(targetNetwork, false) + if !exist { + r.lggr.Debugf("no neighbors found for %v", targetNetwork) + return nil, nil + } + targetLater, err := graphLater.GetData(targetNetwork) + if err != nil { + return nil, fmt.Errorf("get data later of net %v: %w", targetNetwork, err) + } + + for _, source := range neighbors { + transferAmount := new(big.Int).Sub(targetLater.TargetLiquidity, targetLater.Liquidity) + r.lggr.Debugf("checking transfer from %v to %v for amount %v", source, targetNetwork, transferAmount) + + //source network available transferable amount + srcData, dErr := graphLater.GetData(source) + if dErr != nil { + return nil, fmt.Errorf("error during GetData for %v in graphLater: %v", source, dErr) + } + srcAvailableAmount := new(big.Int).Sub(srcData.Liquidity, srcData.MinimumLiquidity) + srcAmountToTarget := new(big.Int).Sub(srcData.Liquidity, srcData.TargetLiquidity) + + if srcAmountToTarget.Cmp(zero) <= 0 || srcAvailableAmount.Cmp(zero) <= 0 { + r.lggr.Debugf("source network %v does not have a surplus to transfer so skipping transfer, source amount to target %v", source, srcAmountToTarget) + continue + } + + if transferAmount.Cmp(srcAmountToTarget) > 0 { + // if transferAmount > srcAmountToTarget take less + r.lggr.Debugf("source network %v does not have the desired amount, desired amount %v taking available %v", source, transferAmount, srcAmountToTarget) + transferAmount = srcAmountToTarget + } + + newAmount := new(big.Int).Sub(networkFunds[source].AvailableAmount, transferAmount) + if newAmount.Cmp(zero) < 0 { + r.lggr.Debugf("source network %v doesn't have enough available liquidity so skipping transfer, desired amount %v but only have %v available", source, transferAmount, networkFunds[source].AvailableAmount) + continue + } + networkFunds[source].AvailableAmount = newAmount + + potentialTransfers = append(potentialTransfers, newTransfer(source, targetNetwork, transferAmount)) + } + + return potentialTransfers, nil +} + +// twoHopTransfers finds networks that can increase liquidity of the target network with an intermediate network. +func (r *TargetMinBalancer) twoHopTransfers(graphLater graph.Graph, targetNetwork models.NetworkSelector, networkFunds map[models.NetworkSelector]*Funds) ([]models.ProposedTransfer, error) { + zero := big.NewInt(0) + iterator := func(nodes ...graph.Data) bool { return true } + potentialTransfers := make([]models.ProposedTransfer, 0) + + for _, source := range graphLater.GetNetworks() { + if source == targetNetwork { + continue + } + path := graphLater.FindPath(source, targetNetwork, 2, iterator) + if len(path) != 2 { + continue + } + middle := path[0] + + targetData, err := graphLater.GetData(targetNetwork) + if err != nil { + return nil, fmt.Errorf("error during GetData for %v in graphLater: %v", targetNetwork, err) + } + transferAmount := new(big.Int).Sub(targetData.TargetLiquidity, targetData.Liquidity) + r.lggr.Debugf("checking transfer from %v -> %v -> %v for amount %v", source, middle, targetNetwork, transferAmount) + + //source network available transferable amount + srcData, dErr := graphLater.GetData(source) + if dErr != nil { + return nil, fmt.Errorf("error during GetData for %v in graphLater: %v", source, dErr) + } + srcAvailableAmount := new(big.Int).Sub(srcData.Liquidity, srcData.MinimumLiquidity) + srcAmountToTarget := new(big.Int).Sub(srcData.Liquidity, srcData.TargetLiquidity) + + //middle network available transferable amount + middleData, dErr := graphLater.GetData(middle) + if dErr != nil { + return nil, fmt.Errorf("error during GetData for %v in graphLater: %v", middle, dErr) + } + middleAvailableAmount := new(big.Int).Sub(middleData.Liquidity, middleData.MinimumLiquidity) + middleAmountToTarget := new(big.Int).Sub(middleData.Liquidity, middleData.TargetLiquidity) + + if transferAmount.Cmp(srcAmountToTarget) > 0 { + // if transferAmount > srcAmountToTarget take less + transferAmount = srcAmountToTarget + } + if transferAmount.Cmp(zero) <= 0 { + continue + } + + if srcAmountToTarget.Cmp(transferAmount) < 0 || srcAvailableAmount.Cmp(transferAmount) < 0 { + continue + } + if middleAvailableAmount.Cmp(transferAmount) < 0 { + // middle hop doesn't have enough available liquidity + r.lggr.Debugf("middle network %v liquidity too low, skipping transfer: middleAmountToTarget %v, middleAvailableAmount %v", middle, middleAmountToTarget, middleAvailableAmount) + continue + } + + newAmount := new(big.Int).Sub(networkFunds[source].AvailableAmount, transferAmount) + if newAmount.Cmp(zero) < 0 { + r.lggr.Debugf("source network %v doesn't have enough available liquidity so skipping transfer, desired amount %v but only have %v available", source, transferAmount, networkFunds[source].AvailableAmount) + continue + } + networkFunds[source].AvailableAmount = newAmount + + potentialTransfers = append(potentialTransfers, newTransfer(source, middle, transferAmount)) + } + + return potentialTransfers, nil +} + +// applyProposedTransfers applies the proposed transfers to the graph. +// increments the raised funds and gives a refund to the sender if more funds have been raised than the required amount. +// It updates the liquidity of the sender and receiver networks in the graph. It stops further transfers if all funds have been raised. +func (r *TargetMinBalancer) applyProposedTransfers(graphLater graph.Graph, potentialTransfers []models.ProposedTransfer, requiredAmount *big.Int) ([]models.ProposedTransfer, error) { + // sort by amount,sender,receiver + sort.Slice(potentialTransfers, func(i, j int) bool { + if potentialTransfers[i].Amount.Cmp(potentialTransfers[j].Amount) == 0 { + if potentialTransfers[i].From == potentialTransfers[j].From { + return potentialTransfers[i].To < potentialTransfers[j].To + } + return potentialTransfers[i].From < potentialTransfers[j].From + } + return potentialTransfers[i].Amount.Cmp(potentialTransfers[j].Amount) > 0 + }) + + fundsRaised := big.NewInt(0) + proposedTransfers := make([]models.ProposedTransfer, 0, len(potentialTransfers)) + skip := false + for _, d := range potentialTransfers { + if skip { + r.lggr.Debugf("skipping transfer: %s", d) + continue + } + + senderData, err := graphLater.GetData(d.From) + if err != nil { + return nil, fmt.Errorf("get liquidity of sender %v: %w", d.From, err) + } + availableAmount := big.NewInt(0).Sub(senderData.Liquidity, senderData.MinimumLiquidity) + if availableAmount.Cmp(big.NewInt(0)) <= 0 { + r.lggr.Debugf("no more tokens to transfer, skipping transfer: %s", d) + continue + } + + if availableAmount.Cmp(d.Amount.ToInt()) < 0 { + d.Amount = big2.New(availableAmount) + r.lggr.Debugf("reducing transfer amount since sender balance has dropped: %s", d) + } + + // increment the raised funds + fundsRaised = big.NewInt(0).Add(fundsRaised, d.Amount.ToInt()) + + // in case we raised more than target amount give refund to the sender + if refund := big.NewInt(0).Sub(fundsRaised, requiredAmount); refund.Cmp(big.NewInt(0)) > 0 { + d.Amount = big2.New(big.NewInt(0).Sub(d.Amount.ToInt(), refund)) + fundsRaised = big.NewInt(0).Sub(fundsRaised, refund) + } + r.lggr.Debugf("applying transfer: %v", d) + proposedTransfers = append(proposedTransfers, models.ProposedTransfer{From: d.From, To: d.To, Amount: d.Amount}) + + liqBefore, err := graphLater.GetLiquidity(d.To) + if err != nil { + return nil, fmt.Errorf("get liquidity of transfer receiver %v: %w", d.To, err) + } + graphLater.SetLiquidity(d.To, big.NewInt(0).Add(liqBefore, d.Amount.ToInt())) + + liqBefore, err = graphLater.GetLiquidity(d.From) + if err != nil { + return nil, fmt.Errorf("get liquidity of sender %v: %w", d.From, err) + } + graphLater.SetLiquidity(d.From, big.NewInt(0).Sub(liqBefore, d.Amount.ToInt())) + + if fundsRaised.Cmp(requiredAmount) >= 0 { + r.lggr.Debugf("all funds raised skipping further transfers") + skip = true + } + } + + return proposedTransfers, nil +} diff --git a/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/target_balance_with_minimum_test.go b/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/target_balance_with_minimum_test.go new file mode 100644 index 0000000000..35e90b94ac --- /dev/null +++ b/core/services/ocr2/plugins/liquiditymanager/liquidityrebalancer/target_balance_with_minimum_test.go @@ -0,0 +1,634 @@ +package liquidityrebalancer + +import ( + "math/big" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + ubig "github.com/smartcontractkit/chainlink/v2/core/chains/evm/utils/big" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/liquiditymanager/graph" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/liquiditymanager/models" +) + +func TestTargetMinBalancer_ComputeTransfersToBalance_arb_eth_opt_0(t *testing.T) { + // Cases with minimums and targets across networks are equal + testCases := []struct { + name string + balances map[models.NetworkSelector]int64 + minimums map[models.NetworkSelector]int64 + targets map[models.NetworkSelector]int64 + pendingTransfers []models.ProposedTransfer + expTransfers []models.ProposedTransfer + }{ + { + name: "imbalanced", + balances: map[models.NetworkSelector]int64{eth: 1100, arb: 800, opt: 1100}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{{From: eth, To: arb, Amount: ubig.New(big.NewInt(200))}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(100))}}, + }, + { + name: "all above target", + balances: map[models.NetworkSelector]int64{eth: 1400, arb: 1000, opt: 1100}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{}, + }, + { + name: "arb below target", + balances: map[models.NetworkSelector]int64{eth: 1400, arb: 800, opt: 1100}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{{From: eth, To: arb, Amount: ubig.New(big.NewInt(200))}}, + }, + { + name: "opt below target", + balances: map[models.NetworkSelector]int64{eth: 1400, arb: 1000, opt: 900}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{{From: eth, To: opt, Amount: ubig.New(big.NewInt(100))}}, + }, + { + name: "eth below target", + balances: map[models.NetworkSelector]int64{eth: 900, arb: 1000, opt: 1300}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{{From: opt, To: eth, Amount: ubig.New(big.NewInt(100))}}, + }, + { + name: "both opt and arb below target", + balances: map[models.NetworkSelector]int64{eth: 1500, arb: 800, opt: 900}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{{From: eth, To: opt, Amount: ubig.New(big.NewInt(100))}, + {From: eth, To: arb, Amount: ubig.New(big.NewInt(200))}}, + }, + { + name: "eth below target and requires two transfers to reach target", + balances: map[models.NetworkSelector]int64{eth: 800, arb: 1100, opt: 1150}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{{From: opt, To: eth, Amount: ubig.New(big.NewInt(150))}, + {From: arb, To: eth, Amount: ubig.New(big.NewInt(50))}}, + }, + { + name: "eth below with two sources to reach target", + balances: map[models.NetworkSelector]int64{eth: 900, arb: 1800, opt: 2000}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{{From: opt, To: eth, Amount: ubig.New(big.NewInt(100))}}, + }, + { + name: "eth below with two sources to reach target", + balances: map[models.NetworkSelector]int64{eth: 700, arb: 1400, opt: 1400}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{{From: opt, To: eth, Amount: ubig.New(big.NewInt(300))}}, + }, + { + name: "arb is below target but there is an inflight transfer that causes arb to have a surplus and give back to eth", + balances: map[models.NetworkSelector]int64{eth: 1000, arb: 800, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{{From: eth, To: arb, Amount: ubig.New(big.NewInt(250)), Status: models.TransferStatusInflight}}, + expTransfers: []models.ProposedTransfer{ + {From: arb, To: eth, Amount: ubig.New(big.NewInt(50))}, + }, + }, + { + name: "arb is below target but there is inflight transfer that isn't onchain yet", + balances: map[models.NetworkSelector]int64{eth: 1250, arb: 800, opt: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{{From: eth, To: arb, Amount: ubig.New(big.NewInt(250)), Status: models.TransferStatusProposed}}, + expTransfers: []models.ProposedTransfer{}, + }, + { + name: "eth is below target there are two sources of funding but one is already inflight", + balances: map[models.NetworkSelector]int64{eth: 800, arb: 2000, opt: 2200}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{{From: arb, To: eth, Amount: ubig.New(big.NewInt(250))}}, + expTransfers: []models.ProposedTransfer{}, + }, + { + name: "eth is below target there are two sources of funding but one is already inflight but will not cover target", + balances: map[models.NetworkSelector]int64{eth: 800, arb: 2000, opt: 2200}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{{From: arb, To: eth, Amount: ubig.New(big.NewInt(100))}}, + expTransfers: []models.ProposedTransfer{{From: opt, To: eth, Amount: ubig.New(big.NewInt(100))}}, + }, + { + name: "eth is below target there are two sources of funding but one is already inflight that will not cover target, both sources are used", + balances: map[models.NetworkSelector]int64{eth: 100, arb: 1100, opt: 1200}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{{From: arb, To: eth, Amount: ubig.New(big.NewInt(100))}}, + expTransfers: []models.ProposedTransfer{ + {From: opt, To: eth, Amount: ubig.New(big.NewInt(200))}, + {From: arb, To: eth, Amount: ubig.New(big.NewInt(100))}}, + }, + { + name: "arb below target but there is no single full funding to reach target", + balances: map[models.NetworkSelector]int64{eth: 1100, arb: 800, opt: 1050}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{ + {From: eth, To: arb, Amount: ubig.New(big.NewInt(150))}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(50))}, + }, + }, + { + name: "opt is below target and arb can fund it with a transfer to eth", + balances: map[models.NetworkSelector]int64{eth: 1000, arb: 1300, opt: 800}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{ + {From: arb, To: eth, Amount: ubig.New(big.NewInt(200))}, //we send 200 to eth knowing we are sending + {From: eth, To: opt, Amount: ubig.New(big.NewInt(200))}, //200 to opt + }, + }, + { + name: "both opt and eth are below target one transfer should be made", + balances: map[models.NetworkSelector]int64{eth: 900, arb: 1300, opt: 900}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{ + {From: arb, To: eth, Amount: ubig.New(big.NewInt(200))}, //we over fill with 200 to eth knowing we are sending + {From: eth, To: opt, Amount: ubig.New(big.NewInt(100))}, //100 to opt + }, + }, + { + name: "both opt and eth are below target arb cannot fully fund both", + balances: map[models.NetworkSelector]int64{eth: 900, arb: 1150, opt: 900}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{ + {From: arb, To: eth, Amount: ubig.New(big.NewInt(150))}, //we over fill with 150 to eth knowing we are sending + {From: eth, To: opt, Amount: ubig.New(big.NewInt(50))}, //50 to opt + }, + }, + { + name: "arb is below target requires transfers From both eth and opt", + balances: map[models.NetworkSelector]int64{eth: 1100, arb: 800, opt: 1400}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{ + {From: eth, To: arb, Amount: ubig.New(big.NewInt(200))}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(100))}, + }, + }, + { + name: "arb rebalancing is disabled and eth is below target", + balances: map[models.NetworkSelector]int64{eth: 800, arb: 1000, opt: 2000}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 0, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{ + {From: opt, To: eth, Amount: ubig.New(big.NewInt(200))}, + }, + }, + { + name: "both arb and opt are below target and balance cannot cover both", + balances: map[models.NetworkSelector]int64{eth: 1200, arb: 900, opt: 800}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{ + {From: eth, To: opt, Amount: ubig.New(big.NewInt(200))}, + }, + }, + } + + lggr := logger.TestLogger(t) + lggr.SetLogLevel(zapcore.DebugLevel) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := graph.NewGraph() + for net, b := range tc.balances { + g.(graph.GraphTest).AddNetwork(net, graph.Data{ + Liquidity: big.NewInt(b), + NetworkSelector: net, + MinimumLiquidity: big.NewInt(tc.minimums[net]), + TargetLiquidity: big.NewInt(tc.targets[net]), + }) + } + assert.NoError(t, g.(graph.GraphTest).AddConnection(eth, arb)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(arb, eth)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(eth, opt)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(opt, eth)) + + r := NewTargetMinBalancer(lggr) + + unexecuted := make([]UnexecutedTransfer, 0, len(tc.pendingTransfers)) + for _, tr := range tc.pendingTransfers { + unexecuted = append(unexecuted, models.PendingTransfer{ + Transfer: models.Transfer{ + From: tr.From, + To: tr.To, + Amount: tr.Amount, + }, + Status: tr.Status, + }) + } + transfersToBalance, err := r.ComputeTransfersToBalance(g, unexecuted) + assert.NoError(t, err) + + for _, tr := range transfersToBalance { + t.Logf("actual transfer: %s -> %s = %s", tr.From, tr.To, tr.Amount) + } + sort.Sort(models.ProposedTransfers(tc.expTransfers)) + require.Len(t, transfersToBalance, len(tc.expTransfers)) + for i, tr := range tc.expTransfers { + t.Logf("expected transfer: %s -> %s = %s", tr.From, tr.To, tr.Amount) + assert.Equal(t, tr.From, transfersToBalance[i].From) + assert.Equal(t, tr.To, transfersToBalance[i].To) + assert.Equal(t, tr.Amount.Int64(), transfersToBalance[i].Amount.Int64()) + } + }) + } +} + +func TestTargetMinBalancer_ComputeTransfersToBalance_arb_eth_opt_pending_status_behavior(t *testing.T) { + testCases := []struct { + name string + balances map[models.NetworkSelector]int64 + minimums map[models.NetworkSelector]int64 + targets map[models.NetworkSelector]int64 + pendingTransfers []models.ProposedTransfer + expTransfers []models.ProposedTransfer + }{ + { + name: "eth is below target there are multiple inflight transfers", + balances: map[models.NetworkSelector]int64{eth: 100, arb: 2000, opt: 2000}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 2000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{ + {From: arb, To: eth, Amount: ubig.New(big.NewInt(50)), Status: models.TransferStatusInflight}, + {From: arb, To: eth, Amount: ubig.New(big.NewInt(100)), Status: models.TransferStatusInflight}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(200)), Status: models.TransferStatusInflight}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(200)), Status: models.TransferStatusInflight}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(50)), Status: models.TransferStatusProposed}, + }, + expTransfers: []models.ProposedTransfer{ + {From: opt, To: eth, Amount: ubig.New(big.NewInt(450))}, + {From: arb, To: eth, Amount: ubig.New(big.NewInt(850))}, + }, + }, + { + name: "eth is below target there are multiple inflight transfers but not enough to balance", + balances: map[models.NetworkSelector]int64{eth: 100, arb: 1100, opt: 2000}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 2000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{ + {From: arb, To: eth, Amount: ubig.New(big.NewInt(50)), Status: models.TransferStatusInflight}, + {From: arb, To: eth, Amount: ubig.New(big.NewInt(100)), Status: models.TransferStatusInflight}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(200)), Status: models.TransferStatusInflight}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(200)), Status: models.TransferStatusInflight}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(50)), Status: models.TransferStatusProposed}, + }, + expTransfers: []models.ProposedTransfer{ + {From: opt, To: eth, Amount: ubig.New(big.NewInt(550))}, + }, + }, + { + name: "eth is below target there are multiple inflight transfers surplus so can take from either", + balances: map[models.NetworkSelector]int64{eth: 100, arb: 4000, opt: 2000}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500}, + targets: map[models.NetworkSelector]int64{eth: 2000, arb: 1000, opt: 1000}, + pendingTransfers: []models.ProposedTransfer{ + {From: arb, To: eth, Amount: ubig.New(big.NewInt(50)), Status: models.TransferStatusReady}, + {From: arb, To: eth, Amount: ubig.New(big.NewInt(100)), Status: models.TransferStatusReady}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(200)), Status: models.TransferStatusReady}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(200)), Status: models.TransferStatusReady}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(50)), Status: models.TransferStatusProposed}, + }, + expTransfers: []models.ProposedTransfer{ + {From: arb, To: eth, Amount: ubig.New(big.NewInt(1300))}, + }, + }, + } + + lggr := logger.TestLogger(t) + lggr.SetLogLevel(zapcore.DebugLevel) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := graph.NewGraph() + for net, b := range tc.balances { + g.(graph.GraphTest).AddNetwork(net, graph.Data{ + Liquidity: big.NewInt(b), + NetworkSelector: net, + MinimumLiquidity: big.NewInt(tc.minimums[net]), + TargetLiquidity: big.NewInt(tc.targets[net]), + }) + } + assert.NoError(t, g.(graph.GraphTest).AddConnection(eth, arb)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(arb, eth)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(eth, opt)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(opt, eth)) + + r := NewTargetMinBalancer(lggr) + + unexecuted := make([]UnexecutedTransfer, 0, len(tc.pendingTransfers)) + for _, tr := range tc.pendingTransfers { + unexecuted = append(unexecuted, models.PendingTransfer{ + Transfer: models.Transfer{ + From: tr.From, + To: tr.To, + Amount: tr.Amount, + }, + Status: tr.Status, + }) + } + transfersToBalance, err := r.ComputeTransfersToBalance(g, unexecuted) + assert.NoError(t, err) + + for _, tr := range transfersToBalance { + t.Logf("actual transfer: %s -> %s = %s", tr.From, tr.To, tr.Amount) + } + sort.Sort(models.ProposedTransfers(tc.expTransfers)) + require.Len(t, transfersToBalance, len(tc.expTransfers)) + for i, tr := range tc.expTransfers { + t.Logf("expected transfer: %s -> %s = %s", tr.From, tr.To, tr.Amount) + assert.Equal(t, tr.From, transfersToBalance[i].From) + assert.Equal(t, tr.To, transfersToBalance[i].To) + assert.Equal(t, tr.Amount.Int64(), transfersToBalance[i].Amount.Int64()) + } + }) + } +} + +func TestTargetMinBalancer_ComputeTransfersToBalance_arb_eth_opt_base(t *testing.T) { + testCases := []struct { + name string + balances map[models.NetworkSelector]int64 + minimums map[models.NetworkSelector]int64 + targets map[models.NetworkSelector]int64 + pendingTransfers []models.ProposedTransfer + expTransfers []models.ProposedTransfer + }{ + { + name: "all above targets", + balances: map[models.NetworkSelector]int64{eth: 1100, arb: 1000, opt: 1100, base: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500, base: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000, base: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{}, + }, + { + name: "arb and base below target, eth and opt above target: eth tops up arb & base, opt tops up eth", + balances: map[models.NetworkSelector]int64{eth: 1100, arb: 900, opt: 1100, base: 900}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500, base: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000, base: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{ + {From: eth, To: arb, Amount: ubig.New(big.NewInt(100))}, + {From: eth, To: base, Amount: ubig.New(big.NewInt(100))}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(100))}, + }, + }, + { + name: "eth below target: gets funding by opt and base", + balances: map[models.NetworkSelector]int64{eth: 500, arb: 1000, opt: 1300, base: 1200}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500, base: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000, base: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{ + {From: opt, To: eth, Amount: ubig.New(big.NewInt(300))}, + {From: base, To: eth, Amount: ubig.New(big.NewInt(200))}, + }, + }, + { + name: "eth and arb below target: eth gets funding by opt and base, eth funds arb", + balances: map[models.NetworkSelector]int64{eth: 500, arb: 700, opt: 1300, base: 1500}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500, base: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000, base: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{ + {From: base, To: eth, Amount: ubig.New(big.NewInt(500))}, + {From: opt, To: eth, Amount: ubig.New(big.NewInt(300))}, + // we send the 300 from opt to eth but will not yet send it to arb + // because it would make eth dip below minimum. + }, + }, + { + name: "eth and arb below target with pending: eth gets funding by opt and base(inflight), eth funds arb", + balances: map[models.NetworkSelector]int64{eth: 700, arb: 700, opt: 1300, base: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500, base: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000, base: 1000}, + pendingTransfers: []models.ProposedTransfer{ + {From: base, To: eth, Amount: ubig.New(big.NewInt(300)), Status: models.TransferStatusNotReady}, + }, + expTransfers: []models.ProposedTransfer{ + {From: opt, To: eth, Amount: ubig.New(big.NewInt(300))}, + // can't send to arb because eth would dip below minimum + //{From: eth, To: arb, Amount: ubig.New(big.NewInt(300))}, + }, + }, + { + name: "opt and arb below target: eth funds opt and arb with base funds heading to eth", + // this scenario shows that we will let eth temporarily go below target to fund opt and arb because we know we have funds coming from base + balances: map[models.NetworkSelector]int64{eth: 1200, arb: 800, opt: 800, base: 1200}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500, base: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000, base: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{ + {From: eth, To: arb, Amount: ubig.New(big.NewInt(200))}, + {From: eth, To: opt, Amount: ubig.New(big.NewInt(200))}, + {From: base, To: eth, Amount: ubig.New(big.NewInt(200))}, + }, + }, + { + name: "all below targets", + balances: map[models.NetworkSelector]int64{eth: 1100, arb: 1000, opt: 1100, base: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500, base: 500}, + targets: map[models.NetworkSelector]int64{eth: 5000, arb: 5000, opt: 5000, base: 5000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{}, + }, + } + + lggr := logger.TestLogger(t) + lggr.SetLogLevel(zapcore.DebugLevel) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := graph.NewGraph() + for net, b := range tc.balances { + g.(graph.GraphTest).AddNetwork(net, graph.Data{ + Liquidity: big.NewInt(b), + NetworkSelector: net, + MinimumLiquidity: big.NewInt(tc.minimums[net]), + TargetLiquidity: big.NewInt(tc.targets[net]), + }) + } + assert.NoError(t, g.(graph.GraphTest).AddConnection(eth, arb)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(arb, eth)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(eth, opt)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(opt, eth)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(eth, base)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(base, eth)) + + r := NewTargetMinBalancer(lggr) + + unexecuted := make([]UnexecutedTransfer, 0, len(tc.pendingTransfers)) + for _, tr := range tc.pendingTransfers { + unexecuted = append(unexecuted, models.PendingTransfer{ + Transfer: models.Transfer{ + From: tr.From, + To: tr.To, + Amount: tr.Amount, + }, + Status: tr.Status, + }) + } + transfersToBalance, err := r.ComputeTransfersToBalance(g, unexecuted) + assert.NoError(t, err) + + for _, tr := range transfersToBalance { + t.Logf("actual transfer: %s -> %s = %s", tr.From, tr.To, tr.Amount) + } + sort.Sort(models.ProposedTransfers(tc.expTransfers)) + require.Len(t, transfersToBalance, len(tc.expTransfers)) + for i, tr := range tc.expTransfers { + t.Logf("expected transfer: %s -> %s = %s", tr.From, tr.To, tr.Amount) + assert.Equal(t, tr.From, transfersToBalance[i].From) + assert.Equal(t, tr.To, transfersToBalance[i].To) + assert.Equal(t, tr.Amount.Int64(), transfersToBalance[i].Amount.Int64()) + } + }) + } +} + +func TestTargetMinBalancer_ComputeTransfersToBalance_islands_in_graph(t *testing.T) { + // these test have 4 networks in a spoke graph with an island node (celo) that does not have connections to the rest of the graph + testCases := []struct { + name string + balances map[models.NetworkSelector]int64 + minimums map[models.NetworkSelector]int64 + targets map[models.NetworkSelector]int64 + pendingTransfers []models.ProposedTransfer + expTransfers []models.ProposedTransfer + }{ + { + name: "all above targets", + balances: map[models.NetworkSelector]int64{eth: 1100, arb: 1000, opt: 1100, base: 1000, celo: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500, base: 500, celo: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000, base: 1000, celo: 1000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{}, + }, + { + name: "eth and arb below: inflight transfer from eth to celo", + // because celo is not connected to anything then nothing is done. + balances: map[models.NetworkSelector]int64{eth: 700, arb: 900, opt: 1000, base: 1000, celo: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500, base: 500, celo: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000, base: 1000, celo: 0}, + pendingTransfers: []models.ProposedTransfer{ + {From: eth, To: celo, Amount: ubig.New(big.NewInt(300)), Status: models.TransferStatusNotReady}, + }, + expTransfers: []models.ProposedTransfer{}, + }, + { + name: "celo stole all our liquidity, so we can't transfer anywhere cause everyone is below target", + balances: map[models.NetworkSelector]int64{eth: 700, arb: 900, opt: 800, base: 900, celo: 1700}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500, base: 500, celo: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000, base: 1000, celo: 0}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{}, + }, + { + name: "celo stole some of our liquidity: base sends surplus to eth", + // base sends it surplus to eth but nothing else can happen because eth is below target + balances: map[models.NetworkSelector]int64{eth: 700, arb: 900, opt: 700, base: 1100, celo: 1600}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500, base: 500, celo: 500}, + targets: map[models.NetworkSelector]int64{eth: 1000, arb: 1000, opt: 1000, base: 1000, celo: 0}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{ + {From: base, To: eth, Amount: ubig.New(big.NewInt(100))}, + }, + }, + { + name: "all below targets", + balances: map[models.NetworkSelector]int64{eth: 1100, arb: 1000, opt: 1100, base: 1000, celo: 1000}, + minimums: map[models.NetworkSelector]int64{eth: 500, arb: 500, opt: 500, base: 500, celo: 500}, + targets: map[models.NetworkSelector]int64{eth: 5000, arb: 5000, opt: 5000, base: 5000, celo: 5000}, + pendingTransfers: []models.ProposedTransfer{}, + expTransfers: []models.ProposedTransfer{}, + }, + } + + lggr := logger.TestLogger(t) + lggr.SetLogLevel(zapcore.DebugLevel) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := graph.NewGraph() + for net, b := range tc.balances { + g.(graph.GraphTest).AddNetwork(net, graph.Data{ + Liquidity: big.NewInt(b), + NetworkSelector: net, + MinimumLiquidity: big.NewInt(tc.minimums[net]), + TargetLiquidity: big.NewInt(tc.targets[net]), + }) + } + assert.NoError(t, g.(graph.GraphTest).AddConnection(eth, arb)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(arb, eth)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(eth, opt)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(opt, eth)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(eth, base)) + assert.NoError(t, g.(graph.GraphTest).AddConnection(base, eth)) + + r := NewTargetMinBalancer(lggr) + + unexecuted := make([]UnexecutedTransfer, 0, len(tc.pendingTransfers)) + for _, tr := range tc.pendingTransfers { + unexecuted = append(unexecuted, models.PendingTransfer{ + Transfer: models.Transfer{ + From: tr.From, + To: tr.To, + Amount: tr.Amount, + }, + Status: tr.Status, + }) + } + transfersToBalance, err := r.ComputeTransfersToBalance(g, unexecuted) + assert.NoError(t, err) + + for _, tr := range transfersToBalance { + t.Logf("actual transfer: %s -> %s = %s", tr.From, tr.To, tr.Amount) + } + sort.Sort(models.ProposedTransfers(tc.expTransfers)) + require.Len(t, transfersToBalance, len(tc.expTransfers)) + for i, tr := range tc.expTransfers { + t.Logf("expected transfer: %s -> %s = %s", tr.From, tr.To, tr.Amount) + assert.Equal(t, tr.From, transfersToBalance[i].From) + assert.Equal(t, tr.To, transfersToBalance[i].To) + assert.Equal(t, tr.Amount.Int64(), transfersToBalance[i].Amount.Int64()) + } + }) + } +} diff --git a/core/services/ocr2/plugins/liquiditymanager/models/config.go b/core/services/ocr2/plugins/liquiditymanager/models/config.go index 3f1df744c7..d7ab10662f 100644 --- a/core/services/ocr2/plugins/liquiditymanager/models/config.go +++ b/core/services/ocr2/plugins/liquiditymanager/models/config.go @@ -32,6 +32,7 @@ func ValidateRebalancerConfig(config RebalancerConfig) error { } const ( + RebalancerTypeTargetAndMin = "target-and-min" RebalancerTypeMinLiquidity = "min-liquidity" RebalancerTypePingPong = "ping-pong" ) diff --git a/core/services/ocr2/plugins/liquiditymanager/models/models.go b/core/services/ocr2/plugins/liquiditymanager/models/models.go index e9880590fe..59327d406f 100644 --- a/core/services/ocr2/plugins/liquiditymanager/models/models.go +++ b/core/services/ocr2/plugins/liquiditymanager/models/models.go @@ -59,11 +59,24 @@ func (n NetworkSelector) ChainID() uint64 { type NetworkType string +func (n NetworkSelector) Chain() (chainsel.Chain, bool) { + return chainsel.ChainBySelector(uint64(n)) +} + +func (n NetworkSelector) String() string { + chain, b := chainsel.ChainBySelector(uint64(n)) + if !b { + return fmt.Sprintf("Unknown(%d)", n) + } + return chain.Name +} + // ProposedTransfer is a transfer that is proposed by the rebalancing algorithm. type ProposedTransfer struct { From NetworkSelector To NetworkSelector Amount *ubig.Big + Status TransferStatus } func (p ProposedTransfer) FromNetwork() NetworkSelector { @@ -79,11 +92,25 @@ func (p ProposedTransfer) TransferAmount() *big.Int { } func (p ProposedTransfer) TransferStatus() TransferStatus { + if p.Status != "" { + return p.Status + } return TransferStatusProposed } func (p ProposedTransfer) String() string { - return fmt.Sprintf("from:%d to:%d amount:%s", p.From, p.To, p.Amount.String()) + return fmt.Sprintf("from:%v to:%v amount:%s", p.From, p.To, p.Amount.String()) +} + +type ProposedTransfers []ProposedTransfer + +func (p ProposedTransfers) Len() int { return len(p) } +func (p ProposedTransfers) Swap(i, j int) { p[i], p[j] = p[j], p[i] } +func (p ProposedTransfers) Less(i, j int) bool { + if p[i].From == p[j].From { + return p[i].To < p[j].To + } + return p[i].From < p[j].From } // Transfer is a ProposedTransfer that has had a lot of its information resolved. @@ -159,9 +186,9 @@ func (t Transfer) Equals(other Transfer) bool { } func (t Transfer) String() string { - return fmt.Sprintf("{From: %d, To: %d, Amount: %s, Sender: %s, Receiver: %s, LocalTokenAddress: %s, RemoteTokenAddress: %s, BridgeData: %s, NativeBridgeFee: %s, Stage: %d}", - t.From, - t.To, + return fmt.Sprintf("{From: %s, To: %s, Amount: %s, Sender: %s, Receiver: %s, LocalTokenAddress: %s, RemoteTokenAddress: %s, BridgeData: %s, NativeBridgeFee: %s, Stage: %d}", + t.From.String(), + t.To.String(), t.Amount.String(), t.Sender.String(), t.Receiver.String(), @@ -217,11 +244,16 @@ func NewPendingTransfer(tr Transfer) PendingTransfer { type TransferStatus string +// Proposed and Inflight are used for transfers that are not yet on-chain. (not deducted from the sender on chain) const ( // TransferStatusProposed indicates that the transfer has been proposed by the rebalancing algorithm. TransferStatusProposed = "proposed" // TransferStatusInflight indicates that the transfer is in-flight, but has not yet been included on-chain. TransferStatusInflight = "inflight" +) + +// the below statuses represent transfers that would have already been started on-chain. (already deducted from the sender on chain) +const ( // TransferStatusNotReady indicates that the transfer is in-flight, but has either not been auto-finalized (e.g L1 -> L2 transfers) // or is not ready to finalize on-chain (e.g L2 -> L1 transfers). TransferStatusNotReady = "not-ready"