package main import ( "context" "encoding/json" "log" "math/rand" "os" "time" "github.com/jessevdk/go-flags" "github.com/lightninglabs/lndclient" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/routerrpc" ) var mainParams struct { Config string `short:"f" long:"config" description:"config file path"` } var params struct { Config string `short:"f" long:"config" description:"config file path"` Connect string `short:"c" long:"connect" description:"connect to lnd using host:port" json:"connect"` TLSCert string `short:"t" long:"tlscert" description:"path to tls.cert to connect" required:"false" json:"tlscert"` MacaroonDir string `long:"macaroon-dir" description:"path to the macaroon directory" required:"false" json:"macaroon_dir"` MacaroonFilename string `long:"macaroon-filename" description:"macaroon filename" json:"macaroon_filename"` Network string `short:"n" long:"network" description:"bitcoin network to use" json:"network"` FromPerc int64 `long:"pfrom" description:"channels with less than this inbound liquidity percentage will be considered as source channels" json:"pfrom"` ToPerc int64 `long:"pto" description:"channels with less than this outbound liquidity percentage will be considered as target channels" json:"pto"` Perc int64 `short:"p" long:"perc" description:"use this value as both pfrom and pto from above" json:"perc"` Amount int64 `short:"a" long:"amount" description:"amount to rebalance" json:"amount"` EconRatio float64 `long:"econ-ratio" description:"economical ratio for fee limit calculation as a multiple of target channel fee (for example, 0.5 means you want to pay at max half the fee you might earn for routing out of the target channel)" json:"econ_ratio"` ProbeSteps int `short:"b" long:"probe-steps" description:"if the payment fails at the last hop try to probe lower amount using this many steps" json:"probe_steps"` MinAmount int64 `long:"min-amount" description:"if probing is enabled this will be the minimum amount to try" json:"min_amount"` ExcludeChannelsIn []uint64 `short:"i" long:"exclude-channel-in" description:"don't use this channel as incoming (can be specified multiple times)" json:"exclude_channels_in"` ExcludeChannelsOut []uint64 `short:"o" long:"exclude-channel-out" description:"don't use this channel as outgoing (can be specified multiple times)" json:"exclude_channels_out"` ExcludeChannels []uint64 `short:"e" long:"exclude-channel" description:"don't use this channel at all (can be specified multiple times)" json:"exclude_channels"` ExcludeNodes []string `short:"d" long:"exclude-node" description:"don't use this node for routing (can be specified multiple times)" json:"exclude_nodes"` ToChannel uint64 `long:"to" description:"try only this channel as target (should satisfy other constraints too)" json:"to"` FromChannel uint64 `long:"from" description:"try only this channel as source (should satisfy other constraints too)" json:"from"` } type regolancer struct { lnClient lnrpc.LightningClient routerClient routerrpc.RouterClient myPK string channels []*lnrpc.Channel fromChannels []*lnrpc.Channel fromChannelId uint64 toChannels []*lnrpc.Channel toChannelId uint64 nodeCache map[string]*lnrpc.NodeInfo chanCache map[uint64]*lnrpc.ChannelEdge failureCache map[string]*time.Time excludeIn map[uint64]struct{} excludeOut map[uint64]struct{} excludeBoth map[uint64]struct{} excludeNodes [][]byte } func loadConfig() { flags.NewParser(&mainParams, flags.PrintErrors|flags.IgnoreUnknown).Parse() if mainParams.Config == "" { return } f, err := os.Open(mainParams.Config) if err != nil { log.Fatalf("Error opening config file %s: %s", mainParams.Config, err) } else { defer f.Close() err = json.NewDecoder(f).Decode(¶ms) if err != nil { log.Fatalf("Error reading config file %s: %s", mainParams.Config, err) } } } func tryRebalance(ctx context.Context, r *regolancer, invoice **lnrpc.AddInvoiceResponse, attempt *int) (err error, repeat bool) { // purely local code with no RPC requests, should never take long unless all routes already failed pickCtx, pickCtxCancel := context.WithTimeout(ctx, time.Second*5) defer pickCtxCancel() from, to, amt, err := r.pickChannelPair(pickCtx, params.Amount) if err != nil { log.Printf(errColor("Error during picking channel: %s"), err) return err, false } routeCtx, routeCtxCancel := context.WithTimeout(ctx, time.Second*30) defer routeCtxCancel() routes, fee, err := r.getRoutes(routeCtx, from, to, amt*1000, params.EconRatio) if err != nil { if routeCtx.Err() == context.DeadlineExceeded { log.Print(errColor("Timed out looking for a route")) return err, false } r.addFailedRoute(from, to) return err, true } routeCtxCancel() if params.Amount == 0 || *invoice == nil { *invoice, err = r.createInvoice(ctx, from, to, amt) if err != nil { log.Printf("Error creating invoice: %s", err) return err, true } } for _, route := range routes { log.Printf("Attempt %s, amount: %s (max fee: %s)", hiWhiteColorF("#%d", *attempt), hiWhiteColor(amt), hiWhiteColor(fee/1000)) r.printRoute(ctx, route) err = r.pay(ctx, *invoice, amt, params.MinAmount, route, params.ProbeSteps) if err == nil { return nil, false } if retryErr, ok := err.(ErrRetry); ok { amt = retryErr.amount log.Printf("Trying to rebalance again with %s", hiWhiteColor(amt)) probedInvoice, err := r.createInvoice(ctx, from, to, amt) if err != nil { log.Printf("Error creating invoice: %s", err) return err, true } probedRoute, err := r.rebuildRoute(ctx, route, amt) if err != nil { log.Printf("Error rebuilding the route for probed payment: %s", errColor(err)) } else { err = r.pay(ctx, probedInvoice, amt, 0, probedRoute, 0) if err == nil { return nil, false } else { log.Printf("Probed rebalance failed with error: %s", errColor(err)) } } } *attempt++ } return nil, true } func main() { rand.Seed(time.Now().UnixNano()) loadConfig() _, err := flags.NewParser(¶ms, flags.Default|flags.IgnoreUnknown).Parse() if err != nil { os.Exit(1) } if params.Connect == "" { params.Connect = "127.0.0.1:10009" } if params.MacaroonFilename == "" { params.MacaroonFilename = "admin.macaroon" } if params.Network == "" { params.Network = "mainnet" } if params.FromPerc == 0 { params.FromPerc = 50 } if params.ToPerc == 0 { params.ToPerc = 50 } if params.EconRatio == 0 { params.EconRatio = 1 } if params.Perc > 0 { params.FromPerc = params.Perc params.ToPerc = params.Perc } if params.MinAmount > 0 && params.MinAmount > params.Amount { log.Fatal("Minimum amount should be more than amount") } conn, err := lndclient.NewBasicConn(params.Connect, params.TLSCert, params.MacaroonDir, params.Network, lndclient.MacFilename(params.MacaroonFilename)) if err != nil { log.Fatal(err) } r := regolancer{ nodeCache: map[string]*lnrpc.NodeInfo{}, chanCache: map[uint64]*lnrpc.ChannelEdge{}, failureCache: map[string]*time.Time{}, } r.lnClient = lnrpc.NewLightningClient(conn) r.routerClient = routerrpc.NewRouterClient(conn) mainCtx, mainCtxCancel := context.WithTimeout(context.Background(), time.Hour*6) defer mainCtxCancel() infoCtx, infoCtxCancel := context.WithTimeout(mainCtx, time.Second*30) defer infoCtxCancel() info, err := r.lnClient.GetInfo(infoCtx, &lnrpc.GetInfoRequest{}) if err != nil { log.Fatal(err) } r.myPK = info.IdentityPubkey err = r.getChannels(infoCtx) if err != nil { log.Fatal("Error listing own channels: ", err) } if params.FromChannel > 0 { r.fromChannelId = params.FromChannel } if params.ToChannel > 0 { r.toChannelId = params.ToChannel } r.excludeIn = makeChanSet(params.ExcludeChannelsIn) r.excludeOut = makeChanSet(params.ExcludeChannelsOut) r.excludeBoth = makeChanSet(params.ExcludeChannels) err = r.makeNodeList(params.ExcludeNodes) if err != nil { log.Fatal("Error parsing excluded node list: ", err) } err = r.getChannelCandidates(params.FromPerc, params.ToPerc, params.Amount) if err != nil { log.Fatal("Error choosing channels: ", err) } if len(r.fromChannels) == 0 { log.Fatal("No source channels selected") } if len(r.toChannels) == 0 { log.Fatal("No target channels selected") } infoCtxCancel() var invoice *lnrpc.AddInvoiceResponse attempt := 1 for { attemptCtx, attemptCancel := context.WithTimeout(mainCtx, time.Minute*5) _, retry := tryRebalance(attemptCtx, &r, &invoice, &attempt) attemptCancel() if attemptCtx.Err() == context.DeadlineExceeded { log.Print(errColor("Attempt timed out")) invoice = nil // create a new invoice next time } if mainCtx.Err() == context.DeadlineExceeded { log.Println(errColor("Rebalancing timed out")) return } if !retry { return } } }