diff --git a/.vscode/launch.json b/.vscode/launch.json index 825370a..dcea7d8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,7 @@ "type": "go", "request": "launch", "mode": "auto", - "buildFlags": "-tags cln", + "buildFlags": "-tags lnd", "program": "${workspaceFolder}/cmd/psweb/", "showLog": false, "envFile": "${workspaceFolder}/.env", diff --git a/CHANGELOG.md b/CHANGELOG.md index 3de3f9f..87c9fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Versions +## 1.3.4 + +- Display channel flows since their last swaps +- Allow filtering swaps history +- Speed up loading of pages +- Display current fee rates for channels +- Add help tooltips +- CLN: implement incremental forwards history polling +- LND 0.18+: exact fee when sending change-less peg-in tx (there was a bug in LND below 0.18) + ## 1.3.3 - Add channel routing statistics on the peer screen diff --git a/README.md b/README.md index 8fa775f..07857e5 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Once opened the UI, set the Links on the Config page for testnet or mainnet. If To enable downloading of a backup file of the Elements wallet it is necessary to have access to .elements folder where this backup is saved by elementsd. If Elements is run in a Docker container, both the internal folder (usually /home/elements/.elements) and the mapped external folder (for Umbrel it is /home/umbrel/umbrel/app-data/elements/data) must be provided in the Configuration panel. -***Warning*** If you tried a Docker version first and then switched to the one built from source, the configuration files will be incorrect. The easiest way to fix this is to delete ```peerswap.conf``` and ```pswebconfig.json```. +***Warning*** If you tried PS Web's Docker version first and then switched to the one built from source, the configuration files will be incorrect. The easiest way to fix this is to delete ```peerswap.conf``` and ```pswebconfig.json```. ## Update diff --git a/cmd/psweb/bitcoin/bitcoin.go b/cmd/psweb/bitcoin/bitcoin.go index afca106..ec748d2 100644 --- a/cmd/psweb/bitcoin/bitcoin.go +++ b/cmd/psweb/bitcoin/bitcoin.go @@ -2,6 +2,7 @@ package bitcoin import ( "bytes" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -315,7 +316,9 @@ func SendRawTransaction(hexstring string) (string, error) { } // extracts Fee from PSBT -func GetFeeFromPsbt(base64string string) (float64, error) { +func GetFeeFromPsbt(psbtBytes *[]byte) (float64, error) { + base64string := base64.StdEncoding.EncodeToString(*psbtBytes) + client := BitcoinClient() service := &Bitcoin{client} diff --git a/cmd/psweb/config/common.go b/cmd/psweb/config/common.go index a75bc1f..b2aded3 100644 --- a/cmd/psweb/config/common.go +++ b/cmd/psweb/config/common.go @@ -57,7 +57,7 @@ func Load(dataDir string) { // load defaults first Config.AllowSwapRequests = true Config.ColorScheme = "dark" // dark or light - Config.MaxHistory = 10 + Config.MaxHistory = 20 Config.ElementsPass = "" Config.BitcoinSwaps = true Config.LocalMempool = "" @@ -104,7 +104,7 @@ func Load(dataDir string) { if err != nil { log.Println("Error creating config file.", err) } else { - log.Println("Config file created using defaults.") + log.Println("Config file created in", Config.DataDir) } return } diff --git a/cmd/psweb/config/lnd.go b/cmd/psweb/config/lnd.go index dc00ac7..45cb792 100644 --- a/cmd/psweb/config/lnd.go +++ b/cmd/psweb/config/lnd.go @@ -98,7 +98,7 @@ func SavePS() { //key, default, new value, env key t += setPeerswapdVariable("host", "localhost:42069", Config.RpcHost, "") - t += setPeerswapdVariable("rpchost", "localhost:42070", "", "") + t += setPeerswapdVariable("resthost", "localhost:42070", "", "") t += setPeerswapdVariable("lnd.host", "localhost:10009", "", "LND_HOST") t += setPeerswapdVariable("lnd.tlscertpath", filepath.Join(Config.LightningDir, "tls.cert"), "", "") t += setPeerswapdVariable("lnd.macaroonpath", filepath.Join(Config.LightningDir, "data", "chain", "bitcoin", Config.Chain, "admin.macaroon"), "", "LND_MACAROONPATH") diff --git a/cmd/psweb/internet/github.go b/cmd/psweb/internet/github.go index a2bb66b..0cd3318 100644 --- a/cmd/psweb/internet/github.go +++ b/cmd/psweb/internet/github.go @@ -22,20 +22,17 @@ func GetLatestTag() string { client := GetHttpClient(true) resp, err := client.Do(req) if err != nil { - log.Println("Error making request:", err) return "" } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - log.Println("Failed to fetch tags. Status code:", resp.StatusCode) return "" } var tags []map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&tags) if err != nil { - log.Println("Error decoding JSON:", err) return "" } @@ -43,7 +40,6 @@ func GetLatestTag() string { latestTag := tags[0]["name"].(string) return latestTag } else { - log.Println("No tags found in the repository.") return "" } } diff --git a/cmd/psweb/internet/mempool.go b/cmd/psweb/internet/mempool.go index 043515e..c7128ab 100644 --- a/cmd/psweb/internet/mempool.go +++ b/cmd/psweb/internet/mempool.go @@ -60,7 +60,7 @@ func GetNodeAlias(id string) string { } // fetch high priority fee rate from mempool.space -func GetFee() uint32 { +func GetFeeRate() uint32 { if config.Config.BitcoinApi != "" { api := config.Config.BitcoinApi + "/api/v1/fees/recommended" req, err := http.NewRequest("GET", api, nil) diff --git a/cmd/psweb/ln/cln.go b/cmd/psweb/ln/cln.go index 9b63d50..f563b0c 100644 --- a/cmd/psweb/ln/cln.go +++ b/cmd/psweb/ln/cln.go @@ -343,7 +343,60 @@ func CanRBF() bool { return true } -// fetch routing statistics for a channel from a given timestamp +type ListForwardsRequest struct { + Status string `json:"status"` + Index string `json:"index"` + Start uint64 `json:"start"` +} + +func (r *ListForwardsRequest) Name() string { + return "listforwards" +} + +type Forwarding struct { + CreatedIndex uint64 `json:"created_index"` + InChannel string `json:"in_channel"` + OutChannel string `json:"out_channel"` + OutMsat uint64 `json:"out_msat"` + FeeMsat uint64 `json:"fee_msat"` + ResolvedTime float64 `json:"resolved_time"` +} + +var forwards struct { + Forwards []Forwarding `json:"forwards"` +} + +// fetch routing statistics from cln +func FetchForwardingStats() { + // refresh history + client, clean, err := GetClient() + if err != nil { + return + } + defer clean() + + var newForwards struct { + Forwards []Forwarding `json:"forwards"` + } + + start := uint64(0) + if len(forwards.Forwards) > 0 { + // continue from the last index + 1 + start = forwards.Forwards[len(forwards.Forwards)-1].CreatedIndex + 1 + } + + // get incremental history + client.Request(&ListForwardsRequest{ + Status: "settled", + Index: "created", + Start: start, + }, &newForwards) + + // append to all history + forwards.Forwards = append(forwards.Forwards, newForwards.Forwards...) +} + +// get routing statistics for a channel func GetForwardingStats(lndChannelId uint64) *ForwardingStats { var ( result ForwardingStats @@ -361,24 +414,6 @@ func GetForwardingStats(lndChannelId uint64) *ForwardingStats { assistedMsat6m uint64 ) - // refresh history - client, clean, err := GetClient() - if err != nil { - log.Println("GetClient:", err) - return &result - } - defer clean() - - var forwards struct { - Forwards []glightning.Forwarding `json:"forwards"` - } - - err = client.Request(&glightning.ListForwardsRequest{}, &forwards) - if err != nil { - log.Println(err) - return &result - } - // historic timestamps in sec now := time.Now() timestamp7d := float64(now.AddDate(0, 0, -7).Unix()) @@ -388,33 +423,31 @@ func GetForwardingStats(lndChannelId uint64) *ForwardingStats { channelId := ConvertLndToClnChannelId(lndChannelId) for _, e := range forwards.Forwards { - if e.Status == "settled" { - if e.OutChannel == channelId { - if e.ReceivedTime > timestamp6m { - amountOut6m += e.MilliSatoshiOut - feeMsat6m += e.Fee - if e.ReceivedTime > timestamp30d { - amountOut30d += e.MilliSatoshiOut - feeMsat30d += e.Fee - if e.ReceivedTime > timestamp7d { - amountOut7d += e.MilliSatoshiOut - feeMsat7d += e.Fee - log.Println(e) - } + if e.OutChannel == channelId { + if e.ResolvedTime > timestamp6m { + amountOut6m += e.OutMsat + feeMsat6m += e.FeeMsat + if e.ResolvedTime > timestamp30d { + amountOut30d += e.OutMsat + feeMsat30d += e.FeeMsat + if e.ResolvedTime > timestamp7d { + amountOut7d += e.OutMsat + feeMsat7d += e.FeeMsat + log.Println(e) } } } - if e.InChannel == channelId { - if e.ReceivedTime > timestamp6m { - amountIn6m += e.MilliSatoshiOut - assistedMsat6m += e.Fee - if e.ReceivedTime > timestamp30d { - amountIn30d += e.MilliSatoshiOut - assistedMsat30d += e.Fee - if e.ReceivedTime > timestamp7d { - amountIn7d += e.MilliSatoshiOut - assistedMsat7d += e.Fee - } + } + if e.InChannel == channelId { + if e.ResolvedTime > timestamp6m { + amountIn6m += e.OutMsat + assistedMsat6m += e.FeeMsat + if e.ResolvedTime > timestamp30d { + amountIn30d += e.OutMsat + assistedMsat30d += e.FeeMsat + if e.ResolvedTime > timestamp7d { + amountIn7d += e.OutMsat + assistedMsat7d += e.FeeMsat } } } @@ -428,12 +461,71 @@ func GetForwardingStats(lndChannelId uint64) *ForwardingStats { result.AmountIn30d = amountIn30d / 1000 result.AmountIn6m = amountIn6m / 1000 - result.FeeSat7d = feeMsat7d - result.AssistedFeeSat7d = assistedMsat7d - result.FeeSat30d = feeMsat30d - result.AssistedFeeSat30d = assistedMsat30d - result.FeeSat6m = feeMsat6m - result.AssistedFeeSat6m = assistedMsat6m + result.FeeSat7d = feeMsat7d / 1000 + result.AssistedFeeSat7d = assistedMsat7d / 1000 + result.FeeSat30d = feeMsat30d / 1000 + result.AssistedFeeSat30d = assistedMsat30d / 1000 + result.FeeSat6m = feeMsat6m / 1000 + result.AssistedFeeSat6m = assistedMsat6m / 1000 return &result } + +// net balance change for a channel +func GetNetFlow(lndChannelId uint64, timeStamp uint64) int64 { + + netFlow := int64(0) + channelId := ConvertLndToClnChannelId(lndChannelId) + timeStampF := float64(timeStamp) + + for _, e := range forwards.Forwards { + if e.InChannel == channelId { + if e.ResolvedTime > timeStampF { + netFlow -= int64(e.OutMsat) + } + } + if e.OutChannel == channelId { + if e.ResolvedTime > timeStampF { + netFlow += int64(e.OutMsat) + } + } + } + return netFlow / 1000 +} + +type ListPeerChannelsRequest struct { + PeerId string `json:"id,omitempty"` +} + +func (r ListPeerChannelsRequest) Name() string { + return "listpeerchannels" +} + +// get fees on the channel +func GetChannelInfo(client *glightning.Lightning, lndChannelId uint64, nodeId string) *ChanneInfo { + info := new(ChanneInfo) + channelId := ConvertLndToClnChannelId(lndChannelId) + + var response map[string]interface{} + + err := client.Request(&ListPeerChannelsRequest{ + PeerId: nodeId, + }, &response) + if err != nil { + log.Println(err) + return info + } + + // Iterate over channels to find ours + channels := response["channels"].([]interface{}) + for _, channel := range channels { + channelMap := channel.(map[string]interface{}) + if channelMap["short_channel_id"].(string) == channelId { + info.FeeBase = uint64(channelMap["fee_base_msat"].(float64)) + info.FeeRate = uint64(channelMap["fee_proportional_millionths"].(float64)) + break + } + } + + return info +} diff --git a/cmd/psweb/ln/common.go b/cmd/psweb/ln/common.go index ed96cd2..7203a5d 100644 --- a/cmd/psweb/ln/common.go +++ b/cmd/psweb/ln/common.go @@ -36,6 +36,14 @@ type ForwardingStats struct { AssistedFeeSat6m uint64 } +type ChanneInfo struct { + LocalBalance uint64 + RemoteBalance uint64 + FeeRate uint64 + FeeBase uint64 + Active bool +} + func toSats(amount float64) int64 { return int64(float64(100000000) * amount) } diff --git a/cmd/psweb/ln/lnd.go b/cmd/psweb/ln/lnd.go index 34d3757..ea392e0 100644 --- a/cmd/psweb/ln/lnd.go +++ b/cmd/psweb/ln/lnd.go @@ -5,7 +5,6 @@ package ln import ( "bytes" "context" - "encoding/base64" "encoding/hex" "errors" "fmt" @@ -18,7 +17,13 @@ import ( "peerswap-web/cmd/psweb/bitcoin" "peerswap-web/cmd/psweb/config" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/macaroons" @@ -32,12 +37,20 @@ const Implementation = "LND" var ( LndVerson = float64(0) // must be 0.18+ for RBF ability forwardingEvents []*lnrpc.ForwardingEvent - internalLockId = []byte{ + // default lock id used by LND + internalLockId = []byte{ 0xed, 0xe1, 0x9a, 0x92, 0xed, 0x32, 0x1a, 0x47, 0x05, 0xf8, 0xa1, 0xcc, 0xcc, 0x1d, 0x4f, 0x61, 0x82, 0x54, 0x5d, 0x4b, 0xb4, 0xfa, 0xe0, 0x8b, 0xd5, 0x93, 0x78, 0x31, 0xb7, 0xe3, 0x8f, 0x98, } + // custom lock id needed when funding manually constructed PSBT + myLockId = []byte{ + 0x00, 0xe1, 0x9a, 0x92, 0xed, 0x32, 0x1a, 0x47, + 0x05, 0xf8, 0xa1, 0xcc, 0xcc, 0x1d, 0x4f, 0x61, + 0x82, 0x54, 0x5d, 0x4b, 0xb4, 0xfa, 0xe0, 0x8b, + 0xd5, 0x93, 0x78, 0x31, 0xb7, 0xe3, 0x8f, 0x98, + } ) func lndConnection() (*grpc.ClientConn, error) { @@ -47,25 +60,25 @@ func lndConnection() (*grpc.ClientConn, error) { tlsCreds, err := credentials.NewClientTLSFromFile(tlsCertPath, "") if err != nil { - log.Println("lndConnection", err) + log.Println("Error reading tlsCert:", err) return nil, err } macaroonBytes, err := os.ReadFile(macaroonPath) if err != nil { - log.Println("lndConnection", err) + log.Println("Error reading macaroon:", err) return nil, err } mac := &macaroon.Macaroon{} if err = mac.UnmarshalBinary(macaroonBytes); err != nil { - log.Println("lndConnection", err) + log.Println("lndConnection UnmarshalBinary:", err) return nil, err } macCred, err := macaroons.NewMacaroonCredential(mac) if err != nil { - log.Println("lndConnection", err) + log.Println("lndConnection NewMacaroonCredential:", err) return nil, err } @@ -77,7 +90,7 @@ func lndConnection() (*grpc.ClientConn, error) { conn, err := grpc.Dial(host, opts...) if err != nil { - fmt.Println("lndConnection", err) + fmt.Println("lndConnection dial:", err) return nil, err } @@ -213,6 +226,7 @@ func SendCoinsWithUtxos(utxos *[]string, addr string, amount int64, feeRate uint outputs[addr] = uint64(amount) } + lockId := internalLockId psbtBytes, err := fundPsbt(cl, utxos, outputs, feeRate) if err != nil { log.Println("FundPsbt:", err) @@ -221,14 +235,13 @@ func SendCoinsWithUtxos(utxos *[]string, addr string, amount int64, feeRate uint if subtractFeeFromAmount { // replace output with correct address and amount - psbtString := base64.StdEncoding.EncodeToString(psbtBytes) - fee, err := bitcoin.GetFeeFromPsbt(psbtString) + fee, err := bitcoin.GetFeeFromPsbt(&psbtBytes) if err != nil { return nil, err } // reduce output amount by fee and small haircut to go around the LND bug - for haircut := int64(0); haircut <= 100; haircut += 5 { - err = releaseOutputs(cl, utxos) + for haircut := int64(0); haircut <= 2000; haircut += 5 { + err = releaseOutputs(cl, utxos, &lockId) if err != nil { return nil, err } @@ -237,15 +250,23 @@ func SendCoinsWithUtxos(utxos *[]string, addr string, amount int64, feeRate uint if finalAmount < 0 { finalAmount = 0 } - outputs[addr] = uint64(finalAmount) - psbtBytes, err = fundPsbt(cl, utxos, outputs, feeRate) + + if CanRBF() { + // for LND 0.18+, change lockID and construct manual psbt + lockId = myLockId + psbtBytes, err = fundPsbt018(cl, utxos, addr, finalAmount, feeRate) + } else { + outputs[addr] = uint64(finalAmount) + psbtBytes, err = fundPsbt(cl, utxos, outputs, feeRate) + } if err == nil { + // PFBT was funded successfully break } } if err != nil { log.Println("FundPsbt:", err) - releaseOutputs(cl, utxos) + releaseOutputs(cl, utxos, &lockId) return nil, err } } @@ -257,7 +278,7 @@ func SendCoinsWithUtxos(utxos *[]string, addr string, amount int64, feeRate uint if err != nil { log.Println("FinalizePsbt:", err) - releaseOutputs(cl, utxos) + releaseOutputs(cl, utxos, &lockId) return nil, err } @@ -279,10 +300,15 @@ func SendCoinsWithUtxos(utxos *[]string, addr string, amount int64, feeRate uint _, err = cl.PublishTransaction(ctx, req) if err != nil { log.Println("PublishTransaction:", err) - releaseOutputs(cl, utxos) + releaseOutputs(cl, utxos, &lockId) return nil, err } + // confirm the final amount sent + if subtractFeeFromAmount { + finalAmount = msgTx.TxOut[0].Value + } + result := SentResult{ RawHex: hex.EncodeToString(rawTx), TxId: msgTx.TxHash().String(), @@ -292,7 +318,7 @@ func SendCoinsWithUtxos(utxos *[]string, addr string, amount int64, feeRate uint return &result, nil } -func releaseOutputs(cl walletrpc.WalletKitClient, utxos *[]string) error { +func releaseOutputs(cl walletrpc.WalletKitClient, utxos *[]string, lockId *[]byte) error { ctx := context.Background() for _, i := range *utxos { parts := strings.Split(i, ":") @@ -304,7 +330,7 @@ func releaseOutputs(cl walletrpc.WalletKitClient, utxos *[]string) error { } _, err = cl.ReleaseOutput(ctx, &walletrpc.ReleaseOutputRequest{ - Id: internalLockId, + Id: *lockId, Outpoint: &lnrpc.OutPoint{ TxidStr: parts[0], OutputIndex: uint32(index), @@ -359,6 +385,109 @@ func fundPsbt(cl walletrpc.WalletKitClient, utxos *[]string, outputs map[string] return res.GetFundedPsbt(), nil } +// manual construction of PSBT in LND 0.18+ to spend exact UTXOs with no change +func fundPsbt018(cl walletrpc.WalletKitClient, utxoStrings *[]string, address string, sendAmount int64, feeRate uint64) ([]byte, error) { + ctx := context.Background() + + unspent, err := cl.ListUnspent(ctx, &walletrpc.ListUnspentRequest{ + MinConfs: 1, + }) + if err != nil { + log.Println("ListUnspent:", err) + return nil, err + } + + tx := wire.NewMsgTx(2) + + for _, utxo := range unspent.Utxos { + for _, u := range *utxoStrings { + parts := strings.Split(u, ":") + index, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, err + } + + if utxo.Outpoint.TxidStr == parts[0] && utxo.Outpoint.OutputIndex == uint32(index) { + hash, err := chainhash.NewHash(utxo.Outpoint.TxidBytes) + if err != nil { + log.Println("NewHash:", err) + return nil, err + } + _, err = cl.LeaseOutput(ctx, &walletrpc.LeaseOutputRequest{ + Id: myLockId, + Outpoint: utxo.Outpoint, + ExpirationSeconds: uint64(10), + }) + if err != nil { + log.Println("LeaseOutput:", err) + return nil, err + } + + tx.TxIn = append(tx.TxIn, &wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: *hash, + Index: utxo.Outpoint.OutputIndex, + }, + }) + } + } + } + + var harnessNetParams = &chaincfg.TestNet3Params + if config.Config.Chain == "mainnet" { + harnessNetParams = &chaincfg.MainNetParams + } + + parsed, err := btcutil.DecodeAddress(address, harnessNetParams) + if err != nil { + log.Println("DecodeAddress:", err) + return nil, err + } + + pkScript, err := txscript.PayToAddrScript(parsed) + if err != nil { + log.Println("PayToAddrScript:", err) + return nil, err + } + + tx.TxOut = append(tx.TxOut, &wire.TxOut{ + PkScript: pkScript, + Value: sendAmount, + }) + + packet, err := psbt.NewFromUnsignedTx(tx) + if err != nil { + log.Println("NewFromUnsignedTx:", err) + return nil, err + } + + var buf bytes.Buffer + _ = packet.Serialize(&buf) + + cs := &walletrpc.PsbtCoinSelect{ + Psbt: buf.Bytes(), + ChangeOutput: &walletrpc.PsbtCoinSelect_ExistingOutputIndex{ + ExistingOutputIndex: 0, + }, + } + + fundResp, err := cl.FundPsbt(ctx, &walletrpc.FundPsbtRequest{ + Template: &walletrpc.FundPsbtRequest_CoinSelect{ + CoinSelect: cs, + }, + Fees: &walletrpc.FundPsbtRequest_SatPerVbyte{ + SatPerVbyte: feeRate, + }, + }) + if err != nil { + log.Println("FundPsbt:", err) + releaseOutputs(cl, utxoStrings, &myLockId) + return nil, err + } + + return fundResp.FundedPsbt, nil +} + func BumpPeginFee(feeRate uint64) (*SentResult, error) { client, cleanup, err := GetClient() @@ -411,10 +540,9 @@ func BumpPeginFee(feeRate uint64) (*SentResult, error) { utxos = append(utxos, input.Outpoint) } - err = releaseOutputs(cl, &utxos) - if err != nil { - return nil, err - } + // sometimes remove transaction is not enough + releaseOutputs(cl, &utxos, &internalLockId) + releaseOutputs(cl, &utxos, &myLockId) return SendCoinsWithUtxos( &utxos, @@ -491,22 +619,12 @@ func CanRBF() bool { return LndVerson >= 0.18 } -// fetch routing statistics for a channel from a given timestamp -func GetForwardingStats(channelId uint64) *ForwardingStats { - var ( - result ForwardingStats - feeMsat7d uint64 - assistedMsat7d uint64 - feeMsat30d uint64 - assistedMsat30d uint64 - feeMsat6m uint64 - assistedMsat6m uint64 - ) - +// fetch all routing statistics from lnd +func FetchForwardingStats() { // refresh history client, cleanup, err := GetClient() if err != nil { - return &result + return } defer cleanup() @@ -527,7 +645,7 @@ func GetForwardingStats(channelId uint64) *ForwardingStats { NumMaxEvents: 50000, }) if err != nil { - return &result + return } forwardingEvents = append(forwardingEvents, res.ForwardingEvents...) @@ -539,6 +657,19 @@ func GetForwardingStats(channelId uint64) *ForwardingStats { // next pull start from the next index offset = res.LastOffsetIndex + 1 } +} + +// get routing statistics for a channel +func GetForwardingStats(channelId uint64) *ForwardingStats { + var ( + result ForwardingStats + feeMsat7d uint64 + assistedMsat7d uint64 + feeMsat30d uint64 + assistedMsat30d uint64 + feeMsat6m uint64 + assistedMsat6m uint64 + ) // historic timestamps in ns now := time.Now() @@ -586,3 +717,49 @@ func GetForwardingStats(channelId uint64) *ForwardingStats { return &result } + +// get fees on the channel +func GetChannelInfo(client lnrpc.LightningClient, channelId uint64, nodeId string) *ChanneInfo { + info := new(ChanneInfo) + + res2, err := client.GetChanInfo(context.Background(), &lnrpc.ChanInfoRequest{ + ChanId: channelId, + }) + if err != nil { + log.Println("GetChanInfo:", err) + return info + } + + policy := res2.Node1Policy + if res2.Node1Pub == nodeId { + // the first policy is not ours, use the second + policy = res2.Node2Policy + } + + info.FeeRate = uint64(policy.GetFeeRateMilliMsat()) + info.FeeBase = uint64(policy.GetFeeBaseMsat()) + + return info +} + +// net balance change for a channel +func GetNetFlow(channelId uint64, timeStamp uint64) int64 { + + netFlow := int64(0) + timestampNs := timeStamp * 1_000_000_000 + + for _, e := range forwardingEvents { + if e.ChanIdOut == channelId { + if e.TimestampNs > timestampNs { + netFlow -= int64(e.AmtOut) + } + } + if e.ChanIdIn == channelId { + if e.TimestampNs > timestampNs { + netFlow += int64(e.AmtIn) + } + } + } + + return netFlow +} diff --git a/cmd/psweb/main.go b/cmd/psweb/main.go index 424ec24..17668c4 100644 --- a/cmd/psweb/main.go +++ b/cmd/psweb/main.go @@ -31,6 +31,8 @@ import ( "github.com/gorilla/mux" ) +const version = "v1.3.4" + type AliasCache struct { PublicKey string Alias string @@ -42,12 +44,12 @@ var ( //go:embed static staticFiles embed.FS //go:embed templates/*.gohtml - tplFolder embed.FS - logFile *os.File + tplFolder embed.FS + logFile *os.File + latestVersion = version + chainFeeRate = uint32(0) ) -const version = "v1.3.3" - func main() { var ( @@ -139,12 +141,12 @@ func main() { log.Println("Listening on http://localhost:" + config.Config.ListenPort) - // Start Telegram bot - go telegramStart() - // Start timer to run every minute go startTimer() + // to speed up first load of home page + go cacheAliases() + // Handle termination signals signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) @@ -219,6 +221,27 @@ func indexHandler(w http.ResponseWriter, r *http.Request) { message = keys[0] } + //check for node Id to filter swaps + nodeId := "" + keys, ok = r.URL.Query()["id"] + if ok && len(keys[0]) > 0 { + nodeId = keys[0] + } + + //check for swaps state to filter + state := "" + keys, ok = r.URL.Query()["state"] + if ok && len(keys[0]) > 0 { + state = keys[0] + } + + //check for swaps role to filter + role := "" + keys, ok = r.URL.Query()["role"] + if ok && len(keys[0]) > 0 { + role = keys[0] + } + type Page struct { AllowSwapRequests bool BitcoinSwaps bool @@ -228,6 +251,7 @@ func indexHandler(w http.ResponseWriter, r *http.Request) { ListPeers string ListSwaps string BitcoinBalance uint64 + Filter bool } data := Page{ @@ -236,9 +260,10 @@ func indexHandler(w http.ResponseWriter, r *http.Request) { Message: message, ColorScheme: config.Config.ColorScheme, LiquidBalance: satAmount, - ListPeers: convertPeersToHTMLTable(peers, allowlistedPeers, suspiciousPeers), - ListSwaps: convertSwapsToHTMLTable(swaps), + ListPeers: convertPeersToHTMLTable(peers, allowlistedPeers, suspiciousPeers, swaps), + ListSwaps: convertSwapsToHTMLTable(swaps, nodeId, state, role), BitcoinBalance: uint64(btcBalance), + Filter: nodeId != "" || state != "" || role != "", } // executing template named "homepage" @@ -281,17 +306,6 @@ func peerHandler(w http.ResponseWriter, r *http.Request) { return } - var sumLocal uint64 - var sumRemote uint64 - var stats []*ln.ForwardingStats - - for _, ch := range peer.Channels { - sumLocal += ch.LocalBalance - sumRemote += ch.RemoteBalance - stat := ln.GetForwardingStats(ch.ChannelId) - stats = append(stats, stat) - } - res2, err := ps.ReloadPolicyFile(client) if err != nil { log.Printf("unable to connect to RPC server: %v", err) @@ -327,6 +341,25 @@ func peerHandler(w http.ResponseWriter, r *http.Request) { btcBalance := ln.ConfirmedWalletBalance(cl) + var sumLocal uint64 + var sumRemote uint64 + var stats []*ln.ForwardingStats + var channelInfo []*ln.ChanneInfo + + for _, ch := range peer.Channels { + stat := ln.GetForwardingStats(ch.ChannelId) + stats = append(stats, stat) + + info := ln.GetChannelInfo(cl, ch.ChannelId, peer.NodeId) + info.LocalBalance = ch.GetLocalBalance() + info.RemoteBalance = ch.GetRemoteBalance() + info.Active = ch.GetActive() + channelInfo = append(channelInfo, info) + + sumLocal += ch.GetLocalBalance() + sumRemote += ch.GetRemoteBalance() + } + //check for error message to display message := "" keys, ok = r.URL.Query()["err"] @@ -351,6 +384,7 @@ func peerHandler(w http.ResponseWriter, r *http.Request) { ActiveSwaps string DirectionIn bool Stats []*ln.ForwardingStats + ChannelInfo []*ln.ChanneInfo } data := Page{ @@ -365,9 +399,10 @@ func peerHandler(w http.ResponseWriter, r *http.Request) { LBTC: stringIsInSlice("lbtc", peer.SupportedAssets), LiquidBalance: satAmount, BitcoinBalance: uint64(btcBalance), - ActiveSwaps: convertSwapsToHTMLTable(activeSwaps), + ActiveSwaps: convertSwapsToHTMLTable(activeSwaps, "", "", ""), DirectionIn: sumLocal < sumRemote, Stats: stats, + ChannelInfo: channelInfo, } // executing template named "peer" @@ -405,15 +440,11 @@ func swapHandler(w http.ResponseWriter, r *http.Request) { isPending := true switch swap.State { - case "State_ClaimedCoop": - isPending = false - case "State_ClaimedCsv": - isPending = false - case "State_SwapCanceled": - isPending = false - case "State_SendCancel": - isPending = false - case "State_ClaimedPreimage": + case "State_ClaimedCoop", + "State_ClaimedCsv", + "State_SwapCanceled", + "State_SendCancel", + "State_ClaimedPreimage": isPending = false } @@ -480,9 +511,9 @@ func updateHandler(w http.ResponseWriter, r *http.Request) {

Swap Details

-

` - swapData += visualiseSwapStatus(swap.State, true) - swapData += `

+

` + swapData += visualiseSwapState(swap.State, true) + swapData += `

@@ -577,7 +608,7 @@ func configHandler(w http.ResponseWriter, r *http.Request) { ColorScheme: config.Config.ColorScheme, Config: config.Config, Version: version, - Latest: internet.GetLatestTag(), + Latest: latestVersion, Implementation: ln.Implementation, } @@ -1118,15 +1149,40 @@ func showVersionInfo() { } func startTimer() { + // first run immediately + onTimer() + + // then every minute for range time.Tick(60 * time.Second) { - // Start Telegram bot if not already running - go telegramStart() + onTimer() + } + +} + +// tasks that run every minute +func onTimer() { + // Start Telegram bot if not already running + go telegramStart() - // Back up to Telegram if Liquid balance changed - liquidBackup(false) + // Back up to Telegram if Liquid balance changed + go liquidBackup(false) - // Check if pegin can be claimed - checkPegin() + // Check if pegin can be claimed + go checkPegin() + + // pre-cache routing statistics + go ln.FetchForwardingStats() + + // check for updates + t := internet.GetLatestTag() + if t != "" { + latestVersion = t + } + + // refresh fee rate + r := internet.GetFeeRate() + if r > 0 { + chainFeeRate = r } } @@ -1222,7 +1278,7 @@ func bitcoinHandler(w http.ResponseWriter, r *http.Request) { } btcBalance := ln.ConfirmedWalletBalance(cl) - fee := internet.GetFee() + fee := chainFeeRate confs := int32(0) minConfs := int32(1) canBump := false @@ -1508,18 +1564,28 @@ func findPeerById(peers []*peerswaprpc.PeerSwapPeer, targetId string) *peerswapr } // converts a list of peers into an HTML table to display -func convertPeersToHTMLTable(peers []*peerswaprpc.PeerSwapPeer, allowlistedPeers []string, suspiciousPeers []string) string { +func convertPeersToHTMLTable(peers []*peerswaprpc.PeerSwapPeer, allowlistedPeers []string, suspiciousPeers []string, swaps []*peerswaprpc.PrettyPrintSwap) string { type Table struct { - AvgLocal uint64 + AvgLocal int HtmlBlob string } + // find last swap timestamps per channel + swapTimestamps := make(map[uint64]int64) + + for _, swap := range swaps { + if swapTimestamps[swap.LndChanId] < swap.CreatedAt { + swapTimestamps[swap.LndChanId] = swap.CreatedAt + } + } + var unsortedTable []Table for _, peer := range peers { - var totalLocal uint64 - var totalCapacity uint64 + + var totalLocal float64 + var totalCapacity float64 table := "
" table += "" @@ -1529,39 +1595,36 @@ func convertPeersToHTMLTable(peers []*peerswaprpc.PeerSwapPeer, allowlistedPeers table += "" if stringIsInSlice(peer.NodeId, allowlistedPeers) { - table += "✅ " + table += "✅ " } else { - table += "⛔ " + table += "⛔ " } if stringIsInSlice(peer.NodeId, suspiciousPeers) { - table += "🔍 " + table += "🕵 " } - table += getNodeAlias(peer.NodeId) - table += "" + table += "" + getNodeAlias(peer.NodeId) + table += "" table += "
" - table += "" if stringIsInSlice("lbtc", peer.SupportedAssets) { - table += "🌊 " + table += " 🌊 " } if stringIsInSlice("btc", peer.SupportedAssets) { - table += "₿ " + table += " " } if peer.SwapsAllowed { - table += "✅" + table += "" } else { - table += "⛔" + table += "" } - table += "" table += "
" - table += "" + table += "
" // Construct channels data for _, channel := range peer.Channels { - // red background for inactive channels bc := "#590202" if config.Config.ColorScheme == "light" { @@ -1577,25 +1640,64 @@ func convertPeersToHTMLTable(peers []*peerswaprpc.PeerSwapPeer, allowlistedPeers } table += "" - table += "" - table += "" } table += "
" - table += formatWithThousandSeparators(channel.LocalBalance) - table += "" - local := channel.LocalBalance - capacity := channel.LocalBalance + channel.RemoteBalance + table += "" + table += toMil(channel.LocalBalance) + table += "" + table += "" + + local := float64(channel.LocalBalance) + capacity := float64(channel.LocalBalance + channel.RemoteBalance) totalLocal += local totalCapacity += capacity - table += "" - table += "1" + tooltip := " in the last 6 months" + + // timestamp frow the last swap or 6m horizon + lastSwapTimestamp := time.Now().AddDate(0, 0, -30).Unix() + if swapTimestamps[channel.ChannelId] > lastSwapTimestamp { + lastSwapTimestamp = swapTimestamps[channel.ChannelId] + tooltip = " since the last swap " + timePassedAgo(time.Unix(lastSwapTimestamp, 0).UTC()) + } + + netFlow := float64(ln.GetNetFlow(channel.ChannelId, uint64(lastSwapTimestamp))) + + bluePct := int(local * 100 / capacity) + greenPct := int(0) + redPct := int(0) + previousBlue := bluePct + previousRed := redPct + + if netFlow == 0 { + tooltip = "No flow" + tooltip + } else { + if netFlow > 0 { + greenPct = int(local * 100 / capacity) + bluePct = int((local - netFlow) * 100 / capacity) + previousBlue = greenPct + tooltip = "Net inflow " + toMil(uint64(netFlow)) + tooltip + } + + if netFlow < 0 { + bluePct = int(local * 100 / capacity) + redPct = int((local - netFlow) * 100 / capacity) + previousRed = bluePct + tooltip = "Net outflow " + toMil(uint64(-netFlow)) + tooltip + } + } + + currentProgress := fmt.Sprintf("%d%% 100%%, %d%% 100%%, %d%% 100%%, 100%% 100%%", bluePct, redPct, greenPct) + previousProgress := fmt.Sprintf("%d%% 100%%, %d%% 100%%, %d%% 100%%, 100%% 100%%", previousBlue, previousRed, greenPct) + + table += "
" table += "
" - table += formatWithThousandSeparators(channel.RemoteBalance) + table += "" + table += toMil(channel.RemoteBalance) table += "
" table += "

" // count total outbound to sort peers later - pct := uint64(1000000 * float64(totalLocal) / float64(totalCapacity)) + pct := int(1000000 * totalLocal / totalCapacity) unsortedTable = append(unsortedTable, Table{ AvgLocal: pct, @@ -1617,7 +1719,8 @@ func convertPeersToHTMLTable(peers []*peerswaprpc.PeerSwapPeer, allowlistedPeers } // converts a list of swaps into an HTML table -func convertSwapsToHTMLTable(swaps []*peerswaprpc.PrettyPrintSwap) string { +// if nodeId != "" then only show swaps for that node Id +func convertSwapsToHTMLTable(swaps []*peerswaprpc.PrettyPrintSwap, nodeId string, swapState string, swapRole string) string { if len(swaps) == 0 { return "" @@ -1630,15 +1733,33 @@ func convertSwapsToHTMLTable(swaps []*peerswaprpc.PrettyPrintSwap) string { var unsortedTable []Table for _, swap := range swaps { + // filter by node Id + if nodeId != "" && nodeId != swap.PeerNodeId { + continue + } + + // filter by simple swap state + if swapState != "" && swapState != simplifySwapState(swap.State) { + continue + } + + // filter by simple swap state + if swapRole != "" && swapRole != swap.Role { + continue + } + table := "" table += "" tm := timePassedAgo(time.Unix(swap.CreatedAt, 0).UTC()) // clicking on timestamp will open swap details page - table += "" + tm + " " + table += "" + tm + " " table += "" - table += visualiseSwapStatus(swap.State, false) + " " + + // clicking on swap status will filter swaps with equal status + table += "" + table += visualiseSwapState(swap.State, false) + " " table += formatWithThousandSeparators(swap.Amount) asset := "🌊" @@ -1659,16 +1780,22 @@ func convertSwapsToHTMLTable(swaps []*peerswaprpc.PrettyPrintSwap) string { table += "" + role := "" switch swap.Role { case "receiver": - table += " ⇦ " + role = "⇦" case "sender": - table += " ⇨ " - default: - table += " ? " + role = "⇨" } + // clicking on role will filter this direction only + table += "" + table += " " + role + " " + + // clicking on node alias will filter its swaps only + table += "" table += getNodeAlias(swap.PeerNodeId) + table += "" table += "" unsortedTable = append(unsortedTable, Table{ @@ -1786,3 +1913,22 @@ func getNodeAlias(key string) string { return alias } + +// preemptively load Aliases in cache +func cacheAliases() { + client, cleanup, err := ps.GetClient(config.Config.RpcHost) + if err != nil { + return + } + defer cleanup() + + res, err := ps.ListPeers(client) + if err != nil { + return + } + + peers := res.GetPeers() + for _, peer := range peers { + getNodeAlias(peer.NodeId) + } +} diff --git a/cmd/psweb/static/styles.css b/cmd/psweb/static/styles.css index 3fc216e..4f042bc 100644 --- a/cmd/psweb/static/styles.css +++ b/cmd/psweb/static/styles.css @@ -22,10 +22,18 @@ header { .progress { position: center; - margin-top: 20px; - height: 20px; + height: 0.6em; width: 100%; - background-color: #001072; + text-align:center; + color:white; + font-size:1em; + background-image: + linear-gradient(blue,darkblue), + linear-gradient(red,darkred), + linear-gradient(green,darkgreen), + linear-gradient(grey,grey); + background-repeat:no-repeat; + transition:1s; } .current-progress { diff --git a/cmd/psweb/telegram.go b/cmd/psweb/telegram.go index 311e05b..f8de39f 100644 --- a/cmd/psweb/telegram.go +++ b/cmd/psweb/telegram.go @@ -10,7 +10,6 @@ import ( "time" "peerswap-web/cmd/psweb/config" - "peerswap-web/cmd/psweb/internet" "peerswap-web/cmd/psweb/ln" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" @@ -104,7 +103,7 @@ func telegramStart() { telegramSendMessage(t) case "/version": t := "Current version: " + version + "\n" - t += "Latest version: " + internet.GetLatestTag() + t += "Latest version: " + latestVersion telegramSendMessage(t) default: telegramSendMessage("I don't understand that command") diff --git a/cmd/psweb/templates/bitcoin.gohtml b/cmd/psweb/templates/bitcoin.gohtml index 7228d32..7bcffaf 100644 --- a/cmd/psweb/templates/bitcoin.gohtml +++ b/cmd/psweb/templates/bitcoin.gohtml @@ -18,13 +18,13 @@
{{if .CanRBF}}
-

+

RBF

{{else}}
-

+

CPFP

diff --git a/cmd/psweb/templates/config.gohtml b/cmd/psweb/templates/config.gohtml index 158a11f..a32f61d 100644 --- a/cmd/psweb/templates/config.gohtml +++ b/cmd/psweb/templates/config.gohtml @@ -269,7 +269,7 @@ Version: {{.Version}}, latest: {{if ne .Version .Latest}} - {{.Latest}} + {{.Latest}} {{else}} {{.Latest}} {{end}} diff --git a/cmd/psweb/templates/homepage.gohtml b/cmd/psweb/templates/homepage.gohtml index 5040efd..2ded7b1 100644 --- a/cmd/psweb/templates/homepage.gohtml +++ b/cmd/psweb/templates/homepage.gohtml @@ -8,13 +8,13 @@

 Bitcoin: {{fmt .BitcoinBalance}} - {{if and .AllowSwapRequests .BitcoinSwaps}}✔️{{else}}❌{{end}} + ✔️{{else}}disabled">❌{{end}}

🌊 Liquid: {{fmt .LiquidBalance}} - {{if .AllowSwapRequests}}✔️{{else}}❌{{end}} + ✔️{{else}}disabled">❌{{end}}

@@ -31,9 +31,18 @@ {{.ListPeers}} -
+
-

Swaps History

+
+
+

Swaps History

+
+
+ {{if .Filter}} +

🔍

+ {{end}} +
+
{{.ListSwaps}}
@@ -66,7 +75,7 @@ function scrambleText(text) { // Scramble starting from the second symbol with Greek alphabet - var scrambledText = text[0]; // Keep the first symbol as is + var scrambledText = ""; for (var i = 0; i < text.length; i++) { // Replace with random Greek alphabet characters diff --git a/cmd/psweb/templates/peer.gohtml b/cmd/psweb/templates/peer.gohtml index 06e3275..18bcde3 100644 --- a/cmd/psweb/templates/peer.gohtml +++ b/cmd/psweb/templates/peer.gohtml @@ -23,6 +23,12 @@ {{if ne .ActiveSwaps ""}}

Active Swaps

{{.ActiveSwaps}} + {{else }}

New Swap

@@ -53,7 +59,7 @@
@@ -118,21 +124,28 @@
-

{{.PeerAlias}}

+
+ +
+

🔍

+
+
- - - - + + + + - + +
SwapsOut# OutIn# In⇨ ⚡#⚡ ⇨#
Sent - {{m .Peer.AsSender.SatsOut}}{{fmt .Peer.AsSender.SwapsOut}} {{m .Peer.AsSender.SatsIn}} {{fmt .Peer.AsSender.SwapsIn}}{{m .Peer.AsSender.SatsOut}}{{fmt .Peer.AsSender.SwapsOut}}
Rcvd @@ -178,15 +191,22 @@

- + + - {{range .Peer.Channels}} + {{range .ChannelInfo}} - + {{if .Active}} + + + {{else}} + + + {{end}} {{end}}
Channel Id Local RemoteFeeRateFeeBase
{{.ChannelId}} {{m .LocalBalance}} {{m .RemoteBalance}}{{fmt .FeeRate}}{{fmt .FeeBase}}CHANNELINACTIVE
@@ -195,7 +215,7 @@
{{if .Suspicious}} -

🔍 This peer is suspicious

+

🕵 This peer is suspicious

diff --git a/cmd/psweb/utils.go b/cmd/psweb/utils.go index 5c7df8c..ce92ea9 100644 --- a/cmd/psweb/utils.go +++ b/cmd/psweb/utils.go @@ -6,7 +6,7 @@ import ( "time" ) -// returns time passed as a srting +// returns time passed as a string func timePassedAgo(t time.Time) string { duration := time.Since(t) @@ -17,7 +17,9 @@ func timePassedAgo(t time.Time) string { var result string - if days > 0 { + if days == 1 { + result = fmt.Sprintf("%d day ago", days) + } else if days > 1 { result = fmt.Sprintf("%d days ago", days) } else if hours > 0 { result = fmt.Sprintf("%d hours ago", hours) @@ -79,18 +81,15 @@ func formatWithThousandSeparators(n uint64) string { var hourGlassRotate = 0 -func visualiseSwapStatus(statusText string, rotate bool) string { - switch statusText { - case "State_ClaimedCoop": - return "" - case "State_ClaimedCsv": - return "" - case "State_SwapCanceled": - return "" - case "State_SendCancel": - return "" +func visualiseSwapState(state string, rotate bool) string { + switch state { + case "State_ClaimedCoop", + "State_ClaimedCsv", + "State_SwapCanceled", + "State_SendCancel": + return "❌" case "State_ClaimedPreimage": - return "💰" + return "💰" } if rotate { @@ -113,6 +112,20 @@ func visualiseSwapStatus(statusText string, rotate bool) string { return "⌛" } +func simplifySwapState(state string) string { + switch state { + case "State_ClaimedCoop", + "State_ClaimedCsv", + "State_SwapCanceled", + "State_SendCancel": + return "failed" + case "State_ClaimedPreimage": + return "success" + } + + return "pending" +} + func toSats(amount float64) uint64 { return uint64(float64(100000000) * amount) } diff --git a/go.mod b/go.mod index f7e1a12..6eeeb88 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,9 @@ replace google.golang.org/protobuf => github.com/lightninglabs/protobuf-go-hex-d require ( github.com/alexmullins/zip v0.0.0-20180717182244-4affb64b04d0 github.com/btcsuite/btcd v0.24.2-beta.rc1.0.20240403021926-ae5533602c46 + github.com/btcsuite/btcd/btcutil v1.1.5 + github.com/btcsuite/btcd/btcutil/psbt v1.1.8 + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 github.com/elementsproject/glightning v0.0.0-20240224063423-55240d61b52a github.com/elementsproject/peerswap v0.2.98-0.20240408181051-0f0d34c6c506 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 @@ -31,9 +34,6 @@ require ( github.com/aead/siphash v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.3 // indirect - github.com/btcsuite/btcd/btcutil v1.1.5 // indirect - github.com/btcsuite/btcd/btcutil/psbt v1.1.8 // indirect - github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect github.com/btcsuite/btcwallet v0.16.10-0.20240404104514-b2f31f9045fb // indirect github.com/btcsuite/btcwallet/wallet/txauthor v1.3.4 // indirect