diff --git a/go.mod b/go.mod index adec8e5..de1b84d 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/holiman/uint256 v1.3.2 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 + github.com/oleiade/lane/v2 v2.0.0 github.com/shopspring/decimal v1.4.0 github.com/sourcegraph/conc v0.3.0 github.com/stretchr/testify v1.10.0 @@ -59,7 +60,6 @@ require ( github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/dmarkham/enumer v1.5.11 // indirect github.com/ethereum/c-kzg-4844 v1.0.3 // indirect github.com/ethereum/go-verkle v0.2.2 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -91,7 +91,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/orcaman/concurrent-map v1.0.0 // indirect - github.com/pascaldekloe/name v1.0.0 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pierrec/lz4/v3 v3.3.5 // indirect github.com/pion/dtls/v2 v2.2.12 // indirect @@ -131,13 +130,11 @@ require ( golang.org/x/arch v0.16.0 // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/mod v0.24.0 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.24.0 // indirect golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.32.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 // indirect google.golang.org/grpc v1.72.0 // indirect diff --git a/go.sum b/go.sum index de698a9..a72118f 100644 --- a/go.sum +++ b/go.sum @@ -132,8 +132,6 @@ github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM= github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dmarkham/enumer v1.5.11 h1:quorLCaEfzjJ23Pf7PB9lyyaHseh91YfTM/sAD/4Mbo= -github.com/dmarkham/enumer v1.5.11/go.mod h1:yixql+kDDQRYqcuBM2n9Vlt7NoT9ixgXhaXry8vmRg8= github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= @@ -386,6 +384,8 @@ github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OS github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oleiade/lane/v2 v2.0.0 h1:XW/ex/Inr+bPkLd3O240xrFOhUkTd4Wy176+Gv0E3Qw= +github.com/oleiade/lane/v2 v2.0.0/go.mod h1:i5FBPFAYSWCgLh58UkUGCChjcCzef/MI7PlQm2TKCeg= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -410,8 +410,6 @@ github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsq github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/orcaman/concurrent-map v1.0.0 h1:I/2A2XPCb4IuQWcQhBhSwGfiuybl/J0ev9HDbW65HOY= github.com/orcaman/concurrent-map v1.0.0/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI= -github.com/pascaldekloe/name v1.0.0 h1:n7LKFgHixETzxpRv2R77YgPUFo85QHGZKrdaYm7eY5U= -github.com/pascaldekloe/name v1.0.0/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= @@ -618,8 +616,6 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -713,8 +709,6 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/finderengine/engine.go b/pkg/finderengine/engine.go new file mode 100644 index 0000000..0b4df29 --- /dev/null +++ b/pkg/finderengine/engine.go @@ -0,0 +1,62 @@ +package finderengine + +import ( + "context" + "fmt" + + "github.com/KyberNetwork/tradinglib/pkg/finderengine/entity" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/finalizer" + finderPkg "github.com/KyberNetwork/tradinglib/pkg/finderengine/finder" + "github.com/pkg/errors" +) + +type PathFinderEngine struct { + finder IFinder + finalizer IFinalizer +} + +func NewPathFinderEngine( + finder IFinder, + finalizer IFinalizer, +) *PathFinderEngine { + return &PathFinderEngine{ + finder: finder, + finalizer: finalizer, + } +} + +func (p *PathFinderEngine) Find(ctx context.Context, params entity.FinderParams) (*entity.FinalizedRoute, error) { + bestRoute, err := p.finder.Find(params) + if err != nil { + if errors.Is(err, finderPkg.ErrRouteNotFound) { + return nil, fmt.Errorf("not found") + } + + return nil, fmt.Errorf("failed to find route, err: %w", err) + } + + if bestRoute.AMMBestRoute == nil { + return nil, fmt.Errorf("invalid swap") + } + + var ammRoute *entity.FinalizedRoute + ammRoute, _ = finalizer.NewFinalizer().Finalize(params, bestRoute.AMMBestRoute) + + return ammRoute, fmt.Errorf("inval") +} + +func (p *PathFinderEngine) SetFinder(finder IFinder) { + p.finder = finder +} + +func (p *PathFinderEngine) GetFinder() IFinder { + return p.finder +} + +func (p *PathFinderEngine) SetFinalizer(finalizer IFinalizer) { + p.finalizer = finalizer +} + +func (p *PathFinderEngine) GetFinalizer() IFinalizer { + return p.finalizer +} diff --git a/pkg/finderengine/entity/entity.go b/pkg/finderengine/entity/entity.go new file mode 100644 index 0000000..2beee0b --- /dev/null +++ b/pkg/finderengine/entity/entity.go @@ -0,0 +1,84 @@ +package entity + +import ( + "math/big" + + dexlibPool "github.com/KyberNetwork/kyberswap-dex-lib/pkg/source/pool" +) + +type Token struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + Name string `json:"name"` + Decimals uint8 `json:"decimals"` +} + +func (t Token) GetAddress() string { + return t.Address +} + +type SimplifiedToken struct { + Address string `json:"address"` + Decimals uint8 `json:"decimals"` +} + +func (t SimplifiedToken) GetAddress() string { + return t.Address +} + +type FinderParams struct { + // TokenIn is the token to be swapped + TokenIn string + // TokenOut is the token to be received + TargetToken string + // AmountIn is the amount of TokenIn to be swapped + AmountIn *big.Int + + // WhitelistHopTokens is the list of tokens that can be used as intermediate tokens + // when finding the best route. + WhitelistHopTokens map[string]struct{} + + // Pools is a mapping between pool address and its simulator. + // The pathfinder will use these pools to find the best route. + Pools map[string]dexlibPool.IPoolSimulator + + // SwapLimits is a mapping between pool type and its swap limit (inventory). + // The pathfinder will use these limits to find the best route. + SwapLimits map[string]dexlibPool.SwapLimit + + // Tokens is a mapping between token address and its information. + // TokenIn, TokenOut, WhitelistTokens (& GasToken if GasInclude = true) + // should be included in this map. + Tokens map[string]SimplifiedToken + + // Prices is a mapping between token address and its price. + // The price can be USD price or Native price (from the On-chain price feed). + // If GasIncluded is true, the pathfinder will use the price information to find the best route. + Prices map[string]float64 + + // GasIncluded is the flag to indicate whether the gas fee is included in finding the best route or not. + // If true, the gas fee will be accounted for in the final result (the best route is the one with the + // highest price of TokenOut after deducting the gas fee). + // If false, the gas fee will be ignored (the best route is the one with the highest amount of TokenOut). + GasIncluded bool + + // GasToken is the token used to pay for the gas fee. Required if GasIncluded is true. + GasToken string + + // GasPrice is the gas price in WEI. Required if GasIncluded is true. + // This field should be differentiated from the price of the gas token: + // GasFee = GasPrice * GasUsed; + // GasFeePrice = GasFee * Price[GasToken] / 10^Tokens[GasToken].Decimals; + GasPrice *big.Int + + // L1GasFeePriceOverhead estimated L1 gas fee for an empty route summary data (without a pool) + // in Price value (USD/Native). + L1GasFeePriceOverhead float64 + + // L1GasFeePricePerPool estimated L1 gas fee for each pool in Price value (USD/Native). + L1GasFeePricePerPool float64 + + MaxHop uint64 + NumPathSplits uint64 + NumHopSplits uint64 +} diff --git a/pkg/finderengine/entity/hop.go b/pkg/finderengine/entity/hop.go new file mode 100644 index 0000000..b5176b6 --- /dev/null +++ b/pkg/finderengine/entity/hop.go @@ -0,0 +1,25 @@ +package entity + +import "math/big" + +type HopSplit struct { + ID string + AmountIn *big.Int + AmountOut *big.Int + Fee *big.Int + GasUsed int64 + GasFeePrice float64 + L1GasFeePrice float64 +} + +type Hop struct { + TokenIn string + TokenOut string + AmountIn *big.Int + AmountOut *big.Int + Fee *big.Int + GasUsed int64 + GasFeePrice float64 + L1GasFeePrice float64 + Splits []HopSplit +} diff --git a/pkg/finderengine/entity/path.go b/pkg/finderengine/entity/path.go new file mode 100644 index 0000000..6513737 --- /dev/null +++ b/pkg/finderengine/entity/path.go @@ -0,0 +1,104 @@ +package entity + +import ( + "math/big" + + "github.com/KyberNetwork/tradinglib/pkg/finderengine/utils" +) + +type Path struct { + ID string + AmountIn *big.Int + AmountOut *big.Int + AmountOutPrice float64 + GasUsed int64 + GasFeePrice float64 + L1GasFeePrice float64 + TokenOrders []string + HopOrders []Hop +} + +func NewPath(amountIn *big.Int) *Path { + return &Path{ + AmountIn: new(big.Int).Set(amountIn), + AmountOut: new(big.Int).Set(amountIn), + TokenOrders: []string{}, + HopOrders: []Hop{}, + } +} + +func (p *Path) AddToken(token string) *Path { + p.TokenOrders = append(p.TokenOrders, token) + return p +} + +func (p *Path) AddHop(hop *Hop) *Path { + p.HopOrders = append(p.HopOrders, *hop) + return p +} + +func (p *Path) SetAmountOutAndPrice( + amountOut *big.Int, + decimals uint8, + price float64, +) *Path { + p.AmountOut.Set(amountOut) + p.AmountOutPrice = utils.CalcAmountPrice(amountOut, decimals, price) + + return p +} + +func (p *Path) SetGasUsedAndPrice( + gasUsed int64, + gasPrice *big.Int, + gasTokenDecimals uint8, + gasTokenPrice float64, + l1GasFeePrice float64, +) *Path { + p.GasUsed = gasUsed + + var gasFee big.Int + gasFee.SetInt64(gasUsed) + gasFee.Mul(&gasFee, gasPrice) + + p.GasFeePrice = utils.CalcAmountPrice(&gasFee, gasTokenDecimals, gasTokenPrice) + + p.L1GasFeePrice = l1GasFeePrice + + return p +} + +func (p *Path) Clone() *Path { + return &Path{ + ID: p.ID, + AmountIn: new(big.Int).Set(p.AmountIn), + AmountOut: new(big.Int).Set(p.AmountOut), + AmountOutPrice: p.AmountOutPrice, + GasUsed: p.GasUsed, + GasFeePrice: p.GasFeePrice, + L1GasFeePrice: p.L1GasFeePrice, + HopOrders: append([]Hop{}, p.HopOrders...), + TokenOrders: append([]string{}, p.TokenOrders...), + } +} + +func (p *Path) Cmp(y *Path, gasIncluded bool) int { + priceAvailable := p.AmountOutPrice != 0 || y.AmountOutPrice != 0 + + if gasIncluded && priceAvailable { + xValue := p.AmountOutPrice - p.GasFeePrice - p.L1GasFeePrice + yValue := y.AmountOutPrice - y.GasFeePrice - y.L1GasFeePrice + + if utils.AlmostEqual(xValue, yValue) { + return p.AmountOut.Cmp(y.AmountOut) + } + + if xValue < yValue { + return -1 + } else { + return 1 + } + } + + return p.AmountOut.Cmp(y.AmountOut) +} diff --git a/pkg/finderengine/entity/route.go b/pkg/finderengine/entity/route.go new file mode 100644 index 0000000..d88d87f --- /dev/null +++ b/pkg/finderengine/entity/route.go @@ -0,0 +1,67 @@ +package entity + +import "math/big" + +type Route struct { + TokenIn string + TokenOut string + + AmountIn *big.Int + AmountInPrice float64 + AmountOut *big.Int + AmountOutPrice float64 + + GasUsed int64 + GasFeePrice float64 + GasPrice *big.Int + GasFee *big.Int + + L1GasFeePrice float64 + + Paths []*Path +} + +type Swap struct { + Pool string + TokenIn string + TokenOut string + AmountIn *big.Int + AmountOut *big.Int +} + +type FinalizedRoute struct { + TokenIn string + TokenOut string + + AmountIn *big.Int + AmountInPrice float64 + AmountOut *big.Int + AmountOutPrice float64 + + GasUsed int64 + GasFeePrice float64 + GasPrice *big.Int + GasFee *big.Int + + L1GasFeePrice float64 + + Route [][]Swap +} + +func NewConstructRoute(tokenIn, tokenOut string) *Route { + return &Route{ + TokenIn: tokenIn, + TokenOut: tokenOut, + AmountIn: big.NewInt(0), + AmountOut: big.NewInt(0), + Paths: []*Path{}, + } +} + +type BestRouteResult struct { + AMMBestRoute *Route +} + +func (res *BestRouteResult) IsRouteNotFound() bool { + return res == nil || res.AMMBestRoute == nil +} diff --git a/pkg/finderengine/finalizer/finalizer.go b/pkg/finderengine/finalizer/finalizer.go new file mode 100644 index 0000000..da1ebb8 --- /dev/null +++ b/pkg/finderengine/finalizer/finalizer.go @@ -0,0 +1,160 @@ +package finalizer + +import ( + "fmt" + "math/big" + + dexlibPool "github.com/KyberNetwork/kyberswap-dex-lib/pkg/source/pool" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/entity" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/isolated" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/utils" + "go.uber.org/zap" +) + +const BASE_GAS int64 = 125000 + +type finalizer struct{} + +func NewFinalizer() *finalizer { + return &finalizer{} +} + +func (f *finalizer) Finalize(params entity.FinderParams, route *entity.Route) (finalRoute *entity.FinalizedRoute, err error) { + defer func() { + if r := recover(); r != nil { + finalRoute = nil + err = fmt.Errorf("panic finalize route: %v", r) + } + }() + + // Build isolated pools safely + isolatedPools := make(map[string]*isolated.Pool, len(params.Pools)) + for address, pool := range params.Pools { + isolatedPools[address] = isolated.NewIsolatedPool(pool) + } + + var ( + amountOut = big.NewInt(0) + gasUsed = BASE_GAS + l1GasFeePrice = params.L1GasFeePriceOverhead + ) + + finalizedRoute := make([][]entity.Swap, 0, len(route.Paths)) + + for _, path := range route.Paths { + if len(path.HopOrders) == 0 { + return nil, fmt.Errorf("route contains an empty path") + } + if len(path.TokenOrders) == 0 { + return nil, fmt.Errorf("path has no token orders") + } + + finalizedPath := make([]entity.Swap, 0, len(path.HopOrders)) + currentAmountIn := new(big.Int).Set(path.AmountIn) + + for _, hop := range path.HopOrders { + fromToken := hop.TokenIn + toToken := hop.TokenOut + + hopAmountOut := big.NewInt(0) + + for _, split := range hop.Splits { + hopAmountIn := new(big.Int).Set(split.AmountIn) + if hopAmountIn.Cmp(currentAmountIn) > 0 { + hopAmountIn = new(big.Int).Set(currentAmountIn) + } + + // Decrease current available + currentAmountIn.Sub(currentAmountIn, hopAmountIn) + + tokenAmountIn := dexlibPool.TokenAmount{Token: fromToken, Amount: hopAmountIn} + + pool, ok := isolatedPools[split.ID] + if !ok || pool == nil { + return nil, fmt.Errorf("unknown or nil pool id: %s", split.ID) + } + + res, calcErr := dexlibPool.CalcAmountOut(pool, tokenAmountIn, toToken, nil) + if calcErr != nil { + zap.S().Warnf( + "failed to swap %s %v to %v in pool %s: %v", + hopAmountIn.String(), fromToken, toToken, pool.GetAddress(), calcErr, + ) + return nil, fmt.Errorf("invalid swap: %w", calcErr) + } + if res == nil || !res.IsValid() || res.TokenAmountOut == nil { + return nil, fmt.Errorf("invalid swap result: empty amountOut for pool %s", pool.GetAddress()) + } + + updateBalanceParams := dexlibPool.UpdateBalanceParams{ + TokenAmountIn: tokenAmountIn, + TokenAmountOut: *res.TokenAmountOut, + Fee: *res.Fee, + SwapInfo: res.SwapInfo, + } + pool.UpdateBalance(updateBalanceParams) + + finalizedPath = append(finalizedPath, entity.Swap{ + Pool: pool.GetAddress(), + TokenIn: fromToken, + TokenOut: toToken, + AmountIn: hopAmountIn, + AmountOut: res.TokenAmountOut.Amount, + }) + + hopAmountOut.Add(hopAmountOut, res.TokenAmountOut.Amount) + gasUsed += res.Gas + } + + l1GasFeePrice += params.L1GasFeePricePerPool * float64(len(hop.Splits)) + currentAmountIn = hopAmountOut + } + + lastToken := path.TokenOrders[len(path.TokenOrders)-1] + if lastToken == params.TargetToken { + amountOut.Add(amountOut, currentAmountIn) + } + + finalizedRoute = append(finalizedRoute, finalizedPath) + } + + gasFee := new(big.Int).Mul(big.NewInt(gasUsed), params.GasPrice) + if _, ok := params.Tokens[params.TokenIn]; !ok { + return nil, fmt.Errorf("missing token metadata for input token %v", params.TokenIn) + } + if _, ok := params.Tokens[params.TargetToken]; !ok { + return nil, fmt.Errorf("missing token metadata for output token %v", params.TargetToken) + } + if _, ok := params.Tokens[params.GasToken]; !ok { + return nil, fmt.Errorf("missing token metadata for gas token %v", params.GasToken) + } + + finalRoute = &entity.FinalizedRoute{ + TokenIn: params.TokenIn, + AmountIn: params.AmountIn, + AmountInPrice: utils.CalcAmountPrice( + params.AmountIn, + params.Tokens[params.TokenIn].Decimals, + params.Prices[params.TokenIn], + ), + TokenOut: params.TargetToken, + AmountOut: amountOut, + AmountOutPrice: utils.CalcAmountPrice( + amountOut, + params.Tokens[params.TargetToken].Decimals, + params.Prices[params.TargetToken], + ), + GasUsed: gasUsed, + GasPrice: params.GasPrice, + GasFee: gasFee, + GasFeePrice: utils.CalcAmountPrice( + gasFee, + params.Tokens[params.GasToken].Decimals, + params.Prices[params.GasToken], + ), + L1GasFeePrice: l1GasFeePrice, + Route: finalizedRoute, + } + + return finalRoute, nil +} diff --git a/pkg/finderengine/finder/error.go b/pkg/finderengine/finder/error.go new file mode 100644 index 0000000..e987426 --- /dev/null +++ b/pkg/finderengine/finder/error.go @@ -0,0 +1,12 @@ +package finder + +import "errors" + +var ( + ErrRouteNotFound = errors.New("route not found") + ErrTokenInNotFound = errors.New("token in not found") + ErrTokenOutNotFound = errors.New("token out not found") + ErrGasTokenRequired = errors.New("gas token required") + ErrGasPriceRequired = errors.New("gas price required") + ErrGasTokenNotFound = errors.New("gas token not found") +) diff --git a/pkg/finderengine/finder/find.go b/pkg/finderengine/finder/find.go new file mode 100644 index 0000000..0df3a7e --- /dev/null +++ b/pkg/finderengine/finder/find.go @@ -0,0 +1,98 @@ +package finder + +import ( + "math/big" + + dexlibPool "github.com/KyberNetwork/kyberswap-dex-lib/pkg/source/pool" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/entity" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/isolated" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/utils" +) + +type Finder struct { + FindHops FindHopFunc + MinThresholdUSD float64 +} + +func (f *Finder) Find(params entity.FinderParams) (*entity.BestRouteResult, error) { + if err := f.validateParameters(params); err != nil { + return nil, err + } + + edges := make(map[string]map[string][]dexlibPool.IPoolSimulator) + for i := range params.Pools { + pool := params.Pools[i] + tokens := pool.GetTokens() + for i := range tokens { + if edges[tokens[i]] == nil { + edges[tokens[i]] = make(map[string][]dexlibPool.IPoolSimulator) + } + for j := range tokens { + if i == j { + continue + } + if edges[tokens[i]][tokens[j]] == nil { + edges[tokens[i]][tokens[j]] = make([]dexlibPool.IPoolSimulator, 0) + } + edges[tokens[i]][tokens[j]] = append(edges[tokens[i]][tokens[j]], pool) + } + } + } + + bestRoute := entity.Route{ + TokenIn: params.TokenIn, + TokenOut: params.TargetToken, + AmountIn: new(big.Int).Set(params.AmountIn), + AmountOut: big.NewInt(0), + GasUsed: 0, + GasFeePrice: 0, + L1GasFeePrice: 0, + Paths: nil, + } + + minHops := f.minHopsToTokenOut(params.TokenIn, params.TargetToken, edges, params.WhitelistHopTokens, params.MaxHop) + tokenInInfo := params.Tokens[params.TokenIn] + tokenInPrice := params.Prices[params.TokenIn] + splits := utils.SplitAmountThreshold( + params.AmountIn, tokenInInfo.Decimals, params.NumPathSplits, tokenInPrice, f.MinThresholdUSD, + ) + + isolatedPools := make(map[string]*isolated.Pool, len(params.Pools)) + for address, pool := range params.Pools { + isolatedPools[address] = isolated.NewIsolatedPool(pool) + } + for _, split := range splits { + params.AmountIn = split + bestPath := f.FindBestPathsOptimized(¶ms, minHops, edges) + bestRoute.AmountOut.Add(bestPath.AmountOut, bestPath.AmountOut) + bestRoute.Paths = append(bestRoute.Paths, bestPath) + updatePoolState(bestPath, isolatedPools) + } + + return &entity.BestRouteResult{ + AMMBestRoute: &bestRoute, + }, nil +} + +func (f *Finder) validateParameters(params entity.FinderParams) error { + if _, exist := params.Tokens[params.TokenIn]; !exist { + return ErrTokenInNotFound + } + if _, exist := params.Tokens[params.TargetToken]; !exist { + return ErrTokenOutNotFound + } + + if params.GasIncluded { + if params.GasToken == "" { + return ErrGasTokenRequired + } + if params.GasPrice == nil { + return ErrGasPriceRequired + } + if _, exist := params.Tokens[params.GasToken]; !exist { + return ErrGasTokenNotFound + } + } + + return nil +} diff --git a/pkg/finderengine/finder/find_test.go b/pkg/finderengine/finder/find_test.go new file mode 100644 index 0000000..e5cac69 --- /dev/null +++ b/pkg/finderengine/finder/find_test.go @@ -0,0 +1,112 @@ +package finder_test + +import ( + "math/big" + "testing" + + dexlibPool "github.com/KyberNetwork/kyberswap-dex-lib/pkg/source/pool" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/entity" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/finder" + "github.com/stretchr/testify/assert" +) + +func PoolTest() map[string]dexlibPool.IPoolSimulator { + return map[string]dexlibPool.IPoolSimulator{ + "AB1": &mockPool{ + address: "AB1", tokenIn: "A", tokenOut: "B", + bids: []Order{{A: big.NewInt(100), R: big.NewInt(50)}, {A: big.NewInt(100), R: big.NewInt(20)}}, // 1 B = 1/2 A + asks: []Order{{A: big.NewInt(100), R: big.NewInt(200)}, {A: big.NewInt(100), R: big.NewInt(90)}}, // 1 A = 2 B + }, + "AB2": &mockPool{ + address: "AB2", tokenIn: "A", tokenOut: "B", + bids: []Order{{A: big.NewInt(100), R: big.NewInt(50)}}, + asks: []Order{{A: big.NewInt(100), R: big.NewInt(150)}}, + }, + "AC1": &mockPool{ + address: "AC1", tokenIn: "A", tokenOut: "C", + bids: []Order{{A: big.NewInt(100), R: big.NewInt(30)}, {A: big.NewInt(100), R: big.NewInt(20)}}, + asks: []Order{{A: big.NewInt(200), R: big.NewInt(300)}, {A: big.NewInt(100), R: big.NewInt(100)}}, + }, + "AC2": &mockPool{ + address: "AC2", tokenIn: "A", tokenOut: "C", + bids: []Order{{A: big.NewInt(100), R: big.NewInt(50)}}, + asks: []Order{{A: big.NewInt(100), R: big.NewInt(250)}}, + }, + + "BC1": &mockPool{ + address: "BC1", tokenIn: "B", tokenOut: "C", + bids: []Order{{A: big.NewInt(100), R: big.NewInt(50)}, {A: big.NewInt(100), R: big.NewInt(20)}}, + asks: []Order{{A: big.NewInt(200), R: big.NewInt(200)}, {A: big.NewInt(100), R: big.NewInt(100)}}, + }, + "BC2": &mockPool{ + address: "BC2", tokenIn: "B", tokenOut: "C", + bids: []Order{{A: big.NewInt(10), R: big.NewInt(50)}, {A: big.NewInt(100), R: big.NewInt(20)}}, + asks: []Order{{A: big.NewInt(10), R: big.NewInt(100)}, {A: big.NewInt(100), R: big.NewInt(50)}}, + }, + } +} + +func Test_Find(t *testing.T) { + f := &finder.Finder{ + FindHops: finder.FindHops, + } + + pools := PoolTest() + + params := entity.FinderParams{ + TokenIn: "A", + TargetToken: "C", + MaxHop: 5, + NumHopSplits: 5, + NumPathSplits: 5, + AmountIn: big.NewInt(555), + GasPrice: big.NewInt(0), + Tokens: map[string]entity.SimplifiedToken{ + "A": {}, "B": {}, "C": {}, + }, + WhitelistHopTokens: map[string]struct{}{ + "B": {}, "C": {}, + }, + Pools: pools, + } + + bestRoute, err := f.Find(params) + assert.NoError(t, err) + assert.NotEmpty(t, bestRoute) + + expectedPools := map[string]dexlibPool.IPoolSimulator{ + "AB1": &mockPool{ + address: "AB1", tokenIn: "A", tokenOut: "B", + bids: []Order{{A: big.NewInt(100), R: big.NewInt(50)}, {A: big.NewInt(100), R: big.NewInt(20)}}, // 1 B = 1/2 A + asks: []Order{{A: big.NewInt(100), R: big.NewInt(200)}, {A: big.NewInt(100), R: big.NewInt(90)}}, // 1 A = 2 B + }, + "AB2": &mockPool{ + address: "AB2", tokenIn: "A", tokenOut: "B", + bids: []Order{{A: big.NewInt(100), R: big.NewInt(50)}}, + asks: []Order{{A: big.NewInt(100), R: big.NewInt(150)}}, + }, + "AC1": &mockPool{ + address: "AC1", tokenIn: "A", tokenOut: "C", + bids: []Order{{A: big.NewInt(100), R: big.NewInt(30)}, {A: big.NewInt(100), R: big.NewInt(20)}}, + asks: []Order{{A: big.NewInt(200), R: big.NewInt(300)}, {A: big.NewInt(100), R: big.NewInt(100)}}, + }, + "AC2": &mockPool{ + address: "AC2", tokenIn: "A", tokenOut: "C", + bids: []Order{{A: big.NewInt(100), R: big.NewInt(50)}}, + asks: []Order{{A: big.NewInt(100), R: big.NewInt(250)}}, + }, + + "BC1": &mockPool{ + address: "BC1", tokenIn: "B", tokenOut: "C", + bids: []Order{{A: big.NewInt(100), R: big.NewInt(50)}, {A: big.NewInt(100), R: big.NewInt(20)}}, + asks: []Order{{A: big.NewInt(200), R: big.NewInt(200)}, {A: big.NewInt(100), R: big.NewInt(100)}}, + }, + "BC2": &mockPool{ + address: "BC2", tokenIn: "B", tokenOut: "C", + bids: []Order{{A: big.NewInt(10), R: big.NewInt(50)}, {A: big.NewInt(100), R: big.NewInt(20)}}, + asks: []Order{{A: big.NewInt(10), R: big.NewInt(100)}, {A: big.NewInt(100), R: big.NewInt(50)}}, + }, + } + + assert.Equal(t, expectedPools, pools) +} diff --git a/pkg/finderengine/finder/findpath.go b/pkg/finderengine/finder/findpath.go new file mode 100644 index 0000000..01e4723 --- /dev/null +++ b/pkg/finderengine/finder/findpath.go @@ -0,0 +1,138 @@ +package finder + +import ( + dexlibPool "github.com/KyberNetwork/kyberswap-dex-lib/pkg/source/pool" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/entity" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/isolated" + mapset "github.com/deckarep/golang-set/v2" +) + +func (f *Finder) FindBestPathsOptimized( + params *entity.FinderParams, + minHops map[string]uint64, + edges map[string]map[string][]dexlibPool.IPoolSimulator, +) *entity.Path { + startNode := entity.NewPath(params.AmountIn) + startNode.AddToken(params.TokenIn) + layer := map[string]*entity.Path{ + params.TokenIn: startNode, + } + + for hop := uint64(0); hop < params.MaxHop; hop++ { + newLayer := f.generateNextLayer(params, layer, minHops, hop, edges) + if layer[params.TargetToken] != nil { + if newLayer[params.TargetToken] == nil { + newLayer[params.TargetToken] = layer[params.TargetToken] + } else if newLayer[params.TargetToken].Cmp(layer[params.TargetToken], true) <= 0 { + newLayer[params.TargetToken] = layer[params.TargetToken] + } + } + + layer = newLayer + } + + return layer[params.TargetToken] +} + +func (f *Finder) generateNextLayer( + params *entity.FinderParams, + currentLayer map[string]*entity.Path, + minHops map[string]uint64, + currentHop uint64, + edges map[string]map[string][]dexlibPool.IPoolSimulator, +) map[string]*entity.Path { + var newPaths []*entity.Path + + for tokenIn, path := range currentLayer { + tokenInEdges := edges[tokenIn] + tokenInInfo := params.Tokens[tokenIn] + tokenInPrice := params.Prices[tokenIn] + usedTokens := mapset.NewThreadUnsafeSet(path.TokenOrders...) + for tokenOut, pools := range tokenInEdges { + if usedTokens.Contains(tokenOut) { + continue + } + + if _, exists := params.WhitelistHopTokens[tokenOut]; tokenOut != params.TargetToken && !exists { + continue + } + + if _, exists := minHops[tokenOut]; !exists { + continue + } + + if currentHop+1+minHops[tokenOut] >= params.MaxHop { + continue + } + + isolatedPools := isolated.NewIsolatedPools(pools) + hop := f.FindHops( + tokenIn, tokenInPrice, tokenInInfo.Decimals, + tokenOut, path.AmountOut, isolatedPools, params.NumHopSplits, + f.MinThresholdUSD, + ) + newPath := f.generateNextPath(params, path, hop) + newPaths = append(newPaths, newPath) + } + } + + nextLayer := make(map[string]*entity.Path) + for _, path := range newPaths { + lastToken := path.TokenOrders[len(path.TokenOrders)-1] + if nextLayer[lastToken] == nil { + nextLayer[lastToken] = path + continue + } + if nextLayer[lastToken].Cmp(path, true) <= 0 { + nextLayer[lastToken] = path + } + } + + return nextLayer +} + +func (f *Finder) generateNextPath(params *entity.FinderParams, currentPath *entity.Path, hop *entity.Hop) *entity.Path { + nextPath := entity.NewPath(currentPath.AmountIn) + nextPath.TokenOrders = make([]string, 0, len(currentPath.TokenOrders)+1) + nextPath.HopOrders = make([]entity.Hop, 0, len(currentPath.HopOrders)+1) + for _, token := range currentPath.TokenOrders { + nextPath.AddToken(token) + } + nextPath.AddToken(hop.TokenOut) + + for _, hop := range currentPath.HopOrders { + nextPath.AddHop(&hop) + } + nextPath.AddHop(hop) + nextPath.SetAmountOutAndPrice( + hop.AmountOut, + params.Tokens[hop.TokenOut].Decimals, + params.Prices[hop.TokenOut], + ) + nextPath.SetGasUsedAndPrice( + currentPath.GasUsed+hop.GasUsed, + params.GasPrice, + params.Tokens[params.GasToken].Decimals, + params.Prices[params.GasToken], + params.L1GasFeePricePerPool, + ) + return nextPath +} + +func updatePoolState(path *entity.Path, pools map[string]*isolated.Pool) { + for _, hop := range path.HopOrders { + for _, hopSplit := range hop.Splits { + pool := pools[hopSplit.ID] + pool.UpdateBalance(dexlibPool.UpdateBalanceParams{ + TokenAmountIn: dexlibPool.TokenAmount{ + Token: hop.TokenIn, + Amount: hopSplit.AmountIn, + }, + TokenAmountOut: dexlibPool.TokenAmount{ + Token: hop.TokenOut, + Amount: hopSplit.AmountOut, + }, + }) + } + } +} diff --git a/pkg/finderengine/finder/findpath_benchmark_test.go b/pkg/finderengine/finder/findpath_benchmark_test.go new file mode 100644 index 0000000..30bd7b8 --- /dev/null +++ b/pkg/finderengine/finder/findpath_benchmark_test.go @@ -0,0 +1,77 @@ +package finder_test + +import ( + "fmt" + "math/big" + "testing" + + dexlibPool "github.com/KyberNetwork/kyberswap-dex-lib/pkg/source/pool" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/entity" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/finder" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/isolated" +) + +func GenTest() (map[string]entity.SimplifiedToken, map[string]struct{}, map[string]map[string][]dexlibPool.IPoolSimulator) { + tokens := make(map[string]entity.SimplifiedToken) + for i := 0; i < 1000; i++ { + addr := fmt.Sprintf("token%d", i) + tokens[addr] = entity.SimplifiedToken{Address: addr, Decimals: 18} + } + whitelist := make(map[string]struct{}) + for i := 0; i < 50; i++ { + addr := fmt.Sprintf("token%d", i) + whitelist[addr] = struct{}{} + } + + edges := make(map[string]map[string][]dexlibPool.IPoolSimulator) + for i := 0; i < 1000; i++ { + from := fmt.Sprintf("token%d", i) + edges[from] = make(map[string][]dexlibPool.IPoolSimulator) + for j := 0; j < 5; j++ { + to := fmt.Sprintf("token%d", (i+j+1)%1000) + edges[from][to] = []dexlibPool.IPoolSimulator{&mockPool{}} + } + } + return tokens, whitelist, edges +} + +func BenchmarkFindBestPathsOptimized(b *testing.B) { + tokens, whitelist, edges := GenTest() + params := &entity.FinderParams{ + MaxHop: 5, + NumHopSplits: 2, + NumPathSplits: 2, + TokenIn: "token0", + TargetToken: "token999", + AmountIn: big.NewInt(1_000_000_000_000_000_000), + GasPrice: big.NewInt(0), + WhitelistHopTokens: whitelist, + Tokens: tokens, + GasIncluded: false, + } + finder := &finder.Finder{ + FindHops: func(tokenIn string, tokenInPrice float64, tokenInDecimals uint8, tokenOut string, amountIn *big.Int, pools []*isolated.Pool, numSplits uint64, minThresholdUSD float64) *entity.Hop { + return &entity.Hop{ + TokenIn: tokenIn, + TokenOut: tokenOut, + AmountIn: amountIn, + AmountOut: new(big.Int).Add(amountIn, big.NewInt(1)), + Fee: big.NewInt(0), + Splits: []entity.HopSplit{ + {ID: fmt.Sprintf("MockPool-%s-%s", tokenIn, tokenOut)}, + }, + } + }, + } + + // Dummy minHops + minHops := make(map[string]uint64) + for k := range tokens { + minHops[k] = 1 + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = finder.FindBestPathsOptimized(params, minHops, edges) + } +} diff --git a/pkg/finderengine/finder/findpath_test.go b/pkg/finderengine/finder/findpath_test.go new file mode 100644 index 0000000..e4b31c7 --- /dev/null +++ b/pkg/finderengine/finder/findpath_test.go @@ -0,0 +1,82 @@ +package finder_test + +import ( + "math/big" + "testing" + + dexlibPool "github.com/KyberNetwork/kyberswap-dex-lib/pkg/source/pool" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/entity" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/finder" + "github.com/stretchr/testify/assert" +) + +func TestFindBestPath_BasicGraph(t *testing.T) { + f := &finder.Finder{ + FindHops: finder.FindHops, + } + + pools := PoolTest() + + edges := map[string]map[string][]dexlibPool.IPoolSimulator{ + "A": { + "B": []dexlibPool.IPoolSimulator{pools["AB1"], pools["AB2"]}, + "C": []dexlibPool.IPoolSimulator{pools["AC1"], pools["AC2"]}, + }, + "B": { + "A": []dexlibPool.IPoolSimulator{pools["AB1"], pools["AB2"]}, + "C": []dexlibPool.IPoolSimulator{pools["BC1"], pools["BC2"]}, + }, + "C": { + "A": []dexlibPool.IPoolSimulator{pools["AC1"], pools["AC2"]}, + "B": []dexlibPool.IPoolSimulator{pools["BC1"], pools["BC2"]}, + }, + } + + minHops := map[string]uint64{ + "A": 1, + "B": 1, + "C": 0, + } + + params := &entity.FinderParams{ + TokenIn: "A", + TargetToken: "C", + MaxHop: 5, + NumHopSplits: 5, + NumPathSplits: 5, + AmountIn: big.NewInt(12), + GasPrice: big.NewInt(0), + Tokens: map[string]entity.SimplifiedToken{ + "A": {}, "B": {}, "C": {}, + }, + WhitelistHopTokens: map[string]struct{}{ + "B": {}, "C": {}, + }, + } + + results := f.FindBestPathsOptimized(params, minHops, edges) + + expectedResult := &entity.Path{ + AmountIn: big.NewInt(12), + AmountOut: big.NewInt(43), + TokenOrders: []string{"A", "B", "C"}, + HopOrders: []entity.Hop{ + { + TokenIn: "A", TokenOut: "B", AmountIn: big.NewInt(12), AmountOut: big.NewInt(23), Fee: big.NewInt(0), + Splits: []entity.HopSplit{ + {ID: "AB1", AmountIn: big.NewInt(10), AmountOut: big.NewInt(20), Fee: big.NewInt(0)}, + {ID: "AB2", AmountIn: big.NewInt(2), AmountOut: big.NewInt(3), Fee: big.NewInt(0)}, + }, + }, + { + TokenIn: "B", TokenOut: "C", AmountIn: big.NewInt(23), AmountOut: big.NewInt(43), Fee: big.NewInt(0), + Splits: []entity.HopSplit{ + {ID: "BC1", AmountIn: big.NewInt(20), AmountOut: big.NewInt(40), Fee: big.NewInt(0)}, + {ID: "BC2", AmountIn: big.NewInt(3), AmountOut: big.NewInt(3), Fee: big.NewInt(0)}, + }, + }, + }, + } + assert.NotEmpty(t, results) + assert.Equal(t, expectedResult, results) +} diff --git a/pkg/finderengine/finder/hop.go b/pkg/finderengine/finder/hop.go new file mode 100644 index 0000000..1677e17 --- /dev/null +++ b/pkg/finderengine/finder/hop.go @@ -0,0 +1,263 @@ +package finder + +import ( + "container/heap" + "fmt" + "math/big" + + dexlibPool "github.com/KyberNetwork/kyberswap-dex-lib/pkg/source/pool" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/entity" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/isolated" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/utils" + "github.com/oleiade/lane/v2" +) + +const maxHopWorker = 8 + +type FindHopFunc func( + tokenIn string, + tokenInPrice float64, + tokenInDecimals uint8, + tokenOut string, + amountIn *big.Int, + pools []*isolated.Pool, + numSplits uint64, + minThresholdUSD float64, +) *entity.Hop + +type poolItem struct { + id uint64 + addr string + amtOut *big.Int + fee *big.Int + gas int64 +} + +type poolMaxHeap []poolItem + +func (h *poolMaxHeap) Len() int { + return len(*h) +} +func (h *poolMaxHeap) Less(i, j int) bool { return (*h)[i].amtOut.Cmp((*h)[j].amtOut) > 0 } +func (h *poolMaxHeap) Swap(i, j int) { (*h)[i], (*h)[j] = (*h)[j], (*h)[i] } +func (h *poolMaxHeap) Push(x any) { + it, ok := x.(poolItem) + if !ok { + panic(fmt.Sprintf("poolMaxHeap: Push got %T, want poolItem", x)) + } + *h = append(*h, it) +} + +func (h *poolMaxHeap) Pop() any { + old := *h + n := len(old) + it := old[n-1] + *h = old[:n-1] + return it +} + +func FindHops( + tokenIn string, + tokenInPrice float64, + tokenInDecimals uint8, + tokenOut string, + amountIn *big.Int, + pools []*isolated.Pool, + numSplits uint64, + minThresholdUSD float64, +) *entity.Hop { + if len(pools) == 0 || amountIn.Sign() <= 0 || numSplits == 0 { + return &entity.Hop{ + TokenIn: tokenIn, + TokenOut: tokenOut, + AmountIn: amountIn, + AmountOut: big.NewInt(0), + Fee: big.NewInt(0), + Splits: nil, + } + } + + splits := utils.SplitAmountThreshold(amountIn, tokenInDecimals, numSplits, minThresholdUSD, tokenInPrice) + baseSplit := splits[0] + + baseParams := dexlibPool.CalcAmountOutParams{ + TokenAmountIn: dexlibPool.TokenAmount{Token: tokenIn, Amount: baseSplit}, + TokenOut: tokenOut, + } + + type input struct{ idx int } + type out struct { + idx int + res *dexlibPool.CalcAmountOutResult + ok bool + } + + n := len(pools) + data := make(chan input, n) + outs := make(chan out, n) + + for w := 0; w < maxHopWorker; w++ { + go func(data <-chan input, results chan<- out) { + for d := range data { + res, err := pools[d.idx].CalcAmountOut(baseParams) + if err != nil || res == nil || res.TokenAmountOut == nil || res.TokenAmountOut.Amount == nil { + results <- out{idx: d.idx, ok: false} + continue + } + results <- out{idx: d.idx, res: res, ok: true} + } + }(data, outs) + } + + for i := 0; i < n; i++ { + data <- input{idx: i} + } + close(data) + + h := make(poolMaxHeap, 0, n) + for i := 0; i < n; i++ { + o := <-outs + if !o.ok { + continue + } + addr := pools[o.idx].GetAddress() + h = append(h, poolItem{ + id: uint64(o.idx), + addr: addr, + amtOut: new(big.Int).Set(o.res.TokenAmountOut.Amount), + fee: new(big.Int).Set(o.res.Fee.Amount), + gas: o.res.Gas, + }) + } + + if len(h) == 0 { + return &entity.Hop{ + TokenIn: tokenIn, + TokenOut: tokenOut, + AmountIn: amountIn, + AmountOut: big.NewInt(0), + Fee: big.NewInt(0), + GasUsed: 0, + Splits: nil, + } + } + heap.Init(&h) + + hopSplitMap := make(map[string]*entity.HopSplit, len(h)) + totalIn := big.NewInt(0) + totalOut := big.NewInt(0) + totalFee := big.NewInt(0) + totalGas := int64(0) + + for i := 0; i < len(splits) && h.Len() > 0; i++ { + chunk := splits[i] + isLast := i == len(splits)-1 + best, _ := heap.Pop(&h).(poolItem) + p := pools[best.id] + + var item poolItem + if isLast && chunk.Cmp(baseSplit) != 0 { + r, err := p.CalcAmountOut(dexlibPool.CalcAmountOutParams{ + TokenAmountIn: dexlibPool.TokenAmount{Token: tokenIn, Amount: chunk}, + TokenOut: tokenOut, + }) + if err == nil && r != nil { + item = poolItem{addr: p.GetAddress(), amtOut: r.TokenAmountOut.Amount, gas: r.Gas, fee: r.Fee.Amount} + } else { + item = poolItem{amtOut: big.NewInt(0), gas: 0, fee: big.NewInt(0)} + } + } else { + item = best + } + + s := hopSplitMap[best.addr] + if s == nil { + s = &entity.HopSplit{ + ID: best.addr, + AmountIn: big.NewInt(0), + AmountOut: big.NewInt(0), + Fee: big.NewInt(0), + } + hopSplitMap[item.addr] = s + } + s.AmountIn.Add(s.AmountIn, chunk) + s.AmountOut.Add(s.AmountOut, item.amtOut) + s.Fee.Add(s.Fee, item.fee) + + totalIn.Add(totalIn, chunk) + totalOut.Add(totalOut, item.amtOut) + totalFee.Add(totalFee, item.fee) + totalGas += item.gas + + p.UpdateBalance(dexlibPool.UpdateBalanceParams{ + TokenAmountIn: dexlibPool.TokenAmount{Token: tokenIn, Amount: chunk}, + TokenAmountOut: dexlibPool.TokenAmount{Token: tokenOut, Amount: item.amtOut}, + Fee: dexlibPool.TokenAmount{Token: tokenIn, Amount: item.fee}, + }) + + if !isLast { + newRes, err := p.CalcAmountOut(baseParams) + if err == nil && newRes != nil && newRes.TokenAmountOut != nil && newRes.TokenAmountOut.Amount != nil { + best.amtOut = new(big.Int).Set(newRes.TokenAmountOut.Amount) + best.fee = new(big.Int).Set(newRes.Fee.Amount) + best.gas = newRes.Gas + heap.Push(&h, best) + } + } + } + + splitsOut := make([]entity.HopSplit, 0, len(hopSplitMap)) + for _, s := range hopSplitMap { + splitsOut = append(splitsOut, *s) + } + return &entity.Hop{ + TokenIn: tokenIn, + TokenOut: tokenOut, + Fee: totalFee, + AmountIn: totalIn, + AmountOut: totalOut, + Splits: splitsOut, + } +} + +func (f *Finder) minHopsToTokenOut( + tokenIn string, + tokenOut string, + edges map[string]map[string][]dexlibPool.IPoolSimulator, + whitelistedHopTokens map[string]struct{}, + maxHop uint64, +) map[string]uint64 { + minHops := make(map[string]uint64) + queue := lane.NewQueue[string]() + + minHops[tokenOut] = 0 + queue.Enqueue(tokenOut) + + for queue.Size() > 0 { + token, _ := queue.Dequeue() + if minHops[token] == maxHop { + continue + } + + if edges[token] == nil { + continue + } + + for tokenFrom := range edges[token] { + if _, visited := minHops[tokenFrom]; visited { + continue + } + + _, isWhitelisted := whitelistedHopTokens[tokenFrom] + isHopToken := tokenFrom != tokenIn + if isHopToken && !isWhitelisted { + continue + } + + minHops[tokenFrom] = minHops[token] + 1 + queue.Enqueue(tokenFrom) + } + } + + return minHops +} diff --git a/pkg/finderengine/finder/hop_test.go b/pkg/finderengine/finder/hop_test.go new file mode 100644 index 0000000..0c86828 --- /dev/null +++ b/pkg/finderengine/finder/hop_test.go @@ -0,0 +1,75 @@ +package finder_test + +import ( + "math/big" + "testing" + + "github.com/KyberNetwork/kyberswap-dex-lib/pkg/source/pool" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/entity" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/finder" + "github.com/KyberNetwork/tradinglib/pkg/finderengine/isolated" + "github.com/stretchr/testify/assert" +) + +func Test_FindHops(t *testing.T) { + pools := []pool.IPoolSimulator{ + &mockPool{ + address: "AB1", tokenIn: "A", tokenOut: "B", + asks: []Order{{A: big.NewInt(20), R: big.NewInt(200)}, {A: big.NewInt(20), R: big.NewInt(90)}}, // 1 A = 2 B + }, + &mockPool{ + address: "AB2", tokenIn: "A", tokenOut: "B", + asks: []Order{{A: big.NewInt(20), R: big.NewInt(150)}, {A: big.NewInt(30), R: big.NewInt(120)}}, + }, + } + + isolatedPools := isolated.NewIsolatedPools(pools) + amountIn := big.NewInt(80) + numSplits := uint64(5) + hop := finder.FindHops("A", 1, 18, "B", amountIn, isolatedPools, numSplits, 1) + assert.Len(t, hop.Splits, 2) + expectedHop := &entity.Hop{ + TokenIn: "A", + TokenOut: "B", + AmountIn: amountIn, + AmountOut: big.NewInt(113), + GasUsed: 0, + GasFeePrice: 0, + L1GasFeePrice: 0, + Fee: big.NewInt(0), + Splits: []entity.HopSplit{ + { + ID: "AB1", + AmountIn: big.NewInt(32), + AmountOut: big.NewInt(50), + Fee: big.NewInt(0), + GasUsed: 0, + GasFeePrice: 0, + L1GasFeePrice: 0, + }, + { + ID: "AB2", + AmountIn: big.NewInt(48), + AmountOut: big.NewInt(63), + Fee: big.NewInt(0), + GasUsed: 0, + GasFeePrice: 0, + L1GasFeePrice: 0, + }, + }, + } + + expectedPools := []pool.IPoolSimulator{ + &mockPool{ + address: "AB1", tokenIn: "A", tokenOut: "B", + asks: []Order{{A: big.NewInt(20), R: big.NewInt(200)}, {A: big.NewInt(20), R: big.NewInt(90)}}, // 1 A = 2 B + }, + &mockPool{ + address: "AB2", tokenIn: "A", tokenOut: "B", + asks: []Order{{A: big.NewInt(20), R: big.NewInt(150)}, {A: big.NewInt(30), R: big.NewInt(120)}}, + }, + } + + assert.Equal(t, expectedHop, hop) + assert.Equal(t, expectedPools, pools) +} diff --git a/pkg/finderengine/finder/mock_test.go b/pkg/finderengine/finder/mock_test.go new file mode 100644 index 0000000..ddba9ce --- /dev/null +++ b/pkg/finderengine/finder/mock_test.go @@ -0,0 +1,142 @@ +package finder_test + +import ( + "errors" + "math/big" + + "github.com/KyberNetwork/kyberswap-dex-lib/pkg/source/pool" +) + +type mockPool struct { + address string + tokenIn string + tokenOut string + bids []Order + asks []Order +} + +type Order struct { + A *big.Int + R *big.Int +} + +func (mp *mockPool) CalcAmountOut(params pool.CalcAmountOutParams) (*pool.CalcAmountOutResult, error) { + if params.TokenAmountIn.Amount == nil { + return nil, errors.New("invalid amount") + } + + remaining := new(big.Int).Set(params.TokenAmountIn.Amount) + amountOut := big.NewInt(0) + + var book []Order + if params.TokenAmountIn.Token == mp.tokenIn { + book = mp.asks + } else { + book = mp.bids + } + + for _, o := range book { + if remaining.Sign() == 0 { + break + } + + if remaining.Cmp(o.A) >= 0 { + cost := new(big.Int).Mul(o.A, o.R) + cost.Div(cost, big.NewInt(100)) + amountOut.Add(amountOut, cost) + remaining.Sub(remaining, o.A) + } else { + cost := new(big.Int).Mul(remaining, o.R) + cost.Div(cost, big.NewInt(100)) + amountOut.Add(amountOut, cost) + remaining.SetInt64(0) + } + } + + return &pool.CalcAmountOutResult{ + TokenAmountOut: &pool.TokenAmount{ + Token: params.TokenOut, + Amount: amountOut, + }, + Fee: &pool.TokenAmount{ + Amount: big.NewInt(0), + }, + }, nil +} + +func (mp *mockPool) UpdateBalance(params pool.UpdateBalanceParams) { + var book *[]Order + switch inToken := params.TokenAmountIn.Token; inToken { + case mp.tokenIn: + book = &mp.asks + case mp.tokenOut: + book = &mp.bids + default: + book = &mp.bids + } + + remaining := new(big.Int).Set(params.TokenAmountIn.Amount) + newBook := make([]Order, 0, len(*book)) + for _, lv := range *book { + if lv.A == nil { + continue + } + switch lv.A.Cmp(remaining) { + case 1: + lv.A = new(big.Int).Sub(lv.A, remaining) + remaining.SetInt64(0) + newBook = append(newBook, lv) + case 0: + remaining.SetInt64(0) + case -1: + remaining.Sub(remaining, lv.A) + } + } + *book = newBook +} + +func (mp *mockPool) CloneState() pool.IPoolSimulator { + newBids := make([]Order, 0, len(mp.bids)) + newAsks := make([]Order, 0, len(mp.asks)) + + for i := range mp.bids { + newBids = append(newBids, Order{ + A: new(big.Int).Set(mp.bids[i].A), + R: new(big.Int).Set(mp.bids[i].R), + }) + } + + for i := range mp.asks { + newAsks = append(newAsks, Order{ + A: new(big.Int).Set(mp.asks[i].A), + R: new(big.Int).Set(mp.asks[i].R), + }) + } + return &mockPool{ + address: mp.address, + tokenIn: mp.tokenIn, + tokenOut: mp.tokenOut, + bids: newBids, + asks: newAsks, + } +} + +func (mp *mockPool) CanSwapFrom(address string) []string { + if address == mp.tokenIn { + return []string{mp.tokenOut} + } + return nil +} +func (mp *mockPool) GetTokens() []string { return []string{mp.tokenIn, mp.tokenOut} } +func (mp *mockPool) GetReserves() []*big.Int { + return nil +} +func (mp *mockPool) GetAddress() string { return mp.address } +func (mp *mockPool) GetExchange() string { + return "" +} +func (mp *mockPool) GetType() string { return "" } +func (mp *mockPool) GetMetaInfo(tokenIn, tokenOut string) interface{} { return nil } +func (mp *mockPool) GetTokenIndex(address string) int { return 0 } +func (mp *mockPool) CalculateLimit() map[string]*big.Int { return nil } +func (mp *mockPool) CanSwapTo(address string) []string { return nil } diff --git a/pkg/finderengine/iface.go b/pkg/finderengine/iface.go new file mode 100644 index 0000000..bb6338d --- /dev/null +++ b/pkg/finderengine/iface.go @@ -0,0 +1,19 @@ +package finderengine + +import "github.com/KyberNetwork/tradinglib/pkg/finderengine/entity" + +type IPathFinderEngine interface { + Find(params entity.FinderParams) (*entity.FinalizedRoute, error) + GetFinder() IFinder + SetFinder(finder IFinder) + GetFinalizer() IFinalizer + SetFinalizer(finalizer IFinalizer) +} + +type IFinder interface { + Find(params entity.FinderParams) (*entity.BestRouteResult, error) +} + +type IFinalizer interface { + Finalize(params entity.FinderParams, bestRoute *entity.BestRouteResult) (*entity.FinalizedRoute, error) +} diff --git a/pkg/finderengine/isolated/pool.go b/pkg/finderengine/isolated/pool.go new file mode 100644 index 0000000..1036d50 --- /dev/null +++ b/pkg/finderengine/isolated/pool.go @@ -0,0 +1,84 @@ +package isolated + +import ( + "math/big" + "sync" + + dexlibPool "github.com/KyberNetwork/kyberswap-dex-lib/pkg/source/pool" +) + +type Pool struct { + mu sync.Mutex + base dexlibPool.IPoolSimulator + local dexlibPool.IPoolSimulator + cloned bool +} + +func NewIsolatedPool(base dexlibPool.IPoolSimulator) *Pool { + return &Pool{ + base: base, + local: base, + cloned: false, + } +} + +func NewIsolatedPools(pools []dexlibPool.IPoolSimulator) []*Pool { + isolatedPools := make([]*Pool, 0, len(pools)) + for i := range pools { + isolatedPools = append(isolatedPools, NewIsolatedPool(pools[i])) + } + + return isolatedPools +} + +func (p *Pool) CalcAmountOut(params dexlibPool.CalcAmountOutParams) (*dexlibPool.CalcAmountOutResult, error) { + return p.local.CalcAmountOut(params) +} + +func (p *Pool) UpdateBalance(params dexlibPool.UpdateBalanceParams) { + p.ensureClone() + p.local.UpdateBalance(params) +} + +func (p *Pool) CloneState() dexlibPool.IPoolSimulator { + src := p.local + return &Pool{base: src, local: src.CloneState(), cloned: true} +} + +func (p *Pool) Reset() { + p.mu.Lock() + p.local = p.base + p.cloned = false + p.mu.Unlock() +} + +func (p *Pool) CanSwapFrom(address string) []string { + return p.local.CanSwapFrom(address) +} +func (p *Pool) GetTokens() []string { return p.local.GetTokens() } +func (p *Pool) GetReserves() []*big.Int { return p.local.GetReserves() } +func (p *Pool) GetAddress() string { return p.local.GetAddress() } +func (p *Pool) GetExchange() string { return p.local.GetExchange() } +func (p *Pool) GetType() string { return p.local.GetType() } +func (p *Pool) GetTokenIndex(address string) int { return p.local.GetTokenIndex(address) } +func (p *Pool) CalculateLimit() map[string]*big.Int { return p.local.CalculateLimit() } +func (p *Pool) CanSwapTo(address string) []string { return p.local.CanSwapTo(address) } +func (p *Pool) GetMetaInfo(tokenIn, tokenOut string) any { + return p.local.GetMetaInfo(tokenIn, tokenOut) +} + +func (p *Pool) ensureClone() { + if p.cloned { + return + } + p.mu.Lock() + if !p.cloned { + p.clone() + } + p.mu.Unlock() +} + +func (p *Pool) clone() { + p.local = p.base.CloneState() + p.cloned = true +} diff --git a/pkg/finderengine/utils/splitamount.go b/pkg/finderengine/utils/splitamount.go new file mode 100644 index 0000000..c04bbcf --- /dev/null +++ b/pkg/finderengine/utils/splitamount.go @@ -0,0 +1,55 @@ +package utils + +import ( + "math" + "math/big" +) + +const float64EqualityThreshold = 1e-9 + +func AlmostEqual(a, b float64) bool { + return math.Abs(a-b) <= float64EqualityThreshold +} + +func SplitAmount(amount *big.Int, splitNums uint64) []*big.Int { + splitNumsBI := new(big.Int).SetUint64(splitNums) + base := new(big.Int).Div(amount, splitNumsBI) + remainder := new(big.Int).Sub(amount, new(big.Int).Mul(splitNumsBI, base)) + + splits := make([]*big.Int, 0, splitNums) + for i := uint64(0); i < splitNums; i++ { + splits = append(splits, new(big.Int).Set(base)) + } + if remainder.Cmp(big.NewInt(0)) != 0 { + splits = append(splits, remainder) + } + + return splits +} + +func SplitAmountThreshold( + amount *big.Int, decimals uint8, splitNums uint64, minThresholdUsd, price float64, +) []*big.Int { + if amount == nil || amount.Sign() <= 0 || splitNums == 0 { + return []*big.Int{new(big.Int).Set(amount)} + } + + if minThresholdUsd <= 0 || price <= 0 { + return SplitAmount(amount, splitNums) + } + + scale := math.Pow10(int(decimals)) + minUnits := int64(math.Ceil((minThresholdUsd / price) * scale)) + if minUnits <= 0 { + return SplitAmount(amount, splitNums) + } + + maxSplits := new(big.Int).Quo(new(big.Int).Set(amount), big.NewInt(minUnits)).Uint64() + if maxSplits == 0 { + return []*big.Int{new(big.Int).Set(amount)} + } + if splitNums > maxSplits { + splitNums = maxSplits + } + return SplitAmount(amount, splitNums) +} diff --git a/pkg/finderengine/utils/utils.go b/pkg/finderengine/utils/utils.go new file mode 100644 index 0000000..fdf3918 --- /dev/null +++ b/pkg/finderengine/utils/utils.go @@ -0,0 +1,21 @@ +package utils + +import ( + "math" + "math/big" +) + +func CalcAmountPrice(amount *big.Int, decimals uint8, price float64) float64 { + amountFloat, _ := amount.Float64() + return amountFloat * price / math.Pow10(int(decimals)) +} + +func CalcAmountFromPrice(amountUSD float64, decimals uint8, price float64) *big.Int { + amountUSDBI := new(big.Float).SetFloat64(amountUSD) + priceUSDBI := new(big.Float).SetFloat64(price) + + amount := amountUSDBI.Mul(amountUSDBI, new(big.Float).SetFloat64(math.Pow10(int(decimals)))) + result, _ := amount.Quo(amount, priceUSDBI).Int(nil) + + return result +}