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" /> <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
</ItemGroup> </ItemGroup>
<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="NBitcoin" Version="7.0.23" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup> </ItemGroup>

View File

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

View File

@@ -19,8 +19,6 @@ using BTCPayServer.Services;
using BTCPayServer.Services.Custodian.Client.MockCustodian; using BTCPayServer.Services.Custodian.Client.MockCustodian;
using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NBitcoin; using NBitcoin;
@@ -1723,7 +1721,7 @@ namespace BTCPayServer.Tests
using var tester = CreateServerTester(); using var tester = CreateServerTester();
await tester.StartAsync(); await tester.StartAsync();
var user = tester.NewAccount(); var user = tester.NewAccount();
await user.GrantAccessAsync(); await user.GrantAccessAsync(true);
await user.MakeAdmin(); await user.MakeAdmin();
await user.SetupWebhook(); await user.SetupWebhook();
var client = await user.CreateClient(Policies.Unrestricted); var client = await user.CreateClient(Policies.Unrestricted);
@@ -2104,9 +2102,13 @@ namespace BTCPayServer.Tests
merchant.RegisterLightningNode("BTC", LightningConnectionType.LndREST); merchant.RegisterLightningNode("BTC", LightningConnectionType.LndREST);
var merchantClient = await merchant.CreateClient($"{Policies.CanUseLightningNodeInStore}:{merchant.StoreId}"); 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))); 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 // The default client is using charge, so we should not be able to query channels
var chargeClient = await user.CreateClient(Policies.CanUseInternalLightningNode); var chargeClient = await user.CreateClient(Policies.CanUseInternalLightningNode);
var info = await chargeClient.GetLightningNodeInfo("BTC"); var info = await chargeClient.GetLightningNodeInfo("BTC");
Assert.Single(info.NodeURIs); Assert.Single(info.NodeURIs);
Assert.NotEqual(0, info.BlockHeight); Assert.NotEqual(0, info.BlockHeight);
@@ -2175,6 +2177,14 @@ namespace BTCPayServer.Tests
Assert.NotNull(payResponse.FeeAmount); Assert.NotNull(payResponse.FeeAmount);
Assert.NotNull(payResponse.TotalAmount); Assert.NotNull(payResponse.TotalAmount);
Assert.NotNull(payResponse.PaymentHash); 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() await Assert.ThrowsAsync<GreenfieldValidationException>(async () => await client.PayLightningInvoice(user.StoreId, "BTC", new PayLightningInvoiceRequest()
{ {
@@ -2191,6 +2201,8 @@ namespace BTCPayServer.Tests
var invoice = await merchantClient.GetLightningInvoice(merchant.StoreId, "BTC", merchantInvoice.Id); var invoice = await merchantClient.GetLightningInvoice(merchant.StoreId, "BTC", merchantInvoice.Id);
Assert.NotNull(invoice.PaidAt); Assert.NotNull(invoice.PaidAt);
Assert.NotNull(invoice.PaymentHash);
Assert.NotNull(invoice.Preimage);
Assert.Equal(LightMoney.Satoshis(1000), invoice.Amount); Assert.Equal(LightMoney.Satoshis(1000), invoice.Amount);
// check list for store with paid invoice // check list for store with paid invoice
@@ -2232,6 +2244,45 @@ namespace BTCPayServer.Tests
await AssertPermissionError("btcpay.store.canuselightningnode", () => client.GetLightningNodeInfo(user.StoreId, "BTC")); 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)] [Fact(Timeout = 60 * 20 * 1000)]
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]
[Trait("Lightning", "Lightning")] [Trait("Lightning", "Lightning")]

View File

@@ -47,7 +47,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="BIP78.Sender" Version="0.2.2" /> <PackageReference Include="BIP78.Sender" Version="0.2.2" />
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.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="CsvHelper" Version="15.0.5" />
<PackageReference Include="Dapper" Version="2.0.123" /> <PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Fido2" Version="2.0.2" /> <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 => var payments = method.ParentEntity.GetPayments(includeAccountedPaymentOnly).Where(paymentEntity =>
paymentEntity.GetPaymentMethodId() == method.GetId()); paymentEntity.GetPaymentMethodId() == method.GetId());
return new InvoicePaymentMethodDataModel() return new InvoicePaymentMethodDataModel
{ {
Activated = details.Activated, Activated = details.Activated,
PaymentMethod = method.GetId().ToStringNormalized(), PaymentMethod = method.GetId().ToStringNormalized(),

View File

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

View File

@@ -594,6 +594,8 @@ namespace BTCPayServer
} }
paymentMethodDetails.BOLT11 = invoice.BOLT11; 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.InvoiceId = invoice.Id;
paymentMethodDetails.GeneratedBoltAmount = new LightMoney(amount.Value); paymentMethodDetails.GeneratedBoltAmount = new LightMoney(amount.Value);
if (lnurlSupportedPaymentMethod.LUD12Enabled) if (lnurlSupportedPaymentMethod.LUD12Enabled)
@@ -604,7 +606,6 @@ namespace BTCPayServer
lightningPaymentMethod.SetPaymentMethodDetails(paymentMethodDetails); lightningPaymentMethod.SetPaymentMethodDetails(paymentMethodDetails);
await _invoiceRepository.UpdateInvoicePaymentMethod(invoiceId, lightningPaymentMethod); await _invoiceRepository.UpdateInvoicePaymentMethod(invoiceId, lightningPaymentMethod);
_eventAggregator.Publish(new InvoiceNewPaymentDetailsEvent(invoiceId, _eventAggregator.Publish(new InvoiceNewPaymentDetailsEvent(invoiceId,
paymentMethodDetails, pmi)); paymentMethodDetails, pmi));
return Ok(new LNURLPayRequest.LNURLPayRequestCallbackResponse return Ok(new LNURLPayRequest.LNURLPayRequestCallbackResponse

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Services.Invoices;
using NBitcoin; using NBitcoin;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@@ -9,6 +11,7 @@ namespace BTCPayServer.Payments.Lightning
{ {
public string BOLT11 { get; set; } public string BOLT11 { get; set; }
public uint256 PaymentHash { get; set; } public uint256 PaymentHash { get; set; }
public uint256 Preimage { get; set; }
public string InvoiceId { get; set; } public string InvoiceId { get; set; }
public string NodeInfo { get; set; } public string NodeInfo { get; set; }
@@ -45,7 +48,12 @@ namespace BTCPayServer.Payments.Lightning
public virtual JObject GetAdditionalData() 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.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NBitcoin;
using NBXplorer; using NBXplorer;
namespace BTCPayServer.Payments.Lightning namespace BTCPayServer.Payments.Lightning
@@ -529,10 +530,11 @@ namespace BTCPayServer.Payments.Lightning
public async Task<bool> AddPayment(LightningInvoice notification, string invoiceId, PaymentType paymentType) 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, BOLT11 = notification.BOLT11,
PaymentHash = BOLT11PaymentRequest.Parse(notification.BOLT11, _network.NBitcoinNetwork).PaymentHash, 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, Amount = notification.AmountReceived ?? notification.Amount, // if running old version amount received might be unavailable,
PaymentType = paymentType.ToString() PaymentType = paymentType.ToString()
}, _network, accounted: true); }, _network, accounted: true);

View File

@@ -9,6 +9,7 @@ using BTCPayServer.JsonConverters;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
using NBitpayClient; using NBitpayClient;
@@ -989,25 +990,35 @@ namespace BTCPayServer.Services.Invoices
// Legacy, old code does not have PaymentMethods // Legacy, old code does not have PaymentMethods
if (string.IsNullOrEmpty(PaymentType) || PaymentMethodDetails == null) if (string.IsNullOrEmpty(PaymentType) || PaymentMethodDetails == null)
{ {
return new Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod() return new BitcoinLikeOnChainPaymentMethod
{ {
FeeRate = FeeRate, FeeRate = FeeRate,
DepositAddress = string.IsNullOrEmpty(DepositAddress) ? null : DepositAddress, DepositAddress = string.IsNullOrEmpty(DepositAddress) ? null : DepositAddress,
NextNetworkFee = NextNetworkFee NextNetworkFee = NextNetworkFee
}; };
} }
else
IPaymentMethodDetails details = GetId().PaymentType.DeserializePaymentMethodDetails(Network, PaymentMethodDetails.ToString());
switch (details)
{ {
IPaymentMethodDetails details = GetId().PaymentType.DeserializePaymentMethodDetails(Network, PaymentMethodDetails.ToString()); case BitcoinLikeOnChainPaymentMethod btcLike:
if (details is Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod btcLike)
{
btcLike.NextNetworkFee = NextNetworkFee; btcLike.NextNetworkFee = NextNetworkFee;
btcLike.DepositAddress = string.IsNullOrEmpty(DepositAddress) ? null : DepositAddress; btcLike.DepositAddress = string.IsNullOrEmpty(DepositAddress) ? null : DepositAddress;
btcLike.FeeRate = FeeRate; btcLike.FeeRate = FeeRate;
} break;
return details; 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;
} }
throw new NotSupportedException(PaymentType);
return details;
#pragma warning restore CS0618 // Type or member is obsolete #pragma warning restore CS0618 // Type or member is obsolete
} }

View File

@@ -149,6 +149,15 @@
"type": "string", "type": "string",
"description": "The amount received in millisatoshi" "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": { "customRecords": {
"type": "object", "type": "object",
"nullable": true, "nullable": true,