diff --git a/README.md b/README.md index 4310eca..b80a3b5 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,22 @@ columns = [ "LAST UPDATE", # last update "DETAIL", # error description ] + +[views.fwdinghist] +columns = [ + "ALIAS_IN", # peer alias name of the incoming peer + "ALIAS_OUT", # peer alias name of the outgoing peer + "AMT_IN", # amount of sats received + "AMT_OUT", # amount of sats forwarded + "FEE", # earned fee + "TIMESTAMP_NS",# forwarding event timestamp +# "CHAN_ID_IN", # channel id of the incomming channel +# "CHAN_ID_OUT", # channel id of the outgoing channel +] + +[views.fwdinghist.options] +START_TIME = { start_time = "-6h" } +MAX_NUM_EVENTS = { max_num_events = "333" } ``` ## Routing view diff --git a/config/config.go b/config/config.go index 3753009..f29ae9b 100644 --- a/config/config.go +++ b/config/config.go @@ -40,6 +40,7 @@ type Views struct { Channels *View `toml:"channels"` Transactions *View `toml:"transactions"` Routing *View `toml:"routing"` + FwdingHist *View `toml:"fwdinghist"` } type ColumnOptions map[string]map[string]string diff --git a/config/default.go b/config/default.go index f69c89e..9546139 100644 --- a/config/default.go +++ b/config/default.go @@ -57,6 +57,14 @@ columns = [ # AGE = { color = "color" } +[views.fwdinghist.options] +# The forwarding history options determine how many forwarding events the +# forwarding history tab is displaying. The higher the number of fetched +# forwarding events is the higher the alias lookup time, so only increase these +# values if you can tolerate the longer loading times. +START_TIME = { start_time = "-12h" } +MAX_NUM_EVENTS = { max_num_events = "333" } + [views.transactions] # It is possible to add, remove and order columns of the # table with the array columns. The available values are: diff --git a/go.mod b/go.mod index 7a1715a..0a0b6a6 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/awesome-gocui/gocui v1.1.0 github.com/gofrs/uuid v4.0.0+incompatible github.com/gookit/color v1.5.2 - github.com/lightningnetwork/lnd v0.15.0-beta + github.com/lightningnetwork/lnd v0.15.4-beta github.com/mattn/go-runewidth v0.0.13 github.com/pkg/errors v0.9.1 go.uber.org/zap v1.17.0 diff --git a/go.sum b/go.sum index d69f845..0801107 100644 --- a/go.sum +++ b/go.sum @@ -75,33 +75,39 @@ github.com/btcsuite/btcd v0.22.0-beta.0.20220204213055-eaf0459ff879/go.mod h1:os github.com/btcsuite/btcd v0.22.0-beta.0.20220207191057-4dc4ff7963b4/go.mod h1:7alexyj/lHlOtr2PJK7L/+HDJZpcGDn/pAU98r7DY08= github.com/btcsuite/btcd v0.22.0-beta.0.20220316175102-8d5c75c28923/go.mod h1:taIcYprAW2g6Z9S0gGUxyR+zDwimyDMK5ePOX+iJ2ds= github.com/btcsuite/btcd v0.23.0/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY= -github.com/btcsuite/btcd v0.23.1 h1:IB8cVQcC2X5mHbnfirLG5IZnkWYNTPlLZVrxUYSotbE= github.com/btcsuite/btcd v0.23.1/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY= +github.com/btcsuite/btcd v0.23.3 h1:4KH/JKy9WiCd+iUS9Mu0Zp7Dnj17TGdKrg9xc/FGj24= +github.com/btcsuite/btcd v0.23.3/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY= github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= github.com/btcsuite/btcd/btcec/v2 v2.1.1/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= -github.com/btcsuite/btcd/btcec/v2 v2.2.0 h1:fzn1qaOt32TuLjFlkzYSsBC35Q3KUjT1SwPxiMSCF5k= -github.com/btcsuite/btcd/btcec/v2 v2.2.0/go.mod h1:U7MHm051Al6XmscBQ0BoNydpOTsFAn707034b5nY8zU= +github.com/btcsuite/btcd/btcec/v2 v2.2.1 h1:xP60mv8fvp+0khmrN0zTdPC3cNm24rfeE6lh2R/Yv3E= +github.com/btcsuite/btcd/btcec/v2 v2.2.1/go.mod h1:9/CSmJxmuvqzX9Wh2fXMWToLOHhPd11lSPuIupwTkI8= github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= -github.com/btcsuite/btcd/btcutil v1.1.1 h1:hDcDaXiP0uEzR8Biqo2weECKqEw0uHDZ9ixIWevVQqY= github.com/btcsuite/btcd/btcutil v1.1.1/go.mod h1:nbKlBMNm9FGsdvKvu0essceubPiAcI57pYBNnsLAa34= -github.com/btcsuite/btcd/btcutil/psbt v1.1.4 h1:Edx4AfBn+YPam2KP5AobDitulGp4r1Oibm8oruzkMdI= +github.com/btcsuite/btcd/btcutil v1.1.2 h1:XLMbX8JQEiwMcYft2EGi8zPUkoa0abKIU6/BJSRsjzQ= +github.com/btcsuite/btcd/btcutil v1.1.2/go.mod h1:UR7dsSJzJUfMmFiiLlIrMq1lS9jh9EdCV7FStZSnpi0= github.com/btcsuite/btcd/btcutil/psbt v1.1.4/go.mod h1:9AyU6EQVJ9Iw9zPyNT1lcdHd6cnEZdno5wLu5FY74os= +github.com/btcsuite/btcd/btcutil/psbt v1.1.5 h1:x0ZRrYY8j75ThV6xBz86CkYAG82F5bzay4H5D1c8b/U= +github.com/btcsuite/btcd/btcutil/psbt v1.1.5/go.mod h1:kA6FLH/JfUx++j9pYU0pyu+Z8XGBQuuTmuKYUf6q7/U= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo= github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= -github.com/btcsuite/btcwallet v0.15.1 h1:SKfh/l2Bgz9sJwHZvfiVbZ8Pl3N/8fFcWWXzsAPz9GU= -github.com/btcsuite/btcwallet v0.15.1/go.mod h1:7OFsQ8ypiRwmr67hE0z98uXgJgXGAihE79jCib9x6ag= -github.com/btcsuite/btcwallet/wallet/txauthor v1.2.3 h1:M2yr5UlULvpqtxUqpMxTME/pA92Z9cpqeyvAFk9lAg0= +github.com/btcsuite/btcwallet v0.16.1 h1:nD8qXJeAU8c7a0Jlx5jwI2ufbf/9ouy29XGapRLTmos= +github.com/btcsuite/btcwallet v0.16.1/go.mod h1:NCO8+5rIcbUm5CtVNSQM0xrtK4iYprlyuvpGzhkejaM= github.com/btcsuite/btcwallet/wallet/txauthor v1.2.3/go.mod h1:T2xSiKGpUkSLCh68aF+FMXmKK9mFqNdHl9VaqOr+JjU= +github.com/btcsuite/btcwallet/wallet/txauthor v1.3.2 h1:etuLgGEojecsDOYTII8rYiGHjGyV5xTqsXi+ZQ715UU= +github.com/btcsuite/btcwallet/wallet/txauthor v1.3.2/go.mod h1:Zpk/LOb2sKqwP2lmHjaZT9AdaKsHPSbNLm2Uql5IQ/0= github.com/btcsuite/btcwallet/wallet/txrules v1.2.0 h1:BtEN5Empw62/RVnZ0VcJaVtVlBijnLlJY+dwjAye2Bg= github.com/btcsuite/btcwallet/wallet/txrules v1.2.0/go.mod h1:AtkqiL7ccKWxuLYtZm8Bu8G6q82w4yIZdgq6riy60z0= -github.com/btcsuite/btcwallet/wallet/txsizes v1.1.0 h1:wZnOolEAeNOHzHTnznw/wQv+j35ftCIokNrnOTOU5o8= github.com/btcsuite/btcwallet/wallet/txsizes v1.1.0/go.mod h1:pauEU8UuMFiThe5PB3EO+gO5kx87Me5NvdQDsTuq6cs= +github.com/btcsuite/btcwallet/wallet/txsizes v1.2.2/go.mod h1:q08Rms52VyWyXcp5zDc4tdFRKkFgNsMQrv3/LvE1448= +github.com/btcsuite/btcwallet/wallet/txsizes v1.2.3 h1:PszOub7iXVYbtGybym5TGCp9Dv1h1iX4rIC3HICZGLg= +github.com/btcsuite/btcwallet/wallet/txsizes v1.2.3/go.mod h1:q08Rms52VyWyXcp5zDc4tdFRKkFgNsMQrv3/LvE1448= github.com/btcsuite/btcwallet/walletdb v1.3.5/go.mod h1:oJDxAEUHVtnmIIBaa22wSBPTVcs6hUp5NKWmI8xDwwU= github.com/btcsuite/btcwallet/walletdb v1.3.6-0.20210803004036-eebed51155ec/go.mod h1:oJDxAEUHVtnmIIBaa22wSBPTVcs6hUp5NKWmI8xDwwU= github.com/btcsuite/btcwallet/walletdb v1.4.0 h1:/C5JRF+dTuE2CNMCO/or5N8epsrhmSM4710uBQoYPTQ= @@ -476,8 +482,8 @@ github.com/lightninglabs/neutrino v0.14.2/go.mod h1:OICUeTCn+4Tu27YRJIpWvvqySxx4 github.com/lightninglabs/protobuf-hex-display v1.4.3-hex-display/go.mod h1:2oKOBU042GKFHrdbgGiKax4xVrFiZu51lhacUZQ9MnE= github.com/lightningnetwork/lightning-onion v1.0.2-0.20220211021909-bb84a1ccb0c5 h1:TkKwqFcQTGYoI+VEqyxA8rxpCin8qDaYX0AfVRinT3k= github.com/lightningnetwork/lightning-onion v1.0.2-0.20220211021909-bb84a1ccb0c5/go.mod h1:7dDx73ApjEZA0kcknI799m2O5kkpfg4/gr7N092ojNo= -github.com/lightningnetwork/lnd v0.15.0-beta h1:smzYjJqL4nGuj4qrAWdikrPzPJ8fcPRFHQ86S2tHR1M= -github.com/lightningnetwork/lnd v0.15.0-beta/go.mod h1:Tm7LZrYeR2JQH1gEOKmd0NTCgjJ1Bnujkx4lcz9b5+A= +github.com/lightningnetwork/lnd v0.15.4-beta h1:vO+UZjuA8RqJdDlfwQeS0h2PCocYwwqv5HkX2IXf5/M= +github.com/lightningnetwork/lnd v0.15.4-beta/go.mod h1:6aoOkifcI9tuk8UV5l2rVZSq0681obuP4zvfK+2ZrT0= github.com/lightningnetwork/lnd/cert v1.1.1/go.mod h1:1P46svkkd73oSoeI4zjkVKgZNwGq8bkGuPR8z+5vQUs= github.com/lightningnetwork/lnd/clock v1.0.1/go.mod h1:KnQudQ6w0IAMZi1SgvecLZQZ43ra2vpDNj7H/aasemg= github.com/lightningnetwork/lnd/clock v1.1.0 h1:/yfVAwtPmdx45aQBoXQImeY7sOIEr7IXlImRMBOZ7GQ= @@ -663,7 +669,7 @@ github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4A github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/urfave/cli v1.22.9/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= @@ -772,7 +778,6 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20220426173459-3bcf042a4bf5/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -798,7 +803,6 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/net v0.0.0-20180406214816-61147c48b25b/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1001,7 +1005,6 @@ golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.8 h1:P1HhGGuLW4aAclzjtmJdf0mJOjVUZUzOTqkAkWL+l6w= golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/logging/logging.go b/logging/logging.go index ff2c7cb..00b945b 100644 --- a/logging/logging.go +++ b/logging/logging.go @@ -35,6 +35,10 @@ func Int64(k string, i int64) Field { return zap.Int64(k, i) } +func Uint64(k string, i uint64) Field { + return zap.Uint64(k, i) +} + func Error(v error) Field { return zap.Error(v) } diff --git a/network/backend/backend.go b/network/backend/backend.go index 5e1c830..4848962 100644 --- a/network/backend/backend.go +++ b/network/backend/backend.go @@ -43,4 +43,6 @@ type Backend interface { SubscribeRoutingEvents(context.Context, chan *models.RoutingEvent) error SubscribeGraphEvents(context.Context, chan *models.ChannelEdgeUpdate) error + + GetForwardingHistory(context.Context, string, uint32) ([]*models.ForwardingEvent, error) } diff --git a/network/backend/lnd/lnd.go b/network/backend/lnd/lnd.go index 8ca7442..672d06f 100644 --- a/network/backend/lnd/lnd.go +++ b/network/backend/lnd/lnd.go @@ -4,6 +4,8 @@ import ( "context" "encoding/hex" "fmt" + "regexp" + "strconv" "time" "github.com/lightningnetwork/lnd/lnrpc" @@ -447,6 +449,93 @@ func (l Backend) GetNode(ctx context.Context, pubkey string, includeChannels boo return result, nil } +func (l Backend) GetForwardingHistory(ctx context.Context, startTime string, maxNumEvents uint32) ([]*models.ForwardingEvent, error) { + l.logger.Debug("GetForwardingHistory") + + clt, err := l.Client(ctx) + if err != nil { + return nil, err + } + defer clt.Close() + t, err := parseTime(startTime, time.Now()) + req := &lnrpc.ForwardingHistoryRequest{ + StartTime: t, + NumMaxEvents: maxNumEvents, + } + resp, err := clt.ForwardingHistory(ctx, req) + if err != nil { + return nil, errors.WithStack(err) + } + result := protoToForwardingHistory(resp) + + // Enrich peer alias names. + // This can be removed once the ForwardingHistory + // contains the peer aliases by default. + enrichPeerAliases := func(ctx context.Context, events []*models.ForwardingEvent) error { + + if len(events) == 0 { + return nil + } + + selfInfo, err := clt.GetInfo(ctx, &lnrpc.GetInfoRequest{}) + if err != nil { + return errors.WithStack(err) + } + + getPeerAlias := func(chanId uint64) (string, error) { + chanInfo, err := clt.GetChanInfo(ctx, &lnrpc.ChanInfoRequest{ + ChanId: chanId, + }) + if err != nil { + return "", errors.WithStack(err) + } + pubKey := chanInfo.Node1Pub + if selfInfo.IdentityPubkey == chanInfo.Node1Pub { + pubKey = chanInfo.Node2Pub + } + nodeInfo, err := clt.GetNodeInfo(ctx, &lnrpc.NodeInfoRequest{ + PubKey: pubKey, + }) + if err != nil { + return "", errors.WithStack(err) + } + + return nodeInfo.Node.Alias, nil + } + + cache := make(map[uint64]string) + for i, event := range events { + + if val, ok := cache[event.ChanIdIn]; ok { + events[i].PeerAliasIn = val + } else { + events[i].PeerAliasIn, err = getPeerAlias(event.ChanIdIn) + if err != nil { + cache[event.ChanIdIn] = events[i].PeerAliasIn + } + } + + if val, ok := cache[event.ChanIdOut]; ok { + events[i].PeerAliasOut = val + } else { + events[i].PeerAliasOut, err = getPeerAlias(event.ChanIdOut) + if err != nil { + cache[event.ChanIdOut] = events[i].PeerAliasOut + } + } + } + + return nil + + } + err = enrichPeerAliases(ctx, result) + if err != nil { + return nil, errors.WithStack(err) + } + + return result, nil +} + func (l Backend) CreateInvoice(ctx context.Context, amount int64, desc string) (*models.Invoice, error) { l.logger.Debug("Create invoice...", logging.Int64("amount", amount), @@ -563,3 +652,36 @@ func New(c *config.Network, logger logging.Logger) (*Backend, error) { return backend, nil } + +// reTimeRange matches systemd.time-like short negative timeranges, e.g. "-200s". +var reTimeRange = regexp.MustCompile(`^-\d{1,18}[s|m|h|d|w|M|y]$`) + +// secondsPer allows translating s(seconds), m(minutes), h(ours), d(ays), +// w(eeks), M(onths) and y(ears) into corresponding seconds. +var secondsPer = map[string]int64{ + "s": 1, + "m": 60, + "h": 3600, + "d": 86400, + "w": 604800, + "M": 2630016, // 30.44 days + "y": 31557600, // 365.25 days +} + +// parseTime parses UNIX timestamps or short timeranges inspired by systemd +// (when starting with "-"), e.g. "-1M" for one month (30.44 days) ago. +func parseTime(s string, base time.Time) (uint64, error) { + if reTimeRange.MatchString(s) { + last := len(s) - 1 + + d, err := strconv.ParseInt(s[1:last], 10, 64) + if err != nil { + return uint64(0), err + } + + mul := secondsPer[string(s[last])] + return uint64(base.Unix() - d*mul), nil + } + + return strconv.ParseUint(s, 10, 64) +} diff --git a/network/backend/lnd/proto.go b/network/backend/lnd/proto.go index 228d3e0..cf68cc0 100644 --- a/network/backend/lnd/proto.go +++ b/network/backend/lnd/proto.go @@ -398,3 +398,30 @@ func protoToRoutingEvent(resp *routerrpc.HtlcEvent) *models.RoutingEvent { FailureDetail: detail, } } + +func protoToForwardingHistory(resp *lnrpc.ForwardingHistoryResponse) []*models.ForwardingEvent { + if resp == nil { + return nil + } + + forwardingEvents := make([]*models.ForwardingEvent, len(resp.ForwardingEvents)) + for i := range resp.ForwardingEvents { + forwardingEvents[i] = protoToForwardingEvent(resp.ForwardingEvents[i]) + } + return forwardingEvents +} + +func protoToForwardingEvent(resp *lnrpc.ForwardingEvent) *models.ForwardingEvent { + return &models.ForwardingEvent{ + + ChanIdIn: resp.ChanIdIn, + ChanIdOut: resp.ChanIdOut, + AmtIn: resp.AmtIn, + AmtOut: resp.AmtOut, + Fee: resp.Fee, + FeeMsat: resp.FeeMsat, + AmtInMsat: resp.AmtInMsat, + AmtOutMsat: resp.AmtOutMsat, + EventTime: time.Unix(0, int64(resp.TimestampNs)), + } +} diff --git a/network/backend/mock/mock.go b/network/backend/mock/mock.go index 4fdc076..40b9091 100644 --- a/network/backend/mock/mock.go +++ b/network/backend/mock/mock.go @@ -86,6 +86,10 @@ func (b *Backend) DecodePayReq(ctx context.Context, payreq string) (*models.PayR return &models.PayReq{}, nil } +func (b *Backend) GetForwardingHistory(ctx context.Context, startTime string, maxNumEvents uint32) ([]*models.ForwardingEvent, error) { + return []*models.ForwardingEvent{}, nil +} + func (b *Backend) CreateInvoice(ctx context.Context, amt int64, desc string) (*models.Invoice, error) { b.Lock() defer b.Unlock() diff --git a/network/models/fwdingevent.go b/network/models/fwdingevent.go new file mode 100644 index 0000000..b080272 --- /dev/null +++ b/network/models/fwdingevent.go @@ -0,0 +1,17 @@ +package models + +import "time" + +type ForwardingEvent struct { + PeerAliasIn string + PeerAliasOut string + ChanIdIn uint64 + ChanIdOut uint64 + AmtIn uint64 + AmtOut uint64 + Fee uint64 + FeeMsat uint64 + AmtInMsat uint64 + AmtOutMsat uint64 + EventTime time.Time +} diff --git a/ui/controller.go b/ui/controller.go index 4df3b6d..f3251d3 100644 --- a/ui/controller.go +++ b/ui/controller.go @@ -227,6 +227,8 @@ func (c *controller) Order(order models.Order) func(*gocui.Gui, *gocui.View) err c.views.Channels.Sort("", order) case views.TRANSACTIONS: c.views.Transactions.Sort("", order) + case views.FWDINGHIST: + c.views.FwdingHist.Sort("", order) } return nil } @@ -293,6 +295,19 @@ func (c *controller) OnEnter(g *gocui.Gui, v *gocui.View) error { if err != nil { return err } + case views.FWDINGHIST: + err := c.views.Main.Delete(g) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second*60) + defer cancel() + c.models.RefreshForwardingHistory(ctx) + c.views.Main = c.views.FwdingHist + err = c.views.FwdingHist.Set(g, 11, 6, maxX-1, maxY) + if err != nil { + return err + } } case views.TRANSACTIONS: diff --git a/ui/models/fwdinghist.go b/ui/models/fwdinghist.go new file mode 100644 index 0000000..78b42ee --- /dev/null +++ b/ui/models/fwdinghist.go @@ -0,0 +1,72 @@ +package models + +import ( + "sort" + "sync" + + "github.com/edouardparis/lntop/network/models" +) + +type FwdinghistSort func(*models.ForwardingEvent, *models.ForwardingEvent) bool + +type FwdingHist struct { + StartTime string + MaxNumEvents uint32 + current *models.ForwardingEvent + list []*models.ForwardingEvent + sort FwdinghistSort + mu sync.RWMutex +} + +func (t *FwdingHist) Current() *models.ForwardingEvent { + return t.current +} + +func (t *FwdingHist) SetCurrent(index int) { + t.current = t.Get(index) +} + +func (t *FwdingHist) List() []*models.ForwardingEvent { + return t.list +} + +func (t *FwdingHist) Len() int { + return len(t.list) +} + +func (t *FwdingHist) Clear() { + t.list = []*models.ForwardingEvent{} +} + +func (t *FwdingHist) Swap(i, j int) { + t.list[i], t.list[j] = t.list[j], t.list[i] +} + +func (t *FwdingHist) Less(i, j int) bool { + return t.sort(t.list[i], t.list[j]) +} + +func (t *FwdingHist) Sort(s FwdinghistSort) { + if s == nil { + return + } + t.sort = s + sort.Sort(t) +} + +func (t *FwdingHist) Get(index int) *models.ForwardingEvent { + if index < 0 || index > len(t.list)-1 { + return nil + } + + return t.list[index] +} + +func (t *FwdingHist) Update(events []*models.ForwardingEvent) { + t.mu.Lock() + defer t.mu.Unlock() + t.Clear() + for _, event := range events { + t.list = append(t.list, event) + } +} diff --git a/ui/models/models.go b/ui/models/models.go index e606f73..08341fe 100644 --- a/ui/models/models.go +++ b/ui/models/models.go @@ -2,6 +2,7 @@ package models import ( "context" + "strconv" "github.com/edouardparis/lntop/app" "github.com/edouardparis/lntop/logging" @@ -19,9 +20,27 @@ type Models struct { ChannelsBalance *ChannelsBalance Transactions *Transactions RoutingLog *RoutingLog + FwdingHist *FwdingHist } func New(app *app.App) *Models { + fwdingHist := FwdingHist{} + startTime := app.Config.Views.FwdingHist.Options.GetOption("START_TIME", "start_time") + maxNumEvents := app.Config.Views.FwdingHist.Options.GetOption("MAX_NUM_EVENTS", "max_num_events") + + if startTime != "" { + fwdingHist.StartTime = startTime + } + + if maxNumEvents != "" { + max, err := strconv.ParseUint(maxNumEvents, 10, 32) + if err != nil { + app.Logger.Info("Couldn't parse the maximum number of forwarding events.") + } else { + fwdingHist.MaxNumEvents = uint32(max) + } + } + return &Models{ logger: app.Logger.With(logging.String("logger", "models")), network: app.Network, @@ -31,6 +50,7 @@ func New(app *app.App) *Models { ChannelsBalance: &ChannelsBalance{}, Transactions: &Transactions{}, RoutingLog: &RoutingLog{}, + FwdingHist: &fwdingHist, } } @@ -47,6 +67,17 @@ func (m *Models) RefreshInfo(ctx context.Context) error { return nil } +func (m *Models) RefreshForwardingHistory(ctx context.Context) error { + forwardingEvents, err := m.network.GetForwardingHistory(ctx, m.FwdingHist.StartTime, m.FwdingHist.MaxNumEvents) + if err != nil { + return err + } + + m.FwdingHist.Update(forwardingEvents) + + return nil +} + func (m *Models) RefreshChannels(ctx context.Context) error { channels, err := m.network.ListChannels(ctx, options.WithChannelPending) if err != nil { diff --git a/ui/views/fwdinghist.go b/ui/views/fwdinghist.go new file mode 100644 index 0000000..65fc933 --- /dev/null +++ b/ui/views/fwdinghist.go @@ -0,0 +1,418 @@ +package views + +import ( + "bytes" + "fmt" + + "github.com/awesome-gocui/gocui" + "golang.org/x/text/language" + "golang.org/x/text/message" + + "github.com/edouardparis/lntop/config" + netmodels "github.com/edouardparis/lntop/network/models" + "github.com/edouardparis/lntop/ui/color" + "github.com/edouardparis/lntop/ui/models" +) + +const ( + FWDINGHIST = "fwdinghist" + FWDINGHIST_COLUMNS = "fwdinghist_columns" + FWDINGHIST_FOOTER = "fwdinghist_footer" +) + +var DefaultFwdinghistColumns = []string{ + "ALIAS_IN", + "ALIAS_OUT", + "AMT_IN", + "AMT_OUT", + "FEE", + "TIMESTAMP_NS", + "CHAN_ID_IN", + "CHAN_ID_OUT", +} + +type FwdingHist struct { + cfg *config.View + + columns []fwdinghistColumn + columnHeadersView *gocui.View + view *gocui.View + fwdinghist *models.FwdingHist + + ox, oy int + cx, cy int +} + +type fwdinghistColumn struct { + name string + width int + sorted bool + sort func(models.Order) models.FwdinghistSort + display func(*netmodels.ForwardingEvent, ...color.Option) string +} + +func (c FwdingHist) Index() int { + _, oy := c.view.Origin() + _, cy := c.view.Cursor() + return cy + oy +} + +func (c FwdingHist) Name() string { + return FWDINGHIST +} + +func (c *FwdingHist) Wrap(v *gocui.View) View { + c.view = v + return c +} + +func (c FwdingHist) currentColumnIndex() int { + x := c.ox + c.cx + index := 0 + sum := 0 + for i := range c.columns { + sum += c.columns[i].width + 1 + if x < sum { + return index + } + index++ + } + return index +} + +func (c FwdingHist) Origin() (int, int) { + return c.ox, c.oy +} + +func (c FwdingHist) Cursor() (int, int) { + return c.cx, c.cy +} + +func (c *FwdingHist) SetCursor(cx, cy int) error { + if err := cursorCompat(c.columnHeadersView, cx, 0); err != nil { + return err + } + err := c.columnHeadersView.SetCursor(cx, 0) + if err != nil { + return err + } + + if err := cursorCompat(c.view, cx, cy); err != nil { + return err + } + err = c.view.SetCursor(cx, cy) + if err != nil { + return err + } + + c.cx, c.cy = cx, cy + return nil +} + +func (c *FwdingHist) SetOrigin(ox, oy int) error { + err := c.columnHeadersView.SetOrigin(ox, 0) + if err != nil { + return err + } + err = c.view.SetOrigin(ox, oy) + if err != nil { + return err + } + + c.ox, c.oy = ox, oy + return nil +} + +func (c *FwdingHist) Speed() (int, int, int, int) { + current := c.currentColumnIndex() + up := 0 + down := 0 + if c.Index() > 0 { + up = 1 + } + if c.Index() < c.fwdinghist.Len()-1 { + down = 1 + } + if current > len(c.columns)-1 { + return 0, c.columns[current-1].width + 1, down, up + } + if current == 0 { + return c.columns[0].width + 1, 0, down, up + } + return c.columns[current].width + 1, + c.columns[current-1].width + 1, + down, up +} + +func (c *FwdingHist) Limits() (pageSize int, fullSize int) { + _, pageSize = c.view.Size() + fullSize = c.fwdinghist.Len() + return +} + +func (c *FwdingHist) Sort(column string, order models.Order) { + if column == "" { + index := c.currentColumnIndex() + if index >= len(c.columns) { + return + } + col := c.columns[index] + if col.sort == nil { + return + } + + c.fwdinghist.Sort(col.sort(order)) + for i := range c.columns { + c.columns[i].sorted = (i == index) + } + } +} + +func (c FwdingHist) Delete(g *gocui.Gui) error { + err := g.DeleteView(FWDINGHIST_COLUMNS) + if err != nil { + return err + } + + err = g.DeleteView(FWDINGHIST) + if err != nil { + return err + } + + return g.DeleteView(FWDINGHIST_FOOTER) +} + +func (c *FwdingHist) Set(g *gocui.Gui, x0, y0, x1, y1 int) error { + var err error + setCursor := false + c.columnHeadersView, err = g.SetView(FWDINGHIST_COLUMNS, x0-1, y0, x1+2, y0+2, 0) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + setCursor = true + } + c.columnHeadersView.Frame = false + c.columnHeadersView.BgColor = gocui.ColorGreen + c.columnHeadersView.FgColor = gocui.ColorBlack + + c.view, err = g.SetView(FWDINGHIST, x0-1, y0+1, x1+2, y1-1, 0) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + setCursor = true + } + c.view.Frame = false + c.view.Autoscroll = false + c.view.SelBgColor = gocui.ColorCyan + c.view.SelFgColor = gocui.ColorBlack | gocui.AttrDim + c.view.Highlight = true + c.display() + + if setCursor { + ox, oy := c.Origin() + err := c.SetOrigin(ox, oy) + if err != nil { + return err + } + + cx, cy := c.Cursor() + err = c.SetCursor(cx, cy) + if err != nil { + return err + } + } + + footer, err := g.SetView(FWDINGHIST_FOOTER, x0-1, y1-2, x1+2, y1, 0) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + } + footer.Frame = false + footer.BgColor = gocui.ColorCyan + footer.FgColor = gocui.ColorBlack + footer.Rewind() + blackBg := color.Black(color.Background) + fmt.Fprintln(footer, fmt.Sprintf("%s%s %s%s %s%s", + blackBg("F2"), "Menu", + blackBg("Enter"), "FwdingHist", + blackBg("F10"), "Quit", + )) + return nil +} + +func (c *FwdingHist) display() { + c.columnHeadersView.Rewind() + var buffer bytes.Buffer + current := c.currentColumnIndex() + for i := range c.columns { + if current == i { + buffer.WriteString(color.Cyan(color.Background)(c.columns[i].name)) + buffer.WriteString(" ") + continue + } else if c.columns[i].sorted { + buffer.WriteString(color.Magenta(color.Background)(c.columns[i].name)) + buffer.WriteString(" ") + continue + } + buffer.WriteString(c.columns[i].name) + buffer.WriteString(" ") + } + fmt.Fprintln(c.columnHeadersView, buffer.String()) + + c.view.Rewind() + for _, item := range c.fwdinghist.List() { + var buffer bytes.Buffer + for i := range c.columns { + var opt color.Option + if current == i { + opt = color.Bold + } + buffer.WriteString(c.columns[i].display(item, opt)) + buffer.WriteString(" ") + } + fmt.Fprintln(c.view, buffer.String()) + } +} + +func NewFwdingHist(cfg *config.View, hist *models.FwdingHist) *FwdingHist { + fwdinghist := &FwdingHist{ + cfg: cfg, + fwdinghist: hist, + } + + printer := message.NewPrinter(language.English) + + columns := DefaultFwdinghistColumns + if cfg != nil && len(cfg.Columns) != 0 { + columns = cfg.Columns + } + + fwdinghist.columns = make([]fwdinghistColumn, len(columns)) + + for i := range columns { + switch columns[i] { + case "ALIAS_IN": + fwdinghist.columns[i] = fwdinghistColumn{ + width: 30, + name: fmt.Sprintf("%30s", columns[i]), + sort: func(order models.Order) models.FwdinghistSort { + return func(e1, e2 *netmodels.ForwardingEvent) bool { + return models.StringSort(e1.PeerAliasIn, e2.PeerAliasOut, order) + } + }, + display: func(e *netmodels.ForwardingEvent, opts ...color.Option) string { + return color.White(opts...)(fmt.Sprintf("%30s", e.PeerAliasIn)) + }, + } + case "ALIAS_OUT": + fwdinghist.columns[i] = fwdinghistColumn{ + width: 30, + name: fmt.Sprintf("%30s", columns[i]), + sort: func(order models.Order) models.FwdinghistSort { + return func(e1, e2 *netmodels.ForwardingEvent) bool { + return models.StringSort(e1.PeerAliasOut, e2.PeerAliasOut, order) + } + }, + display: func(e *netmodels.ForwardingEvent, opts ...color.Option) string { + return color.White(opts...)(fmt.Sprintf("%30s", e.PeerAliasOut)) + }, + } + case "CHAN_ID_IN": + fwdinghist.columns[i] = fwdinghistColumn{ + width: 19, + name: fmt.Sprintf("%19s", columns[i]), + sort: func(order models.Order) models.FwdinghistSort { + return func(e1, e2 *netmodels.ForwardingEvent) bool { + return models.UInt64Sort(e1.ChanIdIn, e2.ChanIdIn, order) + } + }, + display: func(e *netmodels.ForwardingEvent, opts ...color.Option) string { + return color.White(opts...)(fmt.Sprintf("%19d", e.ChanIdIn)) + }, + } + case "CHAN_ID_OUT": + fwdinghist.columns[i] = fwdinghistColumn{ + width: 19, + name: fmt.Sprintf("%19s", columns[i]), + sort: func(order models.Order) models.FwdinghistSort { + return func(e1, e2 *netmodels.ForwardingEvent) bool { + return models.UInt64Sort(e1.ChanIdOut, e2.ChanIdOut, order) + } + }, + display: func(e *netmodels.ForwardingEvent, opts ...color.Option) string { + return color.White(opts...)(fmt.Sprintf("%19d", e.ChanIdOut)) + }, + } + case "AMT_IN": + fwdinghist.columns[i] = fwdinghistColumn{ + width: 12, + name: fmt.Sprintf("%12s", "RECEIVED"), + sort: func(order models.Order) models.FwdinghistSort { + return func(e1, e2 *netmodels.ForwardingEvent) bool { + return models.UInt64Sort(e1.AmtIn, e2.AmtIn, order) + } + }, + display: func(e *netmodels.ForwardingEvent, opts ...color.Option) string { + return color.White(opts...)(printer.Sprintf("%12d", e.AmtIn)) + }, + } + case "AMT_OUT": + fwdinghist.columns[i] = fwdinghistColumn{ + width: 12, + name: fmt.Sprintf("%12s", "SENT"), + sort: func(order models.Order) models.FwdinghistSort { + return func(e1, e2 *netmodels.ForwardingEvent) bool { + return models.UInt64Sort(e1.AmtOut, e2.AmtOut, order) + } + }, + display: func(e *netmodels.ForwardingEvent, opts ...color.Option) string { + return color.White(opts...)(printer.Sprintf("%12d", e.AmtOut)) + }, + } + case "FEE": + fwdinghist.columns[i] = fwdinghistColumn{ + name: fmt.Sprintf("%9s", "EARNED"), + width: 9, + sort: func(order models.Order) models.FwdinghistSort { + return func(e1, e2 *netmodels.ForwardingEvent) bool { + return models.UInt64Sort(e1.Fee, e2.Fee, order) + } + }, + display: func(e *netmodels.ForwardingEvent, opts ...color.Option) string { + return fee(e.Fee) + }, + } + case "TIMESTAMP_NS": + fwdinghist.columns[i] = fwdinghistColumn{ + name: fmt.Sprintf("%15s", "TIME"), + width: 20, + display: func(e *netmodels.ForwardingEvent, opts ...color.Option) string { + return color.White(opts...)(fmt.Sprintf("%20s", e.EventTime.Format("15:04:05 Jan _2"))) + }, + } + default: + fwdinghist.columns[i] = fwdinghistColumn{ + name: fmt.Sprintf("%-21s", columns[i]), + width: 21, + display: func(tx *netmodels.ForwardingEvent, opts ...color.Option) string { + return "column does not exist" + }, + } + } + + } + return fwdinghist +} + +func fee(fee uint64, opts ...color.Option) string { + if fee >= 0 && fee < 100 { + return color.Cyan(opts...)(fmt.Sprintf("%9d", fee)) + } else if fee >= 100 && fee < 999 { + return color.Green(opts...)(fmt.Sprintf("%9d", fee)) + } + + return color.Yellow(opts...)(fmt.Sprintf("%9d", fee)) +} diff --git a/ui/views/menu.go b/ui/views/menu.go index 3e32bbf..98d92e8 100644 --- a/ui/views/menu.go +++ b/ui/views/menu.go @@ -17,6 +17,7 @@ var menu = []string{ "CHANNEL", "TRANSAC", "ROUTING", + "FWDHIST", } type Menu struct { @@ -85,6 +86,8 @@ func (h Menu) Current() string { return TRANSACTIONS case "ROUTING": return ROUTING + case "FWDHIST": + return FWDINGHIST } } return "" diff --git a/ui/views/views.go b/ui/views/views.go index b1f93a2..d18b10c 100644 --- a/ui/views/views.go +++ b/ui/views/views.go @@ -31,6 +31,7 @@ type Views struct { Transactions *Transactions Transaction *Transaction Routing *Routing + FwdingHist *FwdingHist } func (v Views) Get(vi *gocui.View) View { @@ -50,6 +51,8 @@ func (v Views) Get(vi *gocui.View) View { return v.Transaction.Wrap(vi) case ROUTING: return v.Routing.Wrap(vi) + case FWDINGHIST: + return v.FwdingHist.Wrap(vi) default: return nil } @@ -106,6 +109,7 @@ func New(cfg config.Views, m *models.Models) *Views { Transactions: NewTransactions(cfg.Transactions, m.Transactions), Transaction: NewTransaction(m.Transactions), Routing: NewRouting(cfg.Routing, m.RoutingLog, m.Channels), + FwdingHist: NewFwdingHist(cfg.FwdingHist, m.FwdingHist), Main: main, } }