Greenfield: Add payment hash and preimage to Lightning invoices (#4520)

* Greenfield: Add payment hash and preimage to Lightning invoices

Closes #4475.

* Greenfield: Add payment hash and preimage to invoice payment method details

* Refactor LN payment method details retrieval
This commit is contained in:
d11n
2023-01-13 09:29:41 +01:00
committed by GitHub
parent 2301769419
commit 0bc6967dbc
15 changed files with 125 additions and 22 deletions

View File

@@ -28,7 +28,7 @@
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BTCPayServer.Lightning.Common" Version="1.3.20" />
<PackageReference Include="BTCPayServer.Lightning.Common" Version="1.3.21" />
<PackageReference Include="NBitcoin" Version="7.0.23" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup>

View File

@@ -17,8 +17,14 @@ namespace BTCPayServer.Client.Models
[JsonProperty("BOLT11")]
public string BOLT11 { get; set; }
public string PaymentHash { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Preimage { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? PaidAt { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset ExpiresAt { get; set; }

View File

@@ -19,8 +19,6 @@ using BTCPayServer.Services;
using BTCPayServer.Services.Custodian.Client.MockCustodian;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
@@ -1723,7 +1721,7 @@ namespace BTCPayServer.Tests
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync();
await user.GrantAccessAsync(true);
await user.MakeAdmin();
await user.SetupWebhook();
var client = await user.CreateClient(Policies.Unrestricted);
@@ -2104,6 +2102,10 @@ namespace BTCPayServer.Tests
merchant.RegisterLightningNode("BTC", LightningConnectionType.LndREST);
var merchantClient = await merchant.CreateClient($"{Policies.CanUseLightningNodeInStore}:{merchant.StoreId}");
var merchantInvoice = await merchantClient.CreateLightningInvoice(merchant.StoreId, "BTC", new CreateLightningInvoiceRequest(LightMoney.Satoshis(1_000), "hey", TimeSpan.FromSeconds(60)));
Assert.NotNull(merchantInvoice.Id);
Assert.NotNull(merchantInvoice.PaymentHash);
Assert.Equal(merchantInvoice.Id, merchantInvoice.PaymentHash);
// The default client is using charge, so we should not be able to query channels
var chargeClient = await user.CreateClient(Policies.CanUseInternalLightningNode);
@@ -2176,6 +2178,14 @@ namespace BTCPayServer.Tests
Assert.NotNull(payResponse.TotalAmount);
Assert.NotNull(payResponse.PaymentHash);
// check the get invoice response
var merchInvoice = await merchantClient.GetLightningInvoice(merchant.StoreId, "BTC", merchantInvoice.Id);
Assert.NotNull(merchInvoice);
Assert.NotNull(merchInvoice.Preimage);
Assert.NotNull(merchInvoice.PaymentHash);
Assert.Equal(payResponse.Preimage, merchInvoice.Preimage);
Assert.Equal(payResponse.PaymentHash, merchInvoice.PaymentHash);
await Assert.ThrowsAsync<GreenfieldValidationException>(async () => await client.PayLightningInvoice(user.StoreId, "BTC", new PayLightningInvoiceRequest()
{
BOLT11 = "lol"
@@ -2191,6 +2201,8 @@ namespace BTCPayServer.Tests
var invoice = await merchantClient.GetLightningInvoice(merchant.StoreId, "BTC", merchantInvoice.Id);
Assert.NotNull(invoice.PaidAt);
Assert.NotNull(invoice.PaymentHash);
Assert.NotNull(invoice.Preimage);
Assert.Equal(LightMoney.Satoshis(1000), invoice.Amount);
// check list for store with paid invoice
@@ -2232,6 +2244,45 @@ namespace BTCPayServer.Tests
await AssertPermissionError("btcpay.store.canuselightningnode", () => client.GetLightningNodeInfo(user.StoreId, "BTC"));
}
[Fact(Timeout = 60 * 20 * 1000)]
[Trait("Integration", "Integration")]
[Trait("Lightning", "Lightning")]
public async Task CanAccessInvoiceLightningPaymentMethodDetails()
{
using var tester = CreateServerTester();
tester.ActivateLightning();
await tester.StartAsync();
await tester.EnsureChannelsSetup();
var user = tester.NewAccount();
await user.GrantAccessAsync(true);
user.RegisterLightningNode("BTC", LightningConnectionType.CLightning);
var client = await user.CreateClient(Policies.Unrestricted);
var invoice = await client.CreateInvoice(user.StoreId,
new CreateInvoiceRequest
{
Currency = "USD",
Amount = 100,
Checkout = new CreateInvoiceRequest.CheckoutOptions
{
PaymentMethods = new[] { "BTC-LightningNetwork" },
DefaultPaymentMethod = "BTC_LightningLike"
}
});
var pm = Assert.Single(await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id));
Assert.False(pm.AdditionalData.HasValues);
var resp = await tester.CustomerLightningD.Pay(pm.Destination);
Assert.Equal(PayResult.Ok, resp.Result);
Assert.NotNull(resp.Details.PaymentHash);
Assert.NotNull(resp.Details.Preimage);
pm = Assert.Single(await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id));
Assert.True(pm.AdditionalData.HasValues);
Assert.Equal(resp.Details.PaymentHash.ToString(), pm.AdditionalData.GetValue("paymentHash"));
Assert.Equal(resp.Details.Preimage.ToString(), pm.AdditionalData.GetValue("preimage"));
}
[Fact(Timeout = 60 * 20 * 1000)]
[Trait("Integration", "Integration")]
[Trait("Lightning", "Lightning")]

View File

@@ -47,7 +47,7 @@
<ItemGroup>
<PackageReference Include="BIP78.Sender" Version="0.2.2" />
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.16" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.18" />
<PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Fido2" Version="2.0.2" />

View File

@@ -534,7 +534,7 @@ namespace BTCPayServer.Controllers.Greenfield
var payments = method.ParentEntity.GetPayments(includeAccountedPaymentOnly).Where(paymentEntity =>
paymentEntity.GetPaymentMethodId() == method.GetId());
return new InvoicePaymentMethodDataModel()
return new InvoicePaymentMethodDataModel
{
Activated = details.Activated,
PaymentMethod = method.GetId().ToStringNormalized(),

View File

@@ -264,9 +264,12 @@ namespace BTCPayServer.Controllers.Greenfield
}),
PayResult.Ok => Ok(new LightningPaymentData
{
BOLT11 = bolt11?.ToString(),
Status = LightningPaymentStatus.Complete,
TotalAmount = result.Details?.TotalAmount,
FeeAmount = result.Details?.FeeAmount
FeeAmount = result.Details?.FeeAmount,
PaymentHash = result.Details?.PaymentHash.ToString(),
Preimage = result.Details?.Preimage.ToString()
}),
_ => throw new NotSupportedException("Unsupported PayResult")
};
@@ -353,7 +356,9 @@ namespace BTCPayServer.Controllers.Greenfield
AmountReceived = invoice.AmountReceived,
PaidAt = invoice.PaidAt,
BOLT11 = invoice.BOLT11,
ExpiresAt = invoice.ExpiresAt
ExpiresAt = invoice.ExpiresAt,
PaymentHash = invoice.PaymentHash,
Preimage = invoice.Preimage
};
if (invoice.CustomRecords != null)

View File

@@ -594,6 +594,8 @@ namespace BTCPayServer
}
paymentMethodDetails.BOLT11 = invoice.BOLT11;
paymentMethodDetails.PaymentHash = string.IsNullOrEmpty(invoice.PaymentHash) ? null : uint256.Parse(invoice.PaymentHash);
paymentMethodDetails.Preimage = string.IsNullOrEmpty(invoice.Preimage) ? null : uint256.Parse(invoice.Preimage);
paymentMethodDetails.InvoiceId = invoice.Id;
paymentMethodDetails.GeneratedBoltAmount = new LightMoney(amount.Value);
if (lnurlSupportedPaymentMethod.LUD12Enabled)
@@ -604,7 +606,6 @@ namespace BTCPayServer
lightningPaymentMethod.SetPaymentMethodDetails(paymentMethodDetails);
await _invoiceRepository.UpdateInvoicePaymentMethod(invoiceId, lightningPaymentMethod);
_eventAggregator.Publish(new InvoiceNewPaymentDetailsEvent(invoiceId,
paymentMethodDetails, pmi));
return Ok(new LNURLPayRequest.LNURLPayRequestCallbackResponse

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using BTCPayServer.Services.Invoices;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Payments

View File

@@ -2,6 +2,7 @@ using System.Collections.Generic;
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.Lightning;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services.Invoices;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

View File

@@ -11,11 +11,19 @@ namespace BTCPayServer.Payments.Lightning
{
[JsonIgnore]
public BTCPayNetworkBase Network { get; set; }
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney Amount { get; set; }
public string BOLT11 { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
public uint256 PaymentHash { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public uint256 Preimage { get; set; }
public string PaymentType { get; set; }
public string GetDestination()
@@ -25,7 +33,6 @@ namespace BTCPayServer.Payments.Lightning
public decimal NetworkFee { get; set; }
public string GetPaymentId()
{
// Legacy, some old payments don't have the PaymentHash set

View File

@@ -89,7 +89,7 @@ namespace BTCPayServer.Payments.Lightning
if (expiry < TimeSpan.Zero)
expiry = TimeSpan.FromSeconds(1);
LightningInvoice? lightningInvoice = null;
LightningInvoice? lightningInvoice;
string description = storeBlob.LightningDescriptionTemplate;
description = description.Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase)
@@ -118,6 +118,7 @@ namespace BTCPayServer.Payments.Lightning
Activated = true,
BOLT11 = lightningInvoice.BOLT11,
PaymentHash = BOLT11PaymentRequest.Parse(lightningInvoice.BOLT11, network.NBitcoinNetwork).PaymentHash,
Preimage = string.IsNullOrEmpty(lightningInvoice.Preimage) ? null : uint256.Parse(lightningInvoice.Preimage),
InvoiceId = lightningInvoice.Id,
NodeInfo = (await nodeInfo).FirstOrDefault()?.ToString()
};

View File

@@ -1,5 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Lightning;
using BTCPayServer.Services.Invoices;
using NBitcoin;
using Newtonsoft.Json.Linq;
@@ -9,6 +11,7 @@ namespace BTCPayServer.Payments.Lightning
{
public string BOLT11 { get; set; }
public uint256 PaymentHash { get; set; }
public uint256 Preimage { get; set; }
public string InvoiceId { get; set; }
public string NodeInfo { get; set; }
@@ -45,7 +48,12 @@ namespace BTCPayServer.Payments.Lightning
public virtual JObject GetAdditionalData()
{
return new();
var result = new JObject();
if (PaymentHash != null && PaymentHash != default)
result.Add("paymentHash", new JValue(PaymentHash.ToString()));
if (Preimage != null && Preimage != default)
result.Add("preimage", new JValue(Preimage.ToString()));
return result;
}
}
}

View File

@@ -18,6 +18,7 @@ using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NBitcoin;
using NBXplorer;
namespace BTCPayServer.Payments.Lightning
@@ -529,10 +530,11 @@ namespace BTCPayServer.Payments.Lightning
public async Task<bool> AddPayment(LightningInvoice notification, string invoiceId, PaymentType paymentType)
{
var payment = await _paymentService.AddPayment(invoiceId, notification.PaidAt.Value, new LightningLikePaymentData()
var payment = await _paymentService.AddPayment(invoiceId, notification.PaidAt.Value, new LightningLikePaymentData
{
BOLT11 = notification.BOLT11,
PaymentHash = BOLT11PaymentRequest.Parse(notification.BOLT11, _network.NBitcoinNetwork).PaymentHash,
Preimage = string.IsNullOrEmpty(notification.Preimage) ? null : uint256.Parse(notification.Preimage),
Amount = notification.AmountReceived ?? notification.Amount, // if running old version amount received might be unavailable,
PaymentType = paymentType.ToString()
}, _network, accounted: true);

View File

@@ -9,6 +9,7 @@ using BTCPayServer.JsonConverters;
using BTCPayServer.Models;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitpayClient;
@@ -989,25 +990,35 @@ namespace BTCPayServer.Services.Invoices
// Legacy, old code does not have PaymentMethods
if (string.IsNullOrEmpty(PaymentType) || PaymentMethodDetails == null)
{
return new Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod()
return new BitcoinLikeOnChainPaymentMethod
{
FeeRate = FeeRate,
DepositAddress = string.IsNullOrEmpty(DepositAddress) ? null : DepositAddress,
NextNetworkFee = NextNetworkFee
};
}
else
{
IPaymentMethodDetails details = GetId().PaymentType.DeserializePaymentMethodDetails(Network, PaymentMethodDetails.ToString());
if (details is Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod btcLike)
switch (details)
{
case BitcoinLikeOnChainPaymentMethod btcLike:
btcLike.NextNetworkFee = NextNetworkFee;
btcLike.DepositAddress = string.IsNullOrEmpty(DepositAddress) ? null : DepositAddress;
btcLike.FeeRate = FeeRate;
break;
case LightningLikePaymentMethodDetails lnLike:
// use set properties and fall back to values from payment data
var payments = ParentEntity.GetPayments(true).Where(paymentEntity =>
paymentEntity.GetPaymentMethodId() == GetId());
var payment = payments.Select(p => p.GetCryptoPaymentData() as LightningLikePaymentData).FirstOrDefault();
var paymentHash = payment?.PaymentHash != null && payment.PaymentHash != default ? payment.PaymentHash : null;
var preimage = payment?.Preimage != null && payment.Preimage != default ? payment.Preimage : null;
lnLike.PaymentHash = lnLike.PaymentHash != null && lnLike.PaymentHash != default ? lnLike.PaymentHash : paymentHash;
lnLike.Preimage = lnLike.Preimage != null && lnLike.Preimage != default ? lnLike.Preimage : preimage;
break;
}
return details;
}
throw new NotSupportedException(PaymentType);
#pragma warning restore CS0618 // Type or member is obsolete
}

View File

@@ -149,6 +149,15 @@
"type": "string",
"description": "The amount received in millisatoshi"
},
"paymentHash": {
"type": "string",
"description": "The payment hash"
},
"preimage": {
"type": "string",
"nullable": true,
"description": "The payment preimage (available when status is complete)"
},
"customRecords": {
"type": "object",
"nullable": true,