diff --git a/.vscode/launch.json b/.vscode/launch.json index 06ff138..1cda95b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,7 @@ "type": "go", "request": "launch", "mode": "auto", - "buildFlags": "-tags lnd", + "buildFlags": "-tags cln", "program": "${workspaceFolder}/cmd/psweb/", "showLog": false, "envFile": "${workspaceFolder}/.env", diff --git a/CHANGELOG.md b/CHANGELOG.md index f526838..6a972d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Versions +## 1.4.6 + +- Estimate swap fees before submitting +- LND: Fix invoice subscription reconnections +- Auto swap: account for MaxHtlc settings +- Keysend: account for MinHtlc settings +- Fix swap-out fees missing for some swaps + ## 1.4.5 - Add swap costs to home page diff --git a/cmd/psweb/liquid/elements.go b/cmd/psweb/liquid/elements.go index 29da4a6..deb82e0 100644 --- a/cmd/psweb/liquid/elements.go +++ b/cmd/psweb/liquid/elements.go @@ -394,8 +394,8 @@ type MemPoolInfo struct { UnbroadcastCount int `json:"unbroadcastcount"` } -// return min fee in sat/vB -func GetMempoolMinFee() float64 { +// return fee estimate in sat/vB +func EstimateFee() float64 { client := ElementsClient() service := &Elements{client} params := &[]string{} diff --git a/cmd/psweb/ln/cln.go b/cmd/psweb/ln/cln.go index 1198da8..8b7c1b5 100644 --- a/cmd/psweb/ln/cln.go +++ b/cmd/psweb/ln/cln.go @@ -24,8 +24,10 @@ import ( ) const ( - Implementation = "CLN" - fileRPC = "lightning-rpc" + Implementation = "CLN" + fileRPC = "lightning-rpc" + SwapFeeReserveLBTC = uint64(000) + SwapFeeReserveBTC = uint64(2000) ) type Forwarding struct { @@ -371,6 +373,7 @@ type ListForwardsRequest struct { Status string `json:"status"` Index string `json:"index"` Start uint64 `json:"start"` + Limit uint64 `json:"limit"` } func (r *ListForwardsRequest) Name() string { @@ -390,28 +393,34 @@ func CacheForwards() { Forwards []Forwarding `json:"forwards"` } - start := uint64(0) - if forwardsLastIndex > 0 { - // continue from the last index + 1 - start = forwardsLastIndex + 1 + totalForwards := uint64(0) + + for { + // get incremental history + client.Request(&ListForwardsRequest{ + Status: "settled", + Index: "created", + Start: forwardsLastIndex, + Limit: 1000, + }, &newForwards) + + n := len(newForwards.Forwards) + if n > 0 { + forwardsLastIndex = newForwards.Forwards[n-1].CreatedIndex + 1 + for _, f := range newForwards.Forwards { + chIn := ConvertClnToLndChannelId(f.InChannel) + chOut := ConvertClnToLndChannelId(f.OutChannel) + forwardsIn[chIn] = append(forwardsIn[chIn], f) + forwardsOut[chOut] = append(forwardsOut[chOut], f) + } + totalForwards += uint64(n) + } else { + break + } } - // get incremental history - client.Request(&ListForwardsRequest{ - Status: "settled", - Index: "created", - Start: start, - }, &newForwards) - - n := len(newForwards.Forwards) - if n > 0 { - forwardsLastIndex = newForwards.Forwards[n-1].CreatedIndex - for _, f := range newForwards.Forwards { - chIn := ConvertClnToLndChannelId(f.InChannel) - chOut := ConvertClnToLndChannelId(f.OutChannel) - forwardsIn[chIn] = append(forwardsIn[chIn], f) - forwardsOut[chOut] = append(forwardsOut[chOut], f) - } + if totalForwards > 0 { + log.Printf("Cached %d forwards", totalForwards) } } @@ -585,98 +594,12 @@ func GetChannelStats(lndChannelId uint64, timeStamp uint64) *ChannelStats { client, clean, err := GetClient() if err != nil { + log.Println("GetClient:", err) return &result } defer clean() - var harnessNetParams = &chaincfg.TestNet3Params - if config.Config.Chain == "mainnet" { - harnessNetParams = &chaincfg.MainNetParams - } - - var res ListHtlcsResponse - - err = client.Request(&ListHtlcsRequest{ - ChannelId: channelId, - }, &res) - if err != nil { - log.Println("ListHtlcsRequest:", err) - } - - for _, htlc := range res.HTLCs { - switch htlc.State { - case "SENT_REMOVE_ACK_REVOCATION": - // direction in, look for invoices - var inv ListInvoicesResponse - err := client.Request(&ListInvoicesRequest{ - PaymentHash: htlc.PaymentHash, - }, &inv) - if err != nil { - continue - } - if len(inv.Invoices) != 1 { - continue - } - if inv.Invoices[0].Status == "paid" { - continue - } - if inv.Invoices[0].PaidAt > timeStamp { - if len(inv.Invoices[0].Label) > 7 { - if inv.Invoices[0].Label[:8] == "peerswap" { - // find swap id - parts := strings.Split(inv.Invoices[0].Label, " ") - if parts[2] == "fee" && len(parts[4]) > 0 { - // save rebate payment - SwapRebates[parts[4]] = int64(htlc.AmountMsat) / 1000 - } - } else { - invoicedMsat += htlc.AmountMsat - } - } - } - - case "RCVD_REMOVE_ACK_REVOCATION": - // direction out, look for payments - var pmt ListSendPaysResponse - err := client.Request(&ListSendPaysRequest{ - PaymentHash: htlc.PaymentHash, - }, &pmt) - if err != nil { - continue - } - if len(pmt.Payments) != 1 { - continue - } - if pmt.Payments[0].Status != "complete" { - continue - } - if pmt.Payments[0].CompletedAt > timeStamp { - if pmt.Payments[0].Bolt11 != "" { - // Decode the payment request - invoice, err := zpay32.Decode(pmt.Payments[0].Bolt11, harnessNetParams) - if err == nil { - if invoice.Description != nil { - if len(*invoice.Description) > 7 { - if (*invoice.Description)[:8] == "peerswap" { - // find swap id - parts := strings.Split(*invoice.Description, " ") - if parts[2] == "fee" && len(parts[4]) > 0 { - // save rebate payment - SwapRebates[parts[4]] = int64(htlc.AmountMsat) / 1000 - } - // skip peerswap-related payments - continue - } - } - } - } - } - paidOutMsat += htlc.AmountMsat - fee := pmt.Payments[0].AmountSentMsat - pmt.Payments[0].AmountMsat - costMsat += fee - } - } - } + invoicedMsat, paidOutMsat, costMsat = fetchPaymentsStats(client, timeStamp, channelId) timeStampF := float64(timeStamp) @@ -734,6 +657,14 @@ func GetChannelInfo(client *glightning.Lightning, lndChannelId uint64, nodeId st if channelMap["short_channel_id"].(string) == channelId { info.FeeBase = uint64(channelMap["fee_base_msat"].(float64)) info.FeeRate = uint64(channelMap["fee_proportional_millionths"].(float64)) + updates := channelMap["updates"].(map[string]interface{}) + local := updates["local"].(map[string]interface{}) + remote := updates["remote"].(map[string]interface{}) + info.PeerMinHtlcMsat = uint64(remote["htlc_minimum_msat"].(float64)) + info.PeerMaxHtlcMsat = uint64(remote["htlc_maximum_msat"].(float64)) + info.OurMaxHtlcMsat = uint64(local["htlc_maximum_msat"].(float64)) + info.OurMinHtlcMsat = uint64(local["htlc_minimum_msat"].(float64)) + info.Capacity = uint64(channelMap["total_msat"].(float64) / 1000) break } } @@ -803,7 +734,7 @@ func ListPeers(client *glightning.Lightning, peerId string, excludeIds *[]string for _, channel := range clnPeer.Channels { peer.Channels = append(peer.Channels, &peerswaprpc.PeerSwapPeerChannel{ - ChannelId: ConvertClnToLndChannelId(channel.ChannelId), + ChannelId: ConvertClnToLndChannelId(channel.ShortChannelId), LocalBalance: channel.SpendableMilliSatoshi / 1000, RemoteBalance: channel.ReceivableMilliSatoshi / 1000, Active: clnPeer.Connected && channel.State == "CHANNELD_NORMAL", @@ -900,14 +831,141 @@ func GetMyAlias() string { return myNodeAlias } -func CacheHtlcs() { - // not implemented +// scans all channels to get peerswap lightning fees cached +func SubscribeAll() { + client, clean, err := GetClient() + if err != nil { + return + } + defer clean() + + peers, err := ListPeers(client, "", nil) + if err != nil { + return + } + + // last 6 months + timestamp := uint64(time.Now().AddDate(0, -6, 0).Unix()) + + for _, peer := range peers.Peers { + for _, channel := range peer.Channels { + channelId := ConvertLndToClnChannelId(channel.ChannelId) + fetchPaymentsStats(client, timestamp, channelId) + } + } } -func CacheInvoices() { - // not implemented +// get invoicedMsat, paidOutMsat, costMsat +func fetchPaymentsStats(client *glightning.Lightning, timeStamp uint64, channelId string) (uint64, uint64, uint64) { + var harnessNetParams = &chaincfg.TestNet3Params + if config.Config.Chain == "mainnet" { + harnessNetParams = &chaincfg.MainNetParams + } + + var ( + res ListHtlcsResponse + invoicedMsat uint64 + paidOutMsat uint64 + costMsat uint64 + ) + + err := client.Request(&ListHtlcsRequest{ + ChannelId: channelId, + }, &res) + if err != nil { + log.Println("ListHtlcsRequest:", err) + } + + for _, htlc := range res.HTLCs { + switch htlc.State { + case "SENT_REMOVE_ACK_REVOCATION": + // direction in, look for invoices + var inv ListInvoicesResponse + err := client.Request(&ListInvoicesRequest{ + PaymentHash: htlc.PaymentHash, + }, &inv) + if err != nil { + continue + } + if len(inv.Invoices) != 1 { + continue + } + if inv.Invoices[0].Status == "paid" { + continue + } + if inv.Invoices[0].PaidAt > timeStamp { + if len(inv.Invoices[0].Label) > 7 { + if inv.Invoices[0].Label[:8] == "peerswap" { + // find swap id + parts := strings.Split(inv.Invoices[0].Label, " ") + if parts[2] == "fee" && len(parts[4]) > 0 { + // save rebate payment + SwapRebates[parts[4]] = int64(htlc.AmountMsat) / 1000 + } + } else { + invoicedMsat += htlc.AmountMsat + } + } + } + + case "RCVD_REMOVE_ACK_REVOCATION": + // direction out, look for payments + var pmt ListSendPaysResponse + err := client.Request(&ListSendPaysRequest{ + PaymentHash: htlc.PaymentHash, + }, &pmt) + if err != nil { + continue + } + if len(pmt.Payments) != 1 { + continue + } + if pmt.Payments[0].Status != "complete" { + continue + } + if pmt.Payments[0].CompletedAt > timeStamp { + if pmt.Payments[0].Bolt11 != "" { + // Decode the payment request + invoice, err := zpay32.Decode(pmt.Payments[0].Bolt11, harnessNetParams) + if err == nil { + if invoice.Description != nil { + if len(*invoice.Description) > 7 { + if (*invoice.Description)[:8] == "peerswap" { + // find swap id + parts := strings.Split(*invoice.Description, " ") + if parts[2] == "fee" && len(parts[4]) > 0 { + // save rebate payment + SwapRebates[parts[4]] = int64(htlc.AmountMsat) / 1000 + } + // skip peerswap-related payments + continue + } + } + } + } + } + paidOutMsat += htlc.AmountMsat + fee := pmt.Payments[0].AmountSentMsat - pmt.Payments[0].AmountMsat + costMsat += fee + } + } + } + + return invoicedMsat, paidOutMsat, costMsat } -func CachePayments() { - // not implemented +// Estimate sat/vB fee +func EstimateFee() float64 { + client, clean, err := GetClient() + if err != nil { + return 0 + } + defer clean() + + res, err := client.FeeRates(glightning.PerKb) + if err != nil { + return 0 + } + + return float64(res.Details.Urgent) / 1000 } diff --git a/cmd/psweb/ln/common.go b/cmd/psweb/ln/common.go index c3b1240..b14a6e5 100644 --- a/cmd/psweb/ln/common.go +++ b/cmd/psweb/ln/common.go @@ -55,11 +55,16 @@ type ChannelStats struct { } type ChanneInfo struct { - LocalBalance uint64 - RemoteBalance uint64 - FeeRate uint64 - FeeBase uint64 - Active bool + LocalBalance uint64 + RemoteBalance uint64 + FeeRate uint64 + FeeBase uint64 + Active bool + OurMaxHtlcMsat uint64 + OurMinHtlcMsat uint64 + PeerMaxHtlcMsat uint64 + PeerMinHtlcMsat uint64 + Capacity uint64 } // lighting payments from swap out initiator to receiver diff --git a/cmd/psweb/ln/lnd.go b/cmd/psweb/ln/lnd.go index f2637d9..c9354e7 100644 --- a/cmd/psweb/ln/lnd.go +++ b/cmd/psweb/ln/lnd.go @@ -39,7 +39,12 @@ import ( "gopkg.in/macaroon.v2" ) -const Implementation = "LND" +const ( + Implementation = "LND" + // Liquid balance to reserve in auto swap-ins + SwapFeeReserveLBTC = uint64(1000) + SwapFeeReserveBTC = uint64(2000) +) type InflightHTLC struct { OutgoingChannelId uint64 @@ -674,59 +679,6 @@ func GetMyAlias() string { return myNodeAlias } -// cache htlc history per channel, then subscribe to updates -func CacheHtlcs() { - ctx := context.Background() - conn, err := lndConnection() - if err != nil { - return - } - defer conn.Close() - - client := lnrpc.NewLightningClient(conn) - - // initial download forwards - downloadForwards(client) - - // subscribe to htlc events - routerClient := routerrpc.NewRouterClient(conn) - for { - if err := subscribeForwards(ctx, routerClient); err != nil { - log.Println("Forwards subscription error:", err) - time.Sleep(60 * time.Second) - // incremental download from last timestamp + 1 - downloadForwards(client) - } - } -} - -// cache payments history per channel, then subscribe to updates -func CachePayments() { - ctx := context.Background() - conn, err := lndConnection() - if err != nil { - return - } - defer conn.Close() - - client := lnrpc.NewLightningClient(conn) - - // initial download payments - downloadPayments(client) - - // subscribe to htlc events - routerClient := routerrpc.NewRouterClient(conn) - for { - if err := subscribePayments(ctx, routerClient); err != nil { - log.Println("Payments subscription error:", err) - time.Sleep(60 * time.Second) - // incremental download from last timestamp + 1 - downloadPayments(client) - } - } - -} - func downloadForwards(client lnrpc.LightningClient) { // only go back 6 months start := uint64(time.Now().AddDate(0, -6, 0).Unix()) @@ -739,6 +691,7 @@ func downloadForwards(client lnrpc.LightningClient) { // download forwards offset := uint32(0) + totalForwards := uint64(0) for { res, err := client.ForwardingHistory(context.Background(), &lnrpc.ForwardingHistoryRequest{ StartTime: start, @@ -747,6 +700,7 @@ func downloadForwards(client lnrpc.LightningClient) { NumMaxEvents: 50000, }) if err != nil { + log.Println("ForwardingHistory:", err) return } @@ -757,6 +711,8 @@ func downloadForwards(client lnrpc.LightningClient) { } n := len(res.ForwardingEvents) + totalForwards += uint64(n) + if n > 0 { // store the last timestamp lastforwardCreationTs = res.ForwardingEvents[n-1].TimestampNs / 1_000_000_000 @@ -768,6 +724,10 @@ func downloadForwards(client lnrpc.LightningClient) { // next pull start from the next index offset = res.LastOffsetIndex } + + if totalForwards > 0 { + log.Printf("Cached %d forwards", totalForwards) + } } func downloadPayments(client lnrpc.LightningClient) { @@ -781,29 +741,33 @@ func downloadPayments(client lnrpc.LightningClient) { } offset := uint64(0) + totalPayments := uint64(0) for { res, err := client.ListPayments(context.Background(), &lnrpc.ListPaymentsRequest{ CreationDateStart: start, IncludeIncomplete: false, Reversed: false, IndexOffset: offset, - MaxPayments: 50000, + MaxPayments: 100, // labels can be long }) if err != nil { + log.Println("ListPayments:", err) return } - // only append settled ones + // will only append settled ones for _, payment := range res.Payments { appendPayment(payment) } n := len(res.Payments) + totalPayments += uint64(n) + if n > 0 { // store the last timestamp lastPaymentCreationTs = res.Payments[n-1].CreationTimeNs / 1_000_000_000 } - if n < 50000 { + if n < 100 { // all events retrieved break } @@ -811,6 +775,10 @@ func downloadPayments(client lnrpc.LightningClient) { // next pull start from the next index offset = res.LastIndexOffset } + + if totalPayments > 0 { + log.Printf("Cached %d payments", totalPayments) + } } func appendPayment(payment *lnrpc.Payment) { @@ -969,53 +937,93 @@ func removeInflightHTLC(incomingChannelId, incomingHtlcId uint64) { } } -// cache all invoices from lnd -func CacheInvoices() { - // refresh history - client, cleanup, err := GetClient() - if err != nil { - return - } - defer cleanup() - - // only go back 6 months for itinial download - start := uint64(time.Now().AddDate(0, -6, 0).Unix()) - offset := uint64(0) +// cache all and subscribe to lnd +func SubscribeAll() { + // loop forever until connected for { - res, err := client.ListInvoices(context.Background(), &lnrpc.ListInvoiceRequest{ - CreationDateStart: start, - Reversed: false, - IndexOffset: offset, - NumMaxInvoices: 50000, - }) + conn, err := lndConnection() if err != nil { - return + continue } + defer conn.Close() + + client := lnrpc.NewLightningClient(conn) + ctx := context.Background() + + // only go back 6 months for itinial download + start := uint64(time.Now().AddDate(0, -6, 0).Unix()) + offset := uint64(0) + totalInvoices := uint64(0) + + for { + res, err := client.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{ + CreationDateStart: start, + Reversed: false, + IndexOffset: offset, + NumMaxInvoices: 100, // bolt11 fields can be long + }) + if err != nil { + log.Println("ListInvoices:", err) + return + } - for _, invoice := range res.Invoices { - appendInvoice(invoice) - } + for _, invoice := range res.Invoices { + appendInvoice(invoice) + } - n := len(res.Invoices) - if n > 0 { - // global settle index for subscription - lastInvoiceSettleIndex = res.Invoices[n-1].SettleIndex + n := len(res.Invoices) + totalInvoices += uint64(n) + + if n > 0 { + // settle index for subscription + lastInvoiceSettleIndex = res.Invoices[n-1].SettleIndex + } + if n < 100 { + // all invoices retrieved + break + } + + // next pull start from the next index + offset = res.LastIndexOffset } - if n < 50000 { - // all invoices retrieved - break + + if totalInvoices > 0 { + log.Printf("Cached %d invoices", totalInvoices) } - // next pull start from the next index - offset = res.LastIndexOffset - } + routerClient := routerrpc.NewRouterClient(conn) - // subscribe - ctx := context.Background() - for { - if err := subscribeInvoices(ctx, client); err != nil { - log.Println("Invoices subscription error:", err) - time.Sleep(5 * time.Second) + go func() { + // initial or incremental download forwards + downloadForwards(client) + // subscribe to Forwards + for { + if err := subscribeForwards(ctx, routerClient); err != nil { + log.Println("Forwards subscription error:", err) + time.Sleep(60 * time.Second) + } + } + }() + + go func() { + // initial or incremental download payments + downloadPayments(client) + + // subscribe to Payments + for { + if err := subscribePayments(ctx, routerClient); err != nil { + log.Println("Payments subscription error:", err) + time.Sleep(60 * time.Second) + } + } + }() + + // subscribe to Invoices + for { + if err := subscribeInvoices(ctx, client); err != nil { + log.Println("Invoices subscription error:", err) + time.Sleep(60 * time.Second) + } } } } @@ -1027,18 +1035,20 @@ func appendInvoice(invoice *lnrpc.Invoice) { } // only append settled htlcs if invoice.State == lnrpc.Invoice_SETTLED { - if len(invoice.Memo) > 7 && invoice.Memo[:8] == "peerswap" { - // find swap id - parts := strings.Split(invoice.Memo, " ") - if parts[2] == "fee" && len(parts[4]) > 0 { - // save rebate payment - SwapRebates[parts[4]] = int64(invoice.AmtPaidMsat) / 1000 - } - } else { - // skip peerswap-related - for _, htlc := range invoice.Htlcs { - if htlc.State == lnrpc.InvoiceHTLCState_SETTLED { - invoiceHtlcs[htlc.ChanId] = append(invoiceHtlcs[htlc.ChanId], htlc) + if len(invoice.Memo) > 7 { + if invoice.Memo[:8] == "peerswap" { + // find swap id + parts := strings.Split(invoice.Memo, " ") + if parts[2] == "fee" && len(parts[4]) > 0 { + // save rebate payment + SwapRebates[parts[4]] = int64(invoice.AmtPaidMsat) / 1000 + } + } else { + // skip peerswap-related + for _, htlc := range invoice.Htlcs { + if htlc.State == lnrpc.InvoiceHTLCState_SETTLED { + invoiceHtlcs[htlc.ChanId] = append(invoiceHtlcs[htlc.ChanId], htlc) + } } } } @@ -1144,10 +1154,10 @@ func GetForwardingStats(channelId uint64) *ForwardingStats { } // get fees on the channel -func GetChannelInfo(client lnrpc.LightningClient, channelId uint64, nodeId string) *ChanneInfo { +func GetChannelInfo(client lnrpc.LightningClient, channelId uint64, peerNodeId string) *ChanneInfo { info := new(ChanneInfo) - res2, err := client.GetChanInfo(context.Background(), &lnrpc.ChanInfoRequest{ + r, err := client.GetChanInfo(context.Background(), &lnrpc.ChanInfoRequest{ ChanId: channelId, }) if err != nil { @@ -1155,14 +1165,24 @@ func GetChannelInfo(client lnrpc.LightningClient, channelId uint64, nodeId strin return info } - policy := res2.Node1Policy - if res2.Node1Pub == nodeId { + policy := r.Node1Policy + if r.Node1Pub == peerNodeId { // the first policy is not ours, use the second - policy = res2.Node2Policy + policy = r.Node2Policy + info.PeerMaxHtlcMsat = r.GetNode1Policy().GetMaxHtlcMsat() + info.PeerMinHtlcMsat = uint64(r.GetNode1Policy().GetMinHtlc()) + info.OurMaxHtlcMsat = r.GetNode2Policy().GetMaxHtlcMsat() + info.OurMinHtlcMsat = uint64(r.GetNode2Policy().GetMinHtlc()) + } else { + info.PeerMaxHtlcMsat = r.GetNode2Policy().GetMaxHtlcMsat() + info.PeerMinHtlcMsat = uint64(r.GetNode2Policy().GetMinHtlc()) + info.OurMaxHtlcMsat = r.GetNode1Policy().GetMaxHtlcMsat() + info.OurMinHtlcMsat = uint64(r.GetNode1Policy().GetMinHtlc()) } info.FeeRate = uint64(policy.GetFeeRateMilliMsat()) info.FeeBase = uint64(policy.GetFeeBaseMsat()) + info.Capacity = uint64(r.Capacity) return info } @@ -1377,3 +1397,22 @@ func ListPeers(client lnrpc.LightningClient, peerId string, excludeIds *[]string func CacheForwards() { // not implemented } + +// Estimate sat/vB fee +func EstimateFee() float64 { + conn, err := lndConnection() + if err != nil { + return 0 + } + cl := walletrpc.NewWalletKitClient(conn) + + ctx := context.Background() + req := &walletrpc.EstimateFeeRequest{ConfTarget: 2} + res, err := cl.EstimateFee(ctx, req) + + if err != nil { + return 0 + } + + return float64(res.SatPerKw / 250) +} diff --git a/cmd/psweb/main.go b/cmd/psweb/main.go index 8a12325..10ce789 100644 --- a/cmd/psweb/main.go +++ b/cmd/psweb/main.go @@ -33,11 +33,7 @@ import ( const ( // App version tag - version = "v1.4.5" - - // Liquid balance to reserve in auto swaps - // Min is 1000, but the swap will spend it all on fee - swapInFeeReserve = uint64(2000) + version = "v1.4.6" ) type SwapParams struct { @@ -53,9 +49,10 @@ var ( //go:embed static staticFiles embed.FS //go:embed templates/*.gohtml - tplFolder embed.FS - logFile *os.File - latestVersion = version + tplFolder embed.FS + logFile *os.File + latestVersion = version + // Bitcoin sat/vB from mempool.space mempoolFeeRate = float64(0) // onchain realized transaction costs txFee = make(map[string]int64) @@ -161,14 +158,11 @@ func main() { // to speed up first load of home page go cacheAliases() - // download and subscribe to invoices - go ln.CacheInvoices() - - // download and subscribe to forwards - go ln.CacheHtlcs() + // LND: download and subscribe to invoices, forwards and payments + go ln.SubscribeAll() - // download and subscribe to payments - go ln.CachePayments() + // CLN: cache forwarding stats + go ln.CacheForwards() // fetch all chain costs go cacheSwapCosts() @@ -279,6 +273,9 @@ func indexHandler(w http.ResponseWriter, r *http.Request) { } peers = res4.GetPeers() + // refresh forwarding stats + ln.CacheForwards() + peerTable := convertPeersToHTMLTable(peers, allowlistedPeers, suspiciousPeers, swaps) //check whether to display non-PS channels or swaps @@ -325,6 +322,7 @@ func indexHandler(w http.ResponseWriter, r *http.Request) { Filter bool MempoolFeeRate float64 AutoSwapEnabled bool + PeginPending bool } data := Page{ @@ -341,6 +339,7 @@ func indexHandler(w http.ResponseWriter, r *http.Request) { BitcoinBalance: uint64(btcBalance), Filter: nodeId != "" || state != "" || role != "", AutoSwapEnabled: config.Config.AutoSwapEnabled, + PeginPending: config.Config.PeginTxId != "", } // executing template named "homepage" @@ -474,6 +473,13 @@ func peerHandler(w http.ResponseWriter, r *http.Request) { var sumRemote uint64 var stats []*ln.ForwardingStats var channelInfo []*ln.ChanneInfo + var keysendSats = uint64(1) + + var utxosBTC []ln.UTXO + ln.ListUnspent(cl, &utxosBTC, 1) + + var utxosLBTC []liquid.UTXO + liquid.ListUnspent(&utxosLBTC) for _, ch := range peer.Channels { stat := ln.GetForwardingStats(ch.ChannelId) @@ -487,6 +493,10 @@ func peerHandler(w http.ResponseWriter, r *http.Request) { sumLocal += ch.GetLocalBalance() sumRemote += ch.GetRemoteBalance() + + // should not be less than both Min HTLC setting + keysendSats = max(keysendSats, msatToSatUp(info.PeerMinHtlcMsat)) + keysendSats = max(keysendSats, msatToSatUp(info.OurMinHtlcMsat)) } //check for error errorMessage to display @@ -526,17 +536,25 @@ func peerHandler(w http.ResponseWriter, r *http.Request) { SenderInFeePPM int64 ReceiverInFeePPM int64 ReceiverOutFeePPM int64 + KeysendSats uint64 + OutputsBTC *[]ln.UTXO + OutputsLBTC *[]liquid.UTXO + ReserveLBTC uint64 + ReserveBTC uint64 } - feeRate := liquid.GetMempoolMinFee() + feeRate := liquid.EstimateFee() if !psPeer { feeRate = mempoolFeeRate } + // better be conservative + bitcoinFeeRate := max(ln.EstimateFee(), mempoolFeeRate) + data := Page{ ErrorMessage: errorMessage, PopUpMessage: "", - BtcFeeRate: mempoolFeeRate, + BtcFeeRate: bitcoinFeeRate, MempoolFeeRate: feeRate, ColorScheme: config.Config.ColorScheme, Peer: peer, @@ -561,6 +579,11 @@ func peerHandler(w http.ResponseWriter, r *http.Request) { SenderInFeePPM: senderInFeePPM, ReceiverInFeePPM: receiverInFeePPM, ReceiverOutFeePPM: receiverOutFeePPM, + KeysendSats: keysendSats, + OutputsBTC: &utxosBTC, + OutputsLBTC: &utxosLBTC, + ReserveLBTC: ln.SwapFeeReserveLBTC, + ReserveBTC: ln.SwapFeeReserveBTC, } // executing template named "peer" @@ -872,7 +895,7 @@ func liquidHandler(w http.ResponseWriter, r *http.Request) { data := Page{ ErrorMessage: errorMessage, PopUpMessage: popupMessage, - MempoolFeeRate: liquid.GetMempoolMinFee(), + MempoolFeeRate: liquid.EstimateFee(), ColorScheme: config.Config.ColorScheme, LiquidAddress: addr, LiquidBalance: satAmount, @@ -1090,9 +1113,9 @@ func submitHandler(w http.ResponseWriter, r *http.Request) { var id string switch r.FormValue("direction") { case "swapIn": - id, err = ps.SwapIn(client, swapAmount, channelId, r.FormValue("asset"), r.FormValue("force") == "on") + id, err = ps.SwapIn(client, swapAmount, channelId, r.FormValue("asset"), false) case "swapOut": - id, err = ps.SwapOut(client, swapAmount, channelId, r.FormValue("asset"), r.FormValue("force") == "on") + id, err = ps.SwapOut(client, swapAmount, channelId, r.FormValue("asset"), false) } if err != nil { @@ -1474,11 +1497,6 @@ func onTimer() { // Check if pegin can be claimed go checkPegin() - // cache forwards history for CLN - go func() { - ln.CacheForwards() - }() - // check for updates t := internet.GetLatestTag() if t != "" { @@ -1647,7 +1665,7 @@ func bitcoinHandler(w http.ResponseWriter, r *http.Request) { Duration: formattedDuration, FeeRate: config.Config.PeginFeeRate, MempoolFeeRate: mempoolFeeRate, - LiquidFeeRate: liquid.GetMempoolMinFee(), + LiquidFeeRate: liquid.EstimateFee(), SuggestedFeeRate: fee, MinBumpFeeRate: config.Config.PeginFeeRate + 1, CanBump: canBump, @@ -2020,6 +2038,14 @@ func convertPeersToHTMLTable(peers []*peerswaprpc.PeerSwapPeer, allowlistedPeers flowText = "\nNo flows" } + if stats.FeeSat > 0 { + flowText += "\nRevenue: +" + formatWithThousandSeparators(stats.FeeSat) + } + + if stats.PaidCost > 0 { + flowText += "\nCosts: -" + formatWithThousandSeparators(stats.PaidCost) + } + tooltip += flowText currentProgress := fmt.Sprintf("%d%% 100%%, %d%% 100%%, %d%% 100%%, 100%% 100%%", bluePct, redPct, greenPct) @@ -2498,7 +2524,8 @@ func cacheAliases() { // The goal is to spend maximum available liquid // To rebalance a channel with high enough historic fee PPM func findSwapInCandidate(candidate *SwapParams) error { - minAmount := config.Config.AutoSwapThresholdAmount - swapInFeeReserve + // extra 1000 to avoid no-change tx spending all on fees + minAmount := config.Config.AutoSwapThresholdAmount - ln.SwapFeeReserveLBTC - 1000 minPPM := config.Config.AutoSwapThresholdPPM client, cleanup, err := ps.GetClient(config.Config.RpcHost) @@ -2528,20 +2555,36 @@ func findSwapInCandidate(candidate *SwapParams) error { } } + cl, clean, err := ln.GetClient() + if err != nil { + return err + } + defer clean() + for _, peer := range peers { - // ignore peer if Lighting swaps disabled + // ignore peer with Liquid swaps disabled if !peer.SwapsAllowed || !stringIsInSlice("lbtc", peer.SupportedAssets) { continue } for _, channel := range peer.Channels { - // the potential swap amount to bring balance to target - swapAmount := (channel.LocalBalance + channel.RemoteBalance) * config.Config.AutoSwapTargetPct / 100 - if swapAmount > channel.LocalBalance { - swapAmount -= channel.LocalBalance - } else { - swapAmount = 0 + chanInfo := ln.GetChannelInfo(cl, channel.ChannelId, peer.NodeId) + // find the potential swap amount to bring balance to target + targetBalance := chanInfo.Capacity * config.Config.AutoSwapTargetPct / 100 + + // limit target to Remote - reserve + reserve := chanInfo.Capacity / 100 + targetBalance = min(targetBalance, chanInfo.Capacity-reserve) + + if targetBalance < channel.LocalBalance { + continue } + swapAmount := targetBalance - channel.LocalBalance + + // limit to max HTLC setting + swapAmount = min(swapAmount, chanInfo.PeerMaxHtlcMsat/1000) + swapAmount = min(swapAmount, chanInfo.OurMaxHtlcMsat/1000) + // only consider active channels with enough remote balance if channel.Active && swapAmount >= minAmount { // use timestamp of the last swap or 6 months horizon @@ -2558,7 +2601,7 @@ func findSwapInCandidate(candidate *SwapParams) error { } // aim to maximize accumulated PPM - // if ppm ties, choose the candidate with larger RemoteBalance + // if ppm ties, choose the candidate with larger potential swap amount if ppm > minPPM || ppm == minPPM && swapAmount > candidate.Amount { // set the candidate's PPM as the new target to beat minPPM = ppm @@ -2619,10 +2662,8 @@ func executeAutoSwap() { return } - // swap in cannot be larger than this - if amount > satAmount-swapInFeeReserve { - amount = satAmount - swapInFeeReserve - } + // extra 1000 reserve to avoid no-change tx spending all on fees + amount = min(amount, satAmount-ln.SwapFeeReserveLBTC-1000) // execute swap id, err := ps.SwapIn(client, amount, candidate.ChannelId, "lbtc", false) diff --git a/cmd/psweb/templates/bitcoin.gohtml b/cmd/psweb/templates/bitcoin.gohtml index 7d061a8..68c54ad 100644 --- a/cmd/psweb/templates/bitcoin.gohtml +++ b/cmd/psweb/templates/bitcoin.gohtml @@ -58,7 +58,7 @@
- +
@@ -159,7 +159,7 @@
- +
diff --git a/cmd/psweb/templates/homepage.gohtml b/cmd/psweb/templates/homepage.gohtml index 1fd5c2c..7556d22 100644 --- a/cmd/psweb/templates/homepage.gohtml +++ b/cmd/psweb/templates/homepage.gohtml @@ -14,7 +14,11 @@

🌊 Liquid: {{fmt .LiquidBalance}} - ✔️{{else}}disabled">❌{{end}} + {{if .PeginPending}} + + {{else}} + ✔️{{else}}disabled">❌{{end}} + {{end}} {{if .AutoSwapEnabled}} 🤖 {{end}} diff --git a/cmd/psweb/templates/peer.gohtml b/cmd/psweb/templates/peer.gohtml index ff3b78c..2aa92a5 100644 --- a/cmd/psweb/templates/peer.gohtml +++ b/cmd/psweb/templates/peer.gohtml @@ -61,7 +61,7 @@
- @@ -94,40 +94,211 @@

- +

- +
-
-
- -
-
+
+ + {{range .OutputsBTC}} + + + + + {{end}} + + + {{range .OutputsLBTC}} + + + + {{end}} +
{{end}} @@ -160,7 +331,7 @@ Sincerely,

- +

diff --git a/cmd/psweb/utils.go b/cmd/psweb/utils.go index 3d0c005..abef052 100644 --- a/cmd/psweb/utils.go +++ b/cmd/psweb/utils.go @@ -147,3 +147,13 @@ func formatSigned(num int64) string { } return formatWithThousandSeparators(uint64(num)) } + +// msatToSatUp converts millisatoshis to satoshis, rounding up. +func msatToSatUp(msat uint64) uint64 { + // Divide msat by 1000 and round up if there's any remainder. + sat := msat / 1000 + if msat%1000 != 0 { + sat++ + } + return sat +}