Lightning Network support implementation

This commit is contained in:
nicolas.dorier 2018-02-26 00:48:12 +09:00
parent 3d33ecf397
commit c8923af573
40 changed files with 2580 additions and 408 deletions

View file

@ -34,5 +34,6 @@ namespace BTCPayServer.Tests
{
return GetNodeInfoAsync().GetAwaiter().GetResult();
}
}
}

View file

@ -89,13 +89,35 @@ namespace BTCPayServer.Tests
var channels = CustomerEclair.RPC.ChannelsAsync();
var info = await merchantInfo;
var connect = CustomerEclair.RPC.ConnectAsync(new NodeInfo(info.Id, MerchantCharge.P2PHost, info.Port));
var clightning = new NodeInfo(info.Id, MerchantCharge.P2PHost, info.Port);
var connect = CustomerEclair.RPC.ConnectAsync(clightning);
await Task.WhenAll(blockCount, customer, channels, connect);
// Mine until segwit is activated
if (blockCount.Result <= 432)
{
ExplorerNode.Generate(433 - blockCount.Result);
}
if (channels.Result.Length == 0)
{
await CustomerEclair.RPC.OpenAsync(clightning, Money.Satoshis(16777215));
while ((await CustomerEclair.RPC.ChannelsAsync())[0].State != "NORMAL")
{
ExplorerNode.Generate(1);
}
}
}
public void SendLightningPayment(Invoice invoice)
{
SendLightningPaymentAsync(invoice).GetAwaiter().GetResult();
}
public async Task SendLightningPaymentAsync(Invoice invoice)
{
var bolt11 = invoice.CryptoInfo.Where(o => o.PaymentUrls.BOLT11 != null).First().PaymentUrls.BOLT11;
bolt11 = bolt11.Replace("lightning:", "", StringComparison.OrdinalIgnoreCase);
await CustomerEclair.RPC.SendAsync(bolt11);
}
public EclairTester MerchantEclair { get; set; }

View file

@ -81,7 +81,7 @@ namespace BTCPayServer.Tests
DerivationSchemeFormat = "BTCPay",
DerivationScheme = DerivationScheme.ToString(),
Confirmation = true
}, "Save");
});
}
public DerivationStrategyBase DerivationScheme { get; set; }
@ -111,5 +111,20 @@ namespace BTCPayServer.Tests
{
get; set;
}
public void RegisterLightningNode(string cryptoCode)
{
RegisterLightningNodeAsync(cryptoCode).GetAwaiter().GetResult();
}
public async Task RegisterLightningNodeAsync(string cryptoCode)
{
var storeController = parent.PayTester.GetController<StoresController>(UserId);
await storeController.AddLightningNode(StoreId, new LightningNodeViewModel()
{
CryptoCurrency = "BTC",
Url = parent.MerchantCharge.Client.Uri.AbsoluteUri
}, "save");
}
}
}

View file

@ -30,6 +30,7 @@ using System.Globalization;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments.Lightning;
namespace BTCPayServer.Tests
{
@ -181,7 +182,7 @@ namespace BTCPayServer.Tests
Assert.Equal(Money.Coins(1.0m), accounting.CryptoPaid);
Assert.Equal(Money.Coins(1.0m), accounting.Paid);
Assert.Equal(Money.Coins(5.2m), accounting.TotalDue);
Assert.Equal(2, accounting.TxCount);
Assert.Equal(2, accounting.TxRequired);
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike), null);
accounting = paymentMethod.Calculate();
@ -199,7 +200,7 @@ namespace BTCPayServer.Tests
Assert.Equal(Money.Coins(1.0m), accounting.CryptoPaid);
Assert.Equal(Money.Coins(1.5m), accounting.Paid);
Assert.Equal(Money.Coins(5.2m + 0.01m / 2), accounting.TotalDue); // The fee for LTC added
Assert.Equal(2, accounting.TxCount);
Assert.Equal(2, accounting.TxRequired);
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike), null);
accounting = paymentMethod.Calculate();
@ -207,7 +208,7 @@ namespace BTCPayServer.Tests
Assert.Equal(Money.Coins(1.0m), accounting.CryptoPaid);
Assert.Equal(Money.Coins(3.0m), accounting.Paid);
Assert.Equal(Money.Coins(10.01m + 0.1m * 2 + 0.01m), accounting.TotalDue);
Assert.Equal(2, accounting.TxCount);
Assert.Equal(2, accounting.TxRequired);
var remaining = Money.Coins(4.2m - 0.5m + 0.01m / 2);
entity.Payments.Add(new PaymentEntity() { CryptoCode = "BTC", Output = new TxOut(remaining, new Key()), Accounted = true });
@ -219,7 +220,7 @@ namespace BTCPayServer.Tests
Assert.Equal(Money.Coins(1.5m) + remaining, accounting.Paid);
Assert.Equal(Money.Coins(5.2m + 0.01m / 2), accounting.TotalDue);
Assert.Equal(accounting.Paid, accounting.TotalDue);
Assert.Equal(2, accounting.TxCount);
Assert.Equal(2, accounting.TxRequired);
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike), null);
accounting = paymentMethod.Calculate();
@ -228,7 +229,7 @@ namespace BTCPayServer.Tests
Assert.Equal(Money.Coins(3.0m) + remaining * 2, accounting.Paid);
// Paying 2 BTC fee, LTC fee removed because fully paid
Assert.Equal(Money.Coins(10.01m + 0.1m * 2 + 0.1m * 2 /* + 0.01m no need to pay this fee anymore */), accounting.TotalDue);
Assert.Equal(1, accounting.TxCount);
Assert.Equal(1, accounting.TxRequired);
Assert.Equal(accounting.Paid, accounting.TotalDue);
#pragma warning restore CS0618
}
@ -287,6 +288,41 @@ namespace BTCPayServer.Tests
{
var light = LightMoney.MilliSatoshis(1);
Assert.Equal("0.00000000001", light.ToString());
light = LightMoney.MilliSatoshis(200000);
Assert.Equal(200m, light.ToDecimal(LightMoneyUnit.Satoshi));
Assert.Equal(0.00000001m * 200m, light.ToDecimal(LightMoneyUnit.BTC));
}
[Fact]
public void CanSetLightningServer()
{
using (var tester = ServerTester.Create())
{
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
var storeController = tester.PayTester.GetController<StoresController>(user.UserId);
Assert.IsType<ViewResult>(storeController.UpdateStore(user.StoreId).GetAwaiter().GetResult());
Assert.IsType<ViewResult>(storeController.AddLightningNode(user.StoreId).GetAwaiter().GetResult());
var testResult = storeController.AddLightningNode(user.StoreId, new LightningNodeViewModel()
{
CryptoCurrency = "BTC",
Url = tester.MerchantCharge.Client.Uri.AbsoluteUri
}, "test").GetAwaiter().GetResult();
Assert.DoesNotContain("Error", ((LightningNodeViewModel)Assert.IsType<ViewResult>(testResult).Model).StatusMessage, StringComparison.OrdinalIgnoreCase);
Assert.True(storeController.ModelState.IsValid);
Assert.IsType<RedirectToActionResult>(storeController.AddLightningNode(user.StoreId, new LightningNodeViewModel()
{
CryptoCurrency = "BTC",
Url = tester.MerchantCharge.Client.Uri.AbsoluteUri
}, "save").GetAwaiter().GetResult());
var storeVm = Assert.IsType<Models.StoreViewModels.StoreViewModel>(Assert.IsType<ViewResult>(storeController.UpdateStore(user.StoreId).GetAwaiter().GetResult()).Model);
Assert.Single(storeVm.LightningNodes);
}
}
[Fact]
@ -297,7 +333,29 @@ namespace BTCPayServer.Tests
{
tester.Start();
tester.PrepareLightning();
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterLightningNode("BTC");
user.RegisterDerivationScheme("BTC");
var invoice = user.BitPay.CreateInvoice(new Invoice()
{
Price = 0.01,
Currency = "USD",
PosData = "posData",
OrderId = "orderId",
ItemDesc = "Some description"
});
tester.SendLightningPayment(invoice);
Eventually(() =>
{
var localInvoice = user.BitPay.GetInvoice(invoice.Id);
Assert.Equal("complete", localInvoice.Status);
Assert.Equal("False", localInvoice.ExceptionStatus.ToString());
});
}
}
@ -674,7 +732,7 @@ namespace BTCPayServer.Tests
}, Facade.Merchant);
var repo = tester.PayTester.GetService<InvoiceRepository>();
var ctx = tester.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
Assert.Equal(0, invoice.CryptoInfo[0].TxCount);
Eventually(() =>
{
var textSearchResult = tester.PayTester.InvoiceRepository.GetInvoices(new InvoiceQuery()
@ -730,6 +788,7 @@ namespace BTCPayServer.Tests
Assert.Equal(firstPayment, localInvoice.BtcPaid);
txFee = localInvoice.BtcDue - invoice.BtcDue;
Assert.Equal("paidPartial", localInvoice.ExceptionStatus.ToString());
Assert.Equal(1, localInvoice.CryptoInfo[0].TxCount);
Assert.NotEqual(localInvoice.BitcoinAddress, invoice.BitcoinAddress); //New address
Assert.True(IsMapped(invoice, ctx));
Assert.True(IsMapped(localInvoice, ctx));
@ -749,6 +808,7 @@ namespace BTCPayServer.Tests
{
var localInvoice = user.BitPay.GetInvoice(invoice.Id, Facade.Merchant);
Assert.Equal("paid", localInvoice.Status);
Assert.Equal(2, localInvoice.CryptoInfo[0].TxCount);
Assert.Equal(firstPayment + secondPayment, localInvoice.BtcPaid);
Assert.Equal(Money.Zero, localInvoice.BtcDue);
Assert.Equal(localInvoice.BitcoinAddress, invoiceAddress.ToString()); //no new address generated
@ -835,7 +895,7 @@ namespace BTCPayServer.Tests
private void Eventually(Action act)
{
CancellationTokenSource cts = new CancellationTokenSource(20000);
CancellationTokenSource cts = new CancellationTokenSource(200000);
while (true)
{
try

View file

@ -62,11 +62,13 @@ namespace BTCPayServer
}
public string CryptoImagePath { get; set; }
public string LightningImagePath { get; set; }
public NBXplorer.NBXplorerNetwork NBXplorerNetwork { get; set; }
public BTCPayDefaultSettings DefaultSettings { get; set; }
public KeyPath CoinType { get; internal set; }
public int MaxTrackedConfirmation { get; internal set; } = 6;
public string CLightningNetworkName { get; internal set; }
public override string ToString()
{

View file

@ -26,8 +26,12 @@ namespace BTCPayServer
UriScheme = "bitcoin",
DefaultRateProvider = btcRate,
CryptoImagePath = "imlegacy/bitcoin-symbol.svg",
LightningImagePath = "imlegacy/bitcoin-symbol.svg",
DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NBXplorerNetworkProvider.ChainType),
CoinType = NBXplorerNetworkProvider.ChainType == ChainType.Main ? new KeyPath("0'") : new KeyPath("1'")
CoinType = NBXplorerNetworkProvider.ChainType == ChainType.Main ? new KeyPath("0'") : new KeyPath("1'"),
CLightningNetworkName = ChainType == ChainType.Main ? "bitcoin" :
ChainType == ChainType.Test ? "testnet" :
ChainType == ChainType.Regtest ? "regtest" : null
});
}
}

View file

@ -25,8 +25,11 @@ namespace BTCPayServer
UriScheme = "litecoin",
DefaultRateProvider = ltcRate,
CryptoImagePath = "imlegacy/litecoin-symbol.svg",
LightningImagePath = "imlegacy/litecoin-symbol.svg",
DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NBXplorerNetworkProvider.ChainType),
CoinType = NBXplorerNetworkProvider.ChainType == ChainType.Main ? new KeyPath("2'") : new KeyPath("3'")
CoinType = NBXplorerNetworkProvider.ChainType == ChainType.Main ? new KeyPath("2'") : new KeyPath("3'"),
CLightningNetworkName = ChainType == ChainType.Main ? "litecoin" :
ChainType == ChainType.Test ? "litecoin-testnet" : null
});
}
}

View file

@ -25,13 +25,40 @@ namespace BTCPayServer
}
}
BTCPayNetworkProvider(BTCPayNetworkProvider filtered, string[] cryptoCodes)
{
ChainType = filtered.ChainType;
_NBXplorerNetworkProvider = new NBXplorerNetworkProvider(filtered.ChainType);
_Networks = new Dictionary<string, BTCPayNetwork>();
cryptoCodes = cryptoCodes.Select(c => c.ToUpperInvariant()).ToArray();
foreach (var network in filtered._Networks)
{
if(cryptoCodes.Contains(network.Key))
{
_Networks.Add(network.Key, network.Value);
}
}
}
public ChainType ChainType { get; set; }
public BTCPayNetworkProvider(ChainType chainType)
{
_NBXplorerNetworkProvider = new NBXplorerNetworkProvider(chainType);
ChainType = chainType;
InitBitcoin();
InitLitecoin();
}
/// <summary>
/// Keep only the specified crypto
/// </summary>
/// <param name="cryptoCodes">Crypto to support</param>
/// <returns></returns>
public BTCPayNetworkProvider Filter(string[] cryptoCodes)
{
return new BTCPayNetworkProvider(this, cryptoCodes);
}
[Obsolete("To use only for legacy stuff")]
public BTCPayNetwork BTC
{
@ -43,7 +70,7 @@ namespace BTCPayServer
public void Add(BTCPayNetwork network)
{
_Networks.Add(network.CryptoCode, network);
_Networks.Add(network.CryptoCode.ToUpperInvariant(), network);
}
public IEnumerable<BTCPayNetwork> GetAll()
@ -51,6 +78,11 @@ namespace BTCPayServer
return _Networks.Values.ToArray();
}
public bool Support(string cryptoCode)
{
return _Networks.ContainsKey(cryptoCode.ToUpperInvariant());
}
public BTCPayNetwork GetNetwork(string cryptoCode)
{
_Networks.TryGetValue(cryptoCode.ToUpperInvariant(), out BTCPayNetwork network);

View file

@ -26,7 +26,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Filter" Version="1.1.2" />
<PackageReference Include="Microsoft.NetCore.Analyzers" Version="2.6.0" />
<PackageReference Include="NBitcoin" Version="4.0.0.56" />
<PackageReference Include="NBitpayClient" Version="1.0.0.17" />
<PackageReference Include="NBitpayClient" Version="1.0.0.18" />
<PackageReference Include="DBreeze" Version="1.87.0" />
<PackageReference Include="NBXplorer.Client" Version="1.0.1.9" />
<PackageReference Include="NicolasDorier.CommandLine" Version="1.0.0.1" />

View file

@ -58,28 +58,30 @@ namespace BTCPayServer.Configuration
var supportedChains = conf.GetOrDefault<string>("chains", "btc")
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.ToUpperInvariant());
NetworkProvider = new BTCPayNetworkProvider(ChainType).Filter(supportedChains.ToArray());
foreach (var chain in supportedChains)
{
if (NetworkProvider.GetNetwork(chain) == null)
throw new ConfigException($"Invalid chains \"{chain}\"");
}
var validChains = new List<string>();
foreach (var net in new BTCPayNetworkProvider(ChainType).GetAll())
foreach (var net in NetworkProvider.GetAll())
{
if (supportedChains.Contains(net.CryptoCode))
{
validChains.Add(net.CryptoCode);
NBXplorerConnectionSetting setting = new NBXplorerConnectionSetting();
setting.CryptoCode = net.CryptoCode;
setting.ExplorerUri = conf.GetOrDefault<Uri>($"{net.CryptoCode}.explorer.url", net.NBXplorerNetwork.DefaultSettings.DefaultUrl);
setting.CookieFile = conf.GetOrDefault<string>($"{net.CryptoCode}.explorer.cookiefile", net.NBXplorerNetwork.DefaultSettings.DefaultCookieFile);
NBXplorerConnectionSettings.Add(setting);
}
}
var invalidChains = String.Join(',', supportedChains.Where(s => !validChains.Contains(s)).ToArray());
if(!string.IsNullOrEmpty(invalidChains))
throw new ConfigException($"Invalid chains {invalidChains}");
Logs.Configuration.LogInformation("Supported chains: " + String.Join(',', supportedChains.ToArray()));
PostgresConnectionString = conf.GetOrDefault<string>("postgres", null);
ExternalUrl = conf.GetOrDefault<Uri>("externalurl", null);
}
public BTCPayNetworkProvider NetworkProvider { get; set; }
public string PostgresConnectionString
{
get;

View file

@ -198,24 +198,29 @@ namespace BTCPayServer.Controllers
Rate = FormatCurrency(paymentMethod),
MerchantRefLink = invoice.RedirectURL ?? "/",
StoreName = store.StoreName,
InvoiceBitcoinUrl = cryptoInfo.PaymentUrls.BIP21,
TxCount = accounting.TxCount,
InvoiceBitcoinUrl = paymentMethodId.PaymentType == PaymentTypes.BTCLike ? cryptoInfo.PaymentUrls.BIP21 :
paymentMethodId.PaymentType == PaymentTypes.LightningLike ? cryptoInfo.PaymentUrls.BOLT11 :
throw new NotSupportedException(),
InvoiceBitcoinUrlQR = paymentMethodId.PaymentType == PaymentTypes.BTCLike ? cryptoInfo.PaymentUrls.BIP21 :
paymentMethodId.PaymentType == PaymentTypes.LightningLike ? cryptoInfo.PaymentUrls.BOLT11.ToUpperInvariant() :
throw new NotSupportedException(),
TxCount = accounting.TxRequired,
BtcPaid = accounting.Paid.ToString(),
Status = invoice.Status,
CryptoImage = "/" + Url.Content(network.CryptoImagePath),
NetworkFeeDescription = $"{accounting.TxCount} transaction{(accounting.TxCount > 1 ? "s" : "")} x {paymentMethodDetails.GetTxFee()} {network.CryptoCode}",
CryptoImage = "/" + GetImage(paymentMethodId, network),
NetworkFeeDescription = $"{accounting.TxRequired} transaction{(accounting.TxRequired > 1 ? "s" : "")} x {paymentMethodDetails.GetTxFee()} {network.CryptoCode}",
AvailableCryptos = invoice.GetPaymentMethods(_NetworkProvider)
.Where(i => i.Network != null)
.Select(kv=> new PaymentModel.AvailableCrypto()
{
PaymentMethodId = kv.GetId().ToString(),
CryptoImage = "/" + kv.Network.CryptoImagePath,
CryptoImage = "/" + GetImage(kv.GetId(), kv.Network),
Link = Url.Action(nameof(Checkout), new { invoiceId = invoiceId, paymentMethodId = kv.GetId().ToString() })
}).Where(c => c.CryptoImage != "/")
.ToList()
};
var isMultiCurrency = invoice.GetPayments().Select(p=>p.GetCryptoCode()).Concat(new[] { network.CryptoCode }).Distinct().Count() > 1;
var isMultiCurrency = invoice.GetPayments().Select(p=>p.GetpaymentMethodId()).Concat(new[] { paymentMethod.GetId() }).Distinct().Count() > 1;
if (isMultiCurrency)
model.NetworkFeeDescription = $"{accounting.NetworkFee} {network.CryptoCode}";
@ -224,6 +229,11 @@ namespace BTCPayServer.Controllers
return model;
}
private string GetImage(PaymentMethodId paymentMethodId, BTCPayNetwork network)
{
return (paymentMethodId.PaymentType == PaymentTypes.BTCLike ? Url.Content(network.CryptoImagePath) : Url.Content(network.LightningImagePath));
}
private string FormatCurrency(PaymentMethod paymentMethod)
{
string currency = paymentMethod.ParentEntity.ProductInformation.Currency;

View file

@ -138,6 +138,7 @@ namespace BTCPayServer.Controllers
{
var rate = await storeBlob.ApplyRateRules(o.Network, _RateProviders.GetRateProvider(o.Network, false)).GetRateAsync(invoice.Currency);
PaymentMethod paymentMethod = new PaymentMethod();
paymentMethod.ParentEntity = entity;
paymentMethod.SetId(o.SupportedPaymentMethod.PaymentId);
paymentMethod.Rate = rate;
var paymentDetails = await o.Handler.CreatePaymentMethodDetails(o.SupportedPaymentMethod, paymentMethod, o.Network);
@ -162,7 +163,7 @@ namespace BTCPayServer.Controllers
#pragma warning disable CS0618
// Legacy Bitpay clients expect information for BTC information, even if the store do not support it
var legacyBTCisSet = paymentMethods.Any(p => p.GetId().IsBTCOnChain);
if (!legacyBTCisSet)
if (!legacyBTCisSet && _NetworkProvider.BTC != null)
{
var btc = _NetworkProvider.BTC;
var feeProvider = ((IFeeProviderFactory)_ServiceProvider.GetService(typeof(IFeeProviderFactory))).CreateFeeProvider(btc);

View file

@ -0,0 +1,302 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBXplorer.DerivationStrategy;
using Newtonsoft.Json;
namespace BTCPayServer.Controllers
{
public partial class StoresController
{
[HttpGet]
[Route("{storeId}/derivations")]
public async Task<IActionResult> AddDerivationScheme(string storeId, string selectedScheme = null)
{
selectedScheme = selectedScheme ?? "BTC";
var store = await _Repo.FindStore(storeId, GetUserId());
if (store == null)
return NotFound();
DerivationSchemeViewModel vm = new DerivationSchemeViewModel();
vm.ServerUrl = GetStoreUrl(storeId);
vm.SetCryptoCurrencies(_ExplorerProvider, selectedScheme);
return View(vm);
}
[HttpPost]
[Route("{storeId}/derivations")]
public async Task<IActionResult> AddDerivationScheme(string storeId, DerivationSchemeViewModel vm)
{
vm.ServerUrl = GetStoreUrl(storeId);
var store = await _Repo.FindStore(storeId, GetUserId());
if (store == null)
return NotFound();
var network = vm.CryptoCurrency == null ? null : _ExplorerProvider.GetNetwork(vm.CryptoCurrency);
vm.SetCryptoCurrencies(_ExplorerProvider, vm.CryptoCurrency);
if (network == null)
{
ModelState.AddModelError(nameof(vm.CryptoCurrency), "Invalid network");
return View(vm);
}
var wallet = _WalletProvider.GetWallet(network);
if (wallet == null)
{
ModelState.AddModelError(nameof(vm.CryptoCurrency), "Invalid network");
return View(vm);
}
PaymentMethodId paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
DerivationStrategy strategy = null;
try
{
if (!string.IsNullOrEmpty(vm.DerivationScheme))
{
strategy = ParseDerivationStrategy(vm.DerivationScheme, vm.DerivationSchemeFormat, network);
vm.DerivationScheme = strategy.ToString();
}
store.SetSupportedPaymentMethod(paymentMethodId, strategy);
}
catch
{
ModelState.AddModelError(nameof(vm.DerivationScheme), "Invalid Derivation Scheme");
vm.Confirmation = false;
return View(vm);
}
if (vm.Confirmation)
{
try
{
if (strategy != null)
await wallet.TrackAsync(strategy.DerivationStrategyBase);
store.SetSupportedPaymentMethod(paymentMethodId, strategy);
}
catch
{
ModelState.AddModelError(nameof(vm.DerivationScheme), "Invalid Derivation Scheme");
return View(vm);
}
await _Repo.UpdateStore(store);
StatusMessage = $"Derivation scheme for {network.CryptoCode} has been modified.";
return RedirectToAction(nameof(UpdateStore), new { storeId = storeId });
}
else
{
if (!string.IsNullOrEmpty(vm.DerivationScheme))
{
var line = strategy.DerivationStrategyBase.GetLineFor(DerivationFeature.Deposit);
for (int i = 0; i < 10; i++)
{
var address = line.Derive((uint)i);
vm.AddressSamples.Add((DerivationStrategyBase.GetKeyPath(DerivationFeature.Deposit).Derive((uint)i).ToString(), address.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork).ToString()));
}
}
vm.Confirmation = true;
return View(vm);
}
}
public class GetInfoResult
{
public int RecommendedSatoshiPerByte { get; set; }
public double Balance { get; set; }
}
public class SendToAddressResult
{
public string TransactionId { get; set; }
}
[HttpGet]
[Route("{storeId}/ws/ledger")]
public async Task<IActionResult> LedgerConnection(
string storeId,
string command,
// getinfo
string cryptoCode = null,
// sendtoaddress
string destination = null, string amount = null, string feeRate = null, string substractFees = null
)
{
if (!HttpContext.WebSockets.IsWebSocketRequest)
return NotFound();
var store = await _Repo.FindStore(storeId, GetUserId());
if (store == null)
return NotFound();
var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
var hw = new HardwareWalletService(webSocket);
object result = null;
try
{
BTCPayNetwork network = null;
if (cryptoCode != null)
{
network = _NetworkProvider.GetNetwork(cryptoCode);
if (network == null)
throw new FormatException("Invalid value for crypto code");
}
BitcoinAddress destinationAddress = null;
if (destination != null)
{
try
{
destinationAddress = BitcoinAddress.Create(destination);
}
catch { }
if (destinationAddress == null)
throw new FormatException("Invalid value for destination");
}
FeeRate feeRateValue = null;
if (feeRate != null)
{
try
{
feeRateValue = new FeeRate(Money.Satoshis(int.Parse(feeRate, CultureInfo.InvariantCulture)), 1);
}
catch { }
if (feeRateValue == null || feeRateValue.FeePerK <= Money.Zero)
throw new FormatException("Invalid value for fee rate");
}
Money amountBTC = null;
if (amount != null)
{
try
{
amountBTC = Money.Parse(amount);
}
catch { }
if (amountBTC == null || amountBTC <= Money.Zero)
throw new FormatException("Invalid value for amount");
}
bool subsctractFeesValue = false;
if (substractFees != null)
{
try
{
subsctractFeesValue = bool.Parse(substractFees);
}
catch { throw new FormatException("Invalid value for substract fees"); }
}
if (command == "test")
{
result = await hw.Test();
}
if (command == "getxpub")
{
result = await hw.GetExtPubKey(network);
}
if (command == "getinfo")
{
var strategy = GetDirectDerivationStrategy(store, network);
var strategyBase = GetDerivationStrategy(store, network);
if (strategy == null || !await hw.SupportDerivation(network, strategy))
{
throw new Exception($"This store is not configured to use this ledger");
}
var feeProvider = _FeeRateProvider.CreateFeeProvider(network);
var recommendedFees = feeProvider.GetFeeRateAsync();
var balance = _WalletProvider.GetWallet(network).GetBalance(strategyBase);
result = new GetInfoResult() { Balance = (double)(await balance).ToDecimal(MoneyUnit.BTC), RecommendedSatoshiPerByte = (int)(await recommendedFees).GetFee(1).Satoshi };
}
if (command == "sendtoaddress")
{
var strategy = GetDirectDerivationStrategy(store, network);
var strategyBase = GetDerivationStrategy(store, network);
var wallet = _WalletProvider.GetWallet(network);
var change = wallet.GetChangeAddressAsync(strategyBase);
var unspentCoins = await wallet.GetUnspentCoins(strategyBase);
var changeAddress = await change;
var transaction = await hw.SendToAddress(strategy, unspentCoins, network,
new[] { (destinationAddress as IDestination, amountBTC, subsctractFeesValue) },
feeRateValue,
changeAddress.Item1,
changeAddress.Item2);
try
{
var broadcastResult = await wallet.BroadcastTransactionsAsync(new List<Transaction>() { transaction });
if (!broadcastResult[0].Success)
{
throw new Exception($"RPC Error while broadcasting: {broadcastResult[0].RPCCode} {broadcastResult[0].RPCCodeMessage} {broadcastResult[0].RPCMessage}");
}
}
catch (Exception ex)
{
throw new Exception("Error while broadcasting: " + ex.Message);
}
wallet.InvalidateCache(strategyBase);
result = new SendToAddressResult() { TransactionId = transaction.GetHash().ToString() };
}
}
catch (OperationCanceledException)
{ result = new LedgerTestResult() { Success = false, Error = "Timeout" }; }
catch (Exception ex)
{ result = new LedgerTestResult() { Success = false, Error = ex.Message }; }
try
{
if (result != null)
{
UTF8Encoding UTF8NOBOM = new UTF8Encoding(false);
var bytes = UTF8NOBOM.GetBytes(JsonConvert.SerializeObject(result, _MvcJsonOptions.SerializerSettings));
await webSocket.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, new CancellationTokenSource(2000).Token);
}
}
catch { }
finally
{
await webSocket.CloseSocket();
}
return new EmptyResult();
}
private DirectDerivationStrategy GetDirectDerivationStrategy(StoreData store, BTCPayNetwork network)
{
var strategy = GetDerivationStrategy(store, network);
var directStrategy = strategy as DirectDerivationStrategy;
if (directStrategy == null)
directStrategy = (strategy as P2SHDerivationStrategy).Inner as DirectDerivationStrategy;
if (!directStrategy.Segwit)
return null;
return directStrategy;
}
private DerivationStrategyBase GetDerivationStrategy(StoreData store, BTCPayNetwork network)
{
var strategy = store
.GetSupportedPaymentMethods(_NetworkProvider)
.OfType<DerivationStrategy>()
.FirstOrDefault(s => s.Network.NBitcoinNetwork == network.NBitcoinNetwork);
if (strategy == null)
{
throw new Exception($"Derivation strategy for {network.CryptoCode} is not set");
}
return strategy.DerivationStrategyBase;
}
}
}

View file

@ -0,0 +1,103 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning.CLightning;
using Microsoft.AspNetCore.Mvc;
using BTCPayServer.Payments.Lightning;
namespace BTCPayServer.Controllers
{
public partial class StoresController
{
[HttpGet]
[Route("{storeId}/lightning")]
public async Task<IActionResult> AddLightningNode(string storeId, string selectedCrypto = null)
{
selectedCrypto = selectedCrypto ?? "BTC";
var store = await _Repo.FindStore(storeId, GetUserId());
if (store == null)
return NotFound();
LightningNodeViewModel vm = new LightningNodeViewModel();
vm.SetCryptoCurrencies(_NetworkProvider, selectedCrypto);
return View(vm);
}
[HttpPost]
[Route("{storeId}/lightning")]
public async Task<IActionResult> AddLightningNode(string storeId, LightningNodeViewModel vm, string command)
{
var store = await _Repo.FindStore(storeId, GetUserId());
if (store == null)
return NotFound();
var network = vm.CryptoCurrency == null ? null : _ExplorerProvider.GetNetwork(vm.CryptoCurrency);
vm.SetCryptoCurrencies(_NetworkProvider, vm.CryptoCurrency);
if (network == null || network.CLightningNetworkName == null)
{
ModelState.AddModelError(nameof(vm.CryptoCurrency), "Invalid network");
return View(vm);
}
PaymentMethodId paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.LightningLike);
Payments.Lightning.LightningSupportedPaymentMethod paymentMethod = null;
if (!string.IsNullOrEmpty(vm.Url))
{
Uri uri;
if (!Uri.TryCreate(vm.Url, UriKind.Absolute, out uri))
{
ModelState.AddModelError(nameof(vm.Url), "Invalid URL");
return View(vm);
}
if (_NetworkProvider.ChainType == NBXplorer.ChainType.Main)
{
if (uri.Scheme != "https")
{
ModelState.AddModelError(nameof(vm.Url), "The url must be HTTPS");
return View(vm);
}
}
if (string.IsNullOrEmpty(uri.UserInfo) || uri.UserInfo.Split(':').Length != 2)
{
ModelState.AddModelError(nameof(vm.Url), "The url is missing user and password");
return View(vm);
}
paymentMethod = new Payments.Lightning.LightningSupportedPaymentMethod()
{
CryptoCode = paymentMethodId.CryptoCode
};
paymentMethod.SetLightningChargeUrl(uri);
}
if (command == "save")
{
store.SetSupportedPaymentMethod(paymentMethodId, paymentMethod);
await _Repo.UpdateStore(store);
StatusMessage = $"Lightning node modified ({network.CryptoCode})";
return RedirectToAction(nameof(UpdateStore), new { storeId = storeId });
}
else // if(command == "test")
{
if (paymentMethod == null)
{
ModelState.AddModelError(nameof(vm.Url), "Missing url parameter");
return View(vm);
}
var handler = (LightningLikePaymentHandler)_ServiceProvider.GetRequiredService<IPaymentMethodHandler<Payments.Lightning.LightningSupportedPaymentMethod>>();
try
{
await handler.Test(paymentMethod, network);
}
catch (Exception ex)
{
vm.StatusMessage = $"Error: {ex.Message}";
return View(vm);
}
vm.StatusMessage = "Connection to the lightning node succeed";
return View(vm);
}
}
}
}

View file

@ -2,13 +2,9 @@
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Fees;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using LedgerWallet;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
@ -17,16 +13,11 @@ using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Options;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitpayClient;
using NBXplorer.DerivationStrategy;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@ -36,9 +27,10 @@ namespace BTCPayServer.Controllers
[Authorize(AuthenticationSchemes = "Identity.Application")]
[Authorize(Policy = "CanAccessStore")]
[AutoValidateAntiforgeryToken]
public class StoresController : Controller
public partial class StoresController : Controller
{
public StoresController(
IServiceProvider serviceProvider,
IOptions<MvcJsonOptions> mvcJsonOptions,
StoreRepository repo,
TokenRepository tokenRepo,
@ -60,7 +52,9 @@ namespace BTCPayServer.Controllers
_ExplorerProvider = explorerProvider;
_MvcJsonOptions = mvcJsonOptions.Value;
_FeeRateProvider = feeRateProvider;
_ServiceProvider = serviceProvider;
}
IServiceProvider _ServiceProvider;
BTCPayNetworkProvider _NetworkProvider;
private ExplorerClientProvider _ExplorerProvider;
private MvcJsonOptions _MvcJsonOptions;
@ -122,193 +116,6 @@ namespace BTCPayServer.Controllers
return HttpContext.Request.GetAbsoluteRoot() + "/stores/" + storeId + "/";
}
public class GetInfoResult
{
public int RecommendedSatoshiPerByte { get; set; }
public double Balance { get; set; }
}
public class SendToAddressResult
{
public string TransactionId { get; set; }
}
[HttpGet]
[Route("{storeId}/ws/ledger")]
public async Task<IActionResult> LedgerConnection(
string storeId,
string command,
// getinfo
string cryptoCode = null,
// sendtoaddress
string destination = null, string amount = null, string feeRate = null, string substractFees = null
)
{
if (!HttpContext.WebSockets.IsWebSocketRequest)
return NotFound();
var store = await _Repo.FindStore(storeId, GetUserId());
if (store == null)
return NotFound();
var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
var hw = new HardwareWalletService(webSocket);
object result = null;
try
{
BTCPayNetwork network = null;
if (cryptoCode != null)
{
network = _NetworkProvider.GetNetwork(cryptoCode);
if (network == null)
throw new FormatException("Invalid value for crypto code");
}
BitcoinAddress destinationAddress = null;
if (destination != null)
{
try
{
destinationAddress = BitcoinAddress.Create(destination);
}
catch { }
if (destinationAddress == null)
throw new FormatException("Invalid value for destination");
}
FeeRate feeRateValue = null;
if (feeRate != null)
{
try
{
feeRateValue = new FeeRate(Money.Satoshis(int.Parse(feeRate, CultureInfo.InvariantCulture)), 1);
}
catch { }
if (feeRateValue == null || feeRateValue.FeePerK <= Money.Zero)
throw new FormatException("Invalid value for fee rate");
}
Money amountBTC = null;
if (amount != null)
{
try
{
amountBTC = Money.Parse(amount);
}
catch { }
if (amountBTC == null || amountBTC <= Money.Zero)
throw new FormatException("Invalid value for amount");
}
bool subsctractFeesValue = false;
if (substractFees != null)
{
try
{
subsctractFeesValue = bool.Parse(substractFees);
}
catch { throw new FormatException("Invalid value for substract fees"); }
}
if (command == "test")
{
result = await hw.Test();
}
if (command == "getxpub")
{
result = await hw.GetExtPubKey(network);
}
if (command == "getinfo")
{
var strategy = GetDirectDerivationStrategy(store, network);
var strategyBase = GetDerivationStrategy(store, network);
if (strategy == null || !await hw.SupportDerivation(network, strategy))
{
throw new Exception($"This store is not configured to use this ledger");
}
var feeProvider = _FeeRateProvider.CreateFeeProvider(network);
var recommendedFees = feeProvider.GetFeeRateAsync();
var balance = _WalletProvider.GetWallet(network).GetBalance(strategyBase);
result = new GetInfoResult() { Balance = (double)(await balance).ToDecimal(MoneyUnit.BTC), RecommendedSatoshiPerByte = (int)(await recommendedFees).GetFee(1).Satoshi };
}
if (command == "sendtoaddress")
{
var strategy = GetDirectDerivationStrategy(store, network);
var strategyBase = GetDerivationStrategy(store, network);
var wallet = _WalletProvider.GetWallet(network);
var change = wallet.GetChangeAddressAsync(strategyBase);
var unspentCoins = await wallet.GetUnspentCoins(strategyBase);
var changeAddress = await change;
var transaction = await hw.SendToAddress(strategy, unspentCoins, network,
new[] { (destinationAddress as IDestination, amountBTC, subsctractFeesValue) },
feeRateValue,
changeAddress.Item1,
changeAddress.Item2);
try
{
var broadcastResult = await wallet.BroadcastTransactionsAsync(new List<Transaction>() { transaction });
if (!broadcastResult[0].Success)
{
throw new Exception($"RPC Error while broadcasting: {broadcastResult[0].RPCCode} {broadcastResult[0].RPCCodeMessage} {broadcastResult[0].RPCMessage}");
}
}
catch (Exception ex)
{
throw new Exception("Error while broadcasting: " + ex.Message);
}
wallet.InvalidateCache(strategyBase);
result = new SendToAddressResult() { TransactionId = transaction.GetHash().ToString() };
}
}
catch (OperationCanceledException)
{ result = new LedgerTestResult() { Success = false, Error = "Timeout" }; }
catch (Exception ex)
{ result = new LedgerTestResult() { Success = false, Error = ex.Message }; }
try
{
if (result != null)
{
UTF8Encoding UTF8NOBOM = new UTF8Encoding(false);
var bytes = UTF8NOBOM.GetBytes(JsonConvert.SerializeObject(result, _MvcJsonOptions.SerializerSettings));
await webSocket.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, new CancellationTokenSource(2000).Token);
}
}
catch { }
finally
{
await webSocket.CloseSocket();
}
return new EmptyResult();
}
private DirectDerivationStrategy GetDirectDerivationStrategy(StoreData store, BTCPayNetwork network)
{
var strategy = GetDerivationStrategy(store, network);
var directStrategy = strategy as DirectDerivationStrategy;
if (directStrategy == null)
directStrategy = (strategy as P2SHDerivationStrategy).Inner as DirectDerivationStrategy;
if (!directStrategy.Segwit)
return null;
return directStrategy;
}
private DerivationStrategyBase GetDerivationStrategy(StoreData store, BTCPayNetwork network)
{
var strategy = store
.GetSupportedPaymentMethods(_NetworkProvider)
.OfType<DerivationStrategy>()
.FirstOrDefault(s => s.Network.NBitcoinNetwork == network.NBitcoinNetwork);
if (strategy == null)
{
throw new Exception($"Derivation strategy for {network.CryptoCode} is not set");
}
return strategy.DerivationStrategyBase;
}
[HttpGet]
public async Task<IActionResult> ListStores()
{
@ -398,7 +205,7 @@ namespace BTCPayServer.Controllers
vm.StoreWebsite = store.StoreWebsite;
vm.NetworkFee = !storeBlob.NetworkFeeDisabled;
vm.SpeedPolicy = store.SpeedPolicy;
AddDerivationSchemes(store, vm);
AddPaymentMethods(store, vm);
vm.StatusMessage = StatusMessage;
vm.MonitoringExpiration = storeBlob.MonitoringExpiration;
vm.InvoiceExpiration = storeBlob.InvoiceExpiration;
@ -407,114 +214,28 @@ namespace BTCPayServer.Controllers
return View(vm);
}
private void AddDerivationSchemes(StoreData store, StoreViewModel vm)
private void AddPaymentMethods(StoreData store, StoreViewModel vm)
{
var strategies = store
foreach(var strategy in store
.GetSupportedPaymentMethods(_NetworkProvider)
.OfType<DerivationStrategy>()
.ToDictionary(s => s.Network.CryptoCode);
foreach (var explorerProvider in _ExplorerProvider.GetAll())
{
if (strategies.TryGetValue(explorerProvider.Item1.CryptoCode, out DerivationStrategy strat))
.OfType<DerivationStrategy>())
{
vm.DerivationSchemes.Add(new StoreViewModel.DerivationScheme()
{
Crypto = explorerProvider.Item1.CryptoCode,
Value = strat.DerivationStrategyBase.ToString()
Crypto = strategy.PaymentId.CryptoCode,
Value = strategy.DerivationStrategyBase.ToString()
});
}
}
}
[HttpGet]
[Route("{storeId}/derivations")]
public async Task<IActionResult> AddDerivationScheme(string storeId, string selectedScheme = null)
foreach(var lightning in store
.GetSupportedPaymentMethods(_NetworkProvider)
.OfType<Payments.Lightning.LightningSupportedPaymentMethod>())
{
selectedScheme = selectedScheme ?? "BTC";
var store = await _Repo.FindStore(storeId, GetUserId());
if (store == null)
return NotFound();
DerivationSchemeViewModel vm = new DerivationSchemeViewModel();
vm.ServerUrl = GetStoreUrl(storeId);
vm.SetCryptoCurrencies(_ExplorerProvider, selectedScheme);
return View(vm);
}
[HttpPost]
[Route("{storeId}/derivations")]
public async Task<IActionResult> AddDerivationScheme(string storeId, DerivationSchemeViewModel vm, string selectedScheme = null)
vm.LightningNodes.Add(new StoreViewModel.LightningNode()
{
selectedScheme = selectedScheme ?? "BTC";
vm.ServerUrl = GetStoreUrl(storeId);
var store = await _Repo.FindStore(storeId, GetUserId());
if (store == null)
return NotFound();
var network = vm.CryptoCurrency == null ? null : _ExplorerProvider.GetNetwork(vm.CryptoCurrency);
vm.SetCryptoCurrencies(_ExplorerProvider, selectedScheme);
if (network == null)
{
ModelState.AddModelError(nameof(vm.CryptoCurrency), "Invalid network");
return View(vm);
}
var wallet = _WalletProvider.GetWallet(network);
if (wallet == null)
{
ModelState.AddModelError(nameof(vm.CryptoCurrency), "Invalid network");
return View(vm);
}
PaymentMethodId paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
DerivationStrategy strategy = null;
try
{
if (!string.IsNullOrEmpty(vm.DerivationScheme))
{
strategy = ParseDerivationStrategy(vm.DerivationScheme, vm.DerivationSchemeFormat, network);
vm.DerivationScheme = strategy.ToString();
}
store.SetSupportedPaymentMethod(paymentMethodId, strategy);
}
catch
{
ModelState.AddModelError(nameof(vm.DerivationScheme), "Invalid Derivation Scheme");
vm.Confirmation = false;
return View(vm);
}
if (vm.Confirmation)
{
try
{
if (strategy != null)
await wallet.TrackAsync(strategy.DerivationStrategyBase);
store.SetSupportedPaymentMethod(paymentMethodId, strategy);
}
catch
{
ModelState.AddModelError(nameof(vm.DerivationScheme), "Invalid Derivation Scheme");
return View(vm);
}
await _Repo.UpdateStore(store);
StatusMessage = $"Derivation scheme for {network.CryptoCode} has been modified.";
return RedirectToAction(nameof(UpdateStore), new { storeId = storeId });
}
else
{
if (!string.IsNullOrEmpty(vm.DerivationScheme))
{
var line = strategy.DerivationStrategyBase.GetLineFor(DerivationFeature.Deposit);
for (int i = 0; i < 10; i++)
{
var address = line.Derive((uint)i);
vm.AddressSamples.Add((DerivationStrategyBase.GetKeyPath(DerivationFeature.Deposit).Derive((uint)i).ToString(), address.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork).ToString()));
}
}
vm.Confirmation = true;
return View(vm);
CryptoCode = lightning.CryptoCode,
Address = lightning.GetLightningChargeUrl(false).AbsoluteUri
});
}
}
@ -531,7 +252,7 @@ namespace BTCPayServer.Controllers
var store = await _Repo.FindStore(storeId, GetUserId());
if (store == null)
return NotFound();
AddDerivationSchemes(store, model);
AddPaymentMethods(store, model);
bool needUpdate = false;
if (store.SpeedPolicy != model.SpeedPolicy)

View file

@ -129,7 +129,7 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<BTCPayNetworkProvider>(o =>
{
var opts = o.GetRequiredService<BTCPayServerOptions>();
return new BTCPayNetworkProvider(opts.ChainType);
return opts.NetworkProvider;
});
services.TryAddSingleton<NBXplorerDashboard>();
@ -143,9 +143,12 @@ namespace BTCPayServer.Hosting
});
services.AddSingleton<Payments.IPaymentMethodHandler<DerivationStrategy>, Payments.Bitcoin.BitcoinLikePaymentHandler>();
services.AddSingleton<IHostedService, Payments.Bitcoin.NBXplorerListener>();
services.AddSingleton<Payments.IPaymentMethodHandler<Payments.Lightning.LightningSupportedPaymentMethod>, Payments.Lightning.LightningLikePaymentHandler>();
services.AddSingleton<IHostedService, Payments.Lightning.ChargeListener>();
services.AddSingleton<IHostedService, NBXplorerWaiters>();
services.AddSingleton<IHostedService, Payments.Bitcoin.NBXplorerListener>();
services.AddSingleton<IHostedService, InvoiceNotificationManager>();
services.AddSingleton<IHostedService, InvoiceWatcher>();

View file

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json;
using System.Reflection;
using BTCPayServer.Payments.Lightning;
using NBitcoin.JsonConverters;
using System.Globalization;
namespace BTCPayServer.JsonConverters
{
public class LightMoneyJsonConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return typeof(LightMoneyJsonConverter).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo());
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
try
{
return reader.TokenType == JsonToken.Null ? null :
reader.TokenType == JsonToken.Integer ? new LightMoney((long)reader.Value) :
reader.TokenType == JsonToken.String ? new LightMoney(long.Parse((string)reader.Value, CultureInfo.InvariantCulture))
: null;
}
catch (InvalidCastException)
{
throw new JsonObjectException("Money amount should be in millisatoshi", reader);
}
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
writer.WriteValue(((LightMoney)value).MilliSatoshi);
}
}
}

View file

@ -32,6 +32,7 @@ namespace BTCPayServer.Models.InvoicingModels
public string Rate { get; set; }
public string OrderAmount { get; set; }
public string InvoiceBitcoinUrl { get; set; }
public string InvoiceBitcoinUrlQR { get; set; }
public int TxCount { get; set; }
public string BtcPaid { get; set; }
public string StoreEmail { get; set; }

View file

@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Models.StoreViewModels
{
public class LightningNodeViewModel
{
class Format
{
public string Name { get; set; }
public string Value { get; set; }
}
[Display(Name = "Lightning charge url")]
public string Url
{
get;
set;
}
[Display(Name = "Crypto currency")]
public string CryptoCurrency
{
get;
set;
}
public SelectList CryptoCurrencies { get; set; }
public string StatusMessage { get; set; }
public void SetCryptoCurrencies(BTCPayNetworkProvider networkProvider, string selectedScheme)
{
var choices = networkProvider.GetAll()
.Where(n => n.CLightningNetworkName != null)
.Select(o => new Format() { Name = o.CryptoCode, Value = o.CryptoCode }).ToArray();
var chosen = choices.FirstOrDefault(f => f.Name == selectedScheme) ?? choices.FirstOrDefault();
CryptoCurrencies = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen);
CryptoCurrency = chosen.Name;
}
}
}

View file

@ -103,6 +103,16 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Default crypto currency on checkout")]
public string DefaultCryptoCurrency { get; set; }
public class LightningNode
{
public string CryptoCode { get; set; }
public string Address { get; set; }
}
public List<LightningNode> LightningNodes
{
get; set;
} = new List<LightningNode>();
public void SetCryptoCurrencies(ExplorerClientProvider explorerProvider, string defaultCrypto)
{
var choices = explorerProvider.GetAll().Select(o => new Format() { Name = o.Item1.CryptoCode, Value = o.Item1.CryptoCode }).ToArray();

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,16 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NBitcoin;
using NBXplorer;
using Newtonsoft.Json;
namespace BTCPayServer.Payments.Lightning.CLightning
@ -40,29 +44,69 @@ namespace BTCPayServer.Payments.Lightning.CLightning
Credentials = new NetworkCredential(userInfo[0], userInfo[1]);
}
public async Task<CreateInvoiceResponse> CreateInvoiceAsync(CreateInvoiceRequest request, CancellationToken cancellation = default(CancellationToken))
{
var message = CreateMessage(HttpMethod.Post, "invoice");
Dictionary<string, string> parameters = new Dictionary<string, string>();
parameters.Add("msatoshi", request.Amont.MilliSatoshi.ToString(CultureInfo.InvariantCulture));
parameters.Add("expiry", ((int)request.Expiry.TotalSeconds).ToString(CultureInfo.InvariantCulture));
message.Content = new FormUrlEncodedContent(parameters);
var result = await _Client.SendAsync(message, cancellation);
result.EnsureSuccessStatusCode();
var content = await result.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<CreateInvoiceResponse>(content);
}
public async Task<ChargeSession> Listen(CancellationToken cancellation = default(CancellationToken))
{
var socket = new ClientWebSocket();
socket.Options.SetRequestHeader("Authorization", $"Basic {GetBase64Creds()}");
var uri = new UriBuilder(Uri) { UserName = null, Password = null }.Uri.AbsoluteUri;
if (!uri.EndsWith('/'))
uri += "/";
uri += "ws";
uri = ToWebsocketUri(uri);
await socket.ConnectAsync(new Uri(uri), cancellation);
return new ChargeSession(socket);
}
private static string ToWebsocketUri(string uri)
{
if (uri.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
uri = uri.Replace("https://", "wss://", StringComparison.OrdinalIgnoreCase);
if (uri.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
uri = uri.Replace("http://", "ws://", StringComparison.OrdinalIgnoreCase);
return uri;
}
public NetworkCredential Credentials { get; set; }
public GetInfoResponse GetInfo()
{
return GetInfoAsync().GetAwaiter().GetResult();
}
public async Task<GetInfoResponse> GetInfoAsync()
public async Task<GetInfoResponse> GetInfoAsync(CancellationToken cancellation = default(CancellationToken))
{
var request = Get("info");
var message = await _Client.SendAsync(request);
var request = CreateMessage(HttpMethod.Get, "info");
var message = await _Client.SendAsync(request, cancellation);
message.EnsureSuccessStatusCode();
var content = await message.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<GetInfoResponse>(content);
}
private HttpRequestMessage Get(string path)
private HttpRequestMessage CreateMessage(HttpMethod method, string path)
{
var uri = GetFullUri(path);
var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{Credentials.UserName}:{Credentials.Password}")));
var request = new HttpRequestMessage(method, uri);
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", GetBase64Creds());
return request;
}
private string GetBase64Creds()
{
return Convert.ToBase64String(Encoding.ASCII.GetBytes($"{Credentials.UserName}:{Credentials.Password}"));
}
private Uri GetFullUri(string partialUrl)
{
var uri = _Uri.AbsoluteUri;

View file

@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NBXplorer;
using Newtonsoft.Json;
namespace BTCPayServer.Payments.Lightning.CLightning
{
public class ChargeInvoiceNotification
{
public string Id { get; set; }
[JsonProperty("msatoshi")]
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
public LightMoney MilliSatoshi { get; set; }
[JsonProperty("paid_at")]
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? PaidAt { get; set; }
[JsonProperty("expires_at")]
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? ExpiresAt { get; set; }
public string Status { get; set; }
[JsonProperty("payreq")]
public string PaymentRequest { get; set; }
}
public class ChargeSession : IDisposable
{
private ClientWebSocket socket;
const int ORIGINAL_BUFFER_SIZE = 1024 * 5;
const int MAX_BUFFER_SIZE = 1024 * 1024 * 5;
public ChargeSession(ClientWebSocket socket)
{
this.socket = socket;
var buffer = new byte[ORIGINAL_BUFFER_SIZE];
_Buffer = new ArraySegment<byte>(buffer, 0, buffer.Length);
}
ArraySegment<byte> _Buffer;
public async Task<ChargeInvoiceNotification> NextEvent(CancellationToken cancellation = default(CancellationToken))
{
var buffer = _Buffer;
var array = _Buffer.Array;
var originalSize = _Buffer.Array.Length;
var newSize = _Buffer.Array.Length;
while (true)
{
var message = await socket.ReceiveAsync(buffer, cancellation);
if (message.MessageType == WebSocketMessageType.Close)
{
await CloseSocketAndThrow(WebSocketCloseStatus.NormalClosure, "Close message received from the peer", cancellation);
break;
}
if (message.MessageType != WebSocketMessageType.Text)
{
await CloseSocketAndThrow(WebSocketCloseStatus.InvalidMessageType, "Only Text is supported", cancellation);
break;
}
if (message.EndOfMessage)
{
buffer = new ArraySegment<byte>(array, 0, buffer.Offset + message.Count);
try
{
var o = ParseMessage(buffer);
if (newSize != originalSize)
{
Array.Resize(ref array, originalSize);
}
return o;
}
catch (Exception ex)
{
await CloseSocketAndThrow(WebSocketCloseStatus.InvalidPayloadData, $"Invalid payload: {ex.Message}", cancellation);
}
}
else
{
if (buffer.Count - message.Count <= 0)
{
newSize *= 2;
if (newSize > MAX_BUFFER_SIZE)
await CloseSocketAndThrow(WebSocketCloseStatus.MessageTooBig, "Message is too big", cancellation);
Array.Resize(ref array, newSize);
buffer = new ArraySegment<byte>(array, buffer.Offset, newSize - buffer.Offset);
}
buffer = buffer.Slice(message.Count, buffer.Count - message.Count);
}
}
throw new InvalidOperationException("Should never happen");
}
UTF8Encoding UTF8 = new UTF8Encoding(false, true);
private ChargeInvoiceNotification ParseMessage(ArraySegment<byte> buffer)
{
var str = UTF8.GetString(buffer.Array, 0, buffer.Count);
return JsonConvert.DeserializeObject<ChargeInvoiceNotification>(str, new JsonSerializerSettings());
}
private async Task CloseSocketAndThrow(WebSocketCloseStatus status, string description, CancellationToken cancellation)
{
var array = _Buffer.Array;
if (array.Length != ORIGINAL_BUFFER_SIZE)
Array.Resize(ref array, ORIGINAL_BUFFER_SIZE);
await socket.CloseSocket(status, description, cancellation);
throw new WebSocketException($"The socket has been closed ({status}: {description})");
}
public async void Dispose()
{
await this.socket.CloseSocket();
}
public async Task DisposeAsync()
{
await this.socket.CloseSocket();
}
}
}

View file

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Payments.Lightning.CLightning
{
public class CreateInvoiceRequest
{
public LightMoney Amont { get; set; }
public TimeSpan Expiry { get; set; }
}
}

View file

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Payments.Lightning.CLightning
{
public class CreateInvoiceResponse
{
public string PayReq { get; set; }
public string Id { get; set; }
}
}

View file

@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Events;
using BTCPayServer.Payments.Lightning.CLightning;
using BTCPayServer.Services.Invoices;
using Microsoft.Extensions.Hosting;
using NBXplorer;
namespace BTCPayServer.Payments.Lightning
{
public class ChargeListener : IHostedService
{
EventAggregator _Aggregator;
InvoiceRepository _InvoiceRepository;
BTCPayNetworkProvider _NetworkProvider;
public ChargeListener(EventAggregator aggregator,
InvoiceRepository invoiceRepository,
BTCPayNetworkProvider networkProvider)
{
_Aggregator = aggregator;
_InvoiceRepository = invoiceRepository;
_NetworkProvider = networkProvider;
}
CompositeDisposable leases = new CompositeDisposable();
public Task StartAsync(CancellationToken cancellationToken)
{
leases.Add(_Aggregator.Subscribe<Events.InvoiceEvent>(async inv =>
{
if (inv.Name == "invoice_created")
{
var invoice = await _InvoiceRepository.GetInvoice(null, inv.InvoiceId);
await Task.WhenAll(invoice.GetPaymentMethods(_NetworkProvider)
.Where(c => c.GetId().PaymentType == PaymentTypes.LightningLike)
.Select(s => Listen(invoice, s, _NetworkProvider.GetNetwork(s.GetId().CryptoCode)))).ConfigureAwait(false);
}
}));
return Task.CompletedTask;
}
//MultiValueDictionary<string,string>
private async Task Listen(InvoiceEntity invoice, PaymentMethod paymentMethod, BTCPayNetwork network)
{
var lightningMethod = paymentMethod.GetPaymentMethodDetails() as LightningLikePaymentMethodDetails;
if (lightningMethod == null)
return;
var lightningSupportedMethod = invoice.GetSupportedPaymentMethod<LightningSupportedPaymentMethod>(_NetworkProvider)
.FirstOrDefault(c => c.CryptoCode == network.CryptoCode);
if (lightningSupportedMethod == null)
return;
var charge = new ChargeClient(lightningSupportedMethod.GetLightningChargeUrl(true), network.NBitcoinNetwork);
var session = await charge.Listen();
while (true)
{
var notification = await session.NextEvent();
if (notification.Id == lightningMethod.InvoiceId &&
notification.PaymentRequest == lightningMethod.BOLT11)
{
if (notification.Status == "paid" && notification.PaidAt.HasValue)
{
await _InvoiceRepository.AddPayment(invoice.Id, notification.PaidAt.Value, new LightningLikePaymentData()
{
BOLT11 = notification.PaymentRequest,
Amount = notification.MilliSatoshi
}, network.CryptoCode, accounted: true);
_Aggregator.Publish(new InvoiceEvent(invoice.Id, 1002, "invoice_receivedPayment"));
break;
}
if(notification.Status == "expired")
{
break;
}
}
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
leases.Dispose();
return Task.CompletedTask;
}
}
}

View file

@ -11,6 +11,16 @@ using NBitcoin.RPC;
namespace BTCPayServer.Payments.Lightning.Eclair
{
public class SendResponse
{
public string PaymentHash { get; set; }
}
public class ChannelInfo
{
public string NodeId { get; set; }
public string ChannelId { get; set; }
public string State { get; set; }
}
public class EclairRPCClient
{
public EclairRPCClient(Uri address, Network network)
@ -112,14 +122,14 @@ namespace BTCPayServer.Payments.Lightning.Eclair
return await SendCommandAsync<AllChannelResponse[]>(new RPCRequest("allchannels", Array.Empty<object>())).ConfigureAwait(false);
}
public string[] Channels()
public ChannelInfo[] Channels()
{
return ChannelsAsync().GetAwaiter().GetResult();
}
public async Task<string[]> ChannelsAsync()
public async Task<ChannelInfo[]> ChannelsAsync()
{
return await SendCommandAsync<string[]>(new RPCRequest("channels", Array.Empty<object>())).ConfigureAwait(false);
return await SendCommandAsync<ChannelInfo[]>(new RPCRequest("channels", Array.Empty<object>())).ConfigureAwait(false);
}
public void Close(string channelId)
@ -127,6 +137,11 @@ namespace BTCPayServer.Payments.Lightning.Eclair
CloseAsync(channelId).GetAwaiter().GetResult();
}
public async Task SendAsync(string paymentRequest)
{
await SendCommandAsync<SendResponse>(new RPCRequest("send", new[] { paymentRequest })).ConfigureAwait(false);
}
public async Task CloseAsync(string channelId)
{
if (channelId == null)
@ -227,7 +242,7 @@ namespace BTCPayServer.Payments.Lightning.Eclair
throw new ArgumentNullException(nameof(node));
pushAmount = pushAmount ?? LightMoney.Zero;
var result = await SendCommandAsync(new RPCRequest("open", new object[] { node.NodeId, node.Host, node.Port, fundingSatoshi.Satoshi, pushAmount.MilliSatoshi }));
var result = await SendCommandAsync(new RPCRequest("open", new object[] { node.NodeId, fundingSatoshi.Satoshi, pushAmount.MilliSatoshi }));
return result.ResultString;
}

View file

@ -3,8 +3,9 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using NBitcoin;
namespace BTCPayServer.Payments.Lightning.Eclair
namespace BTCPayServer.Payments.Lightning
{
public enum LightMoneyUnit : ulong
{
@ -94,28 +95,31 @@ namespace BTCPayServer.Payments.Lightning.Eclair
return a;
}
public LightMoney(int satoshis)
public LightMoney(int msatoshis)
{
MilliSatoshi = satoshis;
MilliSatoshi = msatoshis;
}
public LightMoney(uint satoshis)
public LightMoney(uint msatoshis)
{
MilliSatoshi = satoshis;
MilliSatoshi = msatoshis;
}
public LightMoney(Money money)
{
MilliSatoshi = checked(money.Satoshi * 1000);
}
public LightMoney(long msatoshis)
{
MilliSatoshi = msatoshis;
}
public LightMoney(long satoshis)
{
MilliSatoshi = satoshis;
}
public LightMoney(ulong satoshis)
public LightMoney(ulong msatoshis)
{
// overflow check.
// ulong.MaxValue is greater than long.MaxValue
checked
{
MilliSatoshi = (long)satoshis;
MilliSatoshi = (long)msatoshis;
}
}
@ -171,7 +175,7 @@ namespace BTCPayServer.Payments.Lightning.Eclair
CheckMoneyUnit(unit, "unit");
// overflow safe because (long / int) always fit in decimal
// decimal operations are checked by default
return (decimal)MilliSatoshi / (int)unit;
return (decimal)MilliSatoshi / (ulong)unit;
}
/// <summary>
/// Convert Money to decimal (same as ToUnit)

View file

@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.JsonConverters;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services.Invoices;
using Newtonsoft.Json;
namespace BTCPayServer.Payments.Lightning
{
public class LightningLikePaymentData : CryptoPaymentData
{
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney Amount { get; set; }
public string BOLT11 { get; set; }
public string GetPaymentId()
{
return BOLT11;
}
public PaymentTypes GetPaymentType()
{
return PaymentTypes.LightningLike;
}
public string[] GetSearchTerms()
{
return new[] { BOLT11 };
}
public decimal GetValue()
{
return Amount.ToDecimal(LightMoneyUnit.BTC);
}
public bool PaymentCompleted(PaymentEntity entity, BTCPayNetwork network)
{
return true;
}
public bool PaymentConfirmed(PaymentEntity entity, SpeedPolicy speedPolicy, BTCPayNetwork network)
{
return true;
}
}
}

View file

@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Payments.Lightning.CLightning;
using BTCPayServer.Services.Invoices;
namespace BTCPayServer.Payments.Lightning
{
public class LightningLikePaymentHandler : PaymentMethodHandlerBase<LightningSupportedPaymentMethod>
{
ExplorerClientProvider _ExplorerClientProvider;
public LightningLikePaymentHandler(ExplorerClientProvider explorerClientProvider)
{
_ExplorerClientProvider = explorerClientProvider;
}
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(LightningSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, BTCPayNetwork network)
{
var invoice = paymentMethod.ParentEntity;
var due = invoice.ProductInformation.Price / paymentMethod.Rate;
var client = GetClient(supportedPaymentMethod, network);
var expiry = invoice.ExpirationTime - DateTimeOffset.UtcNow;
var lightningInvoice = await client.CreateInvoiceAsync(new CreateInvoiceRequest()
{
Amont = new LightMoney(due, LightMoneyUnit.BTC),
Expiry = expiry < TimeSpan.Zero ? TimeSpan.FromSeconds(1) : expiry
});
return new LightningLikePaymentMethodDetails()
{
BOLT11 = lightningInvoice.PayReq,
InvoiceId = lightningInvoice.Id
};
}
public async override Task<bool> IsAvailable(LightningSupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network)
{
try
{
await Test(supportedPaymentMethod, network);
return true;
}
catch { return false; }
}
public async Task Test(LightningSupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network)
{
if (!_ExplorerClientProvider.IsAvailable(network))
throw new Exception($"Full node not available");
var explorerClient = _ExplorerClientProvider.GetExplorerClient(network);
var cts = new CancellationTokenSource(5000);
var client = GetClient(supportedPaymentMethod, network);
var status = explorerClient.GetStatusAsync();
GetInfoResponse info = null;
try
{
info = await client.GetInfoAsync(cts.Token);
}
catch (Exception ex)
{
throw new Exception($"Error while connecting to the lightning charge {client.Uri} ({ex.Message})");
}
var address = info.Address?.FirstOrDefault();
var port = info.Port;
address = address ?? client.Uri.DnsSafeHost;
if (info.Network != network.CLightningNetworkName)
{
throw new Exception($"Lightning node network {info.Network}, but expected is {network.CLightningNetworkName}");
}
if (Math.Abs(info.BlockHeight - (await status).ChainHeight) > 10)
{
throw new Exception($"The lightning node is not synched");
}
try
{
await TestConnection(address, port, cts.Token);
}
catch (Exception ex)
{
throw new Exception($"Error while connecting to the lightning node via {address} ({ex.Message})");
}
}
private static ChargeClient GetClient(LightningSupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network)
{
return new ChargeClient(supportedPaymentMethod.GetLightningChargeUrl(true), network.NBitcoinNetwork);
}
private async Task<bool> TestConnection(string addressStr, int port, CancellationToken cancellation)
{
IPAddress address = null;
try
{
address = IPAddress.Parse(addressStr);
}
catch
{
try
{
address = (await Dns.GetHostAddressesAsync(addressStr)).FirstOrDefault();
}
catch { }
}
if (address == null)
throw new Exception($"DNS did not resolved {addressStr}");
using (var tcp = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp))
{
try
{
await WithTimeout(tcp.ConnectAsync(new IPEndPoint(address, port)), cancellation);
}
catch { return false; }
}
return true;
}
static Task WithTimeout(Task task, CancellationToken token)
{
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
var registration = token.Register(() => { try { tcs.TrySetResult(true); } catch { } });
#pragma warning disable CA2008 // Do not create tasks without passing a TaskScheduler
var timeoutTask = tcs.Task;
#pragma warning restore CA2008 // Do not create tasks without passing a TaskScheduler
return Task.WhenAny(task, timeoutTask).Unwrap().ContinueWith(t => registration.Dispose(), TaskScheduler.Default);
}
}
}

View file

@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Payments.Lightning
{
public class LightningLikePaymentMethodDetails : IPaymentMethodDetails
{
public string BOLT11 { get; set; }
public string InvoiceId { get; set; }
public string GetPaymentDestination()
{
return BOLT11;
}
public PaymentTypes GetPaymentType()
{
return PaymentTypes.LightningLike;
}
public decimal GetTxFee()
{
return 0.0m;
}
public void SetNoTxFee()
{
}
public void SetPaymentDestination(string newPaymentDestination)
{
BOLT11 = newPaymentDestination;
}
}
}

View file

@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Payments.Lightning
{
public class LightningSupportedPaymentMethod : ISupportedPaymentMethod
{
public string CryptoCode { get; set; }
[Obsolete("Use Get/SetLightningChargeUrl")]
public string LightningChargeUrl { get; set; }
public Uri GetLightningChargeUrl(bool withCredentials)
{
#pragma warning disable CS0618 // Type or member is obsolete
UriBuilder uri = new UriBuilder(LightningChargeUrl);
if (withCredentials)
{
uri.UserName = Username;
uri.Password = Password;
}
#pragma warning restore CS0618 // Type or member is obsolete
return uri.Uri;
}
public void SetLightningChargeUrl(Uri uri)
{
if (uri == null)
throw new ArgumentNullException(nameof(uri));
if (string.IsNullOrEmpty(uri.UserInfo))
throw new ArgumentException(paramName: nameof(uri), message: "Uri should have credential information");
var splitted = uri.UserInfo.Split(':');
if (splitted.Length != 2)
throw new ArgumentException(paramName: nameof(uri), message: "Uri should have credential information");
#pragma warning disable CS0618 // Type or member is obsolete
Username = splitted[0];
Password = splitted[1];
LightningChargeUrl = new UriBuilder(uri) { UserName = "", Password = "" }.Uri.AbsoluteUri;
#pragma warning restore CS0618 // Type or member is obsolete
}
[Obsolete("Use Get/SetLightningChargeUrl")]
public string Username { get; set; }
[Obsolete("Use Get/SetLightningChargeUrl")]
public string Password { get; set; }
public PaymentMethodId PaymentId => new PaymentMethodId(CryptoCode, PaymentTypes.LightningLike);
}
}

View file

@ -17,13 +17,27 @@ namespace BTCPayServer.Payments
return BTCPayServer.DerivationStrategy.Parse(((JValue)value).Value<string>(), network);
}
//////////
else // if(paymentMethodId.PaymentType == PaymentTypes.Lightning)
else if (paymentMethodId.PaymentType == PaymentTypes.LightningLike)
{
// return JsonConvert.Deserialize<T>();
return JsonConvert.DeserializeObject<Payments.Lightning.LightningSupportedPaymentMethod>(value.ToString());
}
throw new NotSupportedException();
}
public static IPaymentMethodDetails DeserializePaymentMethodDetails(PaymentMethodId paymentMethodId, JObject jobj)
{
if(paymentMethodId.PaymentType == PaymentTypes.BTCLike)
{
return JsonConvert.DeserializeObject<Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod>(jobj.ToString());
}
if (paymentMethodId.PaymentType == PaymentTypes.LightningLike)
{
return JsonConvert.DeserializeObject<Payments.Lightning.LightningLikePaymentMethodDetails>(jobj.ToString());
}
throw new NotSupportedException(paymentMethodId.PaymentType.ToString());
}
public static JToken Serialize(ISupportedPaymentMethod factory)
{
// Legacy
@ -39,5 +53,6 @@ namespace BTCPayServer.Payments
}
throw new NotSupportedException();
}
}
}

View file

@ -13,6 +13,10 @@ namespace BTCPayServer.Payments
/// <summary>
/// On-Chain UTXO based, bitcoin compatible
/// </summary>
BTCLike
BTCLike,
/// <summary>
/// Lightning payment
/// </summary>
LightningLike
}
}

View file

@ -35,6 +35,14 @@ namespace BTCPayServer.Services
{
get; set;
}
public bool IsDevelopping
{
get
{
return ChainType == ChainType.Regtest && Environment.IsDevelopment();
}
}
public override string ToString()
{
StringBuilder txt = new StringBuilder();

View file

@ -365,6 +365,8 @@ namespace BTCPayServer.Services.Invoices
cryptoInfo.Url = ServerUrl.WithTrailingSlash() + $"invoice{cryptoSuffix}?id=" + Id;
if (info.GetId().PaymentType == PaymentTypes.BTCLike)
{
cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls()
{
BIP72 = $"{scheme}:{cryptoInfo.Address}?amount={cryptoInfo.Due}&r={ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}")}",
@ -372,6 +374,14 @@ namespace BTCPayServer.Services.Invoices
BIP73 = ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}"),
BIP21 = $"{scheme}:{cryptoInfo.Address}?amount={cryptoInfo.Due}",
};
}
if (info.GetId().PaymentType == PaymentTypes.LightningLike)
{
cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls()
{
BOLT11 = $"lightning:{cryptoInfo.Address}"
};
}
#pragma warning disable CS0618
if (info.CryptoCode == "BTC")
{
@ -516,6 +526,11 @@ namespace BTCPayServer.Services.Invoices
/// <summary>
/// Number of transactions required to pay
/// </summary>
public int TxRequired { get; set; }
/// <summary>
/// Number of transactions using this payment method
/// </summary>
public int TxCount { get; set; }
/// <summary>
/// Total amount of network fee to pay to the invoice
@ -573,25 +588,19 @@ namespace BTCPayServer.Services.Invoices
}
else
{
if (GetId().PaymentType == PaymentTypes.BTCLike)
var details = PaymentMethodExtensions.DeserializePaymentMethodDetails(GetId(), PaymentMethodDetails);
if (details is Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod btcLike)
{
var method = DeserializePaymentMethodDetails<Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod>(PaymentMethodDetails);
method.TxFee = TxFee;
method.DepositAddress = BitcoinAddress.Create(DepositAddress, Network?.NBitcoinNetwork);
method.FeeRate = FeeRate;
return method;
btcLike.TxFee = TxFee;
btcLike.DepositAddress = BitcoinAddress.Create(DepositAddress, Network?.NBitcoinNetwork);
btcLike.FeeRate = FeeRate;
}
return details;
}
throw new NotSupportedException(PaymentType);
#pragma warning restore CS0618 // Type or member is obsolete
}
private T DeserializePaymentMethodDetails<T>(JObject jobj) where T : class, IPaymentMethodDetails
{
return JsonConvert.DeserializeObject<T>(jobj.ToString());
}
public PaymentMethod SetPaymentMethodDetails(IPaymentMethodDetails paymentMethod)
{
#pragma warning disable CS0618 // Type or member is obsolete
@ -639,7 +648,7 @@ namespace BTCPayServer.Services.Invoices
var paidTxFee = 0m;
bool paidEnough = paid >= RoundUp(totalDue, 8);
int txCount = 0;
int txRequired = 0;
var payments =
ParentEntity.GetPayments()
.Where(p => p.Accounted && paymentPredicate(p))
@ -657,22 +666,24 @@ namespace BTCPayServer.Services.Invoices
if (GetId() == _.GetpaymentMethodId())
{
cryptoPaid += _.GetCryptoPaymentData().GetValue();
txCount++;
txRequired++;
}
return _;
})
.ToArray();
var accounting = new PaymentMethodAccounting();
accounting.TxCount = txRequired;
if (!paidEnough)
{
txCount++;
txRequired++;
totalDue += GetTxFee();
paidTxFee += GetTxFee();
}
var accounting = new PaymentMethodAccounting();
accounting.TotalDue = Money.Coins(RoundUp(totalDue, 8));
accounting.Paid = Money.Coins(paid);
accounting.TxCount = txCount;
accounting.TxRequired = txRequired;
accounting.CryptoPaid = Money.Coins(cryptoPaid);
accounting.Due = Money.Max(accounting.TotalDue - accounting.Paid, Money.Zero);
accounting.DueUncapped = accounting.TotalDue - accounting.Paid;
@ -763,6 +774,10 @@ namespace BTCPayServer.Services.Invoices
paymentData.Outpoint = Outpoint;
return paymentData;
}
if(GetpaymentMethodId().PaymentType== PaymentTypes.LightningLike)
{
return JsonConvert.DeserializeObject<Payments.Lightning.LightningLikePaymentData>(CryptoPaymentData);
}
throw new NotSupportedException(nameof(CryptoPaymentDataType) + " does not support " + CryptoPaymentDataType);
#pragma warning restore CS0618
@ -778,18 +793,14 @@ namespace BTCPayServer.Services.Invoices
Output = paymentData.Output;
///
}
else
throw new NotSupportedException(cryptoPaymentData.ToString());
CryptoPaymentDataType = paymentData.GetPaymentType().ToString();
CryptoPaymentDataType = cryptoPaymentData.GetPaymentType().ToString();
CryptoPaymentData = JsonConvert.SerializeObject(cryptoPaymentData);
#pragma warning restore CS0618
return this;
}
internal decimal GetValue(PaymentMethodDictionary paymentMethods, PaymentMethodId paymentMethodId, decimal? value = null)
{
#pragma warning disable CS0618
value = value ?? Output.Value.ToDecimal(MoneyUnit.BTC);
#pragma warning restore CS0618
value = value ?? this.GetCryptoPaymentData().GetValue();
var to = paymentMethodId;
var from = this.GetpaymentMethodId();
if (to == from)
@ -838,5 +849,6 @@ namespace BTCPayServer.Services.Invoices
bool PaymentCompleted(PaymentEntity entity, BTCPayNetwork network);
bool PaymentConfirmed(PaymentEntity entity, SpeedPolicy speedPolicy, BTCPayNetwork network);
PaymentTypes GetPaymentType();
}
}

View file

@ -461,7 +461,7 @@ namespace BTCPayServer.Services.Invoices
AddToTextSearch(invoiceId, addresses.Select(a => a.ToString()).ToArray());
}
public async Task<PaymentEntity> AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, string cryptoCode)
public async Task<PaymentEntity> AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, string cryptoCode, bool accounted = false)
{
using (var context = _ContextFactory.CreateContext())
{
@ -471,7 +471,7 @@ namespace BTCPayServer.Services.Invoices
CryptoCode = cryptoCode,
#pragma warning restore CS0618
ReceivedTime = date.UtcDateTime,
Accounted = false
Accounted = accounted
};
entity.SetCryptoPaymentData(paymentData);
@ -481,7 +481,7 @@ namespace BTCPayServer.Services.Invoices
Id = paymentData.GetPaymentId(),
Blob = ToBytes(entity, null),
InvoiceDataId = invoiceId,
Accounted = false
Accounted = accounted
};
context.Payments.Add(data);

View file

@ -159,7 +159,7 @@
<div class="bp-view payment scan" id="scan">
<div class="payment__scan">
<img :src="srvModel.cryptoImage" style="position: absolute; height:64px; width:64px; left:118px; top:96px;" />
<qrcode :val="srvModel.invoiceBitcoinUrl" :size="256" bg-color="#f5f5f7" fg-color="#000" />
<qrcode :val="srvModel.invoiceBitcoinUrlQR" :size="256" bg-color="#f5f5f7" fg-color="#000" />
</div>
<div class="payment__details__instruction__open-wallet">
<a class="payment__details__instruction__open-wallet__btn action-button" v-bind:href="srvModel.invoiceBitcoinUrl">
@ -363,7 +363,7 @@
<div class="manual-box__address__wrapper__logo">
<img :src="srvModel.cryptoImage" />
</div>
<div class="manual-box__address__wrapper__value">{{srvModel.btcAddress}}</div>
<div class="manual-box__address__wrapper__value" style="overflow:hidden;max-width:240px;">{{srvModel.btcAddress}}</div>
</div>
<div class="copied-label" style="top: 5px;">
<span i18n="">Copied</span>

View file

@ -0,0 +1,47 @@
@inject BTCPayServer.Services.BTCPayServerEnvironment env
@model LightningNodeViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["Title"] = "Add lightning node (Experimental)";
ViewData.AddActivePage(BTCPayServer.Views.Stores.StoreNavPages.Index);
}
<h4>@ViewData["Title"]</h4>
@Html.Partial("_StatusMessage", Model.StatusMessage)
<div class="row">
<div class="col-md-6">
<div asp-validation-summary="All" class="text-danger"></div>
</div>
</div>
@if(env.IsDevelopping) {
<div class="alert alert-info alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<span>To test during development, please use http://api-token:foiewnccewuify@127.0.0.1:54938/ </span>
</div>
}
<div class="row">
<div class="col-md-8">
<form method="post">
<div class="form-group">
<h5>Lightning node url</h5>
<span>This URL should point to an installed lightning charge server</span>
</div>
<div class="form-group">
<label asp-for="CryptoCurrency"></label>
<select asp-for="CryptoCurrency" asp-items="Model.CryptoCurrencies" class="form-control"></select>
</div>
<div class="form-group">
<label asp-for="Url"></label>
<input asp-for="Url" class="form-control" />
<span asp-validation-for="Url" class="text-danger"></span>
</div>
<button name="command" type="submit" value="save" class="btn btn-success">Submit</button>
<button name="command" type="submit" value="test" class="btn btn-info">Test connection</button>
</form>
</div>
</div>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
}

View file

@ -72,7 +72,7 @@
</div>
<div class="form-group">
<h5>Derivation Scheme</h5>
<span>The DerivationScheme represents the destination of the funds received by your invoice.</span>
<span>The DerivationScheme represents the destination of the funds received by your invoice on chain.</span>
</div>
<div class="form-group">
@ -95,6 +95,33 @@
</tbody>
</table>
</div>
<div class="form-group">
<div class="form-group">
<h5>Lightning nodes (Experimental)</h5>
<span>Connection to lightning charge node used to generate lignting network payment</span>
</div>
<div class="form-group">
<a asp-action="AddLightningNode" class="btn btn-success" role="button"><span class="glyphicon glyphicon-plus"></span>Add or modify a lightning node</a>
<table class="table">
<thead class="thead-inverse">
<tr>
<th>Crypto</th>
<th>Address</th>
</tr>
</thead>
<tbody>
@foreach(var scheme in Model.LightningNodes)
{
<tr>
<td>@scheme.CryptoCode</td>
<td>@scheme.Address</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<button name="command" type="submit" class="btn btn-success" value="Save">Save</button>
</form>
</div>