diff --git a/init_lnd.go b/init_lnd.go index b3618ba3..3f038b9e 100644 --- a/init_lnd.go +++ b/init_lnd.go @@ -17,6 +17,8 @@ func InitLNClient(c *service.Config, logger *lecho.Logger, ctx context.Context) return InitSingleLNDClient(c, ctx) case service.LND_CLUSTER_CLIENT_TYPE: return InitLNDCluster(c, logger, ctx) + case service.ECLAIR_CLIENT_TYPE: + return lnd.NewEclairClient(c.LNDAddress, c.EclairPassword, ctx) default: return nil, fmt.Errorf("Did not recognize LN client type %s", c.LNClientType) } diff --git a/lib/service/config.go b/lib/service/config.go index ee1f9988..8163c4d4 100644 --- a/lib/service/config.go +++ b/lib/service/config.go @@ -31,6 +31,7 @@ type Config struct { LNDCertFile string `envconfig:"LND_CERT_FILE"` LNDMacaroonHex string `envconfig:"LND_MACAROON_HEX"` LNDCertHex string `envconfig:"LND_CERT_HEX"` + EclairPassword string `envconfig:"ECLAIR_PASSWORD"` LNDClusterLivenessPeriod int `envconfig:"LND_CLUSTER_LIVENESS_PERIOD" default:"10"` LNDClusterActiveChannelRatio float64 `envconfig:"LND_CLUSTER_ACTIVE_CHANNEL_RATIO" default:"0.5"` CustomName string `envconfig:"CUSTOM_NAME"` diff --git a/lnd/eclair.go b/lnd/eclair.go new file mode 100644 index 00000000..1c4b79f7 --- /dev/null +++ b/lnd/eclair.go @@ -0,0 +1,259 @@ +package lnd + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "google.golang.org/grpc" +) + +type EclairClient struct { + host string + password string + IdentityPubkey string +} + +type EclairInvoicesSubscriber struct { + ctx context.Context +} + +func (eis *EclairInvoicesSubscriber) Recv() (*lnrpc.Invoice, error) { + //placeholder + //block indefinitely + <-eis.ctx.Done() + return nil, fmt.Errorf("context canceled") +} + +type EclairPaymentsTracker struct { + ctx context.Context +} + +func (ept *EclairPaymentsTracker) Recv() (*lnrpc.Payment, error) { + //placeholder + //block indefinitely + <-ept.ctx.Done() + return nil, fmt.Errorf("context canceled") +} + +func NewEclairClient(host, password string, ctx context.Context) (result *EclairClient, err error) { + result = &EclairClient{ + host: host, + password: password, + } + info, err := result.GetInfo(ctx, &lnrpc.GetInfoRequest{}) + if err != nil { + return nil, err + } + result.IdentityPubkey = info.IdentityPubkey + return result, nil +} + +func (eclair *EclairClient) ListChannels(ctx context.Context, req *lnrpc.ListChannelsRequest, options ...grpc.CallOption) (*lnrpc.ListChannelsResponse, error) { + channels := []EclairChannel{} + err := eclair.Request(ctx, http.MethodPost, "/channels", "", nil, &channels) + if err != nil { + return nil, err + } + convertedChannels := []*lnrpc.Channel{} + for _, ch := range channels { + convertedChannels = append(convertedChannels, &lnrpc.Channel{ + Active: ch.State == "NORMAL", + RemotePubkey: ch.NodeID, + ChannelPoint: "", + ChanId: 0, + Capacity: int64(ch.Data.Commitments.LocalCommit.Spec.ToLocal)/1000 + int64(ch.Data.Commitments.LocalCommit.Spec.ToRemote)/1000, + LocalBalance: int64(ch.Data.Commitments.LocalCommit.Spec.ToLocal) / 1000, + RemoteBalance: int64(ch.Data.Commitments.LocalCommit.Spec.ToRemote) / 1000, + CommitFee: 0, + CommitWeight: 0, + FeePerKw: 0, + UnsettledBalance: 0, + TotalSatoshisSent: 0, + TotalSatoshisReceived: 0, + NumUpdates: 0, + PendingHtlcs: []*lnrpc.HTLC{}, + CsvDelay: 0, + Private: false, + Initiator: false, + ChanStatusFlags: "", + LocalChanReserveSat: 0, + RemoteChanReserveSat: 0, + StaticRemoteKey: false, + CommitmentType: 0, + Lifetime: 0, + Uptime: 0, + CloseAddress: "", + PushAmountSat: 0, + ThawHeight: 0, + LocalConstraints: &lnrpc.ChannelConstraints{}, + RemoteConstraints: &lnrpc.ChannelConstraints{}, + AliasScids: []uint64{}, + ZeroConf: false, + ZeroConfConfirmedScid: 0, + }) + } + return &lnrpc.ListChannelsResponse{ + Channels: convertedChannels, + }, nil +} + +func (eclair *EclairClient) SendPaymentSync(ctx context.Context, req *lnrpc.SendRequest, options ...grpc.CallOption) (*lnrpc.SendResponse, error) { + payload := url.Values{} + payload.Add("invoice", req.PaymentRequest) + payload.Add("amountMsat", strconv.Itoa(int(req.Amt)*1000)) + payload.Add("maxFeeFlatSat", strconv.Itoa(int(req.FeeLimit.GetFixed()))) + payload.Add("blocking", "true") //wtf + resp := &EclairPayResponse{} + err := eclair.Request(ctx, http.MethodPost, "/payinvoice", "application/x-www-form-urlencoded", payload, resp) + if err != nil { + return nil, err + } + errString := "" + if resp.Type == "payment-failed" && len(resp.Failures) > 0 { + errString = resp.Failures[0].T + } + totalFees := 0 + for _, part := range resp.Parts { + totalFees += part.FeesPaid / 1000 + } + preimage, err := hex.DecodeString(resp.PaymentPreimage) + if err != nil { + return nil, err + } + return &lnrpc.SendResponse{ + PaymentError: errString, + PaymentPreimage: preimage, + PaymentRoute: &lnrpc.Route{ + TotalFees: int64(totalFees), + TotalAmt: int64(resp.RecipientAmount)/1000 + int64(totalFees), + }, + PaymentHash: []byte(resp.PaymentHash), + }, nil +} + +func (eclair *EclairClient) AddInvoice(ctx context.Context, req *lnrpc.Invoice, options ...grpc.CallOption) (*lnrpc.AddInvoiceResponse, error) { + payload := url.Values{} + if req.Memo != "" { + payload.Add("description", req.Memo) + } + if len(req.DescriptionHash) != 0 { + payload.Add("descriptionHash", string(req.DescriptionHash)) + } + payload.Add("amountMsat", strconv.Itoa(int(req.Value*1000))) + payload.Add("paymentPreimage", hex.EncodeToString(req.RPreimage)) + payload.Add("expireIn", strconv.Itoa(int(req.Expiry))) + invoice := &EclairInvoice{} + err := eclair.Request(ctx, http.MethodPost, "/createinvoice", "application/x-www-form-urlencoded", payload, invoice) + if err != nil { + return nil, err + } + rHash, err := hex.DecodeString(invoice.PaymentHash) + if err != nil { + return nil, err + } + return &lnrpc.AddInvoiceResponse{ + RHash: rHash, + PaymentRequest: invoice.Serialized, + AddIndex: uint64(invoice.Timestamp), + }, nil +} + +func (eclair *EclairClient) SubscribeInvoices(ctx context.Context, req *lnrpc.InvoiceSubscription, options ...grpc.CallOption) (SubscribeInvoicesWrapper, error) { + return &EclairInvoicesSubscriber{ + ctx: ctx, + }, nil +} + +func (eclair *EclairClient) SubscribePayment(ctx context.Context, req *routerrpc.TrackPaymentRequest, options ...grpc.CallOption) (SubscribePaymentWrapper, error) { + return &EclairPaymentsTracker{ + ctx: ctx, + }, nil +} + +func (eclair *EclairClient) GetInfo(ctx context.Context, req *lnrpc.GetInfoRequest, options ...grpc.CallOption) (*lnrpc.GetInfoResponse, error) { + info := EclairInfoResponse{} + err := eclair.Request(ctx, http.MethodPost, "/getinfo", "", nil, &info) + if err != nil { + return nil, err + } + addresses := []string{} + for _, addr := range info.PublicAddresses { + addresses = append(addresses, fmt.Sprintf("%s@%s", info.NodeID, addr)) + } + return &lnrpc.GetInfoResponse{ + Version: info.Version, + CommitHash: "", + IdentityPubkey: info.NodeID, + Alias: info.Alias, + Color: info.Color, + NumPendingChannels: 0, + NumActiveChannels: 0, + NumInactiveChannels: 0, + NumPeers: 0, + BlockHeight: uint32(info.BlockHeight), + BlockHash: "", + BestHeaderTimestamp: 0, + SyncedToChain: true, + SyncedToGraph: true, + Testnet: info.Network == "testnet", + Chains: []*lnrpc.Chain{{ + Chain: "bitcoin", + Network: info.Network, + }}, + Uris: addresses, + Features: map[uint32]*lnrpc.Feature{}, + RequireHtlcInterceptor: false, + }, nil +} + +func (eclair *EclairClient) Request(ctx context.Context, method, endpoint, contentType string, body url.Values, response interface{}) error { + httpReq, err := http.NewRequestWithContext(ctx, method, fmt.Sprintf("%s%s", eclair.host, endpoint), strings.NewReader(body.Encode())) + httpReq.Header.Set("Content-type", contentType) + httpReq.SetBasicAuth("", eclair.password) + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + response := map[string]interface{}{} + json.NewDecoder(resp.Body).Decode(&response) + return fmt.Errorf("Got a bad http response status code from Eclair %d for request %s. Body: %s", resp.StatusCode, httpReq.URL, response) + } + return json.NewDecoder(resp.Body).Decode(response) +} + +func (eclair *EclairClient) DecodeBolt11(ctx context.Context, bolt11 string, options ...grpc.CallOption) (*lnrpc.PayReq, error) { + invoice := &EclairInvoice{} + payload := url.Values{} + payload.Add("invoice", bolt11) + err := eclair.Request(ctx, http.MethodPost, "/parseinvoice", "application/x-www-form-urlencoded", payload, invoice) + if err != nil { + return nil, err + } + return &lnrpc.PayReq{ + Destination: invoice.NodeID, + PaymentHash: invoice.PaymentHash, + NumSatoshis: int64(invoice.Amount) / 1000, + Timestamp: int64(invoice.Timestamp), + Expiry: int64(invoice.Expiry), + Description: invoice.Description, + DescriptionHash: invoice.DescriptionHash, + NumMsat: int64(invoice.Amount), + }, nil +} + +func (eclair *EclairClient) IsIdentityPubkey(pubkey string) (isOurPubkey bool) { + return pubkey == eclair.IdentityPubkey +} + +func (eclair *EclairClient) GetMainPubkey() (pubkey string) { + return eclair.IdentityPubkey +} diff --git a/lnd/eclair_models.go b/lnd/eclair_models.go new file mode 100644 index 00000000..f14199ee --- /dev/null +++ b/lnd/eclair_models.go @@ -0,0 +1,295 @@ +package lnd + +import "time" + +type EclairInfoResponse struct { + Version string `json:"version"` + NodeID string `json:"nodeId"` + Alias string `json:"alias"` + Color string `json:"color"` + Features struct { + Activated struct { + OptionOnionMessages string `json:"option_onion_messages"` + GossipQueriesEx string `json:"gossip_queries_ex"` + OptionPaymentMetadata string `json:"option_payment_metadata"` + OptionDataLossProtect string `json:"option_data_loss_protect"` + VarOnionOptin string `json:"var_onion_optin"` + OptionStaticRemotekey string `json:"option_static_remotekey"` + OptionSupportLargeChannel string `json:"option_support_large_channel"` + OptionAnchorsZeroFeeHtlcTx string `json:"option_anchors_zero_fee_htlc_tx"` + PaymentSecret string `json:"payment_secret"` + OptionShutdownAnysegwit string `json:"option_shutdown_anysegwit"` + OptionChannelType string `json:"option_channel_type"` + BasicMpp string `json:"basic_mpp"` + GossipQueries string `json:"gossip_queries"` + } `json:"activated"` + Unknown []interface{} `json:"unknown"` + } `json:"features"` + ChainHash string `json:"chainHash"` + Network string `json:"network"` + BlockHeight int `json:"blockHeight"` + PublicAddresses []string `json:"publicAddresses"` + InstanceID string `json:"instanceId"` +} + +type EclairChannel struct { + NodeID string `json:"nodeId"` + ChannelID string `json:"channelId"` + State string `json:"state"` + Data struct { + Type string `json:"type"` + Commitments struct { + ChannelID string `json:"channelId"` + ChannelConfig []string `json:"channelConfig"` + ChannelFeatures []string `json:"channelFeatures"` + LocalParams struct { + NodeID string `json:"nodeId"` + FundingKeyPath struct { + Path []interface{} `json:"path"` + } `json:"fundingKeyPath"` + DustLimit int `json:"dustLimit"` + MaxHtlcValueInFlightMsat float64 `json:"maxHtlcValueInFlightMsat"` + RequestedChannelReserveOpt int `json:"requestedChannelReserve_opt"` + HtlcMinimum int `json:"htlcMinimum"` + ToSelfDelay int `json:"toSelfDelay"` + MaxAcceptedHtlcs int `json:"maxAcceptedHtlcs"` + IsInitiator bool `json:"isInitiator"` + DefaultFinalScriptPubKey string `json:"defaultFinalScriptPubKey"` + InitFeatures struct { + Activated struct { + OptionOnionMessages string `json:"option_onion_messages"` + GossipQueriesEx string `json:"gossip_queries_ex"` + OptionDataLossProtect string `json:"option_data_loss_protect"` + VarOnionOptin string `json:"var_onion_optin"` + OptionStaticRemotekey string `json:"option_static_remotekey"` + OptionSupportLargeChannel string `json:"option_support_large_channel"` + OptionAnchorsZeroFeeHtlcTx string `json:"option_anchors_zero_fee_htlc_tx"` + PaymentSecret string `json:"payment_secret"` + OptionShutdownAnysegwit string `json:"option_shutdown_anysegwit"` + OptionChannelType string `json:"option_channel_type"` + BasicMpp string `json:"basic_mpp"` + GossipQueries string `json:"gossip_queries"` + } `json:"activated"` + Unknown []interface{} `json:"unknown"` + } `json:"initFeatures"` + } `json:"localParams"` + RemoteParams struct { + NodeID string `json:"nodeId"` + DustLimit int `json:"dustLimit"` + MaxHtlcValueInFlightMsat float64 `json:"maxHtlcValueInFlightMsat"` + RequestedChannelReserveOpt int `json:"requestedChannelReserve_opt"` + HtlcMinimum int `json:"htlcMinimum"` + ToSelfDelay int `json:"toSelfDelay"` + MaxAcceptedHtlcs int `json:"maxAcceptedHtlcs"` + FundingPubKey string `json:"fundingPubKey"` + RevocationBasepoint string `json:"revocationBasepoint"` + PaymentBasepoint string `json:"paymentBasepoint"` + DelayedPaymentBasepoint string `json:"delayedPaymentBasepoint"` + HtlcBasepoint string `json:"htlcBasepoint"` + InitFeatures struct { + Activated struct { + OptionOnionMessages string `json:"option_onion_messages"` + GossipQueriesEx string `json:"gossip_queries_ex"` + OptionDataLossProtect string `json:"option_data_loss_protect"` + VarOnionOptin string `json:"var_onion_optin"` + OptionStaticRemotekey string `json:"option_static_remotekey"` + OptionSupportLargeChannel string `json:"option_support_large_channel"` + OptionAnchorsZeroFeeHtlcTx string `json:"option_anchors_zero_fee_htlc_tx"` + PaymentSecret string `json:"payment_secret"` + OptionShutdownAnysegwit string `json:"option_shutdown_anysegwit"` + OptionChannelType string `json:"option_channel_type"` + BasicMpp string `json:"basic_mpp"` + GossipQueries string `json:"gossip_queries"` + } `json:"activated"` + Unknown []interface{} `json:"unknown"` + } `json:"initFeatures"` + } `json:"remoteParams"` + ChannelFlags struct { + AnnounceChannel bool `json:"announceChannel"` + } `json:"channelFlags"` + LocalCommit struct { + Index int `json:"index"` + Spec struct { + Htlcs []interface{} `json:"htlcs"` + CommitTxFeerate int `json:"commitTxFeerate"` + ToLocal int `json:"toLocal"` + ToRemote int `json:"toRemote"` + } `json:"spec"` + CommitTxAndRemoteSig struct { + CommitTx struct { + Txid string `json:"txid"` + Tx string `json:"tx"` + } `json:"commitTx"` + RemoteSig string `json:"remoteSig"` + } `json:"commitTxAndRemoteSig"` + HtlcTxsAndRemoteSigs []interface{} `json:"htlcTxsAndRemoteSigs"` + } `json:"localCommit"` + RemoteCommit struct { + Index int `json:"index"` + Spec struct { + Htlcs []interface{} `json:"htlcs"` + CommitTxFeerate int `json:"commitTxFeerate"` + ToLocal int `json:"toLocal"` + ToRemote int `json:"toRemote"` + } `json:"spec"` + Txid string `json:"txid"` + RemotePerCommitmentPoint string `json:"remotePerCommitmentPoint"` + } `json:"remoteCommit"` + LocalChanges struct { + Proposed []interface{} `json:"proposed"` + Signed []interface{} `json:"signed"` + Acked []interface{} `json:"acked"` + } `json:"localChanges"` + RemoteChanges struct { + Proposed []interface{} `json:"proposed"` + Acked []interface{} `json:"acked"` + Signed []interface{} `json:"signed"` + } `json:"remoteChanges"` + LocalNextHtlcID int `json:"localNextHtlcId"` + RemoteNextHtlcID int `json:"remoteNextHtlcId"` + OriginChannels struct { + } `json:"originChannels"` + RemoteNextCommitInfo string `json:"remoteNextCommitInfo"` + CommitInput struct { + OutPoint string `json:"outPoint"` + AmountSatoshis int `json:"amountSatoshis"` + } `json:"commitInput"` + RemotePerCommitmentSecrets interface{} `json:"remotePerCommitmentSecrets"` + } `json:"commitments"` + ShortIds struct { + Real struct { + Status string `json:"status"` + RealScid string `json:"realScid"` + } `json:"real"` + LocalAlias string `json:"localAlias"` + RemoteAlias string `json:"remoteAlias"` + } `json:"shortIds"` + ChannelAnnouncement struct { + NodeSignature1 string `json:"nodeSignature1"` + NodeSignature2 string `json:"nodeSignature2"` + BitcoinSignature1 string `json:"bitcoinSignature1"` + BitcoinSignature2 string `json:"bitcoinSignature2"` + Features struct { + Activated struct { + } `json:"activated"` + Unknown []interface{} `json:"unknown"` + } `json:"features"` + ChainHash string `json:"chainHash"` + ShortChannelID string `json:"shortChannelId"` + NodeID1 string `json:"nodeId1"` + NodeID2 string `json:"nodeId2"` + BitcoinKey1 string `json:"bitcoinKey1"` + BitcoinKey2 string `json:"bitcoinKey2"` + TlvStream struct { + Records []interface{} `json:"records"` + Unknown []interface{} `json:"unknown"` + } `json:"tlvStream"` + } `json:"channelAnnouncement"` + ChannelUpdate struct { + Signature string `json:"signature"` + ChainHash string `json:"chainHash"` + ShortChannelID string `json:"shortChannelId"` + Timestamp struct { + Iso time.Time `json:"iso"` + Unix int `json:"unix"` + } `json:"timestamp"` + MessageFlags struct { + DontForward bool `json:"dontForward"` + } `json:"messageFlags"` + ChannelFlags struct { + IsEnabled bool `json:"isEnabled"` + IsNode1 bool `json:"isNode1"` + } `json:"channelFlags"` + CltvExpiryDelta int `json:"cltvExpiryDelta"` + HtlcMinimumMsat int `json:"htlcMinimumMsat"` + FeeBaseMsat int `json:"feeBaseMsat"` + FeeProportionalMillionths int `json:"feeProportionalMillionths"` + HtlcMaximumMsat int `json:"htlcMaximumMsat"` + TlvStream struct { + Records []interface{} `json:"records"` + Unknown []interface{} `json:"unknown"` + } `json:"tlvStream"` + } `json:"channelUpdate"` + } `json:"data"` +} + +type EclairInvoice struct { + Prefix string `json:"prefix"` + Timestamp int `json:"timestamp"` + NodeID string `json:"nodeId"` + Serialized string `json:"serialized"` + Description string `json:"description"` + DescriptionHash string `json:"descriptionHash"` + PaymentHash string `json:"paymentHash"` + PaymentMetadata string `json:"paymentMetadata"` + Expiry int `json:"expiry"` + MinFinalCltvExpiry int `json:"minFinalCltvExpiry"` + Amount int `json:"amount"` + Features struct { + Activated struct { + PaymentSecret string `json:"payment_secret"` + BasicMpp string `json:"basic_mpp"` + OptionPaymentMetadata string `json:"option_payment_metadata"` + VarOnionOptin string `json:"var_onion_optin"` + } `json:"activated"` + Unknown []interface{} `json:"unknown"` + } `json:"features"` + RoutingInfo []interface{} `json:"routingInfo"` +} + +type EclairPayResponse struct { + Type string `json:"type"` + ID string `json:"id"` + PaymentHash string `json:"paymentHash"` + PaymentPreimage string `json:"paymentPreimage"` + RecipientAmount int `json:"recipientAmount"` + RecipientNodeID string `json:"recipientNodeId"` + Failures []struct { + Amount int64 `json:"amount"` + Route []interface{} `json:"route"` + T string `json:"t"` + } `json:"failures"` + Parts []struct { + ID string `json:"id"` + Amount int `json:"amount"` + FeesPaid int `json:"feesPaid"` + ToChannelID string `json:"toChannelId"` + Route []struct { + ShortChannelID string `json:"shortChannelId"` + NodeID string `json:"nodeId"` + NextNodeID string `json:"nextNodeId"` + Params struct { + Type string `json:"type"` + ChannelUpdate struct { + Signature string `json:"signature"` + ChainHash string `json:"chainHash"` + ShortChannelID string `json:"shortChannelId"` + Timestamp struct { + Iso time.Time `json:"iso"` + Unix int `json:"unix"` + } `json:"timestamp"` + MessageFlags struct { + DontForward bool `json:"dontForward"` + } `json:"messageFlags"` + ChannelFlags struct { + IsEnabled bool `json:"isEnabled"` + IsNode1 bool `json:"isNode1"` + } `json:"channelFlags"` + CltvExpiryDelta int `json:"cltvExpiryDelta"` + HtlcMinimumMsat int `json:"htlcMinimumMsat"` + FeeBaseMsat int `json:"feeBaseMsat"` + FeeProportionalMillionths int `json:"feeProportionalMillionths"` + HtlcMaximumMsat int64 `json:"htlcMaximumMsat"` + TlvStream struct { + Records []interface{} `json:"records"` + Unknown []interface{} `json:"unknown"` + } `json:"tlvStream"` + } `json:"channelUpdate"` + } `json:"params"` + } `json:"route"` + Timestamp struct { + Iso time.Time `json:"iso"` + Unix int `json:"unix"` + } `json:"timestamp"` + } `json:"parts"` +}