Compare commits

...

65 Commits

Author SHA1 Message Date
40eee0924a bump 2018-03-01 11:48:55 +09:00
bbf5fb3c30 show port if failing to connect to lightning node 2018-03-01 10:31:01 +09:00
346cdf2431 show block gap if lightning node is not synched 2018-02-28 23:12:09 +09:00
030cb09af8 Fix bug of address parsing for lightning 2018-02-28 22:56:12 +09:00
9f734349da Prettify date on the invoice list, and add orderid 2018-02-28 19:03:23 +09:00
2d5a861df0 Update to work with 0.16.0 2018-02-28 17:01:10 +09:00
061f428a54 fix bundling 2018-02-27 17:29:57 +09:00
9a539fd350 Remove default profile for launchSettings 2018-02-27 17:19:37 +09:00
4149fe10e9 Removing unused bundle.css & bundle.min.css 2018-02-27 17:03:51 +09:00
1014083160 Preserving bundles directory required for build 2018-02-27 17:03:49 +09:00
dfa3167c18 Removing generated bundles from source control 2018-02-27 17:03:45 +09:00
7e09efb9a3 Adding BundleJsCss as property on BtcPayServerOptions 2018-02-27 17:03:41 +09:00
fb736c0d0f Moving AddBundles to BtcPayServerServices 2018-02-27 17:03:39 +09:00
e4a7263e9b Turning JS/CSS bundling on 2018-02-27 17:03:38 +09:00
c52926f2b0 Using min versions of JS and CSS files 2018-02-27 17:03:37 +09:00
b6138b36be Restoring Unobtrusive Jquery validation 2018-02-27 17:03:29 +09:00
04bce3ae00 Bundling of CSS/JS files that's configurable in launchSettings.json
If you set BTCPAY_BUDNLEJSCSS to true it'll bundle all JS/CSS files into one

Ref: https://github.com/btcpayserver/btcpayserver/issues/47
2018-02-27 16:44:28 +09:00
68ca162dd3 Cleaning up JS/CSS references on Checkout page 2018-02-27 16:39:15 +09:00
100bb02cd5 fix logo size in copy part of checkout 2018-02-26 23:21:35 +09:00
856249d52c Update test sdk 2018-02-26 19:06:02 +09:00
309d6fdfe0 Can configure an internallightningnode to make things easier 2018-02-26 18:58:02 +09:00
f289420364 update image 2018-02-26 17:07:19 +09:00
f05e85de5f fix typo 2018-02-26 16:16:15 +09:00
4138849546 Better logo and warning 2018-02-26 16:13:10 +09:00
7052e4e1dc adjust layout of UpdateStore 2018-02-26 15:40:49 +09:00
ffb3e4f1fb Add logo for lightning 2018-02-26 15:33:03 +09:00
297834be66 Tell to users that using lightning is reckless 2018-02-26 15:16:17 +09:00
5924f1730c Poll for charge invoice 2018-02-26 14:52:08 +09:00
adc6bea4dc make tests bit more resilient 2018-02-26 13:36:00 +09:00
ef431f688f Make ChargeListener use only one websocket connection per url 2018-02-26 13:29:23 +09:00
c8923af573 Lightning Network support implementation 2018-02-26 00:48:12 +09:00
3d33ecf397 make IsAvailable async 2018-02-23 16:09:15 +09:00
82d38da18e FAQ 2018-02-23 15:31:19 +09:00
200e259b82 Add lightning dependencies to tests and docker-compose 2018-02-23 15:21:42 +09:00
2d1f4e5e0a update NBitcoin 2018-02-21 14:19:11 +09:00
7fe64612ad bump nbxplorer 2018-02-21 11:56:30 +09:00
5e452a679e simplify code 2018-02-20 14:23:50 +09:00
10be8aec82 bump 2018-02-20 12:46:40 +09:00
0e1a1fd2cd Remove dependencies in StoreController to on chain payment specific stuff 2018-02-20 12:45:04 +09:00
3f07010de8 Rename IPaymentMethodFactory to ISupportedPaymentMethod 2018-02-20 10:44:39 +09:00
2e45c8b190 Isolate PaymentMethodId in its own class, generalise DerivationStrategy 2018-02-19 23:13:23 +09:00
b4f4401cdc remove unused code, remove derivationscheme specific logic from InvoiceEntity 2018-02-19 22:41:47 +09:00
a6b92a0dd5 Fix build 2018-02-19 18:58:58 +09:00
2f3238c65e Use decimal for calculations instead of Money, and round due amount at ceil satoshi 2018-02-19 18:54:21 +09:00
65f5a38b4a bump 2018-02-19 15:14:07 +09:00
271cbf682f fix casing 2018-02-19 15:13:45 +09:00
a634593903 Big refactoring renaming cryptoData => PaymentMethod 2018-02-19 15:09:05 +09:00
af94de93d1 Add some comments 2018-02-19 11:31:34 +09:00
35f669aa15 Isolating code of on chain specific payment in its own folder 2018-02-19 11:06:08 +09:00
4795bd8108 Add some sanity check, make sure to use CrytpoDataId everywhere 2018-02-19 03:35:19 +09:00
29aed99fd1 prevent a crash if the new property DerivationStrategies is notset at invoice level 2018-02-19 02:56:44 +09:00
aa4519ac30 Big refactoring for supporting new type of payment 2018-02-19 02:38:03 +09:00
752133b01c fix bug 2018-02-18 20:37:42 +09:00
fe0c21ba08 Make sure that IPN sent for the send invoice are sent one at a time 2018-02-18 16:09:09 +09:00
90904a6b5e bump 2018-02-18 02:42:38 +09:00
8e3f7ea68d do not block next invoices if one invoice fail processing 2018-02-18 02:40:53 +09:00
a239104a28 fix uppercase 2018-02-18 02:36:11 +09:00
3bc232e1da Further isolate bitcoin related stuff inside BitcoinLikePaymentData 2018-02-18 02:35:02 +09:00
a1ee09cd85 Further abstract payment data by encapsulating bitcoin related logic into BitcoinLikePaymentData 2018-02-18 02:19:35 +09:00
b898cc030c general code cleanup + add analyzers 2018-02-17 13:18:16 +09:00
0602353dd2 fix bug happening if only btc is supported 2018-02-17 01:55:38 +09:00
9d406923ae make sure the waitingInvoices tasks are done 2018-02-17 01:43:43 +09:00
aa8565e3cc forgot remove dev time stuff 2018-02-17 01:35:30 +09:00
5de330b1f9 Refactoring to keep coin logic out of InvoiceWatcher 2018-02-17 01:34:40 +09:00
66597aed46 hide websocket exceptions 2018-02-15 16:17:27 +09:00
107 changed files with 9129 additions and 1513 deletions

4
.gitignore vendored
View File

@ -287,3 +287,7 @@ __pycache__/
*.odx.cs
*.xsd.cs
/BTCPayServer/Build/dockerfiles
# Bundling JS/CSS
BTCPayServer/wwwroot/bundles/*
!BTCPayServer/wwwroot/bundles/.gitignore

View File

@ -4,11 +4,11 @@
<TargetFramework>netcoreapp2.0</TargetFramework>
<IsPackable>false</IsPackable>
<NoWarn>NU1701</NoWarn>
<NoWarn>NU1701,CA1816,CA1308,CA1810,CA2208</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.6.0" />
<PackageReference Include="xunit" Version="2.3.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
</ItemGroup>

View File

@ -84,6 +84,8 @@ namespace BTCPayServer.Tests
config.AppendLine($"ltc.explorer.url={LTCNBXplorerUri.AbsoluteUri}");
config.AppendLine($"ltc.explorer.cookiefile=0");
config.AppendLine($"internallightningnode={IntegratedLightning.AbsoluteUri}");
if (Postgres != null)
config.AppendLine($"postgres=" + Postgres);
var confPath = Path.Combine(chainDirectory, "settings.config");
@ -91,8 +93,8 @@ namespace BTCPayServer.Tests
ServerUri = new Uri("http://" + HostName + ":" + Port + "/");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development");
var conf = new DefaultConfiguration() { Logger = Logs.LogProvider.CreateLogger("Console") }.CreateConfiguration(new[] { "--datadir", _Directory, "--conf", confPath });
_Host = new WebHostBuilder()
.UseConfiguration(conf)
.ConfigureServices(s =>
@ -124,6 +126,7 @@ namespace BTCPayServer.Tests
internal set;
}
public InvoiceRepository InvoiceRepository { get; private set; }
public Uri IntegratedLightning { get; internal set; }
public T GetService<T>()
{

View File

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Text;
using BTCPayServer.Payments.Lightning.CLightning;
using NBitcoin;
namespace BTCPayServer.Tests
{
public class ChargeTester
{
private ServerTester _Parent;
public ChargeTester(ServerTester serverTester, string environmentName, string defaultValue, string defaultHost, Network network)
{
this._Parent = serverTester;
var url = serverTester.GetEnvironment(environmentName, defaultValue);
Client = new ChargeClient(new Uri(url), network);
P2PHost = _Parent.GetEnvironment(environmentName + "_HOST", defaultHost);
}
public ChargeClient Client { get; set; }
public string P2PHost { get; }
}
}

View File

@ -2,17 +2,18 @@
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Eclair;
using BTCPayServer.Payments.Lightning.Eclair;
using NBitcoin;
namespace BTCPayServer.Tests
{
public class EclairTester
{
ServerTester parent;
public EclairTester(ServerTester parent, string environmentName, string defaultRPC, string defaultHost)
public EclairTester(ServerTester parent, string environmentName, string defaultRPC, string defaultHost, Network network)
{
this.parent = parent;
//RPC = new EclairRPCClient(new Uri(parent.GetEnvironment(environmentName, defaultRPC)), parent.Network);
RPC = new EclairRPCClient(new Uri(parent.GetEnvironment(environmentName, defaultRPC)), network);
P2PHost = parent.GetEnvironment(environmentName + "_HOST", defaultHost);
}
@ -33,5 +34,6 @@ namespace BTCPayServer.Tests
{
return GetNodeInfoAsync().GetAwaiter().GetResult();
}
}
}

View File

@ -71,7 +71,11 @@ namespace BTCPayServer.Tests.Logging
public void LogInformation(string msg)
{
if (msg != null)
_Helper.WriteLine(DateTimeOffset.UtcNow + " :" + Name + ": " + msg);
try
{
_Helper.WriteLine(DateTimeOffset.UtcNow + " :" + Name + ": " + msg);
}
catch { }
}
}
public class Logs

View File

@ -46,3 +46,15 @@ If you are using Powershell:
```
.\docker-bitcoin-cli.ps1 sendtoaddress "mohu16LH66ptoWGEL1GtP6KHTBJYXMWhEf" 0.23111090
```
For sending to litecoin, use .\docker-litecoin-cli.ps1 instead.
## FAQ
`docker-compose up dev` failed or tests are not passing, what should I do?
1. Run `docker-compose down --v` (this will reset your test environment)
2. Run `docker-compose pull` (this will ensure you have the lastest images)
3. Run again with `docker-compose up dev`
If you still have issues, try to restart docker.

View File

@ -17,7 +17,8 @@ using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
using System.Threading;
using BTCPayServer.Eclair;
using BTCPayServer.Payments.Lightning.Eclair;
using System.Globalization;
namespace BTCPayServer.Tests
{
@ -54,18 +55,20 @@ namespace BTCPayServer.Tests
ExplorerClient = new ExplorerClient(NetworkProvider.GetNetwork("BTC").NBXplorerNetwork, new Uri(GetEnvironment("TESTS_BTCNBXPLORERURL", "http://127.0.0.1:32838/")));
LTCExplorerClient = new ExplorerClient(NetworkProvider.GetNetwork("LTC").NBXplorerNetwork, new Uri(GetEnvironment("TESTS_LTCNBXPLORERURL", "http://127.0.0.1:32838/")));
var btc = NetworkProvider.GetNetwork("BTC").NBitcoinNetwork;
CustomerEclair = new EclairTester(this, "TEST_ECLAIR", "http://eclair-cli:gpwefwmmewci@127.0.0.1:30992/", "eclair", btc);
MerchantCharge = new ChargeTester(this, "TEST_CHARGE", "http://api-token:foiewnccewuify@127.0.0.1:54938/", "lightning-charged", btc);
PayTester = new BTCPayServerTester(Path.Combine(_Directory, "pay"))
{
NBXplorerUri = ExplorerClient.Address,
LTCNBXplorerUri = LTCExplorerClient.Address,
Postgres = GetEnvironment("TESTS_POSTGRES", "User ID=postgres;Host=127.0.0.1;Port=39372;Database=btcpayserver")
Postgres = GetEnvironment("TESTS_POSTGRES", "User ID=postgres;Host=127.0.0.1;Port=39372;Database=btcpayserver"),
IntegratedLightning = MerchantCharge.Client.Uri
};
PayTester.Port = int.Parse(GetEnvironment("TESTS_PORT", Utils.FreeTcpPort().ToString()));
PayTester.Port = int.Parse(GetEnvironment("TESTS_PORT", Utils.FreeTcpPort().ToString(CultureInfo.InvariantCulture)), CultureInfo.InvariantCulture);
PayTester.HostName = GetEnvironment("TESTS_HOSTNAME", "127.0.0.1");
PayTester.Start();
MerchantEclair = new EclairTester(this, "TEST_ECLAIR1", "http://127.0.0.1:30992/", "eclair1");
CustomerEclair = new EclairTester(this, "TEST_ECLAIR2", "http://127.0.0.1:30993/", "eclair2");
}
@ -82,21 +85,62 @@ namespace BTCPayServer.Tests
// Activate segwit
var blockCount = ExplorerNode.GetBlockCountAsync();
// Fetch node info, but that in cache
var merchant = MerchantEclair.GetNodeInfoAsync();
var merchantInfo = MerchantCharge.Client.GetInfoAsync();
var customer = CustomerEclair.GetNodeInfoAsync();
var channels = CustomerEclair.RPC.ChannelsAsync();
var connect = CustomerEclair.RPC.ConnectAsync(merchant.Result);
await Task.WhenAll(blockCount, merchant, customer, channels, connect);
// Mine until segwit is activated
if (blockCount.Result <= 432)
var info = await merchantInfo;
var clightning = new NodeInfo(info.Id, MerchantCharge.P2PHost, info.Port);
var connect = CustomerEclair.RPC.ConnectAsync(clightning);
await Task.WhenAll(blockCount, customer, channels, connect);
// If the channel is not created, let's do it
if (channels.Result.Length == 0)
{
ExplorerNode.Generate(433 - blockCount.Result);
var c = (await CustomerEclair.RPC.ChannelsAsync());
bool generated = false;
bool createdChannel = false;
CancellationTokenSource timeout = new CancellationTokenSource();
timeout.CancelAfter(10000);
while (c.Length == 0 || c[0].State != "NORMAL")
{
if (timeout.IsCancellationRequested)
{
timeout = new CancellationTokenSource();
timeout.CancelAfter(10000);
createdChannel = c.Length == 0;
generated = false;
}
if (!createdChannel)
{
await CustomerEclair.RPC.OpenAsync(clightning, Money.Satoshis(16777215));
createdChannel = true;
}
if (!generated && c.Length != 0 && c[0].State == "WAIT_FOR_FUNDING_CONFIRMED")
{
ExplorerNode.Generate(6);
generated = true;
}
c = (await CustomerEclair.RPC.ChannelsAsync());
}
}
}
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; }
public EclairTester CustomerEclair { get; set; }
public ChargeTester MerchantCharge { get; private set; }
internal string GetEnvironment(string variable, string defaultValue)
{

View File

@ -46,20 +46,30 @@ namespace BTCPayServer.Tests
Assert.IsType<ViewResult>(await store.RequestPairing(pairingCode.ToString()));
await store.Pair(pairingCode.ToString(), StoreId);
}
public StoresController CreateStore(string cryptoCode = null)
public StoresController CreateStore()
{
return CreateStoreAsync(cryptoCode).GetAwaiter().GetResult();
return CreateStoreAsync().GetAwaiter().GetResult();
}
public string CryptoCode { get; set; } = "BTC";
public async Task<StoresController> CreateStoreAsync(string cryptoCode = null)
public async Task<StoresController> CreateStoreAsync()
{
cryptoCode = cryptoCode ?? CryptoCode;
SupportedNetwork = parent.NetworkProvider.GetNetwork(cryptoCode);
ExtKey = new ExtKey().GetWif(SupportedNetwork.NBitcoinNetwork);
var store = parent.PayTester.GetController<StoresController>(UserId);
await store.CreateStore(new CreateStoreViewModel() { Name = "Test Store" });
StoreId = store.CreatedStoreId;
return store;
}
public BTCPayNetwork SupportedNetwork { get; set; }
public void RegisterDerivationScheme(string crytoCode)
{
RegisterDerivationSchemeAsync(crytoCode).GetAwaiter().GetResult();
}
public async Task RegisterDerivationSchemeAsync(string cryptoCode)
{
SupportedNetwork = parent.NetworkProvider.GetNetwork(cryptoCode);
var store = parent.PayTester.GetController<StoresController>(UserId);
ExtKey = new ExtKey().GetWif(SupportedNetwork.NBitcoinNetwork);
DerivationScheme = new DerivationStrategyFactory(SupportedNetwork.NBitcoinNetwork).Parse(ExtKey.Neuter().ToString() + "-[legacy]");
var vm = (StoreViewModel)((ViewResult)await store.UpdateStore(StoreId)).Model;
vm.SpeedPolicy = SpeedPolicy.MediumSpeed;
@ -71,28 +81,7 @@ namespace BTCPayServer.Tests
DerivationSchemeFormat = "BTCPay",
DerivationScheme = DerivationScheme.ToString(),
Confirmation = true
}, "Save");
return store;
}
public BTCPayNetwork SupportedNetwork { get; set; }
public void RegisterDerivationScheme(string crytoCode)
{
RegisterDerivationSchemeAsync(crytoCode).GetAwaiter().GetResult();
}
public async Task RegisterDerivationSchemeAsync(string crytoCode)
{
var store = parent.PayTester.GetController<StoresController>(UserId);
var networkProvider = parent.PayTester.GetService<BTCPayNetworkProvider>();
var derivation = new DerivationStrategyFactory(networkProvider.GetNetwork(crytoCode).NBitcoinNetwork).Parse(ExtKey.Neuter().ToString() + "-[legacy]");
await store.AddDerivationScheme(StoreId, new DerivationSchemeViewModel()
{
CryptoCurrency = crytoCode,
DerivationSchemeFormat = crytoCode,
DerivationScheme = derivation.ToString(),
Confirmation = true
}, "Save");
});
}
public DerivationStrategyBase DerivationScheme { get; set; }
@ -122,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

@ -22,10 +22,15 @@ using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using BTCPayServer.Services.Rates;
using Microsoft.Extensions.Caching.Memory;
using BTCPayServer.Eclair;
using BTCPayServer.Payments.Lightning.Eclair;
using System.Collections.Generic;
using BTCPayServer.Models.StoreViewModels;
using System.Threading.Tasks;
using System.Globalization;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments.Lightning;
namespace BTCPayServer.Tests
{
@ -37,6 +42,63 @@ namespace BTCPayServer.Tests
Logs.LogProvider = new XUnitLogProvider(helper);
}
[Fact]
public void CanCalculateCryptoDue2()
{
var dummy = new Key().PubKey.GetAddress(Network.RegTest);
#pragma warning disable CS0618
InvoiceEntity invoiceEntity = new InvoiceEntity();
invoiceEntity.Payments = new System.Collections.Generic.List<PaymentEntity>();
invoiceEntity.ProductInformation = new ProductInformation() { Price = 100 };
PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary();
paymentMethods.Add(new PaymentMethod()
{
CryptoCode = "BTC",
Rate = 10513.44m,
}.SetPaymentMethodDetails(new BTCPayServer.Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod()
{
TxFee = Money.Coins(0.00000100m),
DepositAddress = dummy
}));
paymentMethods.Add(new PaymentMethod()
{
CryptoCode = "LTC",
Rate = 216.79m
}.SetPaymentMethodDetails(new BTCPayServer.Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod()
{
TxFee = Money.Coins(0.00010000m),
DepositAddress = dummy
}));
invoiceEntity.SetPaymentMethods(paymentMethods);
var btc = invoiceEntity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike), null);
var accounting = btc.Calculate();
invoiceEntity.Payments.Add(new PaymentEntity() { Accounted = true, CryptoCode = "BTC" }.SetCryptoPaymentData(new BitcoinLikePaymentData()
{
Output = new TxOut() { Value = Money.Coins(0.00151263m) }
}));
accounting = btc.Calculate();
invoiceEntity.Payments.Add(new PaymentEntity() { Accounted = true, CryptoCode = "BTC" }.SetCryptoPaymentData(new BitcoinLikePaymentData()
{
Output = new TxOut() { Value = accounting.Due }
}));
accounting = btc.Calculate();
Assert.Equal(Money.Zero, accounting.Due);
Assert.Equal(Money.Zero, accounting.DueUncapped);
var ltc = invoiceEntity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike), null);
accounting = ltc.Calculate();
Assert.Equal(Money.Zero, accounting.Due);
// LTC might have over paid due to BTC paying above what it should (round 1 satoshi up)
Assert.True(accounting.DueUncapped < Money.Zero);
var paymentMethod = InvoiceWatcher.GetNearestClearedPayment(paymentMethods, out var accounting2, null);
Assert.Equal(btc.CryptoCode, paymentMethod.CryptoCode);
#pragma warning restore CS0618
}
[Fact]
public void CanCalculateCryptoDue()
{
@ -49,79 +111,81 @@ namespace BTCPayServer.Tests
entity.ProductInformation = new ProductInformation() { Price = 5000 };
// Some check that handling legacy stuff does not break things
var cryptoData = entity.GetCryptoData("BTC", null, true);
cryptoData.Calculate();
Assert.NotNull(cryptoData);
Assert.Null(entity.GetCryptoData("BTC", null, false));
entity.SetCryptoData(new CryptoData() { ParentEntity = entity, Rate = entity.Rate, CryptoCode = "BTC", TxFee = entity.TxFee });
Assert.NotNull(entity.GetCryptoData("BTC", null, false));
Assert.NotNull(entity.GetCryptoData("BTC", null, true));
var paymentMethod = entity.GetPaymentMethods(null, true).TryGet("BTC", PaymentTypes.BTCLike);
paymentMethod.Calculate();
Assert.NotNull(paymentMethod);
Assert.Null(entity.GetPaymentMethods(null, false).TryGet("BTC", PaymentTypes.BTCLike));
entity.SetPaymentMethod(new PaymentMethod() { ParentEntity = entity, Rate = entity.Rate, CryptoCode = "BTC", TxFee = entity.TxFee });
Assert.NotNull(entity.GetPaymentMethods(null, false).TryGet("BTC", PaymentTypes.BTCLike));
Assert.NotNull(entity.GetPaymentMethods(null, true).TryGet("BTC", PaymentTypes.BTCLike));
////////////////////
var accounting = cryptoData.Calculate();
var accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(1.1m), accounting.Due);
Assert.Equal(Money.Coins(1.1m), accounting.TotalDue);
entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.5m), new Key()), Accounted = true });
accounting = cryptoData.Calculate();
accounting = paymentMethod.Calculate();
//Since we need to spend one more txout, it should be 1.1 - 0,5 + 0.1
Assert.Equal(Money.Coins(0.7m), accounting.Due);
Assert.Equal(Money.Coins(1.2m), accounting.TotalDue);
entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()), Accounted = true });
accounting = cryptoData.Calculate();
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(0.6m), accounting.Due);
Assert.Equal(Money.Coins(1.3m), accounting.TotalDue);
entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.6m), new Key()), Accounted = true });
accounting = cryptoData.Calculate();
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Zero, accounting.Due);
Assert.Equal(Money.Coins(1.3m), accounting.TotalDue);
entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()), Accounted = true });
accounting = cryptoData.Calculate();
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Zero, accounting.Due);
Assert.Equal(Money.Coins(1.3m), accounting.TotalDue);
entity = new InvoiceEntity();
entity.ProductInformation = new ProductInformation() { Price = 5000 };
entity.SetCryptoData(new System.Collections.Generic.Dictionary<string, CryptoData>(new KeyValuePair<string, CryptoData>[] {
new KeyValuePair<string,CryptoData>("BTC", new CryptoData()
{
Rate = 1000,
TxFee = Money.Coins(0.1m)
}),
new KeyValuePair<string,CryptoData>("LTC", new CryptoData()
{
Rate = 500,
TxFee = Money.Coins(0.01m)
})
}));
PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary();
paymentMethods.Add(new PaymentMethod()
{
CryptoCode = "BTC",
Rate = 1000,
TxFee = Money.Coins(0.1m)
});
paymentMethods.Add(new PaymentMethod()
{
CryptoCode = "LTC",
Rate = 500,
TxFee = Money.Coins(0.01m)
});
entity.SetPaymentMethods(paymentMethods);
entity.Payments = new List<PaymentEntity>();
cryptoData = entity.GetCryptoData("BTC", null);
accounting = cryptoData.Calculate();
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike), null);
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(5.1m), accounting.Due);
cryptoData = entity.GetCryptoData("LTC", null);
accounting = cryptoData.Calculate();
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike), null);
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(10.01m), accounting.TotalDue);
entity.Payments.Add(new PaymentEntity() { CryptoCode = "BTC", Output = new TxOut(Money.Coins(1.0m), new Key()), Accounted = true });
cryptoData = entity.GetCryptoData("BTC", null);
accounting = cryptoData.Calculate();
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike), null);
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(4.2m), accounting.Due);
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);
cryptoData = entity.GetCryptoData("LTC", null);
accounting = cryptoData.Calculate();
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike), null);
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(10.01m + 0.1m * 2 - 2.0m /* 8.21m */), accounting.Due);
Assert.Equal(Money.Coins(0.0m), accounting.CryptoPaid);
Assert.Equal(Money.Coins(2.0m), accounting.Paid);
@ -130,44 +194,43 @@ namespace BTCPayServer.Tests
entity.Payments.Add(new PaymentEntity() { CryptoCode = "LTC", Output = new TxOut(Money.Coins(1.0m), new Key()), Accounted = true });
cryptoData = entity.GetCryptoData("BTC", null);
accounting = cryptoData.Calculate();
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike), null);
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(4.2m - 0.5m + 0.01m / 2), accounting.Due);
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);
cryptoData = entity.GetCryptoData("LTC", null);
accounting = cryptoData.Calculate();
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike), null);
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(8.21m - 1.0m + 0.01m), accounting.Due);
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 });
cryptoData = entity.GetCryptoData("BTC", null);
accounting = cryptoData.Calculate();
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike), null);
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Zero, accounting.Due);
Assert.Equal(Money.Coins(1.0m) + remaining, accounting.CryptoPaid);
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);
cryptoData = entity.GetCryptoData("LTC", null);
accounting = cryptoData.Calculate();
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike), null);
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Zero, accounting.Due);
Assert.Equal(Money.Coins(1.0m), accounting.CryptoPaid);
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
}
@ -179,6 +242,7 @@ namespace BTCPayServer.Tests
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
var invoice = user.BitPay.CreateInvoice(new Invoice()
{
Buyer = new Buyer() { email = "test@fwf.com" },
@ -224,18 +288,101 @@ 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 CanSendLightningPayment()
//{
[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());
// using (var tester = ServerTester.Create())
// {
// tester.Start();
// tester.PrepareLightning();
// }
//}
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]
public void CanSendLightningPayment()
{
using (var tester = ServerTester.Create())
{
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());
});
Task.WaitAll(Enumerable.Range(0, 5)
.Select(_ => CanSendLightningPaymentCore(tester, user))
.ToArray());
}
}
async Task CanSendLightningPaymentCore(ServerTester tester, TestAccount user)
{
await Task.Delay(TimeSpan.FromSeconds(RandomUtils.GetUInt32() % 5));
var invoice = await user.BitPay.CreateInvoiceAsync(new Invoice()
{
Price = 0.01,
Currency = "USD",
PosData = "posData",
OrderId = "orderId",
ItemDesc = "Some description"
});
await tester.SendLightningPaymentAsync(invoice);
await EventuallyAsync(async () =>
{
var localInvoice = await user.BitPay.GetInvoiceAsync(invoice.Id);
Assert.Equal("complete", localInvoice.Status);
Assert.Equal("False", localInvoice.ExceptionStatus.ToString());
});
}
[Fact]
public void CanUseServerInitiatedPairingCode()
@ -272,6 +419,7 @@ namespace BTCPayServer.Tests
tester.Start();
var acc = tester.NewAccount();
acc.GrantAccess();
acc.RegisterDerivationScheme("BTC");
var invoice = acc.BitPay.CreateInvoice(new Invoice()
{
Price = 5.0,
@ -312,7 +460,7 @@ namespace BTCPayServer.Tests
pairingCode = acc.BitPay.RequestClientAuthorization("test1", Facade.Merchant);
var store2 = acc.CreateStore();
store2.Pair(pairingCode.ToString(), store2.CreatedStoreId).GetAwaiter().GetResult();
Assert.Contains(nameof(PairingResult.ReusedKey), store2.StatusMessage);
Assert.Contains(nameof(PairingResult.ReusedKey), store2.StatusMessage, StringComparison.CurrentCultureIgnoreCase);
}
}
@ -324,17 +472,17 @@ namespace BTCPayServer.Tests
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
var invoice = user.BitPay.CreateInvoice(new Invoice()
{
Price = 5000.0,
Currency = "USD"
}, Facade.Merchant);
var payment1 = Money.Coins(0.04m);
var payment2 = Money.Coins(0.08m);
var payment1 = invoice.BtcDue + Money.Coins(0.0001m);
var payment2 = invoice.BtcDue;
var tx1 = new uint256(tester.ExplorerNode.SendCommand("sendtoaddress", new object[]
{
invoice.BitcoinAddress.ToString(),
invoice.BitcoinAddress,
payment1.ToString(),
null, //comment
null, //comment_to
@ -347,6 +495,8 @@ namespace BTCPayServer.Tests
{
invoice = user.BitPay.GetInvoice(invoice.Id);
Assert.Equal(payment1, invoice.BtcPaid);
Assert.Equal("paid", invoice.Status);
Assert.Equal("paidOver", invoice.ExceptionStatus.ToString());
invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, user.SupportedNetwork.NBitcoinNetwork);
});
@ -355,11 +505,9 @@ namespace BTCPayServer.Tests
{
input.ScriptSig = Script.Empty; //Strip signatures
}
var change = tx.Outputs.First(o => o.Value != payment1);
var output = tx.Outputs.First(o => o.Value == payment1);
output.Value = payment2;
output.ScriptPubKey = invoiceAddress.ScriptPubKey;
change.Value -= (payment2 - payment1) * 2; //Add more fees
var replaced = tester.ExplorerNode.SignRawTransaction(tx);
tester.ExplorerNode.SendRawTransaction(replaced);
var test = tester.ExplorerClient.GetUTXOs(user.DerivationScheme, null);
@ -367,6 +515,7 @@ namespace BTCPayServer.Tests
{
invoice = user.BitPay.GetInvoice(invoice.Id);
Assert.Equal(payment2, invoice.BtcPaid);
Assert.Equal("False", invoice.ExceptionStatus.ToString());
});
}
}
@ -391,6 +540,7 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount();
Assert.False(user.BitPay.TestAccess(Facade.Merchant));
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
Assert.True(user.BitPay.TestAccess(Facade.Merchant));
}
}
@ -404,7 +554,7 @@ namespace BTCPayServer.Tests
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
// First we try payment with a merchant having only BTC
var invoice1 = user.BitPay.CreateInvoice(new Invoice()
@ -446,8 +596,8 @@ namespace BTCPayServer.Tests
{
tester.Start();
var user = tester.NewAccount();
user.CryptoCode = "LTC";
user.GrantAccess();
user.RegisterDerivationScheme("LTC");
// First we try payment with a merchant having only BTC
var invoice = user.BitPay.CreateInvoice(new Invoice()
@ -506,7 +656,7 @@ namespace BTCPayServer.Tests
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
// First we try payment with a merchant having only BTC
var invoice = user.BitPay.CreateInvoice(new Invoice()
{
@ -565,7 +715,7 @@ namespace BTCPayServer.Tests
var ltcCryptoInfo = invoice.CryptoInfo.FirstOrDefault(c => c.CryptoCode == "LTC");
Assert.NotNull(ltcCryptoInfo);
invoiceAddress = BitcoinAddress.Create(ltcCryptoInfo.Address, cashCow.Network);
var secondPayment = Money.Coins(decimal.Parse(ltcCryptoInfo.Due));
var secondPayment = Money.Coins(decimal.Parse(ltcCryptoInfo.Due, CultureInfo.InvariantCulture));
cashCow.Generate(2); // LTC is not worth a lot, so just to make sure we have money...
cashCow.SendToAddress(invoiceAddress, secondPayment);
Logs.Tester.LogInformation("Second payment sent to " + invoiceAddress);
@ -595,6 +745,7 @@ namespace BTCPayServer.Tests
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
var invoice = user.BitPay.CreateInvoice(new Invoice()
{
Price = 5000.0,
@ -606,7 +757,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()
@ -629,11 +780,11 @@ namespace BTCPayServer.Tests
Assert.Equal("new", invoice.Status);
Assert.False((bool)((JValue)invoice.ExceptionStatus).Value);
Assert.Single(user.BitPay.GetInvoices(invoice.InvoiceTime.DateTime));
Assert.Empty(user.BitPay.GetInvoices(invoice.InvoiceTime.DateTime + TimeSpan.FromDays(1)));
Assert.Single(user.BitPay.GetInvoices(invoice.InvoiceTime.DateTime - TimeSpan.FromDays(5)));
Assert.Single(user.BitPay.GetInvoices(invoice.InvoiceTime.DateTime - TimeSpan.FromDays(5), invoice.InvoiceTime.DateTime));
Assert.Empty(user.BitPay.GetInvoices(invoice.InvoiceTime.DateTime - TimeSpan.FromDays(5), invoice.InvoiceTime.DateTime - TimeSpan.FromDays(1)));
Assert.Single(user.BitPay.GetInvoices(invoice.InvoiceTime.UtcDateTime));
Assert.Empty(user.BitPay.GetInvoices(invoice.InvoiceTime.UtcDateTime + TimeSpan.FromDays(2)));
Assert.Single(user.BitPay.GetInvoices(invoice.InvoiceTime.UtcDateTime - TimeSpan.FromDays(5)));
Assert.Single(user.BitPay.GetInvoices(invoice.InvoiceTime.UtcDateTime - TimeSpan.FromDays(5), invoice.InvoiceTime.DateTime + TimeSpan.FromDays(1.0)));
Assert.Empty(user.BitPay.GetInvoices(invoice.InvoiceTime.UtcDateTime - TimeSpan.FromDays(5), invoice.InvoiceTime.DateTime - TimeSpan.FromDays(1)));
var firstPayment = Money.Coins(0.04m);
@ -662,14 +813,15 @@ 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));
invoiceEntity = repo.GetInvoice(null, invoice.Id, true).GetAwaiter().GetResult();
var historical1 = invoiceEntity.HistoricalAddresses.FirstOrDefault(h => h.GetAddress() == invoice.BitcoinAddress.ToString());
var historical1 = invoiceEntity.HistoricalAddresses.FirstOrDefault(h => h.GetAddress() == invoice.BitcoinAddress);
Assert.NotNull(historical1.UnAssigned);
var historical2 = invoiceEntity.HistoricalAddresses.FirstOrDefault(h => h.GetAddress() == localInvoice.BitcoinAddress.ToString());
var historical2 = invoiceEntity.HistoricalAddresses.FirstOrDefault(h => h.GetAddress() == localInvoice.BitcoinAddress);
Assert.Null(historical2.UnAssigned);
invoiceAddress = BitcoinAddress.Create(localInvoice.BitcoinAddress, cashCow.Network);
secondPayment = localInvoice.BtcDue;
@ -681,6 +833,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
@ -761,8 +914,8 @@ namespace BTCPayServer.Tests
private static bool IsMapped(Invoice invoice, ApplicationDbContext ctx)
{
var h = BitcoinAddress.Create(invoice.BitcoinAddress).ScriptPubKey.Hash;
return ctx.AddressInvoices.FirstOrDefault(i => i.InvoiceDataId == invoice.Id && i.GetHash() == h) != null;
var h = BitcoinAddress.Create(invoice.BitcoinAddress).ScriptPubKey.Hash.ToString();
return ctx.AddressInvoices.FirstOrDefault(i => i.InvoiceDataId == invoice.Id && i.GetAddress() == h) != null;
}
private void Eventually(Action act)
@ -781,5 +934,22 @@ namespace BTCPayServer.Tests
}
}
}
private async Task EventuallyAsync(Func<Task> act)
{
CancellationTokenSource cts = new CancellationTokenSource(20000);
while (true)
{
try
{
await act();
break;
}
catch (XunitException) when (!cts.Token.IsCancellationRequested)
{
await Task.Delay(500);
}
}
}
}
}

View File

@ -17,17 +17,18 @@ services:
TESTS_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Database=btcpayserver
TESTS_PORT: 80
TESTS_HOSTNAME: tests
TEST_ECLAIR: http://eclair-cli:gpwefwmmewci@eclair:8080/
TEST_CHARGE: http://api-token:foiewnccewuify@lightning-charged:9112/
expose:
- "80"
links:
- nbxplorer
- postgres
- dev
extra_hosts:
- "tests:127.0.0.1"
# The dev container is not actually used, it is just handy to run `docker-compose up dev` to start all services
dev:
image: nicolasdorier/docker-bitcoin:0.15.0.1
image: nicolasdorier/docker-bitcoin:0.16.0
environment:
BITCOIN_EXTRA_ARGS: |
regtest=1
@ -35,9 +36,11 @@ services:
links:
- nbxplorer
- postgres
- eclair
- lightning-charged
nbxplorer:
image: nicolasdorier/nbxplorer:1.0.1.13
image: nicolasdorier/nbxplorer:1.0.1.16
ports:
- "32838:32838"
expose:
@ -62,7 +65,7 @@ services:
bitcoind:
container_name: btcpayserver_dev_bitcoind
image: nicolasdorier/docker-bitcoin:0.15.0.1
image: nicolasdorier/docker-bitcoin:0.16.0
environment:
BITCOIN_EXTRA_ARGS: |
rpcuser=ceiwHEbqWI83
@ -72,11 +75,58 @@ services:
rpcport=43782
port=39388
whitelist=0.0.0.0/0
zmqpubrawblock=tcp://0.0.0.0:29000
zmqpubrawtx=tcp://0.0.0.0:29000
txindex=1
# Eclair is still using addwitnessaddress
deprecatedrpc=addwitnessaddress
ports:
- "43782:43782"
expose:
- "43782" # RPC
- "39388" # P2P
volumes:
- "bitcoin_datadir:/data"
lightning-charged:
image: shesek/lightning-charge:0.3.1
environment:
NETWORK: regtest
API_TOKEN: foiewnccewuify
SKIP_BITCOIND: 1
BITCOIND_RPCCONNECT: bitcoind
volumes:
- "bitcoin_datadir:/etc/bitcoin"
expose:
- "9112" # Charge
- "9735" # Lightning
ports:
- "54938:9112" # Charge
links:
- bitcoind
eclair:
image: acinq/eclair@sha256:758eaf02683046a096ee03390d3a54df8fcfca50883f7560ab946a36ee4e81d8
environment:
JAVA_OPTS: >
-Xmx512m
-Declair.printToConsole
-Declair.bitcoind.host=bitcoind
-Declair.bitcoind.rpcport=43782
-Declair.bitcoind.rpcuser=ceiwHEbqWI83
-Declair.bitcoind.rpcpassword=DwubwWsoo3
-Declair.bitcoind.zmq=tcp://bitcoind:29000
-Declair.api.enabled=true
-Declair.api.password=gpwefwmmewci
-Declair.chain=regtest
-Declair.api.binding-ip=0.0.0.0
links:
- bitcoind
ports:
- "30992:8080" # api port
expose:
- "9735" # server port
- "8080" # api port
litecoind:
container_name: btcpayserver_dev_litecoind
@ -102,3 +152,6 @@ services:
- "39372:5432"
expose:
- "5432"
volumes:
bitcoin_datadir:

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using NBitcoin;
using NBXplorer;
@ -61,15 +62,17 @@ 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()
{
return CryptoCode;
}
}
}
}

View File

@ -20,14 +20,18 @@ namespace BTCPayServer
Add(new BTCPayNetwork()
{
CryptoCode = nbxplorerNetwork.CryptoCode,
BlockExplorerLink = NBXplorerNetworkProvider.ChainType == ChainType.Main? "https://www.smartbit.com.au/tx/{0}" : "https://testnet.smartbit.com.au/tx/{0}",
BlockExplorerLink = NBXplorerNetworkProvider.ChainType == ChainType.Main ? "https://www.smartbit.com.au/tx/{0}" : "https://testnet.smartbit.com.au/tx/{0}",
NBitcoinNetwork = nbxplorerNetwork.NBitcoinNetwork,
NBXplorerNetwork = nbxplorerNetwork,
UriScheme = "bitcoin",
DefaultRateProvider = btcRate,
CryptoImagePath = "imlegacy/bitcoin-symbol.svg",
LightningImagePath = "imlegacy/btc-lightning.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/ltc-lightning.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

@ -2,14 +2,18 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.0</TargetFramework>
<Version>1.0.1.30</Version>
<NoWarn>NU1701</NoWarn>
<Version>1.0.1.41</Version>
<NoWarn>NU1701,CA1816,CA1308,CA1810,CA2208</NoWarn>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Build\dockerfiles\**" />
<Compile Remove="wwwroot\bundles\jqueryvalidate\**" />
<Content Remove="Build\dockerfiles\**" />
<Content Remove="wwwroot\bundles\jqueryvalidate\**" />
<EmbeddedResource Remove="Build\dockerfiles\**" />
<EmbeddedResource Remove="wwwroot\bundles\jqueryvalidate\**" />
<None Remove="Build\dockerfiles\**" />
<None Remove="wwwroot\bundles\jqueryvalidate\**" />
</ItemGroup>
<ItemGroup>
<None Remove="Currencies.txt" />
@ -18,13 +22,17 @@
<EmbeddedResource Include="Currencies.txt" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BuildBundlerMinifier" Version="2.6.362" />
<PackageReference Include="Hangfire" Version="1.6.17" />
<PackageReference Include="Hangfire.MemoryStorage" Version="1.5.2" />
<PackageReference Include="Hangfire.PostgreSql" Version="1.4.8.1" />
<PackageReference Include="LedgerWallet" Version="1.0.1.32" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="2.6.1" />
<PackageReference Include="Meziantou.AspNetCore.BundleTagHelpers" Version="1.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Filter" Version="1.1.2" />
<PackageReference Include="NBitcoin" Version="4.0.0.54" />
<PackageReference Include="NBitpayClient" Version="1.0.0.16" />
<PackageReference Include="Microsoft.NetCore.Analyzers" Version="2.6.0" />
<PackageReference Include="NBitcoin" Version="4.0.0.56" />
<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" />
@ -33,12 +41,12 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="2.0.1" />
<PackageReference Include="System.ValueTuple" Version="4.4.0" />
<PackageReference Include="System.Xml.XmlSerializer" Version="4.0.11" />
<PackageReference Include="Text.Analyzers" Version="2.6.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="2.0.1" PrivateAssets="All" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="2.0.2" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
@ -100,6 +108,7 @@
<ItemGroup>
<Folder Include="Build\" />
<Folder Include="wwwroot\vendor\clipboard.js\" />
</ItemGroup>
<ItemGroup>

View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<RuleSet Name="Microsoft Managed Recommended Rules" Description="These rules focus on the most critical problems in your code, including potential security holes, application crashes, and other important logic and design errors. It is recommended to include this rule set in any custom rule set you create for your projects." ToolsVersion="10.0">
<Localization ResourceAssembly="Microsoft.VisualStudio.CodeAnalysis.RuleSets.Strings.dll" ResourceBaseName="Microsoft.VisualStudio.CodeAnalysis.RuleSets.Strings.Localized">
<Name Resource="MinimumRecommendedRules_Name" />
<Description Resource="MinimumRecommendedRules_Description" />
</Localization>
<Rules AnalyzerId="Microsoft.Analyzers.ManagedCodeAnalysis" RuleNamespace="Microsoft.Rules.Managed">
<Rule Id="CA1001" Action="Warning" />
<Rule Id="CA1009" Action="Warning" />
<Rule Id="CA1016" Action="Warning" />
<Rule Id="CA1033" Action="Warning" />
<Rule Id="CA1049" Action="Warning" />
<Rule Id="CA1060" Action="Warning" />
<Rule Id="CA1061" Action="Warning" />
<Rule Id="CA1063" Action="Warning" />
<Rule Id="CA1065" Action="Warning" />
<Rule Id="CA1301" Action="Warning" />
<Rule Id="CA1400" Action="Warning" />
<Rule Id="CA1401" Action="Warning" />
<Rule Id="CA1403" Action="Warning" />
<Rule Id="CA1404" Action="Warning" />
<Rule Id="CA1405" Action="Warning" />
<Rule Id="CA1410" Action="Warning" />
<Rule Id="CA1415" Action="Warning" />
<Rule Id="CA1821" Action="Warning" />
<Rule Id="CA1900" Action="Warning" />
<Rule Id="CA1901" Action="Warning" />
<Rule Id="CA2002" Action="Warning" />
<Rule Id="CA2100" Action="Warning" />
<Rule Id="CA2101" Action="Warning" />
<Rule Id="CA2108" Action="Warning" />
<Rule Id="CA2111" Action="Warning" />
<Rule Id="CA2112" Action="Warning" />
<Rule Id="CA2114" Action="Warning" />
<Rule Id="CA2116" Action="Warning" />
<Rule Id="CA2117" Action="Warning" />
<Rule Id="CA2122" Action="Warning" />
<Rule Id="CA2123" Action="Warning" />
<Rule Id="CA2124" Action="Warning" />
<Rule Id="CA2126" Action="Warning" />
<Rule Id="CA2131" Action="Warning" />
<Rule Id="CA2132" Action="Warning" />
<Rule Id="CA2133" Action="Warning" />
<Rule Id="CA2134" Action="Warning" />
<Rule Id="CA2137" Action="Warning" />
<Rule Id="CA2138" Action="Warning" />
<Rule Id="CA2140" Action="Warning" />
<Rule Id="CA2141" Action="Warning" />
<Rule Id="CA2146" Action="Warning" />
<Rule Id="CA2147" Action="Warning" />
<Rule Id="CA2149" Action="Warning" />
<Rule Id="CA2200" Action="Warning" />
<Rule Id="CA2202" Action="Warning" />
<Rule Id="CA2207" Action="Warning" />
<Rule Id="CA2212" Action="Warning" />
<Rule Id="CA2213" Action="Warning" />
<Rule Id="CA2214" Action="Warning" />
<Rule Id="CA2216" Action="Warning" />
<Rule Id="CA2220" Action="Warning" />
<Rule Id="CA2229" Action="Warning" />
<Rule Id="CA2231" Action="Warning" />
<Rule Id="CA2232" Action="Warning" />
<Rule Id="CA2235" Action="Warning" />
<Rule Id="CA2236" Action="Warning" />
<Rule Id="CA2237" Action="Warning" />
<Rule Id="CA2238" Action="Warning" />
<Rule Id="CA2240" Action="Warning" />
<Rule Id="CA2241" Action="Warning" />
<Rule Id="CA2242" Action="Warning" />
</Rules>
<Rules AnalyzerId="Microsoft.CodeAnalysis.CSharp.Analyzers" RuleNamespace="Microsoft.CodeAnalysis.CSharp.Analyzers" />
</RuleSet>

View File

@ -58,28 +58,34 @@ namespace BTCPayServer.Configuration
var supportedChains = conf.GetOrDefault<string>("chains", "btc")
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.ToUpperInvariant());
var validChains = new List<string>();
foreach (var net in new BTCPayNetworkProvider(ChainType).GetAll())
NetworkProvider = new BTCPayNetworkProvider(ChainType).Filter(supportedChains.ToArray());
foreach (var chain in supportedChains)
{
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);
}
if (NetworkProvider.GetNetwork(chain) == null)
throw new ConfigException($"Invalid chains \"{chain}\"");
}
var validChains = new List<string>();
foreach (var net in NetworkProvider.GetAll())
{
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);
BundleJsCss = conf.GetOrDefault<bool>("bundlejscss", true);
ExternalUrl = conf.GetOrDefault<Uri>("externalurl", null);
InternalLightningNode = conf.GetOrDefault<Uri>("internallightningnode", null);
}
public Uri InternalLightningNode { get; set; }
public BTCPayNetworkProvider NetworkProvider { get; set; }
public string PostgresConnectionString
{
get;
@ -90,5 +96,10 @@ namespace BTCPayServer.Configuration
get;
set;
}
public bool BundleJsCss
{
get;
set;
}
}
}

View File

@ -13,7 +13,7 @@ namespace BTCPayServer.Configuration
{
public static T GetOrDefault<T>(this IConfiguration configuration, string key, T defaultValue)
{
var str = configuration[key] ?? configuration[key.Replace(".", string.Empty)];
var str = configuration[key] ?? configuration[key.Replace(".", string.Empty, StringComparison.InvariantCulture)];
if (str == null)
return defaultValue;
if (typeof(T) == typeof(bool))
@ -27,17 +27,24 @@ namespace BTCPayServer.Configuration
throw new FormatException();
}
else if (typeof(T) == typeof(Uri))
return (T)(object)new Uri(str, UriKind.Absolute);
if (string.IsNullOrEmpty(str))
{
return defaultValue;
}
else
{
return (T)(object)new Uri(str, UriKind.Absolute);
}
else if (typeof(T) == typeof(string))
return (T)(object)str;
else if (typeof(T) == typeof(IPEndPoint))
{
var separator = str.LastIndexOf(":");
var separator = str.LastIndexOf(":", StringComparison.InvariantCulture);
if (separator == -1)
throw new FormatException();
var ip = str.Substring(0, separator);
var port = str.Substring(separator + 1);
return (T)(object)new IPEndPoint(IPAddress.Parse(ip), int.Parse(port));
return (T)(object)new IPEndPoint(IPAddress.Parse(ip), int.Parse(port, CultureInfo.InvariantCulture));
}
else if (typeof(T) == typeof(int))
{

View File

@ -38,6 +38,8 @@ namespace BTCPayServer.Configuration
app.Option($"--{crypto}explorercookiefile", $"Path to the cookie file (default: {network.NBXplorerNetwork.DefaultSettings.DefaultCookieFile})", CommandOptionType.SingleValue);
}
app.Option("--externalurl", $"The expected external url of this service, to use if BTCPay is behind a reverse proxy (default: empty, use the incoming HTTP request to figure out)", CommandOptionType.SingleValue);
app.Option("--internallightningnode", $"An internal lightning node which can be used without https requirement and easily configured by the admin (default: empty)", CommandOptionType.SingleValue);
app.Option("--bundlejscss", $"Bundle javascript and css files for better performance (default: true)", CommandOptionType.SingleValue);
return app;
}

View File

@ -150,7 +150,7 @@ namespace BTCPayServer.Controllers
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty).Replace("-", string.Empty);
var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty, StringComparison.InvariantCulture).Replace("-", string.Empty, StringComparison.InvariantCulture);
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine);
@ -204,7 +204,7 @@ namespace BTCPayServer.Controllers
throw new ApplicationException($"Unable to load two-factor authentication user.");
}
var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty);
var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty, StringComparison.InvariantCulture);
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);

View File

@ -9,13 +9,15 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Payments;
namespace BTCPayServer.Controllers
{
public partial class InvoiceController
{
[HttpGet]
[Route("i/{invoiceId}")]
[Route("i/{invoiceId}/{cryptoCode?}")]
[AcceptMediaTypeConstraint("application/bitcoin-paymentrequest")]
public async Task<IActionResult> GetInvoiceRequest(string invoiceId, string cryptoCode = null)
{
@ -23,11 +25,12 @@ namespace BTCPayServer.Controllers
cryptoCode = "BTC";
var invoice = await _InvoiceRepository.GetInvoice(null, invoiceId);
var network = _NetworkProvider.GetNetwork(cryptoCode);
if (invoice == null || invoice.IsExpired() || network == null || !invoice.Support(network))
var paymentMethodId = new PaymentMethodId(cryptoCode, Payments.PaymentTypes.BTCLike);
if (invoice == null || invoice.IsExpired() || network == null || !invoice.Support(paymentMethodId))
return NotFound();
var dto = invoice.EntityToDTO(_NetworkProvider);
var cryptoData = dto.CryptoInfo.First(c => c.CryptoCode.Equals(cryptoCode, StringComparison.OrdinalIgnoreCase));
var paymentMethod = dto.CryptoInfo.First(c => c.GetpaymentMethodId() == paymentMethodId);
PaymentRequest request = new PaymentRequest
{
DetailsVersion = 1
@ -35,7 +38,7 @@ namespace BTCPayServer.Controllers
request.Details.Expires = invoice.ExpirationTime;
request.Details.Memo = invoice.ProductInformation.ItemDesc;
request.Details.Network = network.NBitcoinNetwork;
request.Details.Outputs.Add(new PaymentOutput() { Amount = cryptoData.Due, Script = BitcoinAddress.Create(cryptoData.Address, network.NBitcoinNetwork).ScriptPubKey });
request.Details.Outputs.Add(new PaymentOutput() { Amount = paymentMethod.Due, Script = BitcoinAddress.Create(paymentMethod.Address, network.NBitcoinNetwork).ScriptPubKey });
request.Details.MerchantData = Encoding.UTF8.GetBytes(invoice.Id);
request.Details.Time = DateTimeOffset.UtcNow;
request.Details.PaymentUrl = new Uri(invoice.ServerUrl.WithTrailingSlash() + ($"i/{invoice.Id}"), UriKind.Absolute);
@ -69,7 +72,7 @@ namespace BTCPayServer.Controllers
if (cryptoCode == null)
cryptoCode = "BTC";
var network = _NetworkProvider.GetNetwork(cryptoCode);
if (network == null || invoice == null || invoice.IsExpired() || !invoice.Support(network))
if (network == null || invoice == null || invoice.IsExpired() || !invoice.Support(new PaymentMethodId(cryptoCode, Payments.PaymentTypes.BTCLike)))
return NotFound();
var wallet = _WalletProvider.GetWallet(network);

View File

@ -20,6 +20,7 @@ using System.Net.WebSockets;
using System.Threading;
using BTCPayServer.Events;
using NBXplorer;
using BTCPayServer.Payments;
namespace BTCPayServer.Controllers
{
@ -63,33 +64,58 @@ namespace BTCPayServer.Controllers
Events = invoice.Events
};
foreach (var data in invoice.GetCryptoData(null))
foreach (var data in invoice.GetPaymentMethods(null))
{
var cryptoInfo = dto.CryptoInfo.First(o => o.CryptoCode.Equals(data.Key, StringComparison.OrdinalIgnoreCase));
var accounting = data.Value.Calculate();
var paymentNetwork = _NetworkProvider.GetNetwork(data.Key);
var cryptoInfo = dto.CryptoInfo.First(o => o.GetpaymentMethodId() == data.GetId());
var accounting = data.Calculate();
var paymentNetwork = _NetworkProvider.GetNetwork(data.GetId().CryptoCode);
var cryptoPayment = new InvoiceDetailsModel.CryptoPayment();
cryptoPayment.CryptoCode = paymentNetwork.CryptoCode;
cryptoPayment.Due = accounting.Due.ToString() + $" {paymentNetwork.CryptoCode}";
cryptoPayment.Paid = accounting.CryptoPaid.ToString() + $" {paymentNetwork.CryptoCode}";
cryptoPayment.Address = data.Value.DepositAddress.ToString();
cryptoPayment.Rate = FormatCurrency(data.Value);
var onchainMethod = data.GetPaymentMethodDetails() as Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod;
if(onchainMethod != null)
{
cryptoPayment.Address = onchainMethod.DepositAddress.ToString();
}
cryptoPayment.Rate = FormatCurrency(data);
cryptoPayment.PaymentUrl = cryptoInfo.PaymentUrls.BIP21;
model.CryptoPayments.Add(cryptoPayment);
}
var payments = invoice
.GetPayments()
.Where(p => p.GetpaymentMethodId().PaymentType == PaymentTypes.BTCLike)
.Select(async payment =>
{
var paymentData = (Payments.Bitcoin.BitcoinLikePaymentData)payment.GetCryptoPaymentData();
var m = new InvoiceDetailsModel.Payment();
var paymentNetwork = _NetworkProvider.GetNetwork(payment.GetCryptoCode());
m.CryptoCode = payment.GetCryptoCode();
m.DepositAddress = payment.GetScriptPubKey().GetDestinationAddress(paymentNetwork.NBitcoinNetwork);
m.Confirmations = (await _ExplorerClients.GetExplorerClient(payment.GetCryptoCode())?.GetTransactionAsync(payment.Outpoint.Hash))?.Confirmations ?? 0;
m.TransactionId = payment.Outpoint.Hash.ToString();
m.DepositAddress = paymentData.Output.ScriptPubKey.GetDestinationAddress(paymentNetwork.NBitcoinNetwork);
int confirmationCount = 0;
if(paymentData.Legacy) // The confirmation count in the paymentData is not up to date
{
confirmationCount = (await ((ExplorerClientProvider)_ServiceProvider.GetService(typeof(ExplorerClientProvider))).GetExplorerClient(payment.GetCryptoCode())?.GetTransactionAsync(paymentData.Outpoint.Hash))?.Confirmations ?? 0;
}
else
{
confirmationCount = paymentData.ConfirmationCount;
}
if(confirmationCount >= paymentNetwork.MaxTrackedConfirmation)
{
m.Confirmations = "At least " + (paymentNetwork.MaxTrackedConfirmation);
}
else
{
m.Confirmations = confirmationCount.ToString(CultureInfo.InvariantCulture);
}
m.TransactionId = paymentData.Outpoint.Hash.ToString();
m.ReceivedTime = payment.ReceivedTime;
m.TransactionLink = string.Format(paymentNetwork.BlockExplorerLink, m.TransactionId);
m.TransactionLink = string.Format(CultureInfo.InvariantCulture, paymentNetwork.BlockExplorerLink, m.TransactionId);
m.Replaced = !payment.Accounted;
return m;
})
@ -103,60 +129,65 @@ namespace BTCPayServer.Controllers
[HttpGet]
[Route("i/{invoiceId}")]
[Route("i/{invoiceId}/{cryptoCode}")]
[Route("i/{invoiceId}/{paymentMethodId}")]
[Route("invoice")]
[AcceptMediaTypeConstraint("application/bitcoin-paymentrequest", false)]
[XFrameOptionsAttribute(null)]
public async Task<IActionResult> Checkout(string invoiceId, string id = null, string cryptoCode = null)
public async Task<IActionResult> Checkout(string invoiceId, string id = null, string paymentMethodId = null)
{
//Keep compatibility with Bitpay
invoiceId = invoiceId ?? id;
id = invoiceId;
////
var model = await GetInvoiceModel(invoiceId, cryptoCode);
var model = await GetInvoiceModel(invoiceId, paymentMethodId);
if (model == null)
return NotFound();
return View(nameof(Checkout), model);
}
private async Task<PaymentModel> GetInvoiceModel(string invoiceId, string cryptoCode)
private async Task<PaymentModel> GetInvoiceModel(string invoiceId, string paymentMethodIdStr)
{
var invoice = await _InvoiceRepository.GetInvoice(null, invoiceId);
if (invoice == null)
return null;
var store = await _StoreRepository.FindStore(invoice.StoreId);
bool isDefaultCrypto = false;
if (cryptoCode == null)
{
cryptoCode = store.GetDefaultCrypto();
if (paymentMethodIdStr == null)
{
paymentMethodIdStr = store.GetDefaultCrypto();
isDefaultCrypto = true;
}
var network = _NetworkProvider.GetNetwork(cryptoCode);
var paymentMethodId = PaymentMethodId.Parse(paymentMethodIdStr);
var network = _NetworkProvider.GetNetwork(paymentMethodId.CryptoCode);
if (invoice == null || network == null)
return null;
if(!invoice.Support(network))
if (!invoice.Support(paymentMethodId))
{
if(!isDefaultCrypto)
return null;
network = invoice.GetCryptoData(_NetworkProvider).First().Value.Network;
var paymentMethodTemp = invoice.GetPaymentMethods(_NetworkProvider).First();
network = paymentMethodTemp.Network;
paymentMethodId = paymentMethodTemp.GetId();
}
var cryptoData = invoice.GetCryptoData(network, _NetworkProvider);
var paymentMethod = invoice.GetPaymentMethod(paymentMethodId, _NetworkProvider);
var paymentMethodDetails = paymentMethod.GetPaymentMethodDetails();
var dto = invoice.EntityToDTO(_NetworkProvider);
var cryptoInfo = dto.CryptoInfo.First(o => o.CryptoCode == network.CryptoCode);
var cryptoInfo = dto.CryptoInfo.First(o => o.GetpaymentMethodId() == paymentMethodId);
var currency = invoice.ProductInformation.Currency;
var accounting = cryptoData.Calculate();
var accounting = paymentMethod.Calculate();
var model = new PaymentModel()
{
CryptoCode = network.CryptoCode,
PaymentMethodId = paymentMethodId.ToString(),
ServerUrl = HttpContext.Request.GetAbsoluteRoot(),
OrderId = invoice.OrderId,
InvoiceId = invoice.Id,
BtcAddress = cryptoData.DepositAddress,
BtcAddress = paymentMethodDetails.GetPaymentDestination(),
OrderAmount = (accounting.TotalDue - accounting.NetworkFee).ToString(),
BtcDue = accounting.Due.ToString(),
CustomerEmail = invoice.RefundMail,
@ -164,27 +195,32 @@ namespace BTCPayServer.Controllers
MaxTimeSeconds = (int)(invoice.ExpirationTime - invoice.InvoiceTime).TotalSeconds,
MaxTimeMinutes = (int)(invoice.ExpirationTime - invoice.InvoiceTime).TotalMinutes,
ItemDesc = invoice.ProductInformation.ItemDesc,
Rate = FormatCurrency(cryptoData),
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 {cryptoData.TxFee} {network.CryptoCode}",
AvailableCryptos = invoice.GetCryptoData(_NetworkProvider)
.Where(i => i.Value.Network != null)
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()
{
CryptoCode = kv.Key,
CryptoImage = "/" + kv.Value.Network.CryptoImagePath,
Link = Url.Action(nameof(Checkout), new { invoiceId = invoiceId, cryptoCode = kv.Key })
PaymentMethodId = kv.GetId().ToString(),
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}";
@ -193,10 +229,15 @@ namespace BTCPayServer.Controllers
return model;
}
private string FormatCurrency(CryptoData cryptoData)
private string GetImage(PaymentMethodId paymentMethodId, BTCPayNetwork network)
{
string currency = cryptoData.ParentEntity.ProductInformation.Currency;
return FormatCurrency(cryptoData.Rate, currency);
return (paymentMethodId.PaymentType == PaymentTypes.BTCLike ? Url.Content(network.CryptoImagePath) : Url.Content(network.LightningImagePath));
}
private string FormatCurrency(PaymentMethod paymentMethod)
{
string currency = paymentMethod.ParentEntity.ProductInformation.Currency;
return FormatCurrency(paymentMethod.Rate, currency);
}
public string FormatCurrency(decimal price, string currency)
{
@ -207,19 +248,19 @@ namespace BTCPayServer.Controllers
{
StringBuilder builder = new StringBuilder();
if (expiration.Days >= 1)
builder.Append(expiration.Days.ToString());
builder.Append(expiration.Days.ToString(CultureInfo.InvariantCulture));
if (expiration.Hours >= 1)
builder.Append(expiration.Hours.ToString("00"));
builder.Append($"{expiration.Minutes.ToString("00")}:{expiration.Seconds.ToString("00")}");
builder.Append(expiration.Hours.ToString("00", CultureInfo.InvariantCulture));
builder.Append($"{expiration.Minutes.ToString("00", CultureInfo.InvariantCulture)}:{expiration.Seconds.ToString("00", CultureInfo.InvariantCulture)}");
return builder.ToString();
}
[HttpGet]
[Route("i/{invoiceId}/status")]
[Route("i/{invoiceId}/{cryptoCode}/status")]
public async Task<IActionResult> GetStatus(string invoiceId, string cryptoCode)
[Route("i/{invoiceId}/{paymentMethodId}/status")]
public async Task<IActionResult> GetStatus(string invoiceId, string paymentMethodId = null)
{
var model = await GetInvoiceModel(invoiceId, cryptoCode);
var model = await GetInvoiceModel(invoiceId, paymentMethodId);
if (model == null)
return NotFound();
return Json(model);
@ -239,6 +280,7 @@ namespace BTCPayServer.Controllers
try
{
leases.Add(_EventAggregator.Subscribe<Events.InvoiceDataChangedEvent>(async o => await NotifySocket(webSocket, o.InvoiceId, invoiceId)));
leases.Add(_EventAggregator.Subscribe<Events.InvoiceNewAddressEvent>(async o => await NotifySocket(webSocket, o.InvoiceId, invoiceId)));
leases.Add(_EventAggregator.Subscribe<Events.InvoiceEvent>(async o => await NotifySocket(webSocket, o.InvoiceId, invoiceId)));
while (true)
{
@ -303,8 +345,9 @@ namespace BTCPayServer.Controllers
model.Invoices.Add(new InvoiceModel()
{
Status = invoice.Status,
Date = invoice.InvoiceTime,
Date = Prettify(invoice.InvoiceTime),
InvoiceId = invoice.Id,
OrderId = invoice.OrderId ?? string.Empty,
AmountCurrency = $"{invoice.ProductInformation.Price.ToString(CultureInfo.InvariantCulture)} {invoice.ProductInformation.Currency}"
});
}
@ -314,6 +357,30 @@ namespace BTCPayServer.Controllers
return View(model);
}
private string Prettify(DateTimeOffset invoiceTime)
{
var ago = DateTime.UtcNow - invoiceTime;
if(ago.TotalMinutes < 1)
{
return $"{(int)ago.TotalSeconds} second{Plural((int)ago.TotalSeconds)} ago";
}
if (ago.TotalHours < 1)
{
return $"{(int)ago.TotalMinutes} minute{Plural((int)ago.TotalMinutes)} ago";
}
if (ago.Days < 1)
{
return $"{(int)ago.TotalHours} hour{Plural((int)ago.TotalHours)} ago";
}
return $"{(int)ago.TotalDays} day{Plural((int)ago.TotalDays)} ago";
}
private string Plural(int totalDays)
{
return totalDays > 1 ? "s" : string.Empty;
}
[HttpGet]
[Route("invoices/create")]
[Authorize(AuthenticationSchemes = "Identity.Application")]
@ -341,7 +408,7 @@ namespace BTCPayServer.Controllers
return View(model);
}
var store = await _StoreRepository.FindStore(model.StoreId, GetUserId());
if (store.GetDerivationStrategies(_NetworkProvider).Count() == 0)
if (store.GetSupportedPaymentMethods(_NetworkProvider).Count() == 0)
{
StatusMessage = "Error: You need to configure the derivation scheme in order to create an invoice";
return RedirectToAction(nameof(StoresController.UpdateStore), "Stores", new

View File

@ -39,55 +39,73 @@ using Microsoft.AspNetCore.Mvc.Routing;
using NBXplorer.DerivationStrategy;
using NBXplorer;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
namespace BTCPayServer.Controllers
{
public partial class InvoiceController : Controller
{
InvoiceRepository _InvoiceRepository;
BTCPayWalletProvider _WalletProvider;
IRateProviderFactory _RateProviders;
StoreRepository _StoreRepository;
UserManager<ApplicationUser> _UserManager;
IFeeProviderFactory _FeeProviderFactory;
private CurrencyNameTable _CurrencyNameTable;
EventAggregator _EventAggregator;
BTCPayNetworkProvider _NetworkProvider;
ExplorerClientProvider _ExplorerClients;
public InvoiceController(InvoiceRepository invoiceRepository,
private readonly BTCPayWalletProvider _WalletProvider;
IServiceProvider _ServiceProvider;
public InvoiceController(
IServiceProvider serviceProvider,
InvoiceRepository invoiceRepository,
CurrencyNameTable currencyNameTable,
UserManager<ApplicationUser> userManager,
BTCPayWalletProvider walletProvider,
IRateProviderFactory rateProviders,
StoreRepository storeRepository,
EventAggregator eventAggregator,
BTCPayNetworkProvider networkProvider,
ExplorerClientProvider explorerClientProviders,
IFeeProviderFactory feeProviderFactory)
BTCPayWalletProvider walletProvider,
BTCPayNetworkProvider networkProvider)
{
_ExplorerClients = explorerClientProviders;
_ServiceProvider = serviceProvider;
_CurrencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable));
_StoreRepository = storeRepository ?? throw new ArgumentNullException(nameof(storeRepository));
_InvoiceRepository = invoiceRepository ?? throw new ArgumentNullException(nameof(invoiceRepository));
_WalletProvider = walletProvider ?? throw new ArgumentNullException(nameof(walletProvider));
_RateProviders = rateProviders ?? throw new ArgumentNullException(nameof(rateProviders));
_UserManager = userManager;
_FeeProviderFactory = feeProviderFactory ?? throw new ArgumentNullException(nameof(feeProviderFactory));
_EventAggregator = eventAggregator;
_NetworkProvider = networkProvider;
_WalletProvider = walletProvider;
}
internal async Task<DataWrapper<InvoiceResponse>> CreateInvoiceCore(Invoice invoice, StoreData store, string serverUrl)
{
var derivationStrategies = store.GetDerivationStrategies(_NetworkProvider).Where(c => _ExplorerClients.IsAvailable(c.Network.CryptoCode)).ToList();
if (derivationStrategies.Count == 0)
var supportedPaymentMethods = store.GetSupportedPaymentMethods(_NetworkProvider)
.Select(c =>
(Handler: (IPaymentMethodHandler)_ServiceProvider.GetService(typeof(IPaymentMethodHandler<>).MakeGenericType(c.GetType())),
SupportedPaymentMethod: c,
Network: _NetworkProvider.GetNetwork(c.PaymentId.CryptoCode),
IsAvailable: Task.FromResult(false)))
.Where(c => c.Network != null)
.Select(c =>
{
c.IsAvailable = c.Handler.IsAvailable(c.SupportedPaymentMethod, c.Network);
return c;
})
.ToList();
foreach(var supportedPaymentMethod in supportedPaymentMethods.ToList())
{
if(!await supportedPaymentMethod.IsAvailable)
{
supportedPaymentMethods.Remove(supportedPaymentMethod);
}
}
if (supportedPaymentMethods.Count == 0)
throw new BitpayHttpException(400, "No derivation strategy are available now for this store");
var entity = new InvoiceEntity
{
InvoiceTime = DateTimeOffset.UtcNow
};
entity.SetDerivationStrategies(derivationStrategies);
entity.SetSupportedPaymentMethods(supportedPaymentMethods.Select(s => s.SupportedPaymentMethod));
var storeBlob = store.GetStoreBlob();
Uri notificationUri = Uri.IsWellFormedUriString(invoice.NotificationURL, UriKind.Absolute) ? new Uri(invoice.NotificationURL, UriKind.Absolute) : null;
@ -115,55 +133,40 @@ namespace BTCPayServer.Controllers
entity.Status = "new";
entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
var queries = derivationStrategies
.Select(derivationStrategy => (Wallet: _WalletProvider.GetWallet(derivationStrategy.Network),
DerivationStrategy: derivationStrategy.DerivationStrategyBase,
Network: derivationStrategy.Network,
RateProvider: _RateProviders.GetRateProvider(derivationStrategy.Network, false),
FeeRateProvider: _FeeProviderFactory.CreateFeeProvider(derivationStrategy.Network)))
.Where(_ => _.Wallet != null &&
_.FeeRateProvider != null &&
_.RateProvider != null)
.Select(_ =>
{
return new
var methods = supportedPaymentMethods
.Select(async o =>
{
network = _.Network,
getFeeRate = _.FeeRateProvider.GetFeeRateAsync(),
getRate = storeBlob.ApplyRateRules(_.Network, _.RateProvider).GetRateAsync(invoice.Currency),
getAddress = _.Wallet.ReserveAddressAsync(_.DerivationStrategy)
};
});
bool legacyBTCisSet = false;
var cryptoDatas = new Dictionary<string, CryptoData>();
foreach (var q in queries)
{
CryptoData cryptoData = new CryptoData();
cryptoData.CryptoCode = q.network.CryptoCode;
cryptoData.FeeRate = (await q.getFeeRate);
cryptoData.TxFee = GetTxFee(storeBlob, cryptoData.FeeRate); // assume price for 100 bytes
cryptoData.Rate = await q.getRate;
cryptoData.DepositAddress = (await q.getAddress).ToString();
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);
if (storeBlob.NetworkFeeDisabled)
paymentDetails.SetNoTxFee();
paymentMethod.SetPaymentMethodDetails(paymentDetails);
#pragma warning disable CS0618
if (q.network.IsBTC)
{
legacyBTCisSet = true;
entity.TxFee = cryptoData.TxFee;
entity.Rate = cryptoData.Rate;
entity.DepositAddress = cryptoData.DepositAddress;
}
if (paymentMethod.GetId().IsBTCOnChain)
{
entity.TxFee = paymentMethod.TxFee;
entity.Rate = paymentMethod.Rate;
entity.DepositAddress = paymentMethod.DepositAddress;
}
#pragma warning restore CS0618
cryptoDatas.Add(cryptoData.CryptoCode, cryptoData);
}
if (!legacyBTCisSet)
return paymentMethod;
});
var paymentMethods = new PaymentMethodDictionary();
foreach (var method in methods)
{
// Legacy Bitpay clients expect information for BTC information, even if the store do not support it
paymentMethods.Add(await method);
}
#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 && _NetworkProvider.BTC != null)
{
var btc = _NetworkProvider.BTC;
var feeProvider = _FeeProviderFactory.CreateFeeProvider(btc);
var feeProvider = ((IFeeProviderFactory)_ServiceProvider.GetService(typeof(IFeeProviderFactory))).CreateFeeProvider(btc);
var rateProvider = storeBlob.ApplyRateRules(btc, _RateProviders.GetRateProvider(btc, false));
if (feeProvider != null && rateProvider != null)
{
@ -175,7 +178,7 @@ namespace BTCPayServer.Controllers
#pragma warning restore CS0618
}
entity.SetCryptoData(cryptoDatas);
entity.SetPaymentMethods(paymentMethods);
entity.PosData = invoice.PosData;
entity = await _InvoiceRepository.CreateInvoiceAsync(store.Id, entity, _NetworkProvider);
_EventAggregator.Publish(new Events.InvoiceEvent(entity, 1001, "invoice_created"));
@ -183,10 +186,12 @@ namespace BTCPayServer.Controllers
return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
}
#pragma warning disable CS0618
private static Money GetTxFee(StoreBlob storeBlob, FeeRate feeRate)
{
return storeBlob.NetworkFeeDisabled ? Money.Zero : feeRate.GetFee(100);
}
#pragma warning restore CS0618
private SpeedPolicy ParseSpeedPolicy(string transactionSpeed, SpeedPolicy defaultPolicy)
{

View File

@ -20,6 +20,7 @@ using NBitcoin;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using BTCPayServer.Services.Mails;
using System.Globalization;
namespace BTCPayServer.Controllers
{
@ -434,7 +435,7 @@ namespace BTCPayServer.Controllers
}
// Strip spaces and hypens
var verificationCode = model.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
var verificationCode = model.Code.Replace(" ", string.Empty, StringComparison.InvariantCulture).Replace("-", string.Empty, StringComparison.InvariantCulture);
var is2faTokenValid = await _userManager.VerifyTwoFactorTokenAsync(
user, _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
@ -524,7 +525,7 @@ namespace BTCPayServer.Controllers
private string GenerateQrCodeUri(string email, string unformattedKey)
{
return string.Format(
return string.Format(CultureInfo.InvariantCulture,
AuthenicatorUriFormat,
_urlEncoder.Encode("BTCPayServer"),
_urlEncoder.Encode(email),

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,134 @@
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);
vm.InternalLightningNode = GetInternalLightningNodeIfAuthorized();
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);
vm.InternalLightningNode = GetInternalLightningNodeIfAuthorized();
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 (uri.Scheme != "https")
{
var internalNode = GetInternalLightningNodeIfAuthorized();
if (internalNode == null || GetDomain(internalNode) != GetDomain(uri.AbsoluteUri))
{
ModelState.AddModelError(nameof(vm.Url), "The url must be HTTPS");
return View(vm);
}
}
if (!CanUseInternalLightning() && GetDomain(_BtcpayServerOptions.InternalLightningNode.AbsoluteUri) == GetDomain(uri.AbsoluteUri))
{
ModelState.AddModelError(nameof(vm.Url), "Unauthorized url");
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);
}
}
private string GetInternalLightningNodeIfAuthorized()
{
if (_BtcpayServerOptions.InternalLightningNode != null &&
CanUseInternalLightning())
{
return _BtcpayServerOptions.InternalLightningNode.AbsoluteUri;
}
return null;
}
private bool CanUseInternalLightning()
{
return (_BTCPayEnv.IsDevelopping || User.IsInRole(Roles.ServerAdmin));
}
string GetDomain(string uri)
{
return new UriBuilder(uri).Host;
}
}
}

View File

@ -1,13 +1,11 @@
using BTCPayServer.Authentication;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
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;
@ -16,15 +14,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.Linq;
using System.Net.Http;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@ -34,9 +28,12 @@ namespace BTCPayServer.Controllers
[Authorize(AuthenticationSchemes = "Identity.Application")]
[Authorize(Policy = "CanAccessStore")]
[AutoValidateAntiforgeryToken]
public class StoresController : Controller
public partial class StoresController : Controller
{
public StoresController(
IServiceProvider serviceProvider,
BTCPayServerOptions btcpayServerOptions,
BTCPayServerEnvironment btcpayEnv,
IOptions<MvcJsonOptions> mvcJsonOptions,
StoreRepository repo,
TokenRepository tokenRepo,
@ -58,7 +55,13 @@ namespace BTCPayServer.Controllers
_ExplorerProvider = explorerProvider;
_MvcJsonOptions = mvcJsonOptions.Value;
_FeeRateProvider = feeRateProvider;
_ServiceProvider = serviceProvider;
_BtcpayServerOptions = btcpayServerOptions;
_BTCPayEnv = btcpayEnv;
}
BTCPayServerOptions _BtcpayServerOptions;
BTCPayServerEnvironment _BTCPayEnv;
IServiceProvider _ServiceProvider;
BTCPayNetworkProvider _NetworkProvider;
private ExplorerClientProvider _ExplorerProvider;
private MvcJsonOptions _MvcJsonOptions;
@ -120,190 +123,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)), 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 (!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;
unspentCoins.Item2.TryAdd(changeAddress.Item1.ScriptPubKey, changeAddress.Item2);
var transaction = await hw.SendToAddress(strategy, unspentCoins.Item1, network,
new[] { (destinationAddress as IDestination, amountBTC, subsctractFeesValue) },
feeRateValue,
changeAddress.Item1,
unspentCoins.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);
}
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.GetDerivationStrategies(_NetworkProvider).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()
{
@ -311,11 +130,12 @@ namespace BTCPayServer.Controllers
result.StatusMessage = StatusMessage;
var stores = await _Repo.GetStoresByUserId(GetUserId());
var balances = stores
.Select(s => s.GetDerivationStrategies(_NetworkProvider)
.Select(s => s.GetSupportedPaymentMethods(_NetworkProvider)
.OfType<DerivationStrategy>()
.Select(d => ((Wallet: _WalletProvider.GetWallet(d.Network),
DerivationStrategy: d.DerivationStrategyBase)))
.Where(_ => _.Wallet != null)
.Select(async _ => (await GetBalanceString(_)).ToString() + " " + _.Wallet.Network.CryptoCode))
.Select(async _ => (await GetBalanceString(_)) + " " + _.Wallet.Network.CryptoCode))
.ToArray();
await Task.WhenAll(balances.SelectMany(_ => _));
@ -392,7 +212,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;
@ -401,113 +221,28 @@ namespace BTCPayServer.Controllers
return View(vm);
}
private void AddDerivationSchemes(StoreData store, StoreViewModel vm)
private void AddPaymentMethods(StoreData store, StoreViewModel vm)
{
var strategies = store
.GetDerivationStrategies(_NetworkProvider)
.ToDictionary(s => s.Network.CryptoCode);
foreach (var explorerProvider in _ExplorerProvider.GetAll())
foreach(var strategy in store
.GetSupportedPaymentMethods(_NetworkProvider)
.OfType<DerivationStrategy>())
{
if (strategies.TryGetValue(explorerProvider.Item1.CryptoCode, out DerivationStrategy strat))
vm.DerivationSchemes.Add(new StoreViewModel.DerivationScheme()
{
vm.DerivationSchemes.Add(new StoreViewModel.DerivationScheme()
{
Crypto = explorerProvider.Item1.CryptoCode,
Value = strat.DerivationStrategyBase.ToString()
});
}
}
}
[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, string selectedScheme = null)
{
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);
Crypto = strategy.PaymentId.CryptoCode,
Value = strategy.DerivationStrategyBase.ToString()
});
}
DerivationStrategyBase strategy = null;
try
foreach(var lightning in store
.GetSupportedPaymentMethods(_NetworkProvider)
.OfType<Payments.Lightning.LightningSupportedPaymentMethod>())
{
if (!string.IsNullOrEmpty(vm.DerivationScheme))
vm.LightningNodes.Add(new StoreViewModel.LightningNode()
{
strategy = ParseDerivationStrategy(vm.DerivationScheme, vm.DerivationSchemeFormat, network);
vm.DerivationScheme = strategy.ToString();
}
store.SetDerivationStrategy(network, vm.DerivationScheme);
}
catch
{
ModelState.AddModelError(nameof(vm.DerivationScheme), "Invalid Derivation Scheme");
vm.Confirmation = false;
return View(vm);
}
if (strategy == null || vm.Confirmation)
{
try
{
if (strategy != null)
await wallet.TrackAsync(strategy);
store.SetDerivationStrategy(network, vm.DerivationScheme);
}
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.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
});
}
}
@ -524,7 +259,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)
@ -590,7 +325,7 @@ namespace BTCPayServer.Controllers
});
}
private DerivationStrategyBase ParseDerivationStrategy(string derivationScheme, string format, BTCPayNetwork network)
private DerivationStrategy ParseDerivationStrategy(string derivationScheme, string format, BTCPayNetwork network)
{
if (format == "Electrum")
{
@ -604,7 +339,7 @@ namespace BTCPayServer.Controllers
var p2wpkh_p2sh = 0x049d7cb2U;
electrumMapping.Add(p2wpkh_p2sh, new string[] { "p2sh" });
var p2wpkh = 0x4b24746U;
electrumMapping.Add(p2wpkh, new string[] { });
electrumMapping.Add(p2wpkh, Array.Empty<string>());
var data = Encoders.Base58Check.DecodeData(derivationScheme);
if (data.Length < 4)
@ -624,7 +359,7 @@ namespace BTCPayServer.Controllers
}
}
return new DerivationStrategyFactory(network.NBitcoinNetwork).Parse(derivationScheme);
return new DerivationStrategy(new DerivationStrategyFactory(network.NBitcoinNetwork).Parse(derivationScheme), network);
}
[HttpGet]

View File

@ -2,6 +2,8 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Payments;
using BTCPayServer.Services.Invoices;
using NBitcoin;
namespace BTCPayServer.Data
@ -20,28 +22,30 @@ namespace BTCPayServer.Data
#pragma warning disable CS0618
public ScriptId GetHash()
public string GetAddress()
{
if (Address == null)
return null;
var index = Address.IndexOf("#");
var index = Address.LastIndexOf("#", StringComparison.InvariantCulture);
if (index == -1)
return new ScriptId(Address);
return new ScriptId(Address.Substring(0, index));
return Address;
return Address.Substring(0, index);
}
public AddressInvoiceData SetHash(ScriptId scriptId, string cryptoCode)
public AddressInvoiceData Set(string address, PaymentMethodId paymentMethodId)
{
Address = scriptId + "#" + cryptoCode;
Address = address + "#" + paymentMethodId?.ToString();
return this;
}
public string GetCryptoCode()
public PaymentMethodId GetpaymentMethodId()
{
if (Address == null)
return null;
var index = Address.IndexOf("#");
var index = Address.LastIndexOf("#", StringComparison.InvariantCulture);
// Legacy AddressInvoiceData does not have the paymentMethodId attached to the Address
if (index == -1)
return "BTC";
return Address.Substring(index + 1);
return PaymentMethodId.Parse("BTC");
/////////////////////////
return PaymentMethodId.Parse(Address.Substring(index + 1));
}
#pragma warning restore CS0618

View File

@ -35,7 +35,7 @@ namespace BTCPayServer.Data
{
if (Address == null)
return null;
var index = Address.IndexOf("#");
var index = Address.IndexOf("#", StringComparison.InvariantCulture);
if (index == -1)
return Address;
return Address.Substring(0, index);

View File

@ -12,6 +12,7 @@ using System.ComponentModel;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using BTCPayServer.Services.Rates;
using BTCPayServer.Payments;
namespace BTCPayServer.Data
{
@ -41,10 +42,12 @@ namespace BTCPayServer.Data
set;
}
public IEnumerable<DerivationStrategy> GetDerivationStrategies(BTCPayNetworkProvider networks)
public IEnumerable<ISupportedPaymentMethod> GetSupportedPaymentMethods(BTCPayNetworkProvider networks)
{
#pragma warning disable CS0618
bool btcReturned = false;
// Legacy stuff which should go away
if (!string.IsNullOrEmpty(DerivationStrategy))
{
if (networks.BTC != null)
@ -60,54 +63,63 @@ namespace BTCPayServer.Data
JObject strategies = JObject.Parse(DerivationStrategies);
foreach (var strat in strategies.Properties())
{
var network = networks.GetNetwork(strat.Name);
var paymentMethodId = PaymentMethodId.Parse(strat.Name);
var network = networks.GetNetwork(paymentMethodId.CryptoCode);
if (network != null)
{
if (network == networks.BTC && btcReturned)
if (network == networks.BTC && paymentMethodId.PaymentType == PaymentTypes.BTCLike && btcReturned)
continue;
if (strat.Value.Type == JTokenType.Null)
continue;
yield return BTCPayServer.DerivationStrategy.Parse(strat.Value.Value<string>(), network);
yield return PaymentMethodExtensions.Deserialize(paymentMethodId, strat.Value, network);
}
}
}
#pragma warning restore CS0618
}
public void SetDerivationStrategy(BTCPayNetwork network, string derivationScheme)
/// <summary>
/// Set or remove a new supported payment method for the store
/// </summary>
/// <param name="paymentMethodId">The paymentMethodId</param>
/// <param name="supportedPaymentMethod">The payment method, or null to remove</param>
public void SetSupportedPaymentMethod(PaymentMethodId paymentMethodId, ISupportedPaymentMethod supportedPaymentMethod)
{
if (supportedPaymentMethod != null && paymentMethodId != supportedPaymentMethod.PaymentId)
throw new InvalidOperationException("Argument mismatch");
#pragma warning disable CS0618
JObject strategies = string.IsNullOrEmpty(DerivationStrategies) ? new JObject() : JObject.Parse(DerivationStrategies);
bool existing = false;
foreach (var strat in strategies.Properties().ToList())
{
if (strat.Name == network.CryptoCode)
var stratId = PaymentMethodId.Parse(strat.Name);
if (stratId.IsBTCOnChain)
{
if (network.IsBTC)
DerivationStrategy = null;
if (string.IsNullOrEmpty(derivationScheme))
// Legacy stuff which should go away
DerivationStrategy = null;
}
if (stratId == paymentMethodId)
{
if (supportedPaymentMethod == null)
{
strat.Remove();
}
else
{
strat.Value = new JValue(derivationScheme);
strat.Value = PaymentMethodExtensions.Serialize(supportedPaymentMethod);
}
existing = true;
break;
}
}
if (!existing && string.IsNullOrEmpty(derivationScheme))
if(!existing && supportedPaymentMethod == null && paymentMethodId.IsBTCOnChain)
{
if (network.IsBTC)
DerivationStrategy = null;
DerivationStrategy = null;
}
else if (!existing)
strategies.Add(new JProperty(network.CryptoCode, new JValue(derivationScheme)));
// This is deprecated so we don't have to set anymore
//if (network.IsBTC)
// DerivationStrategy = derivationScheme;
strategies.Add(new JProperty(supportedPaymentMethod.PaymentId.ToString(), PaymentMethodExtensions.Serialize(supportedPaymentMethod)));
DerivationStrategies = strategies.ToString();
#pragma warning restore CS0618
}

View File

@ -2,17 +2,19 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Payments;
using BTCPayServer.Services.Invoices;
using NBitcoin;
using NBXplorer.DerivationStrategy;
namespace BTCPayServer
{
public class DerivationStrategy
public class DerivationStrategy : ISupportedPaymentMethod
{
private DerivationStrategyBase _DerivationStrategy;
private BTCPayNetwork _Network;
DerivationStrategy(DerivationStrategyBase result, BTCPayNetwork network)
public DerivationStrategy(DerivationStrategyBase result, BTCPayNetwork network)
{
this._DerivationStrategy = result;
this._Network = network;
@ -32,6 +34,8 @@ namespace BTCPayServer
public DerivationStrategyBase DerivationStrategyBase { get { return this._DerivationStrategy; } }
public PaymentMethodId PaymentId => new PaymentMethodId(Network.CryptoCode, PaymentTypes.BTCLike);
public override string ToString()
{
return _DerivationStrategy.ToString();

View File

@ -88,7 +88,9 @@ namespace BTCPayServer
}
}
Logs.Events.LogInformation(evt.ToString());
var log = evt.ToString();
if(!String.IsNullOrEmpty(log))
Logs.Events.LogInformation(log);
foreach (var sub in actionList)
{
try

View File

@ -2,11 +2,18 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Services.Invoices;
namespace BTCPayServer.Events
{
public class InvoiceDataChangedEvent
{
public InvoiceDataChangedEvent(InvoiceEntity invoice)
{
InvoiceId = invoice.Id;
Status = invoice.Status;
ExceptionStatus = invoice.ExceptionStatus;
}
public string InvoiceId { get; set; }
public string Status { get; internal set; }
public string ExceptionStatus { get; internal set; }

View File

@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Events
{
public class InvoiceNeedUpdateEvent
{
public InvoiceNeedUpdateEvent(string invoiceId)
{
if (invoiceId == null)
throw new ArgumentNullException(nameof(invoiceId));
InvoiceId = invoiceId;
}
public string InvoiceId { get; set; }
public override string ToString()
{
return string.Empty;
}
}
}

View File

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Events
{
public class InvoiceNewAddressEvent
{
public InvoiceNewAddressEvent(string invoiceId, string address, BTCPayNetwork network)
{
Address = address;
InvoiceId = invoiceId;
Network = network;
}
public string Address { get; set; }
public string InvoiceId { get; set; }
public BTCPayNetwork Network { get; set; }
public override string ToString()
{
return $"{Network.CryptoCode}: New address {Address} for invoice {InvoiceId}";
}
}
}

View File

@ -7,6 +7,10 @@ namespace BTCPayServer.Events
{
public class InvoiceStopWatchedEvent
{
public InvoiceStopWatchedEvent(string invoiceId)
{
this.InvoiceId = invoiceId;
}
public string InvoiceId { get; set; }
public override string ToString()
{

View File

@ -1,22 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NBitcoin;
using NBXplorer.DerivationStrategy;
namespace BTCPayServer.Events
{
public class TxOutReceivedEvent
{
public BTCPayNetwork Network { get; set; }
public Script ScriptPubKey { get; set; }
public DerivationStrategyBase DerivationStrategy { get; set; }
public override string ToString()
{
String address = ScriptPubKey.GetDestinationAddress(Network.NBitcoinNetwork)?.ToString() ?? ScriptPubKey.ToString();
return $"{address} received a transaction ({Network.CryptoCode})";
}
}
}

View File

@ -22,11 +22,18 @@ using System.IO;
using BTCPayServer.Logging;
using Microsoft.Extensions.Logging;
using System.Net.WebSockets;
using BTCPayServer.Services.Invoices;
using NBitpayClient;
using BTCPayServer.Payments;
namespace BTCPayServer
{
public static class Extensions
{
public static PaymentMethodId GetpaymentMethodId(this InvoiceCryptoInfo info)
{
return new PaymentMethodId(info.CryptoCode, Enum.Parse<PaymentTypes>(info.PaymentType));
}
public static async Task CloseSocket(this WebSocket webSocket)
{
try
@ -63,7 +70,7 @@ namespace BTCPayServer
}
public static string WithTrailingSlash(this string str)
{
if (str.EndsWith("/"))
if (str.EndsWith("/", StringComparison.InvariantCulture))
return str;
return str + "/";
}

View File

@ -19,6 +19,7 @@ using Microsoft.Extensions.Hosting;
using BTCPayServer.Events;
using NBXplorer;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Payments;
namespace BTCPayServer.HostedServices
{
@ -137,7 +138,7 @@ namespace BTCPayServer.HostedServices
reschedule = true;
List<string> messages = new List<string>();
while(ex != null)
while (ex != null)
{
messages.Add(ex.Message);
ex = ex.InnerException;
@ -201,7 +202,7 @@ namespace BTCPayServer.HostedServices
// We keep backward compatibility with bitpay by passing BTC info to the notification
// we don't pass other info, as it is a bad idea to use IPN data for logic processing (can be faked)
var btcCryptoInfo = dto.CryptoInfo.FirstOrDefault(c => c.CryptoCode == "BTC");
var btcCryptoInfo = dto.CryptoInfo.FirstOrDefault(c => c.GetpaymentMethodId() == new PaymentMethodId("BTC", Payments.PaymentTypes.BTCLike));
if (btcCryptoInfo != null)
{
#pragma warning disable CS0618
@ -228,10 +229,68 @@ namespace BTCPayServer.HostedServices
request.RequestUri = new Uri(invoice.NotificationURL, UriKind.Absolute);
request.Content = new StringContent(notificationString, UTF8, "application/json");
var response = await _Client.SendAsync(request, cancellation);
var response = await Enqueue(invoice.Id, async () => await _Client.SendAsync(request, cancellation));
return response;
}
Dictionary<string, Task> _SendingRequestsByInvoiceId = new Dictionary<string, Task>();
/// <summary>
/// Will make sure only one callback is called at once on the same invoiceId
/// </summary>
/// <param name="id"></param>
/// <param name="sendRequest"></param>
/// <returns></returns>
private async Task<T> Enqueue<T>(string id, Func<Task<T>> sendRequest)
{
Task<T> sending = null;
lock (_SendingRequestsByInvoiceId)
{
if (_SendingRequestsByInvoiceId.TryGetValue(id, out var executing))
{
var completion = new TaskCompletionSource<T>();
sending = completion.Task;
_SendingRequestsByInvoiceId.Remove(id);
_SendingRequestsByInvoiceId.Add(id, sending);
executing.ContinueWith(_ =>
{
sendRequest()
.ContinueWith(t =>
{
if(t.Status == TaskStatus.RanToCompletion)
{
completion.TrySetResult(t.Result);
}
if(t.Status == TaskStatus.Faulted)
{
completion.TrySetException(t.Exception);
}
if(t.Status == TaskStatus.Canceled)
{
completion.TrySetCanceled();
}
}, TaskScheduler.Default);
}, TaskScheduler.Default);
}
else
{
sending = sendRequest();
_SendingRequestsByInvoiceId.Add(id, sending);
}
sending.ContinueWith(o =>
{
lock (_SendingRequestsByInvoiceId)
{
_SendingRequestsByInvoiceId.TryGetValue(id, out var executing2);
if(executing2 == sending)
_SendingRequestsByInvoiceId.Remove(id);
}
}, TaskScheduler.Default);
}
return await sending;
}
int MaxTry = 6;
private static string GetHttpJobId(InvoiceEntity invoice)

View File

@ -25,9 +25,9 @@ namespace BTCPayServer.HostedServices
{
class UpdateInvoiceContext
{
public UpdateInvoiceContext()
public UpdateInvoiceContext(InvoiceEntity invoice)
{
Invoice = invoice;
}
public InvoiceEntity Invoice { get; set; }
public List<object> Events { get; set; } = new List<object>();
@ -43,99 +43,20 @@ namespace BTCPayServer.HostedServices
InvoiceRepository _InvoiceRepository;
EventAggregator _EventAggregator;
BTCPayWalletProvider _WalletProvider;
BTCPayNetworkProvider _NetworkProvider;
public InvoiceWatcher(
IHostingEnvironment env,
BTCPayNetworkProvider networkProvider,
InvoiceRepository invoiceRepository,
EventAggregator eventAggregator,
BTCPayWalletProvider walletProvider)
EventAggregator eventAggregator)
{
PollInterval = TimeSpan.FromMinutes(1.0);
_WalletProvider = walletProvider ?? throw new ArgumentNullException(nameof(walletProvider));
_InvoiceRepository = invoiceRepository ?? throw new ArgumentNullException(nameof(invoiceRepository));
_EventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_NetworkProvider = networkProvider;
}
CompositeDisposable leases = new CompositeDisposable();
async Task NotifyReceived(Events.TxOutReceivedEvent evt)
{
var invoiceId = await _InvoiceRepository.GetInvoiceIdFromScriptPubKey(evt.ScriptPubKey, evt.Network.CryptoCode);
if (invoiceId != null)
{
String address = evt.ScriptPubKey.GetDestinationAddress(evt.Network.NBitcoinNetwork)?.ToString() ?? evt.ScriptPubKey.ToString();
_WalletProvider.GetWallet(evt.Network).InvalidateCache(evt.DerivationStrategy);
Logs.PayServer.LogInformation($"{address} is mapping to invoice {invoiceId}");
_WatchRequests.Add(invoiceId);
}
}
async Task NotifyBlock()
{
foreach (var invoice in await _InvoiceRepository.GetPendingInvoices())
{
_WatchRequests.Add(invoice);
}
}
private async Task UpdateInvoice(string invoiceId, CancellationToken cancellation)
{
Dictionary<BTCPayNetwork, KnownState> changes = new Dictionary<BTCPayNetwork, KnownState>();
int maxLoop = 5;
int loopCount = -1;
while (!cancellation.IsCancellationRequested && loopCount < maxLoop)
{
loopCount++;
try
{
var invoice = await _InvoiceRepository.GetInvoice(null, invoiceId, true).ConfigureAwait(false);
if (invoice == null)
break;
var stateBefore = invoice.Status;
var updateContext = new UpdateInvoiceContext()
{
Invoice = invoice
};
await UpdateInvoice(updateContext).ConfigureAwait(false);
if (updateContext.Dirty)
{
await _InvoiceRepository.UpdateInvoiceStatus(invoice.Id, invoice.Status, invoice.ExceptionStatus).ConfigureAwait(false);
updateContext.Events.Add(new InvoiceDataChangedEvent() { Status = invoice.Status, ExceptionStatus = invoice.ExceptionStatus, InvoiceId = invoice.Id });
}
var changed = stateBefore != invoice.Status;
foreach (var evt in updateContext.Events)
{
_EventAggregator.Publish(evt, evt.GetType());
}
if (invoice.Status == "complete" ||
((invoice.Status == "invalid" || invoice.Status == "expired") && invoice.MonitoringExpiration < DateTimeOffset.UtcNow))
{
if (await _InvoiceRepository.RemovePendingInvoice(invoice.Id).ConfigureAwait(false))
_EventAggregator.Publish<InvoiceStopWatchedEvent>(new InvoiceStopWatchedEvent() { InvoiceId = invoice.Id });
break;
}
if (updateContext.Events.Count == 0 || cancellation.IsCancellationRequested)
break;
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
Logs.PayServer.LogError(ex, "Unhandled error on watching invoice " + invoiceId);
await Task.Delay(10000, cancellation).ConfigureAwait(false);
}
}
}
private async Task UpdateInvoice(UpdateInvoiceContext context)
{
@ -149,241 +70,118 @@ namespace BTCPayServer.HostedServices
invoice.Status = "expired";
}
var derivationStrategies = invoice.GetDerivationStrategies(_NetworkProvider).ToArray();
var payments = await GetPaymentsWithTransaction(derivationStrategies, invoice);
foreach (Task<NetworkCoins> coinsAsync in GetCoinsPerNetwork(context, invoice, derivationStrategies))
var payments = invoice.GetPayments().Where(p => p.Accounted).ToArray();
var allPaymentMethods = invoice.GetPaymentMethods(_NetworkProvider);
var paymentMethod = GetNearestClearedPayment(allPaymentMethods, out var accounting, _NetworkProvider);
if (paymentMethod == null)
return;
var network = _NetworkProvider.GetNetwork(paymentMethod.GetId().CryptoCode);
if (invoice.Status == "new" || invoice.Status == "expired")
{
var coins = await coinsAsync;
if (coins.TimestampedCoins.Length == 0)
continue;
bool dirtyAddress = false;
var alreadyAccounted = new HashSet<OutPoint>(invoice.GetPayments(coins.Wallet.Network).Select(p => p.Outpoint));
foreach (var coin in coins.TimestampedCoins.Where(c => !alreadyAccounted.Contains(c.Coin.Outpoint)))
if (accounting.Paid >= accounting.TotalDue)
{
var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.DateTime, coin.Coin, coins.Wallet.Network.CryptoCode).ConfigureAwait(false);
#pragma warning disable CS0618
invoice.Payments.Add(payment);
#pragma warning restore CS0618
alreadyAccounted.Add(coin.Coin.Outpoint);
context.Events.Add(new InvoiceEvent(invoice, 1002, "invoice_receivedPayment"));
dirtyAddress = true;
}
if (dirtyAddress)
{
payments = await GetPaymentsWithTransaction(derivationStrategies, invoice);
}
var network = coins.Wallet.Network;
var cryptoData = invoice.GetCryptoData(network, _NetworkProvider);
var cryptoDataAll = invoice.GetCryptoData(_NetworkProvider);
var accounting = cryptoData.Calculate();
if (invoice.Status == "new" || invoice.Status == "expired")
{
var totalPaid = payments.Select(p => p.Payment.GetValue(cryptoDataAll, cryptoData.CryptoCode)).Sum();
if (totalPaid >= accounting.TotalDue)
{
if (invoice.Status == "new")
{
context.Events.Add(new InvoiceEvent(invoice, 1003, "invoice_paidInFull"));
invoice.Status = "paid";
invoice.ExceptionStatus = totalPaid > accounting.TotalDue ? "paidOver" : null;
await _InvoiceRepository.UnaffectAddress(invoice.Id);
context.MarkDirty();
}
else if (invoice.Status == "expired" && invoice.ExceptionStatus != "paidLate")
{
invoice.ExceptionStatus = "paidLate";
context.Events.Add(new InvoiceEvent(invoice, 1009, "invoice_paidAfterExpiration"));
context.MarkDirty();
}
}
if (totalPaid < accounting.TotalDue && invoice.GetPayments().Count != 0 && invoice.ExceptionStatus != "paidPartial")
{
invoice.ExceptionStatus = "paidPartial";
context.MarkDirty();
if (dirtyAddress)
{
var address = await coins.Wallet.ReserveAddressAsync(coins.Strategy);
Logs.PayServer.LogInformation("Generate new " + address);
await _InvoiceRepository.NewAddress(invoice.Id, address, network);
}
}
}
if (invoice.Status == "paid")
{
IEnumerable<AccountedPaymentEntity> transactions = payments;
if (invoice.SpeedPolicy == SpeedPolicy.HighSpeed)
{
transactions = transactions.Where(t => t.Confirmations >= 1 || !t.Transaction.RBF);
}
else if (invoice.SpeedPolicy == SpeedPolicy.MediumSpeed)
{
transactions = transactions.Where(t => t.Confirmations >= 1);
}
else if (invoice.SpeedPolicy == SpeedPolicy.LowSpeed)
{
transactions = transactions.Where(t => t.Confirmations >= 6);
}
var totalConfirmed = transactions.Select(t => t.Payment.GetValue(cryptoDataAll, cryptoData.CryptoCode)).Sum();
if (// Is after the monitoring deadline
(invoice.MonitoringExpiration < DateTimeOffset.UtcNow)
&&
// And not enough amount confirmed
(totalConfirmed < accounting.TotalDue))
if (invoice.Status == "new")
{
context.Events.Add(new InvoiceEvent(invoice, 1003, "invoice_paidInFull"));
invoice.Status = "paid";
invoice.ExceptionStatus = accounting.Paid > accounting.TotalDue ? "paidOver" : null;
await _InvoiceRepository.UnaffectAddress(invoice.Id);
context.Events.Add(new InvoiceEvent(invoice, 1013, "invoice_failedToConfirm"));
invoice.Status = "invalid";
context.MarkDirty();
}
else if (totalConfirmed >= accounting.TotalDue)
else if (invoice.Status == "expired" && invoice.ExceptionStatus != "paidLate")
{
await _InvoiceRepository.UnaffectAddress(invoice.Id);
context.Events.Add(new InvoiceEvent(invoice, 1005, "invoice_confirmed"));
invoice.Status = "confirmed";
invoice.ExceptionStatus = "paidLate";
context.Events.Add(new InvoiceEvent(invoice, 1009, "invoice_paidAfterExpiration"));
context.MarkDirty();
}
}
if (invoice.Status == "confirmed")
if (accounting.Paid < accounting.TotalDue && invoice.GetPayments().Count != 0 && invoice.ExceptionStatus != "paidPartial")
{
IEnumerable<AccountedPaymentEntity> transactions = payments;
transactions = transactions.Where(t => t.Confirmations >= 6);
var totalConfirmed = transactions.Select(t => t.Payment.GetValue(cryptoDataAll, cryptoData.CryptoCode)).Sum();
if (totalConfirmed >= accounting.TotalDue)
{
context.Events.Add(new InvoiceEvent(invoice, 1006, "invoice_completed"));
invoice.Status = "complete";
context.MarkDirty();
}
invoice.ExceptionStatus = "paidPartial";
context.MarkDirty();
}
}
}
private IEnumerable<Task<NetworkCoins>> GetCoinsPerNetwork(UpdateInvoiceContext context, InvoiceEntity invoice, DerivationStrategy[] strategies)
{
return strategies
.Select(d => (Wallet: _WalletProvider.IsAvailable(d.Network) ? _WalletProvider.GetWallet(d.Network) : null,
Network: d.Network,
Strategy: d.DerivationStrategyBase))
.Where(d => d.Wallet != null)
.Select(d => (Network: d.Network,
Coins: d.Wallet.GetCoins(d.Strategy)))
.Select(async d =>
{
var coins = await d.Coins;
// Keep only coins from the invoice
coins.TimestampedCoins = coins.TimestampedCoins.Where(c => invoice.AvailableAddressHashes.Contains(c.Coin.ScriptPubKey.Hash.ToString() + d.Network.CryptoCode)).ToArray();
return coins;
})
.ToArray();
}
private async Task<IEnumerable<AccountedPaymentEntity>> GetPaymentsWithTransaction(DerivationStrategy[] derivations, InvoiceEntity invoice)
{
List<PaymentEntity> updatedPaymentEntities = new List<PaymentEntity>();
List<AccountedPaymentEntity> accountedPayments = new List<AccountedPaymentEntity>();
foreach (var network in derivations.Select(d => d.Network))
// Just make sure RBF did not cancelled a payment
if (invoice.Status == "paid")
{
var wallet = _WalletProvider.GetWallet(network);
if (wallet == null)
if (accounting.Paid == accounting.TotalDue && invoice.ExceptionStatus == "paidOver")
{
invoice.ExceptionStatus = null;
context.MarkDirty();
}
if (accounting.Paid > accounting.TotalDue && invoice.ExceptionStatus != "paidOver")
{
invoice.ExceptionStatus = "paidOver";
context.MarkDirty();
}
if (accounting.Paid < accounting.TotalDue)
{
invoice.Status = "new";
invoice.ExceptionStatus = accounting.Paid == Money.Zero ? null : "paidPartial";
context.MarkDirty();
}
}
if (invoice.Status == "paid")
{
var confirmedAccounting = paymentMethod.Calculate(p => p.GetCryptoPaymentData().PaymentConfirmed(p, invoice.SpeedPolicy, network));
if (// Is after the monitoring deadline
(invoice.MonitoringExpiration < DateTimeOffset.UtcNow)
&&
// And not enough amount confirmed
(confirmedAccounting.Paid < accounting.TotalDue))
{
await _InvoiceRepository.UnaffectAddress(invoice.Id);
context.Events.Add(new InvoiceEvent(invoice, 1013, "invoice_failedToConfirm"));
invoice.Status = "invalid";
context.MarkDirty();
}
else if (confirmedAccounting.Paid >= accounting.TotalDue)
{
await _InvoiceRepository.UnaffectAddress(invoice.Id);
context.Events.Add(new InvoiceEvent(invoice, 1005, "invoice_confirmed"));
invoice.Status = "confirmed";
context.MarkDirty();
}
}
if (invoice.Status == "confirmed")
{
var completedAccounting = paymentMethod.Calculate(p => p.GetCryptoPaymentData().PaymentCompleted(p, network));
if (completedAccounting.Paid >= accounting.TotalDue)
{
context.Events.Add(new InvoiceEvent(invoice, 1006, "invoice_completed"));
invoice.Status = "complete";
context.MarkDirty();
}
}
}
public static PaymentMethod GetNearestClearedPayment(PaymentMethodDictionary allPaymentMethods, out PaymentMethodAccounting accounting, BTCPayNetworkProvider networkProvider)
{
PaymentMethod result = null;
accounting = null;
decimal nearestToZero = 0.0m;
foreach (var paymentMethod in allPaymentMethods)
{
if (networkProvider != null && networkProvider.GetNetwork(paymentMethod.GetId().CryptoCode) == null)
continue;
var transactions = await wallet.GetTransactions(invoice.GetPayments(wallet.Network)
.Select(t => t.Outpoint.Hash)
.ToArray());
var conflicts = GetConflicts(transactions.Select(t => t.Value));
foreach (var payment in invoice.GetPayments(network))
var currentAccounting = paymentMethod.Calculate();
var distanceFromZero = Math.Abs(currentAccounting.DueUncapped.ToDecimal(MoneyUnit.BTC));
if (result == null || distanceFromZero < nearestToZero)
{
if (!transactions.TryGetValue(payment.Outpoint.Hash, out TransactionResult tx))
continue;
AccountedPaymentEntity accountedPayment = new AccountedPaymentEntity()
{
Confirmations = tx.Confirmations,
Transaction = tx.Transaction,
Payment = payment
};
var txId = accountedPayment.Transaction.GetHash();
var txConflict = conflicts.GetConflict(txId);
var accounted = txConflict == null || txConflict.IsWinner(txId);
if (accounted != payment.Accounted)
{
updatedPaymentEntities.Add(payment);
payment.Accounted = accounted;
}
if (accounted)
accountedPayments.Add(accountedPayment);
result = paymentMethod;
nearestToZero = distanceFromZero;
accounting = currentAccounting;
}
}
await _InvoiceRepository.UpdatePayments(updatedPaymentEntities);
return accountedPayments;
}
class TransactionConflict
{
public Dictionary<uint256, TransactionResult> Transactions { get; set; } = new Dictionary<uint256, TransactionResult>();
uint256 _Winner;
public bool IsWinner(uint256 txId)
{
if (_Winner == null)
{
var confirmed = Transactions.FirstOrDefault(t => t.Value.Confirmations >= 1);
if (!confirmed.Equals(default(KeyValuePair<uint256, TransactionResult>)))
{
_Winner = confirmed.Key;
}
else
{
// Take the most recent (bitcoin node would not forward a conflict without a successfull RBF)
_Winner = Transactions
.OrderByDescending(t => t.Value.Timestamp)
.First()
.Key;
}
}
return _Winner == txId;
}
}
class TransactionConflicts : List<TransactionConflict>
{
public TransactionConflicts(IEnumerable<TransactionConflict> collection) : base(collection)
{
}
public TransactionConflict GetConflict(uint256 txId)
{
return this.FirstOrDefault(c => c.Transactions.ContainsKey(txId));
}
}
private TransactionConflicts GetConflicts(IEnumerable<TransactionResult> transactions)
{
Dictionary<OutPoint, TransactionConflict> conflictsByOutpoint = new Dictionary<OutPoint, TransactionConflict>();
foreach (var tx in transactions)
{
var hash = tx.Transaction.GetHash();
foreach (var input in tx.Transaction.Inputs)
{
TransactionConflict conflict = new TransactionConflict();
if (!conflictsByOutpoint.TryAdd(input.PrevOut, conflict))
{
conflict = conflictsByOutpoint[input.PrevOut];
}
if (!conflict.Transactions.ContainsKey(hash))
conflict.Transactions.Add(hash, tx);
}
}
return new TransactionConflicts(conflictsByOutpoint.Where(c => c.Value.Transactions.Count > 1).Select(c => c.Value));
return result;
}
TimeSpan _PollInterval;
@ -399,11 +197,15 @@ namespace BTCPayServer.HostedServices
}
}
private async Task Watch(string invoiceId)
private void Watch(string invoiceId)
{
if (invoiceId == null)
throw new ArgumentNullException(nameof(invoiceId));
_WatchRequests.Add(invoiceId);
}
private async Task Wait(string invoiceId)
{
var invoice = await _InvoiceRepository.GetInvoice(null, invoiceId);
try
{
@ -411,103 +213,120 @@ namespace BTCPayServer.HostedServices
if (invoice.ExpirationTime > now)
{
await Task.Delay(invoice.ExpirationTime - now, _Cts.Token);
_WatchRequests.Add(invoiceId);
}
Watch(invoiceId);
now = DateTimeOffset.UtcNow;
if (invoice.MonitoringExpiration > now)
{
await Task.Delay(invoice.MonitoringExpiration - now, _Cts.Token);
}
Watch(invoiceId);
}
catch when (_Cts.IsCancellationRequested)
{ }
}
BlockingCollection<string> _WatchRequests = new BlockingCollection<string>(new ConcurrentQueue<string>());
Task _Poller;
Task _Loop;
Task _WaitingInvoices;
CancellationTokenSource _Cts;
public Task StartAsync(CancellationToken cancellationToken)
{
_Cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_Poller = StartPoller(_Cts.Token);
_Loop = StartLoop(_Cts.Token);
_WaitingInvoices = WaitPendingInvoices();
leases.Add(_EventAggregator.Subscribe<Events.NewBlockEvent>(async b => { await NotifyBlock(); }));
leases.Add(_EventAggregator.Subscribe<Events.TxOutReceivedEvent>(async b => { await NotifyReceived(b); }));
leases.Add(_EventAggregator.Subscribe<Events.InvoiceNeedUpdateEvent>(b =>
{
Watch(b.InvoiceId);
}));
leases.Add(_EventAggregator.Subscribe<Events.InvoiceEvent>(async b =>
{
if (b.Name == "invoice_created")
{
await Watch(b.InvoiceId);
Watch(b.InvoiceId);
await Wait(b.InvoiceId);
}
if (b.Name == "invoice_receivedPayment")
{
Watch(b.InvoiceId);
}
}));
return Task.CompletedTask;
}
private async Task StartPoller(CancellationToken cancellation)
private async Task WaitPendingInvoices()
{
try
{
while (!cancellation.IsCancellationRequested)
{
try
{
foreach (var pending in await _InvoiceRepository.GetPendingInvoices())
{
_WatchRequests.Add(pending);
}
await Task.Delay(PollInterval, cancellation);
}
catch (Exception ex) when (!cancellation.IsCancellationRequested)
{
Logs.PayServer.LogError(ex, $"Unhandled exception in InvoiceWatcher poller");
await Task.Delay(PollInterval, cancellation);
}
}
}
catch when (cancellation.IsCancellationRequested) { }
await Task.WhenAll((await _InvoiceRepository.GetPendingInvoices())
.Select(id => Wait(id)).ToArray());
_WaitingInvoices = null;
}
async Task StartLoop(CancellationToken cancellation)
{
Logs.PayServer.LogInformation("Start watching invoices");
await Task.Delay(1).ConfigureAwait(false); // Small hack so that the caller does not block on GetConsumingEnumerable
ConcurrentDictionary<string, Lazy<Task>> executing = new ConcurrentDictionary<string, Lazy<Task>>();
try
{
// This loop just make sure an invoice will not be updated at the same time by two tasks.
// If an update is happening while a request come, then the update is deferred when the executing task is over
foreach (var item in _WatchRequests.GetConsumingEnumerable(cancellation))
foreach (var invoiceId in _WatchRequests.GetConsumingEnumerable(cancellation))
{
var localItem = item;
var toExecute = new Lazy<Task>(async () =>
int maxLoop = 5;
int loopCount = -1;
while (!cancellation.IsCancellationRequested && loopCount < maxLoop)
{
loopCount++;
try
{
await UpdateInvoice(localItem, cancellation);
var invoice = await _InvoiceRepository.GetInvoice(null, invoiceId, true);
if (invoice == null)
break;
var updateContext = new UpdateInvoiceContext(invoice);
await UpdateInvoice(updateContext);
if (updateContext.Dirty)
{
await _InvoiceRepository.UpdateInvoiceStatus(invoice.Id, invoice.Status, invoice.ExceptionStatus);
updateContext.Events.Add(new InvoiceDataChangedEvent(invoice));
}
foreach (var evt in updateContext.Events)
{
_EventAggregator.Publish(evt, evt.GetType());
}
if (invoice.Status == "complete" ||
((invoice.Status == "invalid" || invoice.Status == "expired") && invoice.MonitoringExpiration < DateTimeOffset.UtcNow))
{
if (await _InvoiceRepository.RemovePendingInvoice(invoice.Id))
_EventAggregator.Publish(new InvoiceStopWatchedEvent(invoice.Id));
break;
}
if (updateContext.Events.Count == 0 || cancellation.IsCancellationRequested)
break;
}
finally
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
executing.TryRemove(localItem, out Lazy<Task> unused);
break;
}
catch (Exception ex)
{
Logs.PayServer.LogError(ex, "Unhandled error on watching invoice " + invoiceId);
#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
Task.Delay(10000, cancellation)
.ContinueWith(t => _WatchRequests.Add(invoiceId), TaskScheduler.Default);
#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
break;
}
}, false);
var executingTask = executing.GetOrAdd(item, toExecute);
executingTask.Value.GetAwaiter(); // Make sure it run
if (executingTask != toExecute)
{
// What was planned can't run for now, rebook it when the executingTask finish
var unused = executingTask.Value.ContinueWith(t => _WatchRequests.Add(localItem));
}
}
}
catch when (cancellation.IsCancellationRequested)
{
}
finally
{
await Task.WhenAll(executing.Values.Select(v => v.Value).ToArray());
}
Logs.PayServer.LogInformation("Stop watching invoices");
}
@ -515,7 +334,8 @@ namespace BTCPayServer.HostedServices
{
leases.Dispose();
_Cts.Cancel();
return Task.WhenAll(_Poller, _Loop);
var waitingPendingInvoices = _WaitingInvoices ?? Task.CompletedTask;
return Task.WhenAll(waitingPendingInvoices, _Loop);
}
}
}

View File

@ -1,209 +0,0 @@
using System;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Logging;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using NBXplorer;
using System.Collections.Concurrent;
using NBXplorer.DerivationStrategy;
using BTCPayServer.Events;
using BTCPayServer.Services;
namespace BTCPayServer.HostedServices
{
public class NBXplorerListener : IHostedService
{
EventAggregator _Aggregator;
ExplorerClientProvider _ExplorerClients;
IApplicationLifetime _Lifetime;
InvoiceRepository _InvoiceRepository;
private TaskCompletionSource<bool> _RunningTask;
private CancellationTokenSource _Cts;
NBXplorerDashboard _Dashboards;
public NBXplorerListener(ExplorerClientProvider explorerClients,
NBXplorerDashboard dashboard,
InvoiceRepository invoiceRepository,
EventAggregator aggregator, IApplicationLifetime lifetime)
{
PollInterval = TimeSpan.FromMinutes(1.0);
_Dashboards = dashboard;
_InvoiceRepository = invoiceRepository;
_ExplorerClients = explorerClients;
_Aggregator = aggregator;
_Lifetime = lifetime;
}
CompositeDisposable leases = new CompositeDisposable();
ConcurrentDictionary<string, NotificationSession> _Sessions = new ConcurrentDictionary<string, NotificationSession>();
private Timer _ListenPoller;
TimeSpan _PollInterval;
public TimeSpan PollInterval
{
get
{
return _PollInterval;
}
set
{
_PollInterval = value;
if (_ListenPoller != null)
{
_ListenPoller.Change(0, (int)value.TotalMilliseconds);
}
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
_RunningTask = new TaskCompletionSource<bool>();
_Cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
leases.Add(_Aggregator.Subscribe<Events.NBXplorerStateChangedEvent>(async nbxplorerEvent =>
{
if (nbxplorerEvent.NewState == NBXplorerState.Ready)
{
await Listen(nbxplorerEvent.Network);
}
}));
_ListenPoller = new Timer(async s =>
{
foreach (var nbxplorerState in _Dashboards.GetAll())
{
if (nbxplorerState.Status != null && nbxplorerState.Status.IsFullySynched)
{
await Listen(nbxplorerState.Network);
}
}
}, null, 0, (int)PollInterval.TotalMilliseconds);
leases.Add(_ListenPoller);
leases.Add(_Aggregator.Subscribe<Events.InvoiceEvent>(async inv =>
{
if (inv.Name == "invoice_created")
{
var invoice = await _InvoiceRepository.GetInvoice(null, inv.InvoiceId);
List<Task> listeningDerivations = new List<Task>();
foreach (var notificationSessions in _Sessions)
{
var derivationStrategy = GetStrategy(notificationSessions.Key, invoice);
if (derivationStrategy != null)
{
listeningDerivations.Add(notificationSessions.Value.ListenDerivationSchemesAsync(new[] { derivationStrategy }, _Cts.Token));
}
}
await Task.WhenAll(listeningDerivations.ToArray()).ConfigureAwait(false);
}
}));
return Task.CompletedTask;
}
private async Task Listen(BTCPayNetwork network)
{
bool cleanup = false;
try
{
if (_Sessions.ContainsKey(network.CryptoCode))
return;
var client = _ExplorerClients.GetExplorerClient(network);
if (client == null)
return;
if (_Cts.IsCancellationRequested)
return;
var session = await client.CreateNotificationSessionAsync(_Cts.Token).ConfigureAwait(false);
if (!_Sessions.TryAdd(network.CryptoCode, session))
{
await session.DisposeAsync();
return;
}
cleanup = true;
using (session)
{
await session.ListenNewBlockAsync(_Cts.Token).ConfigureAwait(false);
await session.ListenDerivationSchemesAsync((await GetStrategies(network)).ToArray(), _Cts.Token).ConfigureAwait(false);
Logs.PayServer.LogInformation($"Connected to WebSocket of NBXplorer ({network.CryptoCode})");
while (!_Cts.IsCancellationRequested)
{
var newEvent = await session.NextEventAsync(_Cts.Token).ConfigureAwait(false);
switch (newEvent)
{
case NBXplorer.Models.NewBlockEvent evt:
_Aggregator.Publish(new Events.NewBlockEvent() { CryptoCode = evt.CryptoCode });
break;
case NBXplorer.Models.NewTransactionEvent evt:
foreach (var txout in evt.Outputs)
{
_Aggregator.Publish(new Events.TxOutReceivedEvent()
{
Network = network,
ScriptPubKey = txout.ScriptPubKey,
DerivationStrategy = txout.DerivationStrategy
});
}
break;
default:
Logs.PayServer.LogWarning("Received unknown message from NBXplorer");
break;
}
}
}
}
catch when (_Cts.IsCancellationRequested) { }
catch (Exception ex)
{
Logs.PayServer.LogError(ex, $"Error while connecting to WebSocket of NBXplorer ({network.CryptoCode})");
}
finally
{
if (cleanup)
{
Logs.PayServer.LogInformation($"Disconnected from WebSocket of NBXplorer ({network.CryptoCode})");
_Sessions.TryRemove(network.CryptoCode, out NotificationSession unused);
if (_Sessions.Count == 0 && _Cts.IsCancellationRequested)
{
_RunningTask.TrySetResult(true);
}
}
}
}
private async Task<List<DerivationStrategyBase>> GetStrategies(BTCPayNetwork network)
{
List<DerivationStrategyBase> strategies = new List<DerivationStrategyBase>();
foreach (var invoiceId in await _InvoiceRepository.GetPendingInvoices())
{
var invoice = await _InvoiceRepository.GetInvoice(null, invoiceId);
var strategy = GetStrategy(network.CryptoCode, invoice);
if (strategy != null)
strategies.Add(strategy);
}
return strategies;
}
private DerivationStrategyBase GetStrategy(string cryptoCode, InvoiceEntity invoice)
{
foreach (var derivationStrategy in invoice.GetDerivationStrategies(_ExplorerClients.NetworkProviders))
{
if (derivationStrategy.Network.CryptoCode == cryptoCode)
{
return derivationStrategy.DerivationStrategyBase;
}
}
return null;
}
public Task StopAsync(CancellationToken cancellationToken)
{
leases.Dispose();
_Cts.Cancel();
return Task.WhenAny(_RunningTask.Task, Task.Delay(-1, cancellationToken));
}
}
}

View File

@ -193,6 +193,7 @@ namespace BTCPayServer.HostedServices
_Aggregator.Publish(new NBXplorerErrorEvent(_Network, error));
}
_Dashboard.Publish(_Network, State, status, error);
if (oldState != State)
{
if (State == NBXplorerState.Synching)
@ -205,7 +206,6 @@ namespace BTCPayServer.HostedServices
}
_Aggregator.Publish(new NBXplorerStateChangedEvent(_Network, oldState, State));
}
_Dashboard.Publish(_Network, State, status, error);
return oldState != State;
}

View File

@ -37,6 +37,7 @@ using BTCPayServer.Authentication;
using Microsoft.Extensions.Caching.Memory;
using BTCPayServer.Logging;
using BTCPayServer.HostedServices;
using Meziantou.AspNetCore.BundleTagHelpers;
namespace BTCPayServer.Hosting
{
@ -129,7 +130,7 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<BTCPayNetworkProvider>(o =>
{
var opts = o.GetRequiredService<BTCPayServerOptions>();
return new BTCPayNetworkProvider(opts.ChainType);
return opts.NetworkProvider;
});
services.TryAddSingleton<NBXplorerDashboard>();
@ -142,8 +143,13 @@ namespace BTCPayServer.Hosting
BlockTarget = 20
});
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, NBXplorerListener>();
services.AddSingleton<IHostedService, InvoiceNotificationManager>();
services.AddSingleton<IHostedService, InvoiceWatcher>();
@ -177,6 +183,18 @@ namespace BTCPayServer.Hosting
});
});
// bundling
services.AddBundles();
services.AddTransient<BundleOptions>(provider =>
{
var opts = provider.GetRequiredService<BTCPayServerOptions>();
var bundle = new BundleOptions();
bundle.UseMinifiedFiles = opts.BundleJsCss;
bundle.AppendVersion = true;
return bundle;
});
return services;
}
@ -192,7 +210,7 @@ namespace BTCPayServer.Hosting
}
app.UseMiddleware<BTCPayMiddleware>();
return app;
return app;
}
static void Retry(Action act)

View File

@ -22,6 +22,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Http.Extensions;
using BTCPayServer.Controllers;
using System.Net.WebSockets;
namespace BTCPayServer.Hosting
{
@ -69,7 +70,7 @@ namespace BTCPayServer.Hosting
if (BitIdExtensions.CheckBitIDSignature(key, sig, url, body))
{
var bitid = new BitIdentity(key);
httpContext.User = new GenericPrincipal(bitid, new string[0]);
httpContext.User = new GenericPrincipal(bitid, Array.Empty<string>());
Logs.PayServer.LogDebug($"BitId signature check success for SIN {bitid.SIN}");
}
}
@ -82,6 +83,8 @@ namespace BTCPayServer.Hosting
{
await _Next(httpContext);
}
catch (WebSocketException)
{ }
catch (UnauthorizedAccessException ex)
{
await HandleBitpayHttpException(httpContext, new BitpayHttpException(401, ex.Message));

View File

@ -39,6 +39,7 @@ using Microsoft.ApplicationInsights.AspNetCore.Extensions;
using Microsoft.AspNetCore.Mvc.Cors.Internal;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using System.Net;
using Meziantou.AspNetCore.BundleTagHelpers;
namespace BTCPayServer.Hosting
{

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

@ -6,6 +6,7 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace BTCPayServer.Logging
@ -333,7 +334,8 @@ namespace BTCPayServer.Logging
_outputTask = Task.Factory.StartNew(
ProcessLogQueue,
this,
TaskCreationOptions.LongRunning);
default(CancellationToken),
TaskCreationOptions.LongRunning, TaskScheduler.Default);
}
public virtual void EnqueueMessage(LogMessageEntry message)

View File

@ -1,12 +1,9 @@
// <auto-generated />
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.Storage.Internal;
using System;
namespace BTCPayServer.Migrations

View File

@ -22,7 +22,7 @@ namespace BTCPayServer.Models.InvoicingModels
public class Payment
{
public string CryptoCode { get; set; }
public int Confirmations
public string Confirmations
{
get; set;
}

View File

@ -33,11 +33,12 @@ namespace BTCPayServer.Models.InvoicingModels
public class InvoiceModel
{
public DateTimeOffset Date
public string Date
{
get; set;
}
public string OrderId { get; set; }
public string InvoiceId
{
get; set;

View File

@ -9,7 +9,7 @@ namespace BTCPayServer.Models.InvoicingModels
{
public class AvailableCrypto
{
public string CryptoCode { get; set; }
public string PaymentMethodId { get; set; }
public string CryptoImage { get; set; }
public string Link { get; set; }
}
@ -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; }
@ -40,5 +41,7 @@ namespace BTCPayServer.Models.InvoicingModels
public string CryptoImage { get; set; }
public string NetworkFeeDescription { get; internal set; }
public int MaxTimeMinutes { get; internal set; }
public string PaymentType { get; internal set; }
public string PaymentMethodId { get; internal set; }
}
}

View File

@ -0,0 +1,44 @@
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 string InternalLightningNode { get; internal 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

@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Services.Invoices;
using NBitcoin;
using Newtonsoft.Json;
namespace BTCPayServer.Payments.Bitcoin
{
public class BitcoinLikeOnChainPaymentMethod : IPaymentMethodDetails
{
public PaymentTypes GetPaymentType()
{
return PaymentTypes.BTCLike;
}
public string GetPaymentDestination()
{
return DepositAddress?.ToString();
}
public decimal GetTxFee()
{
return TxFee.ToDecimal(MoneyUnit.BTC);
}
public void SetNoTxFee()
{
TxFee = Money.Zero;
}
public void SetPaymentDestination(string newPaymentDestination)
{
if (newPaymentDestination == null)
DepositAddress = null;
else
DepositAddress = BitcoinAddress.Create(newPaymentDestination, DepositAddress.Network);
}
// Those properties are JsonIgnore because their data is inside CryptoData class for legacy reason
[JsonIgnore]
public FeeRate FeeRate { get; set; }
[JsonIgnore]
public Money TxFee { get; set; }
[JsonIgnore]
public BitcoinAddress DepositAddress { get; set; }
///////////////////////////////////////////////////////////////////////////////////////
}
}

View File

@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Services.Invoices;
using NBitcoin;
using Newtonsoft.Json;
namespace BTCPayServer.Payments.Bitcoin
{
public class BitcoinLikePaymentData : CryptoPaymentData
{
public PaymentTypes GetPaymentType()
{
return PaymentTypes.BTCLike;
}
public BitcoinLikePaymentData()
{
}
public BitcoinLikePaymentData(Coin coin, bool rbf)
{
Outpoint = coin.Outpoint;
Output = coin.TxOut;
ConfirmationCount = 0;
RBF = rbf;
}
[JsonIgnore]
public OutPoint Outpoint { get; set; }
[JsonIgnore]
public TxOut Output { get; set; }
public int ConfirmationCount { get; set; }
public bool RBF { get; set; }
/// <summary>
/// This is set to true if the payment was created before CryptoPaymentData existed in BTCPayServer
/// </summary>
public bool Legacy { get; set; }
public string GetPaymentId()
{
return Outpoint.ToString();
}
public string[] GetSearchTerms()
{
return new[] { Outpoint.Hash.ToString() };
}
public decimal GetValue()
{
return Output.Value.ToDecimal(MoneyUnit.BTC);
}
public bool PaymentCompleted(PaymentEntity entity, BTCPayNetwork network)
{
return ConfirmationCount >= network.MaxTrackedConfirmation;
}
public bool PaymentConfirmed(PaymentEntity entity, SpeedPolicy speedPolicy, BTCPayNetwork network)
{
if (speedPolicy == SpeedPolicy.HighSpeed)
{
return ConfirmationCount >= 1 || !RBF;
}
else if (speedPolicy == SpeedPolicy.MediumSpeed)
{
return ConfirmationCount >= 1;
}
else if (speedPolicy == SpeedPolicy.LowSpeed)
{
return ConfirmationCount >= 6;
}
return false;
}
}
}

View File

@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using NBitcoin;
namespace BTCPayServer.Payments.Bitcoin
{
public class BitcoinLikePaymentHandler : PaymentMethodHandlerBase<DerivationStrategy>
{
ExplorerClientProvider _ExplorerProvider;
private IFeeProviderFactory _FeeRateProviderFactory;
private Services.Wallets.BTCPayWalletProvider _WalletProvider;
public BitcoinLikePaymentHandler(ExplorerClientProvider provider,
IFeeProviderFactory feeRateProviderFactory,
Services.Wallets.BTCPayWalletProvider walletProvider)
{
if (provider == null)
throw new ArgumentNullException(nameof(provider));
_ExplorerProvider = provider;
this._FeeRateProviderFactory = feeRateProviderFactory;
_WalletProvider = walletProvider;
}
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(DerivationStrategy supportedPaymentMethod, PaymentMethod paymentMethod, BTCPayNetwork network)
{
var getFeeRate = _FeeRateProviderFactory.CreateFeeProvider(network).GetFeeRateAsync();
var getAddress = _WalletProvider.GetWallet(network).ReserveAddressAsync(supportedPaymentMethod.DerivationStrategyBase);
Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod onchainMethod = new Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod();
onchainMethod.FeeRate = await getFeeRate;
onchainMethod.TxFee = onchainMethod.FeeRate.GetFee(100); // assume price for 100 bytes
onchainMethod.DepositAddress = await getAddress;
return onchainMethod;
}
public override Task<bool> IsAvailable(DerivationStrategy supportedPaymentMethod, BTCPayNetwork network)
{
return Task.FromResult(_ExplorerProvider.IsAvailable(network));
}
}
}

View File

@ -0,0 +1,404 @@
using System;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Logging;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using NBXplorer;
using System.Collections.Concurrent;
using NBXplorer.DerivationStrategy;
using BTCPayServer.Events;
using BTCPayServer.Services;
using BTCPayServer.Services.Wallets;
using NBitcoin;
using NBXplorer.Models;
using BTCPayServer.Payments;
using BTCPayServer.HostedServices;
namespace BTCPayServer.Payments.Bitcoin
{
/// <summary>
/// This class listener NBXplorer instances to detect incoming on-chain, bitcoin like payment
/// </summary>
public class NBXplorerListener : IHostedService
{
EventAggregator _Aggregator;
ExplorerClientProvider _ExplorerClients;
IApplicationLifetime _Lifetime;
InvoiceRepository _InvoiceRepository;
private TaskCompletionSource<bool> _RunningTask;
private CancellationTokenSource _Cts;
BTCPayWalletProvider _Wallets;
BTCPayNetworkProvider _NetworkProvider;
public NBXplorerListener(ExplorerClientProvider explorerClients,
BTCPayWalletProvider wallets,
InvoiceRepository invoiceRepository,
BTCPayNetworkProvider networkProvider,
EventAggregator aggregator, IApplicationLifetime lifetime)
{
PollInterval = TimeSpan.FromMinutes(1.0);
_Wallets = wallets;
_InvoiceRepository = invoiceRepository;
_ExplorerClients = explorerClients;
_Aggregator = aggregator;
_Lifetime = lifetime;
_NetworkProvider = networkProvider;
}
CompositeDisposable leases = new CompositeDisposable();
ConcurrentDictionary<string, NotificationSession> _SessionsByCryptoCode = new ConcurrentDictionary<string, NotificationSession>();
private Timer _ListenPoller;
TimeSpan _PollInterval;
public TimeSpan PollInterval
{
get
{
return _PollInterval;
}
set
{
_PollInterval = value;
if (_ListenPoller != null)
{
_ListenPoller.Change(0, (int)value.TotalMilliseconds);
}
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
_RunningTask = new TaskCompletionSource<bool>();
_Cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
leases.Add(_Aggregator.Subscribe<Events.NBXplorerStateChangedEvent>(async nbxplorerEvent =>
{
if (nbxplorerEvent.NewState == NBXplorerState.Ready)
{
var wallet = _Wallets.GetWallet(nbxplorerEvent.Network);
if (_Wallets.IsAvailable(wallet.Network))
{
await Listen(wallet);
}
}
}));
_ListenPoller = new Timer(async s =>
{
foreach (var wallet in _Wallets.GetWallets())
{
if (_Wallets.IsAvailable(wallet.Network))
{
await Listen(wallet);
}
}
}, null, 0, (int)PollInterval.TotalMilliseconds);
leases.Add(_ListenPoller);
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.GetSupportedPaymentMethod<DerivationStrategy>(_NetworkProvider)
.Select(s => (Session: _SessionsByCryptoCode.TryGet(s.PaymentId.CryptoCode),
DerivationStrategy: s.DerivationStrategyBase))
.Where(s => s.Session != null)
.Select(s => s.Session.ListenDerivationSchemesAsync(new[] { s.DerivationStrategy }))
.ToArray()).ConfigureAwait(false);
}
}));
return Task.CompletedTask;
}
private async Task Listen(BTCPayWallet wallet)
{
var network = wallet.Network;
bool cleanup = false;
try
{
if (_SessionsByCryptoCode.ContainsKey(network.CryptoCode))
return;
var client = _ExplorerClients.GetExplorerClient(network);
if (client == null)
return;
if (_Cts.IsCancellationRequested)
return;
var session = await client.CreateNotificationSessionAsync(_Cts.Token).ConfigureAwait(false);
if (!_SessionsByCryptoCode.TryAdd(network.CryptoCode, session))
{
await session.DisposeAsync();
return;
}
cleanup = true;
using (session)
{
await session.ListenNewBlockAsync(_Cts.Token).ConfigureAwait(false);
await session.ListenDerivationSchemesAsync((await GetStrategies(network)).ToArray(), _Cts.Token).ConfigureAwait(false);
Logs.PayServer.LogInformation($"{network.CryptoCode}: Checking if any pending invoice got paid while offline...");
int paymentCount = await FindPaymentViaPolling(wallet, network);
Logs.PayServer.LogInformation($"{network.CryptoCode}: {paymentCount} payments happened while offline");
Logs.PayServer.LogInformation($"Connected to WebSocket of NBXplorer ({network.CryptoCode})");
while (!_Cts.IsCancellationRequested)
{
var newEvent = await session.NextEventAsync(_Cts.Token).ConfigureAwait(false);
switch (newEvent)
{
case NBXplorer.Models.NewBlockEvent evt:
await Task.WhenAll((await _InvoiceRepository.GetPendingInvoices())
.Select(invoiceId => UpdatePaymentStates(wallet, invoiceId))
.ToArray());
_Aggregator.Publish(new Events.NewBlockEvent() { CryptoCode = evt.CryptoCode });
break;
case NBXplorer.Models.NewTransactionEvent evt:
wallet.InvalidateCache(evt.DerivationStrategy);
foreach (var output in evt.Outputs)
{
foreach (var txCoin in evt.TransactionData.Transaction.Outputs.AsCoins()
.Where(o => o.ScriptPubKey == output.ScriptPubKey)
.Select(o => output.Redeem == null ? o : o.ToScriptCoin(output.Redeem)))
{
var invoice = await _InvoiceRepository.GetInvoiceFromScriptPubKey(output.ScriptPubKey, network.CryptoCode);
if (invoice != null)
{
var paymentData = new BitcoinLikePaymentData(txCoin, evt.TransactionData.Transaction.RBF);
var alreadyExist = GetAllBitcoinPaymentData(invoice).Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any();
if (!alreadyExist)
{
var payment = await _InvoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, paymentData, network.CryptoCode);
await ReceivedPayment(wallet, invoice.Id, payment, evt.DerivationStrategy);
}
else
{
await UpdatePaymentStates(wallet, invoice.Id);
}
}
}
}
break;
default:
Logs.PayServer.LogWarning("Received unknown message from NBXplorer");
break;
}
}
}
}
catch when (_Cts.IsCancellationRequested) { }
catch (Exception ex)
{
Logs.PayServer.LogError(ex, $"Error while connecting to WebSocket of NBXplorer ({network.CryptoCode})");
}
finally
{
if (cleanup)
{
Logs.PayServer.LogInformation($"Disconnected from WebSocket of NBXplorer ({network.CryptoCode})");
_SessionsByCryptoCode.TryRemove(network.CryptoCode, out NotificationSession unused);
if (_SessionsByCryptoCode.Count == 0 && _Cts.IsCancellationRequested)
{
_RunningTask.TrySetResult(true);
}
}
}
}
IEnumerable<BitcoinLikePaymentData> GetAllBitcoinPaymentData(InvoiceEntity invoice)
{
return invoice.GetPayments()
.Where(p => p.GetpaymentMethodId().PaymentType == PaymentTypes.BTCLike)
.Select(p => (BitcoinLikePaymentData)p.GetCryptoPaymentData());
}
async Task<InvoiceEntity> UpdatePaymentStates(BTCPayWallet wallet, string invoiceId)
{
var invoice = await _InvoiceRepository.GetInvoice(null, invoiceId, false);
List<PaymentEntity> updatedPaymentEntities = new List<PaymentEntity>();
var transactions = await wallet.GetTransactions(GetAllBitcoinPaymentData(invoice)
.Select(p => p.Outpoint.Hash)
.ToArray());
var conflicts = GetConflicts(transactions.Select(t => t.Value));
foreach (var payment in invoice.GetPayments(wallet.Network))
{
if (payment.GetpaymentMethodId().PaymentType != PaymentTypes.BTCLike)
continue;
var paymentData = (BitcoinLikePaymentData)payment.GetCryptoPaymentData();
if (!transactions.TryGetValue(paymentData.Outpoint.Hash, out TransactionResult tx))
continue;
var txId = tx.Transaction.GetHash();
var txConflict = conflicts.GetConflict(txId);
var accounted = txConflict == null || txConflict.IsWinner(txId);
bool updated = false;
if (accounted != payment.Accounted)
{
updated = true;
payment.Accounted = accounted;
}
if (paymentData.ConfirmationCount != tx.Confirmations)
{
if (wallet.Network.MaxTrackedConfirmation >= paymentData.ConfirmationCount)
{
paymentData.ConfirmationCount = tx.Confirmations;
payment.SetCryptoPaymentData(paymentData);
updated = true;
}
}
if (updated)
updatedPaymentEntities.Add(payment);
}
await _InvoiceRepository.UpdatePayments(updatedPaymentEntities);
if (updatedPaymentEntities.Count != 0)
_Aggregator.Publish(new Events.InvoiceNeedUpdateEvent(invoice.Id));
return invoice;
}
class TransactionConflict
{
public Dictionary<uint256, TransactionResult> Transactions { get; set; } = new Dictionary<uint256, TransactionResult>();
uint256 _Winner;
public bool IsWinner(uint256 txId)
{
if (_Winner == null)
{
var confirmed = Transactions.FirstOrDefault(t => t.Value.Confirmations >= 1);
if (!confirmed.Equals(default(KeyValuePair<uint256, TransactionResult>)))
{
_Winner = confirmed.Key;
}
else
{
// Take the most recent (bitcoin node would not forward a conflict without a successfull RBF)
_Winner = Transactions
.OrderByDescending(t => t.Value.Timestamp)
.First()
.Key;
}
}
return _Winner == txId;
}
}
class TransactionConflicts : List<TransactionConflict>
{
public TransactionConflicts(IEnumerable<TransactionConflict> collection) : base(collection)
{
}
public TransactionConflict GetConflict(uint256 txId)
{
return this.FirstOrDefault(c => c.Transactions.ContainsKey(txId));
}
}
private TransactionConflicts GetConflicts(IEnumerable<TransactionResult> transactions)
{
Dictionary<OutPoint, TransactionConflict> conflictsByOutpoint = new Dictionary<OutPoint, TransactionConflict>();
foreach (var tx in transactions)
{
var hash = tx.Transaction.GetHash();
foreach (var input in tx.Transaction.Inputs)
{
TransactionConflict conflict = new TransactionConflict();
if (!conflictsByOutpoint.TryAdd(input.PrevOut, conflict))
{
conflict = conflictsByOutpoint[input.PrevOut];
}
if (!conflict.Transactions.ContainsKey(hash))
conflict.Transactions.Add(hash, tx);
}
}
return new TransactionConflicts(conflictsByOutpoint.Where(c => c.Value.Transactions.Count > 1).Select(c => c.Value));
}
private async Task<int> FindPaymentViaPolling(BTCPayWallet wallet, BTCPayNetwork network)
{
int totalPayment = 0;
var invoices = await _InvoiceRepository.GetPendingInvoices();
foreach (var invoiceId in invoices)
{
var invoice = await _InvoiceRepository.GetInvoice(null, invoiceId, true);
var alreadyAccounted = GetAllBitcoinPaymentData(invoice).Select(p => p.Outpoint).ToHashSet();
var strategy = GetDerivationStrategy(invoice, network);
if (strategy == null)
continue;
var cryptoId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
if (!invoice.Support(cryptoId))
continue;
var coins = (await wallet.GetUnspentCoins(strategy))
.Where(c => invoice.AvailableAddressHashes.Contains(c.Coin.ScriptPubKey.Hash.ToString() + cryptoId))
.ToArray();
foreach (var coin in coins.Where(c => !alreadyAccounted.Contains(c.Coin.Outpoint)))
{
var transaction = await wallet.GetTransactionAsync(coin.Coin.Outpoint.Hash);
var paymentData = new BitcoinLikePaymentData(coin.Coin, transaction.Transaction.RBF);
var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.Timestamp, paymentData, network.CryptoCode).ConfigureAwait(false);
alreadyAccounted.Add(coin.Coin.Outpoint);
invoice = await ReceivedPayment(wallet, invoice.Id, payment, strategy);
totalPayment++;
}
}
return totalPayment;
}
private DerivationStrategyBase GetDerivationStrategy(InvoiceEntity invoice, BTCPayNetwork network)
{
return invoice.GetSupportedPaymentMethod<DerivationStrategy>(new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike), _ExplorerClients.NetworkProviders)
.Select(d => d.DerivationStrategyBase)
.FirstOrDefault();
}
private async Task<InvoiceEntity> ReceivedPayment(BTCPayWallet wallet, string invoiceId, PaymentEntity payment, DerivationStrategyBase strategy)
{
var paymentData = (BitcoinLikePaymentData)payment.GetCryptoPaymentData();
var invoice = (await UpdatePaymentStates(wallet, invoiceId));
var paymentMethod = invoice.GetPaymentMethod(wallet.Network, PaymentTypes.BTCLike, _ExplorerClients.NetworkProviders);
if (paymentMethod != null &&
paymentMethod.GetPaymentMethodDetails() is BitcoinLikeOnChainPaymentMethod btc &&
btc.DepositAddress.ScriptPubKey == paymentData.Output.ScriptPubKey &&
paymentMethod.Calculate().Due > Money.Zero)
{
var address = await wallet.ReserveAddressAsync(strategy);
btc.DepositAddress = address;
await _InvoiceRepository.NewAddress(invoiceId, btc, wallet.Network);
_Aggregator.Publish(new InvoiceNewAddressEvent(invoiceId, address.ToString(), wallet.Network));
paymentMethod.SetPaymentMethodDetails(btc);
invoice.SetPaymentMethod(paymentMethod);
}
wallet.InvalidateCache(strategy);
_Aggregator.Publish(new InvoiceEvent(invoiceId, 1002, "invoice_receivedPayment"));
return invoice;
}
private async Task<List<DerivationStrategyBase>> GetStrategies(BTCPayNetwork network)
{
List<DerivationStrategyBase> strategies = new List<DerivationStrategyBase>();
foreach (var invoiceId in await _InvoiceRepository.GetPendingInvoices())
{
var invoice = await _InvoiceRepository.GetInvoice(null, invoiceId);
var strategy = GetDerivationStrategy(invoice, network);
if (strategy != null)
strategies.Add(strategy);
}
return strategies;
}
public Task StopAsync(CancellationToken cancellationToken)
{
leases.Dispose();
_Cts.Cancel();
return Task.WhenAny(_RunningTask.Task, Task.Delay(-1, cancellationToken));
}
}
}

View File

@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NBitcoin;
namespace BTCPayServer.Payments
{
/// <summary>
/// Represent information necessary to track a payment
/// </summary>
public interface IPaymentMethodDetails
{
/// <summary>
/// A string representation of the payment destination
/// </summary>
/// <returns></returns>
string GetPaymentDestination();
PaymentTypes GetPaymentType();
/// <summary>
/// Returns what a merchant would need to pay to cashout this payment
/// </summary>
/// <returns></returns>
decimal GetTxFee();
void SetNoTxFee();
/// <summary>
/// Change the payment destination (internal plumbing)
/// </summary>
/// <param name="newPaymentDestination"></param>
void SetPaymentDestination(string newPaymentDestination);
}
}

View File

@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Services.Invoices;
namespace BTCPayServer.Payments
{
/// <summary>
/// This class customize invoice creation by the creation of payment details for the PaymentMethod during invoice creation
/// </summary>
public interface IPaymentMethodHandler
{
/// <summary>
/// Returns true if the dependencies for a specific payment method are satisfied.
/// </summary>
/// <param name="supportedPaymentMethod"></param>
/// <param name="network"></param>
/// <returns>true if this payment method is available</returns>
Task<bool> IsAvailable(ISupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network);
/// <summary>
/// Create needed to track payments of this invoice
/// </summary>
/// <param name="supportedPaymentMethod"></param>
/// <param name="paymentMethod"></param>
/// <param name="network"></param>
/// <returns></returns>
Task<IPaymentMethodDetails> CreatePaymentMethodDetails(ISupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, BTCPayNetwork network);
}
public interface IPaymentMethodHandler<T> : IPaymentMethodHandler where T : ISupportedPaymentMethod
{
Task<bool> IsAvailable(T supportedPaymentMethod, BTCPayNetwork network);
Task<IPaymentMethodDetails> CreatePaymentMethodDetails(T supportedPaymentMethod, PaymentMethod paymentMethod, BTCPayNetwork network);
}
public abstract class PaymentMethodHandlerBase<T> : IPaymentMethodHandler<T> where T : ISupportedPaymentMethod
{
public abstract Task<IPaymentMethodDetails> CreatePaymentMethodDetails(T supportedPaymentMethod, PaymentMethod paymentMethod, BTCPayNetwork network);
Task<IPaymentMethodDetails> IPaymentMethodHandler.CreatePaymentMethodDetails(ISupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, BTCPayNetwork network)
{
if (supportedPaymentMethod is T method)
{
return CreatePaymentMethodDetails(method, paymentMethod, network);
}
throw new NotSupportedException("Invalid supportedPaymentMethod");
}
public abstract Task<bool> IsAvailable(T supportedPaymentMethod, BTCPayNetwork network);
Task<bool> IPaymentMethodHandler.IsAvailable(ISupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network)
{
if(supportedPaymentMethod is T method)
{
return IsAvailable(method, network);
}
return Task.FromResult(false);
}
}
}

View File

@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Services.Invoices;
namespace BTCPayServer.Payments
{
/// <summary>
/// This class represent a mode of payment supported by a store.
/// It is stored at the store level and cloned to the invoice during invoice creation.
/// This object will be serialized in database in json
/// </summary>
public interface ISupportedPaymentMethod
{
PaymentMethodId PaymentId { get; }
}
}

View File

@ -0,0 +1,130 @@
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
{
public class ChargeClient
{
private Uri _Uri;
public Uri Uri
{
get
{
return _Uri;
}
}
private Network _Network;
static HttpClient _Client = new HttpClient();
public ChargeClient(Uri uri, Network network)
{
if (uri == null)
throw new ArgumentNullException(nameof(uri));
if (network == null)
throw new ArgumentNullException(nameof(network));
this._Uri = uri;
this._Network = network;
if (uri.UserInfo == null)
throw new ArgumentException(paramName: nameof(uri), message: "User information not present in uri");
var userInfo = uri.UserInfo.Split(':');
if (userInfo.Length != 2)
throw new ArgumentException(paramName: nameof(uri), message: "User information not present in uri");
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<ChargeInvoice> GetInvoice(string invoiceId, CancellationToken cancellation = default(CancellationToken))
{
var request = CreateMessage(HttpMethod.Get, $"invoice/{invoiceId}");
var message = await _Client.SendAsync(request, cancellation);
if (message.StatusCode == HttpStatusCode.NotFound)
return null;
message.EnsureSuccessStatusCode();
var content = await message.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<ChargeInvoice>(content);
}
public async Task<GetInfoResponse> GetInfoAsync(CancellationToken cancellation = default(CancellationToken))
{
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 CreateMessage(HttpMethod method, string path)
{
var uri = GetFullUri(path);
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;
if (!uri.EndsWith("/", StringComparison.InvariantCultureIgnoreCase))
uri += "/";
return new Uri(uri + partialUrl);
}
}
}

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 ChargeInvoice
{
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<ChargeInvoice> 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 ChargeInvoice ParseMessage(ArraySegment<byte> buffer)
{
var str = UTF8.GetString(buffer.Array, 0, buffer.Count);
return JsonConvert.DeserializeObject<ChargeInvoice>(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,24 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Payments.Lightning.CLightning
{
//[{"type":"ipv4","address":"52.166.90.122","port":9735}]
public class GetInfoResponse
{
public class GetInfoAddress
{
public string Type { get; set; }
public string Address { get; set; }
public int Port { get; set; }
}
public string Id { get; set; }
public int Port { get; set; }
public GetInfoAddress[] Address { get; set; }
public string Version { get; set; }
public int BlockHeight { get; set; }
public string Network { get; set; }
}
}

View File

@ -0,0 +1,260 @@
using System;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Events;
using BTCPayServer.Logging;
using BTCPayServer.Payments.Lightning.CLightning;
using BTCPayServer.Services.Invoices;
using Microsoft.Extensions.Hosting;
using NBXplorer;
namespace BTCPayServer.Payments.Lightning
{
public class ChargeListener : IHostedService
{
class ListenedInvoice
{
public LightningLikePaymentMethodDetails PaymentMethodDetails { get; set; }
public LightningSupportedPaymentMethod SupportedPaymentMethod { get; set; }
public PaymentMethod PaymentMethod { get; set; }
public string Uri { get; internal set; }
public BTCPayNetwork Network { get; internal set; }
public string InvoiceId { get; internal set; }
}
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")
{
await EnsureListening(inv.InvoiceId, false);
}
}));
_ListenPoller = new Timer(async s =>
{
await Task.WhenAll((await _InvoiceRepository.GetPendingInvoices())
.Select(async invoiceId => await EnsureListening(invoiceId, true))
.ToArray());
}, null, 0, (int)PollInterval.TotalMilliseconds);
leases.Add(_ListenPoller);
return Task.CompletedTask;
}
private async Task EnsureListening(string invoiceId, bool poll)
{
if (Listening(invoiceId))
return;
var invoice = await _InvoiceRepository.GetInvoice(null, invoiceId);
foreach (var paymentMethod in invoice.GetPaymentMethods(_NetworkProvider)
.Where(c => c.GetId().PaymentType == PaymentTypes.LightningLike))
{
var lightningMethod = paymentMethod.GetPaymentMethodDetails() as LightningLikePaymentMethodDetails;
if (lightningMethod == null)
continue;
var lightningSupportedMethod = invoice.GetSupportedPaymentMethod<LightningSupportedPaymentMethod>(_NetworkProvider)
.FirstOrDefault(c => c.CryptoCode == paymentMethod.GetId().CryptoCode);
if (lightningSupportedMethod == null)
continue;
var network = _NetworkProvider.GetNetwork(paymentMethod.GetId().CryptoCode);
var listenedInvoice = new ListenedInvoice()
{
Uri = lightningSupportedMethod.GetLightningChargeUrl(false).AbsoluteUri,
PaymentMethodDetails = lightningMethod,
SupportedPaymentMethod = lightningSupportedMethod,
PaymentMethod = paymentMethod,
Network = network,
InvoiceId = invoice.Id
};
if (poll)
{
var charge = GetChargeClient(lightningSupportedMethod, network);
var chargeInvoice = await charge.GetInvoice(lightningMethod.InvoiceId);
if (chargeInvoice == null)
continue;
if(chargeInvoice.Status == "paid")
await AddPayment(network, chargeInvoice, listenedInvoice);
if (chargeInvoice.Status == "paid" || chargeInvoice.Status == "expired")
continue;
}
StartListening(listenedInvoice);
}
}
TimeSpan _PollInterval = TimeSpan.FromMinutes(1.0);
public TimeSpan PollInterval
{
get
{
return _PollInterval;
}
set
{
_PollInterval = value;
if (_ListenPoller != null)
{
_ListenPoller.Change(0, (int)value.TotalMilliseconds);
}
}
}
CancellationTokenSource _Cts = new CancellationTokenSource();
private async Task Listen(LightningSupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network)
{
try
{
Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): Start listening {supportedPaymentMethod.GetLightningChargeUrl(false)}");
var charge = GetChargeClient(supportedPaymentMethod, network);
var session = await charge.Listen(_Cts.Token);
while (true)
{
var notification = await session.NextEvent(_Cts.Token);
ListenedInvoice listenedInvoice = GetListenedInvoice(notification.Id);
if (listenedInvoice == null)
continue;
if (notification.Id == listenedInvoice.PaymentMethodDetails.InvoiceId &&
notification.PaymentRequest == listenedInvoice.PaymentMethodDetails.BOLT11)
{
if (notification.Status == "paid" && notification.PaidAt.HasValue)
{
await AddPayment(network, notification, listenedInvoice);
if (DoneListening(listenedInvoice))
break;
}
if (notification.Status == "expired")
{
if (DoneListening(listenedInvoice))
break;
}
}
}
}
catch when (_Cts.IsCancellationRequested)
{
}
catch (Exception ex)
{
Logs.PayServer.LogError(ex, $"{supportedPaymentMethod.CryptoCode} (Lightning): Error while contacting {supportedPaymentMethod.GetLightningChargeUrl(false)}");
}
Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): Stop listening {supportedPaymentMethod.GetLightningChargeUrl(false)}");
}
private async Task AddPayment(BTCPayNetwork network, ChargeInvoice notification, ListenedInvoice listenedInvoice)
{
await _InvoiceRepository.AddPayment(listenedInvoice.InvoiceId, notification.PaidAt.Value, new LightningLikePaymentData()
{
BOLT11 = notification.PaymentRequest,
Amount = notification.MilliSatoshi
}, network.CryptoCode, accounted: true);
_Aggregator.Publish(new InvoiceEvent(listenedInvoice.InvoiceId, 1002, "invoice_receivedPayment"));
}
private static ChargeClient GetChargeClient(LightningSupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network)
{
return new ChargeClient(supportedPaymentMethod.GetLightningChargeUrl(true), network.NBitcoinNetwork);
}
List<Task> _ListeningLightning = new List<Task>();
MultiValueDictionary<string, ListenedInvoice> _ListenedInvoiceByLightningUrl = new MultiValueDictionary<string, ListenedInvoice>();
Dictionary<string, ListenedInvoice> _ListenedInvoiceByChargeInvoiceId = new Dictionary<string, ListenedInvoice>();
HashSet<string> _InvoiceIds = new HashSet<string>();
private Timer _ListenPoller;
/// <summary>
/// Stop listening an invoice
/// </summary>
/// <param name="listenedInvoice">The invoice to stop listening</param>
/// <returns>true if still need to listen the lightning instance</returns>
bool DoneListening(ListenedInvoice listenedInvoice)
{
lock (_ListenedInvoiceByLightningUrl)
{
_ListenedInvoiceByChargeInvoiceId.Remove(listenedInvoice.PaymentMethodDetails.InvoiceId);
_ListenedInvoiceByLightningUrl.Remove(listenedInvoice.Uri, listenedInvoice);
_InvoiceIds.Remove(listenedInvoice.InvoiceId);
if (!_ListenedInvoiceByLightningUrl.ContainsKey(listenedInvoice.Uri))
{
return true;
}
}
return false;
}
bool Listening(string invoiceId)
{
lock(_ListenedInvoiceByLightningUrl)
{
return _InvoiceIds.Contains(invoiceId);
}
}
private ListenedInvoice GetListenedInvoice(string chargeInvoiceId)
{
ListenedInvoice listenedInvoice = null;
lock (_ListenedInvoiceByLightningUrl)
{
_ListenedInvoiceByChargeInvoiceId.TryGetValue(chargeInvoiceId, out listenedInvoice);
}
return listenedInvoice;
}
bool StartListening(ListenedInvoice listenedInvoice)
{
lock (_ListenedInvoiceByLightningUrl)
{
if (_InvoiceIds.Contains(listenedInvoice.InvoiceId))
return false;
if (!_ListenedInvoiceByLightningUrl.ContainsKey(listenedInvoice.Uri))
{
var listen = Listen(listenedInvoice.SupportedPaymentMethod, listenedInvoice.Network);
_ListeningLightning.Add(listen);
listen.ContinueWith(_ =>
{
lock (_ListenedInvoiceByLightningUrl)
{
_ListeningLightning.Remove(listen);
}
}, TaskScheduler.Default);
}
_ListenedInvoiceByLightningUrl.Add(listenedInvoice.Uri, listenedInvoice);
_ListenedInvoiceByChargeInvoiceId.Add(listenedInvoice.PaymentMethodDetails.InvoiceId, listenedInvoice);
_InvoiceIds.Add(listenedInvoice.InvoiceId);
}
return true;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
leases.Dispose();
_Cts.Cancel();
Task[] listening = null;
lock (_ListenedInvoiceByLightningUrl)
{
listening = _ListeningLightning.ToArray();
}
await Task.WhenAll(listening);
}
}
}

View File

@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Eclair
namespace BTCPayServer.Payments.Lightning.Eclair
{
public class AllChannelResponse
{

View File

@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Eclair
namespace BTCPayServer.Payments.Lightning.Eclair
{
public class ChannelResponse
{

View File

@ -9,8 +9,18 @@ using NBitcoin;
using NBitcoin.JsonConverters;
using NBitcoin.RPC;
namespace BTCPayServer.Eclair
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)
@ -21,8 +31,13 @@ namespace BTCPayServer.Eclair
throw new ArgumentNullException(nameof(network));
Address = address;
Network = network;
if (string.IsNullOrEmpty(address.UserInfo))
throw new ArgumentException(paramName: nameof(address), message: "User info in the url should be provided");
Password = address.UserInfo;
}
public string Password { get; set; }
public Network Network { get; private set; }
@ -33,7 +48,7 @@ namespace BTCPayServer.Eclair
public Task<GetInfoResponse> GetInfoAsync()
{
return SendCommandAsync<GetInfoResponse>(new RPCRequest("getinfo", new object[] { }));
return SendCommandAsync<GetInfoResponse>(new RPCRequest("getinfo", Array.Empty<object>()));
}
public async Task<T> SendCommandAsync<T>(RPCRequest request, bool throwIfRPCError = true)
@ -104,17 +119,17 @@ namespace BTCPayServer.Eclair
public async Task<AllChannelResponse[]> AllChannelsAsync()
{
return await SendCommandAsync<AllChannelResponse[]>(new RPCRequest("allchannels", new object[] { })).ConfigureAwait(false);
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", new object[] { })).ConfigureAwait(false);
return await SendCommandAsync<ChannelInfo[]>(new RPCRequest("channels", Array.Empty<object>())).ConfigureAwait(false);
}
public void Close(string channelId)
@ -122,6 +137,11 @@ namespace BTCPayServer.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)
@ -155,7 +175,7 @@ namespace BTCPayServer.Eclair
public async Task<string[]> AllNodesAsync()
{
return await SendCommandAsync<string[]>(new RPCRequest("allnodes", new object[] { })).ConfigureAwait(false);
return await SendCommandAsync<string[]>(new RPCRequest("allnodes", Array.Empty<object>())).ConfigureAwait(false);
}
public Uri Address { get; private set; }
@ -165,6 +185,8 @@ namespace BTCPayServer.Eclair
var webRequest = (HttpWebRequest)WebRequest.Create(Address.AbsoluteUri);
webRequest.ContentType = "application/json";
webRequest.Method = "POST";
var auth = Convert.ToBase64String(Encoding.ASCII.GetBytes(Password));
webRequest.Headers[HttpRequestHeader.Authorization] = $"Basic {auth}";
return webRequest;
}
@ -220,7 +242,7 @@ namespace BTCPayServer.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

@ -5,7 +5,7 @@ using System.Threading.Tasks;
using NBitcoin;
using Newtonsoft.Json;
namespace BTCPayServer.Eclair
namespace BTCPayServer.Payments.Lightning.Eclair
{
public class GetInfoResponse
{

View File

@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Eclair
namespace BTCPayServer.Payments.Lightning.Eclair
{
public class NodeInfo
{

View File

@ -3,8 +3,9 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using NBitcoin;
namespace BTCPayServer.Eclair
namespace BTCPayServer.Payments.Lightning
{
public enum LightMoneyUnit : ulong
{
@ -94,28 +95,31 @@ namespace BTCPayServer.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;
}
}
@ -139,7 +143,7 @@ namespace BTCPayServer.Eclair
public IEnumerable<LightMoney> Split(int parts)
{
if (parts <= 0)
throw new ArgumentOutOfRangeException("Parts should be more than 0", "parts");
throw new ArgumentOutOfRangeException(nameof(parts), "Parts should be more than 0");
long remain;
long result = DivRem(_MilliSatoshis, parts, out remain);
@ -171,7 +175,7 @@ namespace BTCPayServer.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)
@ -431,7 +435,7 @@ namespace BTCPayServer.Eclair
/// <returns></returns>
public string ToString(bool fplus, bool trimExcessZero = true)
{
var fmt = string.Format("{{0:{0}{1}B}}",
var fmt = string.Format(CultureInfo.InvariantCulture, "{{0:{0}{1}B}}",
(fplus ? "+" : null),
(trimExcessZero ? "2" : "11"));
return string.Format(BitcoinFormatter.Formatter, fmt, _MilliSatoshis);
@ -479,7 +483,7 @@ namespace BTCPayServer.Eclair
unitToUseInCalc = LightMoneyUnit.BTC;
break;
}
var val = Convert.ToDecimal(arg) / (long)unitToUseInCalc;
var val = Convert.ToDecimal(arg, CultureInfo.InvariantCulture) / (long)unitToUseInCalc;
var zeros = new string('0', decPos);
var rest = new string('#', 11 - decPos);
var fmt = plus && val > 0 ? "+" : string.Empty;

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,137 @@
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.Select(a=>a.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}");
}
var blocksGap = Math.Abs(info.BlockHeight - (await status).ChainHeight);
if (blocksGap > 10)
{
throw new Exception($"The lightning is not synched ({blocksGap} blocks)");
}
try
{
await TestConnection(address, port, cts.Token);
}
catch (Exception ex)
{
throw new Exception($"Error while connecting to the lightning node via {address}:{port} ({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

@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Payments
{
public class PaymentMethodExtensions
{
public static ISupportedPaymentMethod Deserialize(PaymentMethodId paymentMethodId, JToken value, BTCPayNetwork network)
{
// Legacy
if (paymentMethodId.PaymentType == PaymentTypes.BTCLike)
{
return BTCPayServer.DerivationStrategy.Parse(((JValue)value).Value<string>(), network);
}
//////////
else if (paymentMethodId.PaymentType == PaymentTypes.LightningLike)
{
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
if (factory.PaymentId.PaymentType == PaymentTypes.BTCLike)
{
return new JValue(((DerivationStrategy)factory).DerivationStrategyBase.ToString());
}
//////////////
else
{
var str = JsonConvert.SerializeObject(factory);
return JObject.Parse(str);
}
throw new NotSupportedException();
}
}
}

View File

@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Payments
{
/// <summary>
/// A value object which represent a crypto currency with his payment type (ie, onchain or offchain)
/// </summary>
public class PaymentMethodId
{
public PaymentMethodId(string cryptoCode, PaymentTypes paymentType)
{
if (cryptoCode == null)
throw new ArgumentNullException(nameof(cryptoCode));
PaymentType = paymentType;
CryptoCode = cryptoCode;
}
[Obsolete("Should only be used for legacy stuff")]
public bool IsBTCOnChain
{
get
{
return CryptoCode == "BTC" && PaymentType == PaymentTypes.BTCLike;
}
}
public string CryptoCode { get; private set; }
public PaymentTypes PaymentType { get; private set; }
public override bool Equals(object obj)
{
PaymentMethodId item = obj as PaymentMethodId;
if (item == null)
return false;
return ToString().Equals(item.ToString(), StringComparison.InvariantCulture);
}
public static bool operator ==(PaymentMethodId a, PaymentMethodId b)
{
if (System.Object.ReferenceEquals(a, b))
return true;
if (((object)a == null) || ((object)b == null))
return false;
return a.ToString() == b.ToString();
}
public static bool operator !=(PaymentMethodId a, PaymentMethodId b)
{
return !(a == b);
}
public override int GetHashCode()
{
#pragma warning disable CA1307 // Specify StringComparison
return ToString().GetHashCode();
#pragma warning restore CA1307 // Specify StringComparison
}
public override string ToString()
{
if (PaymentType == PaymentTypes.BTCLike)
return CryptoCode;
return CryptoCode + "_" + PaymentType.ToString();
}
public static PaymentMethodId Parse(string str)
{
var parts = str.Split('_');
return new PaymentMethodId(parts[0], parts.Length == 1 ? PaymentTypes.BTCLike : Enum.Parse<PaymentTypes>(parts[1]));
}
}
}

View File

@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Payments
{
/// <summary>
/// The different ways to pay an invoice
/// </summary>
public enum PaymentTypes
{
/// <summary>
/// On-Chain UTXO based, bitcoin compatible
/// </summary>
BTCLike,
/// <summary>
/// Lightning payment
/// </summary>
LightningLike
}
}

View File

@ -1,17 +1,5 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:14139/",
"sslPort": 0
}
},
"profiles": {
"Default": {
"commandName": "Project",
"commandLineArgs": "--network testnet --chains ltc --ltcexplorerurl http://127.0.0.1:2727/"
},
"Docker-Regtest": {
"commandName": "Project",
"launchBrowser": true,
@ -20,10 +8,11 @@
"BTCPAY_LTCEXPLORERURL": "http://127.0.0.1:32838/",
"BTCPAY_BTCEXPLORERURL": "http://127.0.0.1:32838/",
"ASPNETCORE_ENVIRONMENT": "Development",
"BTCPAY_INTERNALLIGHTNINGNODE": "http://api-token:foiewnccewuify@127.0.0.1:54938/",
"BTCPAY_CHAINS": "btc,ltc",
"BTCPAY_POSTGRES": "User ID=postgres;Host=127.0.0.1;Port=39372;Database=btcpayserver"
},
"applicationUrl": "http://localhost:14142/"
}
}
}
}

View File

@ -27,7 +27,7 @@ namespace BTCPayServer
{
if(filter.Split(new char[] { ':' }, StringSplitOptions.RemoveEmptyEntries).Length == 2)
{
TextSearch = TextSearch.Replace(filter, string.Empty);
TextSearch = TextSearch.Replace(filter, string.Empty, StringComparison.InvariantCulture);
}
}
TextSearch = TextSearch.Trim();

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

@ -4,6 +4,7 @@ using System.Linq;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Services.Wallets;
using LedgerWallet;
using NBitcoin;
using NBXplorer.DerivationStrategy;
@ -112,9 +113,11 @@ namespace BTCPayServer.Services
public async Task<bool> SupportDerivation(BTCPayNetwork network, DirectDerivationStrategy strategy)
{
if (network == null)
throw new ArgumentNullException(nameof(Network));
throw new ArgumentNullException(nameof(network));
if (strategy == null)
throw new ArgumentNullException(nameof(strategy));
if (!strategy.Segwit)
return false;
return await GetKeyPath(_Ledger, network, strategy) != null;
}
@ -145,11 +148,11 @@ namespace BTCPayServer.Services
}
public async Task<Transaction> SendToAddress(DirectDerivationStrategy strategy,
Coin[] coins, BTCPayNetwork network,
ReceivedCoin[] coins, BTCPayNetwork network,
(IDestination destination, Money amount, bool substractFees)[] send,
FeeRate feeRate,
IDestination changeAddress,
Dictionary<Script, KeyPath> keypaths = null)
KeyPath changeKeyPath)
{
if (strategy == null)
throw new ArgumentNullException(nameof(strategy));
@ -159,8 +162,6 @@ namespace BTCPayServer.Services
throw new ArgumentNullException(nameof(feeRate));
if (changeAddress == null)
throw new ArgumentNullException(nameof(changeAddress));
if (keypaths == null)
throw new ArgumentNullException(nameof(keypaths));
if (feeRate.FeePerK <= Money.Zero)
{
throw new ArgumentOutOfRangeException(nameof(feeRate), "The fee rate should be above zero");
@ -184,7 +185,7 @@ namespace BTCPayServer.Services
}
TransactionBuilder builder = new TransactionBuilder();
builder.AddCoins(coins);
builder.AddCoins(coins.Select(c=>c.Coin).ToArray());
foreach (var element in send)
{
@ -197,6 +198,12 @@ namespace BTCPayServer.Services
builder.Shuffle();
var unsigned = builder.BuildTransaction(false);
var keypaths = new Dictionary<Script, KeyPath>();
foreach(var c in coins)
{
keypaths.TryAdd(c.Coin.ScriptPubKey, c.KeyPath);
}
var hasChange = unsigned.Outputs.Count == 2;
var usedCoins = builder.FindSpentCoins(unsigned);
_Transport.Timeout = TimeSpan.FromMinutes(5);
@ -208,7 +215,7 @@ namespace BTCPayServer.Services
PubKey = strategy.Root.Derive(keypaths[c.TxOut.ScriptPubKey]).PubKey
}).ToArray(),
unsigned,
hasChange ? foundKeyPath.Derive(keypaths[changeAddress.ScriptPubKey]) : null);
hasChange ? foundKeyPath.Derive(changeKeyPath) : null);
return fullySigned;
}
}

View File

@ -10,6 +10,8 @@ using NBitcoin.DataEncoders;
using BTCPayServer.Data;
using NBXplorer.Models;
using NBXplorer;
using NBXplorer.DerivationStrategy;
using BTCPayServer.Payments;
namespace BTCPayServer.Services.Invoices
{
@ -120,7 +122,7 @@ namespace BTCPayServer.Services.Invoices
{
get; set;
}
[Obsolete("Use GetCryptoData(network).Rate instead")]
[Obsolete("Use GetPaymentMethod(network) instead")]
public decimal Rate
{
get; set;
@ -134,7 +136,7 @@ namespace BTCPayServer.Services.Invoices
get; set;
}
[Obsolete("Use GetCryptoData(network).DepositAddress instead")]
[Obsolete("Use GetPaymentMethod(network).GetPaymentMethodDetails().GetDestinationAddress() instead")]
public string DepositAddress
{
get; set;
@ -160,14 +162,24 @@ namespace BTCPayServer.Services.Invoices
set;
}
[Obsolete("Use GetDerivationStrategies instead")]
[Obsolete("Use GetPaymentMethodFactories() instead")]
public string DerivationStrategies
{
get;
set;
}
public IEnumerable<DerivationStrategy> GetDerivationStrategies(BTCPayNetworkProvider networks)
public IEnumerable<T> GetSupportedPaymentMethod<T>(PaymentMethodId paymentMethodId, BTCPayNetworkProvider networks) where T : ISupportedPaymentMethod
{
return
GetSupportedPaymentMethod(networks)
.Where(p => paymentMethodId == null || p.PaymentId == paymentMethodId)
.OfType<T>();
}
public IEnumerable<T> GetSupportedPaymentMethod<T>(BTCPayNetworkProvider networks) where T : ISupportedPaymentMethod
{
return GetSupportedPaymentMethod<T>(null, networks);
}
public IEnumerable<ISupportedPaymentMethod> GetSupportedPaymentMethod(BTCPayNetworkProvider networks)
{
#pragma warning disable CS0618
bool btcReturned = false;
@ -176,12 +188,13 @@ namespace BTCPayServer.Services.Invoices
JObject strategies = JObject.Parse(DerivationStrategies);
foreach (var strat in strategies.Properties())
{
var network = networks.GetNetwork(strat.Name);
var paymentMethodId = PaymentMethodId.Parse(strat.Name);
var network = networks.GetNetwork(paymentMethodId.CryptoCode);
if (network != null)
{
if (network == networks.BTC)
if (network == networks.BTC && paymentMethodId.PaymentType == PaymentTypes.BTCLike)
btcReturned = true;
yield return BTCPayServer.DerivationStrategy.Parse(strat.Value.Value<string>(), network);
yield return PaymentMethodExtensions.Deserialize(paymentMethodId, strat.Value, network);
}
}
}
@ -196,15 +209,15 @@ namespace BTCPayServer.Services.Invoices
#pragma warning restore CS0618
}
internal void SetDerivationStrategies(IEnumerable<DerivationStrategy> derivationStrategies)
internal void SetSupportedPaymentMethods(IEnumerable<ISupportedPaymentMethod> derivationStrategies)
{
JObject obj = new JObject();
foreach (var strat in derivationStrategies)
{
obj.Add(strat.Network.CryptoCode, new JValue(strat.DerivationStrategyBase.ToString()));
obj.Add(strat.PaymentId.ToString(), PaymentMethodExtensions.Serialize(strat));
#pragma warning disable CS0618
if (strat.Network.IsBTC)
DerivationStrategy = strat.DerivationStrategyBase.ToString();
if (strat.PaymentId.IsBTCOnChain)
DerivationStrategy = ((JValue)PaymentMethodExtensions.Serialize(strat)).Value<string>();
}
DerivationStrategies = JsonConvert.SerializeObject(obj);
#pragma warning restore CS0618
@ -256,7 +269,7 @@ namespace BTCPayServer.Services.Invoices
set;
}
[Obsolete("Use GetCryptoData(network).TxFee instead")]
[Obsolete("Use GetPaymentMethod(network).GetTxFee() instead")]
public Money TxFee
{
get;
@ -278,8 +291,9 @@ namespace BTCPayServer.Services.Invoices
set;
}
[Obsolete("Use Set/GetCryptoData() instead")]
public JObject CryptoData { get; set; }
[Obsolete("Use Set/GetPaymentMethod() instead")]
[JsonProperty(PropertyName = "cryptoData")]
public JObject PaymentMethod { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public DateTimeOffset MonitoringExpiration
@ -324,11 +338,12 @@ namespace BTCPayServer.Services.Invoices
};
dto.CryptoInfo = new List<NBitpayClient.InvoiceCryptoInfo>();
foreach (var info in this.GetCryptoData(networkProvider, true).Values)
foreach (var info in this.GetPaymentMethods(networkProvider, true))
{
var accounting = info.Calculate();
var cryptoInfo = new NBitpayClient.InvoiceCryptoInfo();
cryptoInfo.CryptoCode = info.CryptoCode;
cryptoInfo.CryptoCode = info.GetId().CryptoCode;
cryptoInfo.PaymentType = info.GetId().PaymentType.ToString();
cryptoInfo.Rate = info.Rate;
cryptoInfo.Price = Money.Coins(ProductInformation.Price / cryptoInfo.Rate).ToString();
@ -339,7 +354,7 @@ namespace BTCPayServer.Services.Invoices
cryptoInfo.TxCount = accounting.TxCount;
cryptoInfo.CryptoPaid = accounting.CryptoPaid.ToString();
cryptoInfo.Address = info.DepositAddress;
cryptoInfo.Address = info.GetPaymentMethodDetails()?.GetPaymentDestination();
cryptoInfo.ExRates = new Dictionary<string, double>
{
{ ProductInformation.Currency, (double)cryptoInfo.Rate }
@ -350,13 +365,23 @@ namespace BTCPayServer.Services.Invoices
cryptoInfo.Url = ServerUrl.WithTrailingSlash() + $"invoice{cryptoSuffix}?id=" + Id;
cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls()
if (info.GetId().PaymentType == PaymentTypes.BTCLike)
{
BIP72 = $"{scheme}:{cryptoInfo.Address}?amount={cryptoInfo.Due}&r={ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}")}",
BIP72b = $"{scheme}:?r={ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}")}",
BIP73 = ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}"),
BIP21 = $"{scheme}:{cryptoInfo.Address}?amount={cryptoInfo.Due}",
};
cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls()
{
BIP72 = $"{scheme}:{cryptoInfo.Address}?amount={cryptoInfo.Due}&r={ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}")}",
BIP72b = $"{scheme}:?r={ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}")}",
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")
{
@ -370,7 +395,7 @@ namespace BTCPayServer.Services.Invoices
dto.PaymentUrls = cryptoInfo.PaymentUrls;
}
#pragma warning restore CS0618
if(!info.IsPhantomBTC)
if (!info.IsPhantomBTC)
dto.CryptoInfo.Add(cryptoInfo);
}
@ -390,48 +415,50 @@ namespace BTCPayServer.Services.Invoices
JsonConvert.PopulateObject(str, dest);
}
internal bool Support(BTCPayNetwork network)
internal bool Support(PaymentMethodId paymentMethodId)
{
var rates = GetCryptoData(null);
return rates.TryGetValue(network.CryptoCode, out var data);
var rates = GetPaymentMethods(null);
return rates.TryGet(paymentMethodId) != null;
}
public CryptoData GetCryptoData(string cryptoCode, BTCPayNetworkProvider networkProvider, bool alwaysIncludeBTC = false)
public PaymentMethod GetPaymentMethod(PaymentMethodId paymentMethodId, BTCPayNetworkProvider networkProvider)
{
GetCryptoData(networkProvider, alwaysIncludeBTC).TryGetValue(cryptoCode, out var data);
GetPaymentMethods(networkProvider).TryGetValue(paymentMethodId, out var data);
return data;
}
public CryptoData GetCryptoData(BTCPayNetwork network, BTCPayNetworkProvider networkProvider)
public PaymentMethod GetPaymentMethod(BTCPayNetwork network, PaymentTypes paymentType, BTCPayNetworkProvider networkProvider)
{
GetCryptoData(networkProvider).TryGetValue(network.CryptoCode, out var data);
return data;
return GetPaymentMethod(new PaymentMethodId(network.CryptoCode, paymentType), networkProvider);
}
public Dictionary<string, CryptoData> GetCryptoData(BTCPayNetworkProvider networkProvider, bool alwaysIncludeBTC = false)
public PaymentMethodDictionary GetPaymentMethods(BTCPayNetworkProvider networkProvider, bool alwaysIncludeBTC = false)
{
Dictionary<string, CryptoData> rates = new Dictionary<string, CryptoData>();
PaymentMethodDictionary rates = new PaymentMethodDictionary(networkProvider);
var serializer = new Serializer(Dummy);
CryptoData phantom = null;
PaymentMethod phantom = null;
#pragma warning disable CS0618
// Legacy
if (alwaysIncludeBTC)
{
var btcNetwork = networkProvider?.GetNetwork("BTC");
phantom = new CryptoData() { ParentEntity = this, IsPhantomBTC = true, Rate = Rate, CryptoCode = "BTC", TxFee = TxFee, FeeRate = new FeeRate(TxFee, 100), DepositAddress = DepositAddress, Network = btcNetwork };
rates.Add("BTC", phantom);
phantom = new PaymentMethod() { ParentEntity = this, IsPhantomBTC = true, Rate = Rate, CryptoCode = "BTC", TxFee = TxFee, FeeRate = new FeeRate(TxFee, 100), DepositAddress = DepositAddress, Network = btcNetwork };
if (btcNetwork != null || networkProvider == null)
rates.Add(phantom);
}
if (CryptoData != null)
if (PaymentMethod != null)
{
foreach (var prop in CryptoData.Properties())
foreach (var prop in PaymentMethod.Properties())
{
if (prop.Name == "BTC" && phantom != null)
rates.Remove("BTC");
var r = serializer.ToObject<CryptoData>(prop.Value.ToString());
r.CryptoCode = prop.Name;
rates.Remove(phantom);
var r = serializer.ToObject<PaymentMethod>(prop.Value.ToString());
var paymentMethodId = PaymentMethodId.Parse(prop.Name);
r.CryptoCode = paymentMethodId.CryptoCode;
r.PaymentType = paymentMethodId.PaymentType.ToString();
r.ParentEntity = this;
r.Network = networkProvider?.GetNetwork(r.CryptoCode);
rates.Add(r.CryptoCode, r);
if (r.Network != null || networkProvider == null)
rates.Add(r);
}
}
#pragma warning restore CS0618
@ -440,30 +467,37 @@ namespace BTCPayServer.Services.Invoices
Network Dummy = Network.Main;
public void SetCryptoData(CryptoData cryptoData)
public void SetPaymentMethod(PaymentMethod paymentMethod)
{
var dict = GetCryptoData(null);
dict.AddOrReplace(cryptoData.CryptoCode, cryptoData);
SetCryptoData(dict);
var dict = GetPaymentMethods(null);
dict.AddOrReplace(paymentMethod);
SetPaymentMethods(dict);
}
public void SetCryptoData(Dictionary<string, CryptoData> cryptoData)
public void SetPaymentMethods(PaymentMethodDictionary paymentMethods)
{
if (paymentMethods.NetworkProvider != null)
throw new InvalidOperationException($"{nameof(paymentMethods)} should have NetworkProvider to null");
var obj = new JObject();
var serializer = new Serializer(Dummy);
foreach (var kv in cryptoData)
{
var clone = serializer.ToObject<CryptoData>(serializer.ToString(kv.Value));
clone.CryptoCode = null;
obj.Add(new JProperty(kv.Key, JObject.Parse(serializer.ToString(clone))));
}
#pragma warning disable CS0618
CryptoData = obj;
foreach (var v in paymentMethods)
{
var clone = serializer.ToObject<PaymentMethod>(serializer.ToString(v));
clone.CryptoCode = null;
clone.PaymentType = null;
obj.Add(new JProperty(v.GetId().ToString(), JObject.Parse(serializer.ToString(clone))));
}
PaymentMethod = obj;
foreach (var cryptoData in paymentMethods)
{
cryptoData.ParentEntity = this;
}
#pragma warning restore CS0618
}
}
public class CryptoDataAccounting
public class PaymentMethodAccounting
{
/// <summary>
/// Total amount of this invoice
@ -475,6 +509,10 @@ namespace BTCPayServer.Services.Invoices
/// </summary>
public Money Due { get; set; }
/// <summary>
/// Same as Due, can be negative
/// </summary>
public Money DueUncapped { get; set; }
/// <summary>
/// Total amount of the invoice paid after conversion to this crypto currency
/// </summary>
@ -488,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
@ -495,86 +538,180 @@ namespace BTCPayServer.Services.Invoices
public Money NetworkFee { get; set; }
}
public class CryptoData
public class PaymentMethod
{
[JsonIgnore]
public InvoiceEntity ParentEntity { get; set; }
[JsonIgnore]
public BTCPayNetwork Network { get; set; }
[JsonProperty(PropertyName = "cryptoCode", DefaultValueHandling = DefaultValueHandling.Ignore)]
[Obsolete("Use GetId().CryptoCode instead")]
public string CryptoCode { get; set; }
[JsonProperty(PropertyName = "paymentType", DefaultValueHandling = DefaultValueHandling.Ignore)]
[Obsolete("Use GetId().PaymentType instead")]
public string PaymentType { get; set; }
public PaymentMethodId GetId()
{
#pragma warning disable CS0618 // Type or member is obsolete
return new PaymentMethodId(CryptoCode, string.IsNullOrEmpty(PaymentType) ? PaymentTypes.BTCLike : Enum.Parse<PaymentTypes>(PaymentType));
#pragma warning restore CS0618 // Type or member is obsolete
}
public void SetId(PaymentMethodId id)
{
#pragma warning disable CS0618 // Type or member is obsolete
CryptoCode = id.CryptoCode;
PaymentType = id.PaymentType.ToString();
#pragma warning restore CS0618 // Type or member is obsolete
}
[JsonProperty(PropertyName = "rate")]
public decimal Rate { get; set; }
[Obsolete("Use GetPaymentMethodDetails() instead")]
[JsonProperty(PropertyName = "paymentMethod")]
public JObject PaymentMethodDetails { get; set; }
public IPaymentMethodDetails GetPaymentMethodDetails()
{
#pragma warning disable CS0618 // Type or member is obsolete
// Legacy, old code does not have PaymentMethods
if (string.IsNullOrEmpty(PaymentType) || PaymentMethodDetails == null)
{
return new Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod()
{
FeeRate = FeeRate,
DepositAddress = string.IsNullOrEmpty(DepositAddress) ? null : BitcoinAddress.Create(DepositAddress, Network?.NBitcoinNetwork),
TxFee = TxFee
};
}
else
{
var details = PaymentMethodExtensions.DeserializePaymentMethodDetails(GetId(), PaymentMethodDetails);
if (details is Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod btcLike)
{
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
}
public PaymentMethod SetPaymentMethodDetails(IPaymentMethodDetails paymentMethod)
{
#pragma warning disable CS0618 // Type or member is obsolete
// Legacy, need to fill the old fields
if (PaymentType == null)
PaymentType = paymentMethod.GetPaymentType().ToString();
else if (PaymentType != paymentMethod.GetPaymentType().ToString())
throw new InvalidOperationException("Invalid payment method affected");
if (paymentMethod is Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod bitcoinPaymentMethod)
{
TxFee = bitcoinPaymentMethod.TxFee;
FeeRate = bitcoinPaymentMethod.FeeRate;
DepositAddress = bitcoinPaymentMethod.DepositAddress.ToString();
}
var jobj = JObject.Parse(JsonConvert.SerializeObject(paymentMethod));
PaymentMethodDetails = jobj;
#pragma warning restore CS0618 // Type or member is obsolete
return this;
}
[JsonProperty(PropertyName = "feeRate")]
[Obsolete("Use ((BitcoinLikeOnChainPaymentMethod)GetPaymentMethod()).FeeRate")]
public FeeRate FeeRate { get; set; }
[JsonProperty(PropertyName = "txFee")]
[Obsolete("Use ((BitcoinLikeOnChainPaymentMethod)GetPaymentMethod()).TxFee")]
public Money TxFee { get; set; }
[JsonProperty(PropertyName = "depositAddress")]
[Obsolete("Use ((BitcoinLikeOnChainPaymentMethod)GetPaymentMethod()).DepositAddress")]
public string DepositAddress { get; set; }
[JsonIgnore]
public bool IsPhantomBTC { get; set; }
public CryptoDataAccounting Calculate()
public PaymentMethodAccounting Calculate(Func<PaymentEntity, bool> paymentPredicate = null)
{
var cryptoData = ParentEntity.GetCryptoData(null, IsPhantomBTC);
var totalDue = Money.Coins(ParentEntity.ProductInformation.Price / Rate);
var paid = Money.Zero;
var cryptoPaid = Money.Zero;
paymentPredicate = paymentPredicate ?? new Func<PaymentEntity, bool>((p) => true);
var paymentMethods = ParentEntity.GetPaymentMethods(null, IsPhantomBTC);
var paidTxFee = Money.Zero;
bool paidEnough = totalDue <= paid;
int txCount = 0;
var totalDue = ParentEntity.ProductInformation.Price / Rate;
var paid = 0m;
var cryptoPaid = 0.0m;
var paidTxFee = 0m;
bool paidEnough = paid >= RoundUp(totalDue, 8);
int txRequired = 0;
var payments =
ParentEntity.GetPayments()
.Where(p => p.Accounted)
.Where(p => p.Accounted && paymentPredicate(p))
.OrderBy(p => p.ReceivedTime)
.Select(_ =>
{
var txFee = _.GetValue(cryptoData, CryptoCode, cryptoData[_.GetCryptoCode()].TxFee);
paid += _.GetValue(cryptoData, CryptoCode);
var txFee = _.GetValue(paymentMethods, GetId(), paymentMethods[_.GetpaymentMethodId()].GetTxFee());
paid += _.GetValue(paymentMethods, GetId());
if (!paidEnough)
{
totalDue += txFee;
paidTxFee += txFee;
}
paidEnough |= totalDue <= paid;
if (CryptoCode == _.GetCryptoCode())
paidEnough |= paid >= RoundUp(totalDue, 8);
if (GetId() == _.GetpaymentMethodId())
{
cryptoPaid += _.GetValue();
txCount++;
cryptoPaid += _.GetCryptoPaymentData().GetValue();
txRequired++;
}
return _;
})
.ToArray();
var accounting = new PaymentMethodAccounting();
accounting.TxCount = txRequired;
if (!paidEnough)
{
txCount++;
totalDue += TxFee;
paidTxFee += TxFee;
txRequired++;
totalDue += GetTxFee();
paidTxFee += GetTxFee();
}
var accounting = new CryptoDataAccounting();
accounting.TotalDue = totalDue;
accounting.Paid = paid;
accounting.TxCount = txCount;
accounting.CryptoPaid = cryptoPaid;
accounting.TotalDue = Money.Coins(RoundUp(totalDue, 8));
accounting.Paid = Money.Coins(paid);
accounting.TxRequired = txRequired;
accounting.CryptoPaid = Money.Coins(cryptoPaid);
accounting.Due = Money.Max(accounting.TotalDue - accounting.Paid, Money.Zero);
accounting.NetworkFee = paidTxFee;
accounting.DueUncapped = accounting.TotalDue - accounting.Paid;
accounting.NetworkFee = Money.Coins(paidTxFee);
return accounting;
}
}
public class AccountedPaymentEntity
{
public int Confirmations
private static decimal RoundUp(decimal value, int precision)
{
get;
set;
for (int i = 0; i < precision; i++)
{
value = value * 10m;
}
value = Math.Ceiling(value);
for (int i = 0; i < precision; i++)
{
value = value / 10m;
}
return value;
}
private decimal GetTxFee()
{
var method = GetPaymentMethodDetails();
if (method == null)
return 0.0m;
return method.GetTxFee();
}
public PaymentEntity Payment { get; set; }
public Transaction Transaction { get; set; }
}
public class PaymentEntity
@ -583,56 +720,104 @@ namespace BTCPayServer.Services.Invoices
{
get; set;
}
[Obsolete("Use ((BitcoinLikePaymentData)GetCryptoPaymentData()).Outpoint")]
public OutPoint Outpoint
{
get; set;
}
[Obsolete("Use GetValue() or GetScriptPubKey() instead")]
[Obsolete("Use ((BitcoinLikePaymentData)GetCryptoPaymentData()).Output")]
public TxOut Output
{
get; set;
}
public Script GetScriptPubKey()
{
#pragma warning disable CS0618
return Output.ScriptPubKey;
#pragma warning restore CS0618
}
public bool Accounted
{
get; set;
}
[Obsolete("Use GetCryptoCode() instead")]
[Obsolete("Use GetpaymentMethodId().CryptoCode instead")]
public string CryptoCode
{
get;
set;
}
public Money GetValue()
[Obsolete("Use GetCryptoPaymentData() instead")]
public string CryptoPaymentData { get; set; }
[Obsolete("Use GetpaymentMethodId().PaymentType instead")]
public string CryptoPaymentDataType { get; set; }
public CryptoPaymentData GetCryptoPaymentData()
{
#pragma warning disable CS0618
return Output.Value;
if (string.IsNullOrEmpty(CryptoPaymentDataType))
{
// In case this is a payment done before this update, consider it unconfirmed with RBF for safety
var paymentData = new Payments.Bitcoin.BitcoinLikePaymentData();
paymentData.Outpoint = Outpoint;
paymentData.Output = Output;
paymentData.RBF = true;
paymentData.ConfirmationCount = 0;
paymentData.Legacy = true;
return paymentData;
}
if (GetpaymentMethodId().PaymentType == PaymentTypes.BTCLike)
{
var paymentData = JsonConvert.DeserializeObject<Payments.Bitcoin.BitcoinLikePaymentData>(CryptoPaymentData);
// legacy
paymentData.Output = Output;
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
}
public Money GetValue(Dictionary<string, CryptoData> cryptoData, string cryptoCode, Money value = null)
public PaymentEntity SetCryptoPaymentData(CryptoPaymentData cryptoPaymentData)
{
#pragma warning disable CS0618
value = value ?? Output.Value;
if (cryptoPaymentData is Payments.Bitcoin.BitcoinLikePaymentData paymentData)
{
// Legacy
Outpoint = paymentData.Outpoint;
Output = paymentData.Output;
///
}
CryptoPaymentDataType = cryptoPaymentData.GetPaymentType().ToString();
CryptoPaymentData = JsonConvert.SerializeObject(cryptoPaymentData);
#pragma warning restore CS0618
var to = cryptoCode;
var from = GetCryptoCode();
return this;
}
internal decimal GetValue(PaymentMethodDictionary paymentMethods, PaymentMethodId paymentMethodId, decimal? value = null)
{
value = value ?? this.GetCryptoPaymentData().GetValue();
var to = paymentMethodId;
var from = this.GetpaymentMethodId();
if (to == from)
return value;
var fromRate = cryptoData[from].Rate;
var toRate = cryptoData[to].Rate;
return decimal.Round(value.Value, 8);
var fromRate = paymentMethods[from].Rate;
var toRate = paymentMethods[to].Rate;
var fiatValue = fromRate * value.ToDecimal(MoneyUnit.BTC);
var fiatValue = fromRate * decimal.Round(value.Value, 8);
var otherCurrencyValue = toRate == 0 ? 0.0m : fiatValue / toRate;
return Money.Coins(otherCurrencyValue);
return otherCurrencyValue;
}
public PaymentMethodId GetpaymentMethodId()
{
#pragma warning disable CS0618 // Type or member is obsolete
return new PaymentMethodId(CryptoCode ?? "BTC", string.IsNullOrEmpty(CryptoPaymentDataType) ? PaymentTypes.BTCLike : Enum.Parse<PaymentTypes>(CryptoPaymentDataType));
#pragma warning restore CS0618 // Type or member is obsolete
}
public string GetCryptoCode()
@ -641,6 +826,29 @@ namespace BTCPayServer.Services.Invoices
return CryptoCode ?? "BTC";
#pragma warning restore CS0618
}
}
public interface CryptoPaymentData
{
/// <summary>
/// Returns an identifier which uniquely identify the payment
/// </summary>
/// <returns>The payment id</returns>
string GetPaymentId();
/// <summary>
/// Returns terms which will be indexed and searchable in the search bar of invoice
/// </summary>
/// <returns>The search terms</returns>
string[] GetSearchTerms();
/// <summary>
/// Get value of what as been paid
/// </summary>
/// <returns>The amount paid</returns>
decimal GetValue();
bool PaymentCompleted(PaymentEntity entity, BTCPayNetwork network);
bool PaymentConfirmed(PaymentEntity entity, SpeedPolicy speedPolicy, BTCPayNetwork network);
PaymentTypes GetPaymentType();
}
}

View File

@ -18,6 +18,7 @@ using BTCPayServer.Data;
using System.Globalization;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Logging;
using BTCPayServer.Payments;
namespace BTCPayServer.Services.Invoices
{
@ -58,12 +59,22 @@ namespace BTCPayServer.Services.Invoices
}
}
public async Task<string> GetInvoiceIdFromScriptPubKey(Script scriptPubKey, string cryptoCode)
public async Task<InvoiceEntity> GetInvoiceFromScriptPubKey(Script scriptPubKey, string cryptoCode)
{
using (var db = _ContextFactory.CreateContext())
{
var result = await db.AddressInvoices.FindAsync(scriptPubKey.Hash.ToString() + "#" + cryptoCode);
return result?.InvoiceDataId;
var key = scriptPubKey.Hash.ToString() + "#" + cryptoCode;
var result = await db.AddressInvoices
#pragma warning disable CS0618
.Where(a => a.Address == key)
#pragma warning restore CS0618
.Select(a => a.InvoiceData)
.Include(a => a.Payments)
.Include(a => a.RefundAddresses)
.FirstOrDefaultAsync();
if (result == null)
return null;
return ToEntity(result);
}
}
@ -113,22 +124,26 @@ namespace BTCPayServer.Services.Invoices
CustomerEmail = invoice.RefundMail
});
foreach (var cryptoData in invoice.GetCryptoData(networkProvider).Values)
foreach (var paymentMethod in invoice.GetPaymentMethods(networkProvider))
{
if (cryptoData.Network == null)
if (paymentMethod.Network == null)
throw new InvalidOperationException("CryptoCode unsupported");
var paymentDestination = paymentMethod.GetPaymentMethodDetails().GetPaymentDestination();
string address = GetDestination(paymentMethod);
context.AddressInvoices.Add(new AddressInvoiceData()
{
InvoiceDataId = invoice.Id,
CreatedTime = DateTimeOffset.UtcNow,
}.SetHash(BitcoinAddress.Create(cryptoData.DepositAddress, cryptoData.Network.NBitcoinNetwork).ScriptPubKey.Hash, cryptoData.CryptoCode));
}.Set(address, paymentMethod.GetId()));
context.HistoricalAddressInvoices.Add(new HistoricalAddressInvoiceData()
{
InvoiceDataId = invoice.Id,
Assigned = DateTimeOffset.UtcNow
}.SetAddress(cryptoData.DepositAddress, cryptoData.CryptoCode));
textSearch.Add(cryptoData.DepositAddress);
textSearch.Add(cryptoData.Calculate().TotalDue.ToString());
}.SetAddress(paymentDestination, paymentMethod.GetId().ToString()));
textSearch.Add(paymentDestination);
textSearch.Add(paymentMethod.Calculate().TotalDue.ToString());
}
context.PendingInvoices.Add(new PendingInvoiceData() { Id = invoice.Id });
await context.SaveChangesAsync().ConfigureAwait(false);
@ -147,7 +162,18 @@ namespace BTCPayServer.Services.Invoices
return invoice;
}
public async Task<bool> NewAddress(string invoiceId, BitcoinAddress bitcoinAddress, BTCPayNetwork network)
private static string GetDestination(PaymentMethod paymentMethod)
{
// For legacy reason, BitcoinLikeOnChain is putting the hashes of addresses in database
if (paymentMethod.GetId().PaymentType == Payments.PaymentTypes.BTCLike)
{
return ((Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod)paymentMethod.GetPaymentMethodDetails()).DepositAddress.ScriptPubKey.Hash.ToString();
}
///////////////
return paymentMethod.GetPaymentMethodDetails().GetPaymentDestination();
}
public async Task<bool> NewAddress(string invoiceId, IPaymentMethodDetails paymentMethod, BTCPayNetwork network)
{
using (var context = _ContextFactory.CreateContext())
{
@ -156,24 +182,26 @@ namespace BTCPayServer.Services.Invoices
return false;
var invoiceEntity = ToObject<InvoiceEntity>(invoice.Blob, network.NBitcoinNetwork);
var currencyData = invoiceEntity.GetCryptoData(network, null);
var currencyData = invoiceEntity.GetPaymentMethod(network, paymentMethod.GetPaymentType(), null);
if (currencyData == null)
return false;
if (currencyData.DepositAddress != null)
var existingPaymentMethod = currencyData.GetPaymentMethodDetails();
if (existingPaymentMethod.GetPaymentDestination() != null)
{
MarkUnassigned(invoiceId, invoiceEntity, context, network.CryptoCode);
MarkUnassigned(invoiceId, invoiceEntity, context, currencyData.GetId());
}
currencyData.DepositAddress = bitcoinAddress.ToString();
existingPaymentMethod.SetPaymentDestination(paymentMethod.GetPaymentDestination());
currencyData.SetPaymentMethodDetails(existingPaymentMethod);
#pragma warning disable CS0618
if (network.IsBTC)
{
invoiceEntity.DepositAddress = currencyData.DepositAddress;
}
#pragma warning restore CS0618
invoiceEntity.SetCryptoData(currencyData);
invoiceEntity.SetPaymentMethod(currencyData);
invoice.Blob = ToBytes(invoiceEntity, network.NBitcoinNetwork);
context.AddressInvoices.Add(new AddressInvoiceData()
@ -181,15 +209,15 @@ namespace BTCPayServer.Services.Invoices
InvoiceDataId = invoiceId,
CreatedTime = DateTimeOffset.UtcNow
}
.SetHash(bitcoinAddress.ScriptPubKey.Hash, network.CryptoCode));
.Set(GetDestination(currencyData), currencyData.GetId()));
context.HistoricalAddressInvoices.Add(new HistoricalAddressInvoiceData()
{
InvoiceDataId = invoiceId,
Assigned = DateTimeOffset.UtcNow
}.SetAddress(bitcoinAddress.ToString(), network.CryptoCode));
}.SetAddress(paymentMethod.GetPaymentDestination(), network.CryptoCode));
await context.SaveChangesAsync();
AddToTextSearch(invoice.Id, bitcoinAddress.ToString());
AddToTextSearch(invoice.Id, paymentMethod.GetPaymentDestination());
return true;
}
}
@ -209,15 +237,15 @@ namespace BTCPayServer.Services.Invoices
}
}
private static void MarkUnassigned(string invoiceId, InvoiceEntity entity, ApplicationDbContext context, string cryptoCode)
private static void MarkUnassigned(string invoiceId, InvoiceEntity entity, ApplicationDbContext context, PaymentMethodId paymentMethodId)
{
foreach (var address in entity.GetCryptoData(null))
foreach (var address in entity.GetPaymentMethods(null))
{
if (cryptoCode != null && cryptoCode != address.Value.CryptoCode)
if (paymentMethodId != null && paymentMethodId != address.GetId())
continue;
var historical = new HistoricalAddressInvoiceData();
historical.InvoiceDataId = invoiceId;
historical.SetAddress(address.Value.DepositAddress, address.Value.CryptoCode);
historical.SetAddress(address.GetPaymentMethodDetails().GetPaymentDestination(), address.GetId().ToString());
historical.UnAssigned = DateTimeOffset.UtcNow;
context.Attach(historical);
context.Entry(historical).Property(o => o.UnAssigned).IsModified = true;
@ -333,9 +361,9 @@ namespace BTCPayServer.Services.Invoices
}
if (invoice.AddressInvoices != null)
{
entity.AvailableAddressHashes = invoice.AddressInvoices.Select(a => a.GetHash() + a.GetCryptoCode()).ToHashSet();
entity.AvailableAddressHashes = invoice.AddressInvoices.Select(a => a.GetAddress() + a.GetpaymentMethodId().ToString()).ToHashSet();
}
if(invoice.Events != null)
if (invoice.Events != null)
{
entity.Events = invoice.Events.OrderBy(c => c.Timestamp).ToList();
}
@ -374,7 +402,7 @@ namespace BTCPayServer.Services.Invoices
{
var ids = new HashSet<string>(SearchInvoice(queryObject.TextSearch));
if (ids.Count == 0)
return new InvoiceEntity[0];
return Array.Empty<InvoiceEntity>();
query = query.Where(i => ids.Contains(i.Id));
}
@ -433,31 +461,33 @@ namespace BTCPayServer.Services.Invoices
AddToTextSearch(invoiceId, addresses.Select(a => a.ToString()).ToArray());
}
public async Task<PaymentEntity> AddPayment(string invoiceId, DateTimeOffset date, Coin receivedCoin, string cryptoCode)
public async Task<PaymentEntity> AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, string cryptoCode, bool accounted = false)
{
using (var context = _ContextFactory.CreateContext())
{
PaymentEntity entity = new PaymentEntity
{
Outpoint = receivedCoin.Outpoint,
#pragma warning disable CS0618
Output = receivedCoin.TxOut,
CryptoCode = cryptoCode,
#pragma warning restore CS0618
ReceivedTime = date.UtcDateTime
ReceivedTime = date.UtcDateTime,
Accounted = accounted
};
entity.SetCryptoPaymentData(paymentData);
PaymentData data = new PaymentData
{
Id = receivedCoin.Outpoint.ToString(),
Id = paymentData.GetPaymentId(),
Blob = ToBytes(entity, null),
InvoiceDataId = invoiceId
InvoiceDataId = invoiceId,
Accounted = accounted
};
context.Payments.Add(data);
await context.SaveChangesAsync().ConfigureAwait(false);
AddToTextSearch(invoiceId, receivedCoin.Outpoint.Hash.ToString());
AddToTextSearch(invoiceId, paymentData.GetSearchTerms());
return entity;
}
}
@ -470,11 +500,14 @@ namespace BTCPayServer.Services.Invoices
{
foreach (var payment in payments)
{
var paymentData = payment.GetCryptoPaymentData();
var data = new PaymentData();
data.Id = payment.Outpoint.ToString();
data.Id = paymentData.GetPaymentId();
data.Accounted = payment.Accounted;
data.Blob = ToBytes(payment, null);
context.Attach(data);
context.Entry(data).Property(o => o.Accounted).IsModified = true;
context.Entry(data).Property(o => o.Blob).IsModified = true;
}
await context.SaveChangesAsync().ConfigureAwait(false);
}

View File

@ -0,0 +1,81 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Payments;
namespace BTCPayServer.Services.Invoices
{
public class PaymentMethodDictionary : IEnumerable<PaymentMethod>
{
Dictionary<PaymentMethodId, PaymentMethod> _Inner = new Dictionary<PaymentMethodId, PaymentMethod>();
public PaymentMethodDictionary()
{
}
public PaymentMethodDictionary(BTCPayNetworkProvider networkProvider)
{
NetworkProvider = networkProvider;
}
public BTCPayNetworkProvider NetworkProvider { get; set; }
public PaymentMethod this[PaymentMethodId index]
{
get
{
return _Inner[index];
}
}
public void Add(PaymentMethod paymentMethod)
{
_Inner.Add(paymentMethod.GetId(), paymentMethod);
}
public void Remove(PaymentMethod paymentMethod)
{
_Inner.Remove(paymentMethod.GetId());
}
public bool TryGetValue(PaymentMethodId paymentMethodId, out PaymentMethod data)
{
if (paymentMethodId == null)
throw new ArgumentNullException(nameof(paymentMethodId));
return _Inner.TryGetValue(paymentMethodId, out data);
}
public void AddOrReplace(PaymentMethod paymentMethod)
{
var key = paymentMethod.GetId();
_Inner.Remove(key);
_Inner.Add(key, paymentMethod);
}
public IEnumerator<PaymentMethod> GetEnumerator()
{
return _Inner.Values.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
public PaymentMethod TryGet(PaymentMethodId paymentMethodId)
{
if (paymentMethodId == null)
throw new ArgumentNullException(nameof(paymentMethodId));
_Inner.TryGetValue(paymentMethodId, out var value);
return value;
}
public PaymentMethod TryGet(string network, PaymentTypes paymentType)
{
if (network == null)
throw new ArgumentNullException(nameof(network));
var id = new PaymentMethodId(network, paymentType);
return TryGet(id);
}
}
}

View File

@ -16,9 +16,11 @@ using System.Collections.Concurrent;
namespace BTCPayServer.Services.Wallets
{
public class KnownState
public class ReceivedCoin
{
public UTXOChanges PreviousCall { get; set; }
public Coin Coin { get; set; }
public DateTimeOffset Timestamp { get; set; }
public KeyPath KeyPath { get; set; }
}
public class NetworkCoins
{
@ -56,7 +58,7 @@ namespace BTCPayServer.Services.Wallets
}
}
public TimeSpan CacheSpan { get; private set; } = TimeSpan.FromMinutes(30);
public TimeSpan CacheSpan { get; private set; } = TimeSpan.FromMinutes(5);
public async Task<BitcoinAddress> ReserveAddressAsync(DerivationStrategyBase derivationStrategy)
{
@ -106,18 +108,6 @@ namespace BTCPayServer.Services.Wallets
}
ConcurrentDictionary<string, TaskCompletionSource<UTXOChanges>> _FetchingUTXOs = new ConcurrentDictionary<string, TaskCompletionSource<UTXOChanges>>();
public async Task<NetworkCoins> GetCoins(DerivationStrategyBase strategy, CancellationToken cancellation = default(CancellationToken))
{
UTXOChanges changes = await GetUTXOChanges(strategy, cancellation);
return new NetworkCoins()
{
TimestampedCoins = changes.Confirmed.UTXOs.Concat(changes.Unconfirmed.UTXOs).Select(c => new NetworkCoins.TimestampedCoin() { Coin = c.AsCoin(), DateTime = c.Timestamp }).ToArray(),
Strategy = strategy,
Wallet = this
};
}
private async Task<UTXOChanges> GetUTXOChanges(DerivationStrategyBase strategy, CancellationToken cancellation)
{
var thisCompletionSource = new TaskCompletionSource<UTXOChanges>();
@ -149,7 +139,7 @@ namespace BTCPayServer.Services.Wallets
});
completionSource.TrySetResult(utxos);
}
catch(Exception ex)
catch (Exception ex)
{
completionSource.TrySetException(ex);
}
@ -168,15 +158,18 @@ namespace BTCPayServer.Services.Wallets
public async Task<(Coin[], Dictionary<Script, KeyPath>)> GetUnspentCoins(DerivationStrategyBase derivationStrategy, CancellationToken cancellation = default(CancellationToken))
public async Task<ReceivedCoin[]> GetUnspentCoins(DerivationStrategyBase derivationStrategy, CancellationToken cancellation = default(CancellationToken))
{
var changes = await GetUTXOChanges(derivationStrategy, cancellation);
var keyPaths = new Dictionary<Script, KeyPath>();
foreach (var coin in changes.GetUnspentUTXOs())
{
keyPaths.TryAdd(coin.ScriptPubKey, coin.KeyPath);
}
return (changes.GetUnspentCoins(), keyPaths);
if (derivationStrategy == null)
throw new ArgumentNullException(nameof(derivationStrategy));
return (await GetUTXOChanges(derivationStrategy, cancellation))
.GetUnspentUTXOs()
.Select(c => new ReceivedCoin()
{
Coin = c.AsCoin(derivationStrategy),
KeyPath = c.KeyPath,
Timestamp = c.Timestamp
}).ToArray();
}
public async Task<Money> GetBalance(DerivationStrategyBase derivationStrategy, CancellationToken cancellation = default(CancellationToken))

View File

@ -24,7 +24,10 @@ namespace BTCPayServer.Services.Wallets
foreach(var network in networkProvider.GetAll())
{
_Wallets.Add(network.CryptoCode, new BTCPayWallet(_Client.GetExplorerClient(network.CryptoCode), new MemoryCache(_Options), network));
var explorerClient = _Client.GetExplorerClient(network.CryptoCode);
if (explorerClient == null)
continue;
_Wallets.Add(network.CryptoCode, new BTCPayWallet(explorerClient, new MemoryCache(_Options), network));
}
}
@ -48,5 +51,11 @@ namespace BTCPayServer.Services.Wallets
{
return _Client.IsAvailable(network);
}
public IEnumerable<BTCPayWallet> GetWallets()
{
foreach (var w in _Wallets)
yield return w.Value;
}
}
}

View File

@ -7,7 +7,7 @@
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<!-- base href="/" -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>BTCPay Invoice</title>
@ -15,20 +15,15 @@
<link href="~/vendor/font-awesome/css/font-awesome.css" rel="stylesheet" />
<link href="~/css/css.css" rel="stylesheet" type="text/css">
<link href="~/css/normalizer.css" rel="stylesheet" type="text/css">
<script src="https://code.jquery.com/jquery-3.2.1.min.js"
crossorigin="anonymous"></script>
<script type="text/javascript">
@Model.ToJSVariableModel("srvModel")
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/1.7.1/clipboard.min.js"></script>
<script src="~/js/vue.min.js" type="text/javascript" defer="defer"></script>
<script src="~/js/vue-qrcode.js" type="text/javascript" defer="defer"></script>
<script src="~/vendor/clipboard.js/clipboard.js"></script>
<script src="~/vendor/jquery/jquery.js"></script>
<script src="~/js/vue.min.js" type="text/javascript"></script>
<script src="~/js/vue-qrcode.js" type="text/javascript"></script>
<script src="~/js/core.js" type="text/javascript" defer="defer"></script>
<!-- <script src="img/Intl.js" type="text/javascript" defer="defer"></script>
<script src="img/en-US.js" type="text/javascript" defer="defer"></script>
<script src="img/polyfills.js" type="text/javascript" defer="defer"></script>
<script src="img/vendor.js" type="text/javascript" defer="defer"></script>
<script src="img/main-en-US.js" defer="defer"></script> -->
</head>
<body style="background: #E4E4E4">
<noscript>
@ -159,7 +154,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">
@ -361,9 +356,9 @@
<div class="manual-box__address__value copy-cursor" ngxclipboard="">
<div class="manual-box__address__wrapper">
<div class="manual-box__address__wrapper__logo">
<img :src="srvModel.cryptoImage" />
<img :src="srvModel.cryptoImage" height="16" />
</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>
@ -613,7 +608,7 @@
<div style="text-align:center">
@foreach(var crypto in Model.AvailableCryptos)
{
<a style="text-decoration:none;" href="@crypto.Link" onclick="srvModel.cryptoCode='@crypto.CryptoCode'; fetchStatus(); return false;"><img style="height:32px; margin-right:5px; margin-left:5px;" alt="@crypto.CryptoCode" src="@crypto.CryptoImage" /></a>
<a style="text-decoration:none;" href="@crypto.Link" onclick="srvModel.paymentMethodId='@crypto.PaymentMethodId'; fetchStatus(); return false;"><img style="height:32px; margin-right:5px; margin-left:5px;" alt="@crypto.PaymentMethodId" src="@crypto.CryptoImage" /></a>
}
</div>
}

View File

@ -44,6 +44,7 @@
<thead class="thead-inverse">
<tr>
<th>Date</th>
<th>OrderId</th>
<th>InvoiceId</th>
<th>Status</th>
<th>Amount</th>
@ -55,6 +56,7 @@
{
<tr>
<td>@invoice.Date</td>
<td>@invoice.OrderId</td>
<td>@invoice.InvoiceId</td>
@if(invoice.Status == "paid")
{

View File

@ -77,7 +77,7 @@
</div>
<link href="~/vendor/animatecss/animate.css" rel="stylesheet" />
@*<link href="~/vendor/animatecss/animate.css" rel="stylesheet" />*@
<script type="text/javascript">
function dismissSyncModal() {
$("#modalDialog").addClass('animated bounceOutRight')

View File

@ -3,12 +3,12 @@
@inject RoleManager<IdentityRole> RoleManager
@inject BTCPayServer.Services.BTCPayServerEnvironment env
@inject BTCPayServer.HostedServices.NBXplorerDashboard dashboard
@addTagHelper *, Meziantou.AspNetCore.BundleTagHelpers
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
@ -16,23 +16,11 @@
<title>BTCPay Server</title>
<!-- Bootstrap core CSS -->
<link href="~/vendor/bootstrap/css/bootstrap.css" rel="stylesheet">
<!-- Custom fonts for this template -->
@*<link href="~/vendor/font-awesome/css/font-awesome.css" rel="stylesheet" type="text/css">
<link href='https://fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,600italic,700italic,800italic,400,300,600,700,800' rel='stylesheet' type='text/css'>
<link href='https://fonts.googleapis.com/css?family=Merriweather:400,300,300italic,400italic,700,700italic,900,900italic' rel='stylesheet' type='text/css'>*@
<!-- Plugin CSS -->
<link href="~/vendor/magnific-popup/magnific-popup.css" rel="stylesheet">
<!-- Custom styles for this template -->
<link href="~/css/creative.css" rel="stylesheet" />
<!-- Custom styles for this template -->
<link href="~/css/site.css" rel="stylesheet" />
@* CSS *@
<bundle name="wwwroot/bundles/main-bundle.min.css" />
@* JS *@
<bundle name="wwwroot/bundles/main-bundle.min.js" />
</head>
<body id="page-top">
@ -50,9 +38,9 @@
<div class="container">
<a class="navbar-brand js-scroll-trigger" href="~/">
<img src="~/img/logo.png" height="45">
@if(env.ChainType != NBXplorer.ChainType.Main)
@if (env.ChainType != NBXplorer.ChainType.Main)
{
<span class="badge badge-warning" style="font-size:10px;">@env.ChainType.ToString()</span>
<span class="badge badge-warning" style="font-size:10px;">@env.ChainType.ToString()</span>
}
</a>
<button class="navbar-toggler navbar-toggler-right" type="button" data-toggle="collapse" data-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation">
@ -60,24 +48,24 @@
</button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<ul class="navbar-nav ml-auto">
@if(SignInManager.IsSignedIn(User))
{
@if(User.IsInRole(Roles.ServerAdmin))
{
<li class="nav-item"><a asp-area="" asp-controller="Server" asp-action="ListUsers" class="nav-link js-scroll-trigger">Server settings</a></li>
}
<li class="nav-item"><a asp-area="" asp-controller="Stores" asp-action="ListStores" class="nav-link js-scroll-trigger">Stores</a></li>
<li class="nav-item"><a asp-area="" asp-controller="Invoice" asp-action="ListInvoices" class="nav-link js-scroll-trigger">Invoices</a></li>
<li class="nav-item">
<a asp-area="" asp-controller="Manage" asp-action="Index" title="Manage" class="nav-link js-scroll-trigger">My settings</a>
</li>
<li class="nav-item">
<a asp-area="" asp-controller="Account" asp- asp-action="Logout" title="Manage" class="nav-link js-scroll-trigger">Log out</a>
</li>}
else
{
<li class="nav-item"><a asp-area="" asp-controller="Account" asp-action="Register" class="nav-link js-scroll-trigger">Register</a></li>
<li class="nav-item"><a asp-area="" asp-controller="Account" asp-action="Login" class="nav-link js-scroll-trigger">Log in</a></li>}
@if (SignInManager.IsSignedIn(User))
{
@if (User.IsInRole(Roles.ServerAdmin))
{
<li class="nav-item"><a asp-area="" asp-controller="Server" asp-action="ListUsers" class="nav-link js-scroll-trigger">Server settings</a></li>
}
<li class="nav-item"><a asp-area="" asp-controller="Stores" asp-action="ListStores" class="nav-link js-scroll-trigger">Stores</a></li>
<li class="nav-item"><a asp-area="" asp-controller="Invoice" asp-action="ListInvoices" class="nav-link js-scroll-trigger">Invoices</a></li>
<li class="nav-item">
<a asp-area="" asp-controller="Manage" asp-action="Index" title="Manage" class="nav-link js-scroll-trigger">My settings</a>
</li>
<li class="nav-item">
<a asp-area="" asp-controller="Account" asp- asp-action="Logout" title="Manage" class="nav-link js-scroll-trigger">Log out</a>
</li>}
else
{
<li class="nav-item"><a asp-area="" asp-controller="Account" asp-action="Register" class="nav-link js-scroll-trigger">Register</a></li>
<li class="nav-item"><a asp-area="" asp-controller="Account" asp-action="Login" class="nav-link js-scroll-trigger">Log in</a></li>}
</ul>
</div>
@ -90,20 +78,6 @@
<div class="container text-right"><span style="font-size:8px;">@env.ToString()</span></div>
</footer>
<!-- Bootstrap core JavaScript -->
<script src="~/vendor/jquery/jquery.min.js"></script>
<script src="~/vendor/popper/popper.min.js"></script>
<script src="~/vendor/bootstrap/js/bootstrap.min.js"></script>
<!-- Plugin JavaScript -->
<script src="~/vendor/jquery-easing/jquery.easing.min.js"></script>
<script src="~/vendor/scrollreveal/scrollreveal.min.js"></script>
<script src="~/vendor/magnific-popup/jquery.magnific-popup.min.js"></script>
<!-- Custom scripts for this template -->
<script src="~/js/creative.js"></script>
@if (!dashboard.IsFullySynched())
{
@Html.Partial("SyncModal")

View File

@ -1,18 +1,3 @@
<environment include="Development">
<script src="~/lib/jquery-validation/dist/jquery.validate.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js"></script>
</environment>
<environment exclude="Development">
<script src="https://ajax.aspnetcdn.com/ajax/jquery.validate/1.14.0/jquery.validate.min.js"
asp-fallback-src="~/lib/jquery-validation/dist/jquery.validate.min.js"
asp-fallback-test="window.jQuery && window.jQuery.validator"
crossorigin="anonymous"
integrity="sha384-Fnqn3nxp3506LP/7Y3j/25BlWeA3PXTyT1l78LjECcPaKCV12TsZP7yyMxOe/G/k">
</script>
<script src="https://ajax.aspnetcdn.com/ajax/jquery.validation.unobtrusive/3.2.6/jquery.validate.unobtrusive.min.js"
asp-fallback-src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"
asp-fallback-test="window.jQuery && window.jQuery.validator && window.jQuery.validator.unobtrusive"
crossorigin="anonymous"
integrity="sha384-JrXK+k53HACyavUKOsL+NkmSesD2P+73eDMrbTtTk0h4RmOF8hF8apPlkp26JlyH">
</script>
</environment>
@addTagHelper *, Meziantou.AspNetCore.BundleTagHelpers
<bundle name="wwwroot/bundles/jqueryvalidate-bundle.min.js" />

View File

@ -0,0 +1,62 @@
@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>
<div class="alert alert-warning alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<p>
<span>A connection to a lightning charge node is required to generate lignting network enabled invoices. <br /></span>
<span>This is experimental and not advised for production so keep in mind:</span>
</p>
<ul>
<li>You might lose your money</li>
<li>The devs of BTCPay Server don't know what they are doing and won't be able to help you if shit hit the fan</li>
<li>You approve being #reckless and being the sole responsible party for your loss</li>
<li>BTCPay Server relies on a <a href="https://github.com/ElementsProject/lightning-charge">Lightning Charge</a> node</li>
<li>If you have no idea what above mean, search by yourself</li>
<li>If you still have no idea how to use lightning, give up for now, we'll make it easier later</li>
</ul>
</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 id="lightningurl" asp-for="Url" class="form-control" />
<span asp-validation-for="Url" class="text-danger"></span>
@if(Model.InternalLightningNode != null)
{
<p class="form-text text-muted">
You can use the internal lightning node by <a href="#" onclick="$('#lightningurl').val('@Model.InternalLightningNode'); return false;">clicking here</a>
</p>
}
</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

@ -14,7 +14,7 @@
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="col-md-8">
<form method="post">
<div class="form-group">
<label asp-for="Id"></label>
@ -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">
@ -86,15 +86,45 @@
</thead>
<tbody>
@foreach(var scheme in Model.DerivationSchemes)
{
<tr>
<td>@scheme.Crypto</td>
<td>@scheme.Value</td>
</tr>
}
{
<tr>
<td>@scheme.Crypto</td>
<td style="max-width:400px;overflow:hidden;">@scheme.Value</td>
</tr>
}
</tbody>
</table>
</div>
<div class="form-group">
<div class="form-group">
<h5>Lightning nodes (Experimental)</h5>
<p>
<span>A connection to a lightning charge node is required to generate lignting network enabled invoices.<br /></span>
<span>This is experimental and not advised for production so keep in mind:</span>
</p>
</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>

View File

@ -0,0 +1,31 @@
[
{
"outputFileName": "wwwroot/bundles/main-bundle.min.css",
"inputFiles": [
"wwwroot/vendor/bootstrap/css/bootstrap.css",
"wwwroot/vendor/magnific-popup/magnific-popup.css",
"wwwroot/css/creative.css",
"wwwroot/css/site.css",
"wwwroot/vendor/animatecss/animate.css"
]
},
{
"outputFileName": "wwwroot/bundles/main-bundle.min.js",
"inputFiles": [
"wwwroot/vendor/jquery/jquery.js",
"wwwroot/vendor/popper/popper.js",
"wwwroot/vendor/bootstrap/js/bootstrap.js",
"wwwroot/vendor/jquery-easing/jquery.easing.js",
"wwwroot/vendor/scrollreveal/scrollreveal.min.js",
"wwwroot/vendor/magnific-popup/jquery.magnific-popup.js",
"wwwroot/js/creative.js"
]
},
{
"outputFileName": "wwwroot/bundles/jqueryvalidate-bundle.min.js",
"inputFiles": [
"wwwroot/vendor/jquery-validate/jquery.validate.js",
"wwwroot/vendor/jquery-validate-unobtrusive/jquery.validate.unobtrusive.js"
]
}
]

View File

View File

@ -0,0 +1,986 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 200 200" style="enable-background:new 0 0 200 200;" xml:space="preserve">
<g id="&#x30EC;&#x30A4;&#x30E4;&#x30FC;_2" style="display:none;">
<image style="display:inline;overflow:visible;" width="440" height="702" xlink:href="data:image/jpeg;base64,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
juY7n1RP8f6S20LHGZHGZHGZHGZHGZHGZHGZHGZHGZHGZHGZHGZHGZHGZHGZHGZCEJQX/RJpMv6I
bWs1x3UF9OWjhElJ6tbURmDVudSSXPpiPsDWRkGZBJSclpCTMzP/AKE//9oACAEBAAEFAF+oGYNQ
NY8hDyDyEPIQ8hDyEPIQ8hDyEPIQ8hDyEPIQ8hDyEPIQ8hDyEPIQ8hDyEPIQ8hDyEPIQ8hDyEPIQ
8hDyEPIQ8hDyEPIQ8hDyEPIQ8hDyEPIQ8g8hAlgldx361+ozCldgt3sFSCIHJIcohyiHKIcohyiH
KIcohyiHKIcohyiHKIcohyiHKIcohyiHKIcohyiHKIcohyiHKIcohyiHKIcohyiHKIcohyiHKIco
hyiHKIcohyiHKIcogUkgh8jCHO4SoF1L9SjDq+wsJTpOeV0x5HBvWN6xvWN6xvWN6xvWN6xvWN6x
vWN6xvWN6xvWN6xvWN6xvWN6xvWN6xvWN6xvWN6xvWN6xvWN6xvWN6xvWN6xvWN6xvWN6xvWPIse
RwE88Qr5C3Gm1dwR9S/U4YkK7FLPu9T1zdhIm45JZC0LQr9pr1dksq7knTpX6nNJJ/2fPu5i3vJF
5Kh2LRQbli1gtwZJIWZElRg0qIGhZESVGDIyHY/6afsEIxHMI0LpX6ndJWj3rxb3jiYa7yyvERiW
tbijKX4HXHWpm9a5CZj5KN1xuI4hb7co0OIJDbhrWa1/Xw9Y+iNC6V+p3SVo968W94/MTDun4cC5
ZlxHojq0IeZddb8yXkFJTHZQopam4zMlRKalpWCWkon7BD1j6I0LpX6ndJWj3rxb3jtCy7MduK2v
ROnPTnv2iHrH0RoXSv1O6StHvXBnvwHZdnMln+1Q9Y+iNC6V+p3SVophbim4TriviZI+Jkj4mSPi
ZI+Jkj4mSPiZI+Jkj4mSPiZI+Jkj4mSPiZI+Jkj4mSPiZI+Jkj4mSPiZI+Jkj4mSPiZI+Jkj4mSP
iZI+Jkj4mSPiZI+Jkj4mSPiZI+Jkj4mSPiZI+Jkj4mSPiZI+Jkj4mSPiZI+Jkj4mSPiZIbYXHdj6
I0LpX6ndJWjekX/M6bhIjvE8giMzdjutr8bhjxObUxVrM2VbTbcI1NuICIqlINh4leNweF4G24k/
E6RojPLWpJoV9GcdZNNx3HENsOOK4jm51lTX9JPu4+iNC6V+p3SVo3pHVtc58fbDQokxdhOm+0tJ
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
8+pIh/44oN9HTL/qIb4bSJwsnHjSQ/2lA9QS6HUjSTU/ULCj0xASlKVko+npyJ3208AiScUpF5S7
/kNrYhweP5hSTuQwyiXoxr7HnLCsMZW8DZtEQaMZXIiOnLFOY3YaR8yg9rOLiyws7SEWr6UJxAHr
MBhcWDi+9QkamIfZ+BFCjKGGOKwsPhwQAoEOr0zh6g40LIHrz9I3O93BACgs9xf/2gAIAQMCBj8A
22wtaFjGIjuKqb1U3qpvVTeqm9VN6qb1U3qpvVTeqm9VN6qb1U3qpvVTeqm9VN6qb1U3qpvUSbfT
2f1BQwnCTM2oRslMVkLFv3buKqrKsCmB/tsMPt7P6goYKiZKFo/9N+Gipw5K0Oh3bFD7ey0Q5cLD
hk3BZJLJJZJLJJZJLJJZJLJJZJLJJZJLJJZJLJJZJLJJZJLJJZJLJJZJKIIYgdpicLoE12unvfil
RVNyqblU3KpuVTcqm5VNyqblU3KpuQFtvds9fyOcU72cx4qwiXx2p98vgpNYQK/NYx6e75LFvodp
ie5kGr1DaeCERSK8ZfDaWNCrGnE1H9FiBsAtBtkF3Cm1uLP9V44Szus3ILNyCzcgs3ILNyCzcgs3
ILNyCzcgs3ILNyCzcgs3ILNyCzcgs3IJ5Wl2/Dh59qw0Lf2VpCNrMWRtHpLIOat/L5oknc/w+faP
3H8OHn2q7mVfbUqvaP3H8MYg7LLzKy8ysvMrLzKy8ysvMrLzKy8ysvMrLzKy8ysvMrLzKy8ysvMr
LzKaIYe5Vo9jRDpzGzut2g3IPmDDz3+zE7PEl+9GEi/oEji4ng25SAoJHaGk5an9fYenMYoFEdKN
p3syc7/cX//aAAgBAQEGPwA+U/rg+U+Dvxvxvxvxvxvxvxvxvxvxvxvxvxvxvxvxvxvxvxvxvxvx
vxvxvxvxvxvxvxvxvxvxvxvxvxvxvxvxvxvxvxv/ALJvKflCIxUUqabMbXbrOO0es47R68do9eO0
evHaPXjtHrx2j147R68do9eO0evHaPXjtHrx2j147R68do9eO0evHaPXjtHrx2j147R68do9eO0e
vHaPXjtHrx2j147R68do9eO0evHaPXjtHrx2j147R68do9eO0evHaPXjtHrx2j147R68do9eO0ev
HaPXjtHrONkjdZwC5qQaV/sG8p+Q4r82HjlZlVFzcNK7wOkHBe3POQdHpYKupVhvBFP1VT58Dw28
p+Q4OJvd/wDmGJoz6yENsU9Gwbjgu8JB3VIoR5Gxyo3zqRXbvHzHFQpI8dMbAT5BjaCPKMVKkDx0
xUAnyDFDs+Xb+oPpwPDbyn5Dg4m93/5hiVbuuQvspuJoN+Da2K5SuwvSgXyDBdyWY7STvxb8hiq5
ehgu2p+fCKrFWOXmUNAW6cTPIxdYczKrGorWgxV2Lqe0hNQRhDEzIC7bj5MW5bbM5Iqd5XZQnAkj
FOQchI6R0HAvT2FFZF/xjcPpwXO9jX9Qjw28p+Q4OJvd/wDmGJpWjEgDba7xsG7HPgIWanaG+viY
YMUwow3HoI8eIaSqpRaMGrWtT4hiEK2YRAKX8e2uJCx9VIWBI8R6cZ5JVaMbaLWrfNuwqwtkOdiV
HiNKYe4lbPKBRM23acNFKqIkgILKoFD0HZh468RcED5gD+oh4beU/IcHE3u//MMS3V1J6tjUKNmy
nScGKzQOw6F2LX5z045stKgUAG4D9VDw28p+Q4ODLCFJYZSGBIpv6CMeufh/dXYvV+rB4beU/IcF
gRTdtwFBWp8ZP1Y7SdZ+7jtJ1n7uO0nWfu47SdZ+7jtJ1n7uO0nWfu47SdZ+7jtJ1n7uO0nWfu47
SdZ+7jtJ1n7uO0nWfu47SdZ+7jtJ1n7uO0nWfu47SdZ+7jtJ1n7uO0nWfu47SdZ+7jtJ1n7uO0nW
fu47SdZ+7jtJ1n7uO0nWfu47SdZ+7jtJ1n7uO0nWfu47SdZ+7jtJ1n7uO0nWfu47SdZ+7jtJ1n7u
O0nWfu47SdZ+7jtJ1n7uO0nWfu47SdZ+7jtJ1n7uO0nWfu47SdZ+7jtJ1n7uO0nWfu47SdZ+7jlu
QTv2bsDw28p+Q4PlwPp82CYgC/QDitKMDRh4jgAbzgIRmYioy7cCinbsGw4zZGyjeaGmBk21XMSQ
QPJhSAxZq8OU9GACjAncCDtxxqV8oIwr50UNuDEg+bBXISV30BOBwnbsGw7cezbqOAChBO6oO3GU
o2Y9FDXATKVJ28QIwVYUI2H9E5pK5dmwGp24aQUCrvrilMuytW2CnjwFBUhhUMDw0GASQwbcy7R8
g/6R/fgeG3lPyHB8uM1K0B2DyYrmo37tNuHkYZTI2YD5sZ3ICpt29OFkUkNG1aMRUg78MAw4BmU1
G0mtcAl1NUIOZttfFl3YjGcD1dN+yvz4hBdarmqajEeZwWzNtJqRXCZmDnMeIGuIqqHYE7K7voGM
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
4DbejZh5y5RIyA+dHRlJ3VVlDfsw3JYkoaMrKyMK7uFwD+hu0UOeKM5WbNRiRvyrQ1p5cAOxBIDU
yk0B6WoDT6cZXY1pm4VZuHx8IOG49iqHJ29k9OAw3EVH0/2I92vnbA8NvKfkOG963mX+xyrClwhP
rYXpxL/hzELXy4lEcTRRBo3gtpXDmqHMwqGcKD4q4laOA28hyhVzJzWUHi4lLKPm24uSIXRZWhKL
LLzXoh4qszv58XEzLSORIwjVG0rmrs+n9Dkijiz5mLJJUBRm/eBIOz5sSlY+bz1AzAgBWAptzHdj
lJGZmECqaEDbX/ERsxawgjMwySgdK9o/2Q92vnbA8NvKfkOG963mX9RZ6DMRQtTbTHMyjPSmagrT
xV/sh7tfO2B4beU/IcNUgesbefmXHaHWMdodYx2h1jHaHWMdodYx2h1jHaHWMdodYx2h1jHaHWMd
odYx2h1jHaHWMdodYx2h1jHaHWMdodYx2h1jHaHWMdodYx2h1jHaHWMdodYx2h1jHaHWMdodYx2h
1jHaHWMdodYx2h1jHaHWMdodYx2h1jHaHWMdodYx2h1jHaHWMdodYx2h1jHaHWMdodYx2h1jHaHW
MdodYwCDX1a+dsDw28p+Q4b3jeYfq0e7XznA8NvKfkOG943mH6tHu185wPDbyn5DhveN5h+rR7tf
OcDw28p+Q4b3jeYeFcXuTmfDxtJy65c2UVpWhp1YWa37u3EsT7VkRnZW6NjLb0x/+s3fXJ/8fF5L
c2408WJAl5klQK5s2YsiZcuXpxJHYaNNqNopHKvIGZopRTaVKQuuw7NhxyrvRZLeSmbJLKUah6aN
ADiK+mKwRvEszl2AVAyhjVjQbPHiM6UiavIzgSxW0odokptkblrJwj58R2Vnb/FWLoWbUonzwq4B
PLJRStdg9Lpxp2n8jm/mDlOZny8uhVa5cpzdrxjFzYSWMkNvbqGjvWzcuUnLsWqAdP7x3fJN8PcQ
3V5GGCWccqc2SRf9NVXM2YnZSmAzoIL9ULzaeXBmioaAOtFYV2b16ccq70WS3kpmySylGoemjQA4
WKHSWkkc5URJyzMTuAAhqcPax6BO9xEKyQq7mRB42UQVG/Fgi6c9xPfpmEAcq6Pw+ry8tixq1N2J
rPU7ddIaFMxNzMFJao4MsiR0NDXEdlZ2/wAVYuhZtSifPCrgE8slFK12D0unFtZ2EHx6SPy7qeB8
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
/9k=" transform="matrix(1 0 0 1 -440.3528 -197.892)">
</image>
</g>
<g id="&#x30EC;&#x30A4;&#x30E4;&#x30FC;_3" style="display:none;">
<g style="display:inline;">
<path style="fill:#FFFFFF;" d="M57.285,130.792"/>
<g>
<rect x="120.103" y="145.331" transform="matrix(-0.4854 0.8743 -0.8743 -0.4854 306.1181 110.4958)" style="fill:#FFFFFF;" width="0.879" height="0"/>
<path style="fill:#FFFFFF;" d="M100.587,15.843c-9.32,0-18.292,1.513-26.689,4.299L48.676,2.633l-2.814,3.538l19.23,17.454
C35.928,37.093,15.64,66.615,15.64,100.79c0,46.84,38.107,84.947,84.947,84.947c10.025,0,19.648-1.751,28.585-4.954l-6.518-4.546
c-7.004,2.052-14.408,3.158-22.067,3.158c-43.343,0-78.605-35.262-78.605-78.605c0-32.587,19.933-60.604,48.246-72.503
c0.605-0.254,0.605-0.254,0,0l48.901,44.386l-26.995,54.239l29.651,26.416l-1.028-8.381l-0.427,0.769l0.427-0.769l-2.656-21.652
l26.874-53.809L80.567,24.773c6.394-1.685,13.104-2.587,20.02-2.587c43.343,0,78.605,35.262,78.605,78.605
c0,32.035-19.265,59.652-46.817,71.883l5.155,4.601c28.385-13.766,48.004-42.876,48.004-76.484
C185.534,53.95,147.427,15.843,100.587,15.843z"/>
</g>
<path style="fill:#FFFFFF;" d="M137.53,177.275l-5.155-4.601c-0.785,0.348-0.785,0.348,0,0l-50.419-45.002l27.016-54.397
L79.224,46.49l-0.111,0.238l4.563,31.512v0l-25.961,52.71l64.938,45.287l6.518,4.546l25.202,17.576l2.804-3.548L137.53,177.275z"
/>
</g>
<g style="display:inline;">
<path style="fill:#FFFFFF;" d="M57.285,130.792"/>
<g>
<rect x="120.103" y="145.331" transform="matrix(-0.4854 0.8743 -0.8743 -0.4854 306.1181 110.4958)" style="fill:#FFFFFF;" width="0.879" height="0"/>
<path style="fill:#FFFFFF;" d="M100.587,15.843c-9.32,0-18.292,1.513-26.689,4.299L48.676,2.633l-2.814,3.538l19.23,17.454
C35.928,37.093,15.64,66.615,15.64,100.79c0,46.84,38.107,84.947,84.947,84.947c10.025,0,19.648-1.751,28.585-4.954l-6.518-4.546
c-7.004,2.052-14.408,3.158-22.067,3.158c-43.343,0-78.605-35.262-78.605-78.605c0-32.587,19.933-60.604,48.246-72.503
c0.605-0.254,0.605-0.254,0,0l48.901,44.386l-26.995,54.239l29.651,26.416l-1.028-8.381l-0.427,0.769l0.427-0.769l-2.656-21.652
l26.874-53.809L80.567,24.773c6.394-1.685,13.104-2.587,20.02-2.587c43.343,0,78.605,35.262,78.605,78.605
c0,32.035-19.265,59.652-46.817,71.883l5.155,4.601c28.385-13.766,48.004-42.876,48.004-76.484
C185.534,53.95,147.427,15.843,100.587,15.843z"/>
</g>
<path style="fill:#FFFFFF;" d="M137.53,177.275l-5.155-4.601c-0.785,0.348-0.785,0.348,0,0l-50.419-45.002l27.016-54.397
L79.224,46.49l-0.111,0.238l4.563,31.512v0l-25.961,52.71l64.938,45.287l6.518,4.546l25.202,17.576l2.804-3.548L137.53,177.275z"
/>
</g>
<g style="display:inline;">
<path style="fill:#FFFFFF;" d="M57.285,130.792"/>
<g>
<circle style="fill:#FFFFFF;" cx="102" cy="100" r="81.667"/>
<rect x="120.103" y="145.331" transform="matrix(-0.4854 0.8743 -0.8743 -0.4854 306.1181 110.4958)" style="fill:#FFFFFF;" width="0.879" height="0"/>
<path style="fill:#FAD31A;" d="M100.587,15.843c-9.32,0-18.292,1.513-26.689,4.299L48.676,2.633l-2.814,3.538l19.23,17.454
C35.928,37.093,15.64,66.615,15.64,100.79c0,46.84,38.107,84.947,84.947,84.947c10.025,0,19.648-1.751,28.585-4.954l-6.518-4.546
c-7.004,2.052-14.408,3.158-22.067,3.158c-43.343,0-78.605-35.262-78.605-78.605c0-32.587,19.933-60.604,48.246-72.503
c0.605-0.254,0.605-0.254,0,0l48.901,44.386l-26.995,54.239l29.651,26.416l-1.028-8.381l-0.427,0.769l0.427-0.769l-2.656-21.652
l26.874-53.809L80.567,24.773c6.394-1.685,13.104-2.587,20.02-2.587c43.343,0,78.605,35.262,78.605,78.605
c0,32.035-19.265,59.652-46.817,71.883l5.155,4.601c28.385-13.766,48.004-42.876,48.004-76.484
C185.534,53.95,147.427,15.843,100.587,15.843z"/>
</g>
<path style="fill:#F7B421;" d="M137.53,177.275l-5.155-4.601c-0.785,0.348-0.785,0.348,0,0l-50.419-45.002l27.016-54.397
L79.224,46.49l-0.111,0.238l4.563,31.512v0l-25.961,52.71l64.938,45.287l6.518,4.546l25.202,17.576l2.804-3.548L137.53,177.275z"
/>
</g>
</g>
<g id="&#x30EC;&#x30A4;&#x30E4;&#x30FC;_3&#x306E;&#x30B3;&#x30D4;&#x30FC;_3">
<g>
<path style="fill:#FFFFFF;" d="M57.285,130.792"/>
<g>
<rect x="120.103" y="145.331" transform="matrix(-0.4854 0.8743 -0.8743 -0.4854 306.1181 110.4958)" style="fill:#FFFFFF;" width="0.879" height="0"/>
<path style="fill:#FFFFFF;" d="M100.587,15.843c-9.32,0-18.292,1.513-26.689,4.299L48.676,2.633l-2.814,3.538l19.23,17.454
C35.928,37.093,15.64,66.615,15.64,100.79c0,46.84,38.107,84.947,84.947,84.947c10.025,0,19.648-1.751,28.585-4.954l-6.518-4.546
c-7.004,2.052-14.408,3.158-22.067,3.158c-43.343,0-78.605-35.262-78.605-78.605c0-32.587,19.933-60.604,48.246-72.503
c0.605-0.254,0.605-0.254,0,0l48.901,44.386l-26.995,54.239l29.651,26.416l-1.028-8.381l-0.427,0.769l0.427-0.769l-2.656-21.652
l26.874-53.809L80.567,24.773c6.394-1.685,13.104-2.587,20.02-2.587c43.343,0,78.605,35.262,78.605,78.605
c0,32.035-19.265,59.652-46.817,71.883l5.155,4.601c28.385-13.766,48.004-42.876,48.004-76.484
C185.534,53.95,147.427,15.843,100.587,15.843z"/>
</g>
<path style="fill:#FFFFFF;" d="M137.53,177.275l-5.155-4.601c-0.785,0.348-0.785,0.348,0,0l-50.419-45.002l27.016-54.397
L79.224,46.49l-0.111,0.238l4.563,31.512v0l-25.961,52.71l64.938,45.287l6.518,4.546l25.202,17.576l2.804-3.548L137.53,177.275z"
/>
</g>
<g>
<path style="fill:#FFFFFF;" d="M57.285,130.792"/>
<g>
<rect x="120.103" y="145.331" transform="matrix(-0.4854 0.8743 -0.8743 -0.4854 306.1181 110.4958)" style="fill:#FFFFFF;" width="0.879" height="0"/>
<path style="fill:#FFFFFF;" d="M100.587,15.843c-9.32,0-18.292,1.513-26.689,4.299L48.676,2.633l-2.814,3.538l19.23,17.454
C35.928,37.093,15.64,66.615,15.64,100.79c0,46.84,38.107,84.947,84.947,84.947c10.025,0,19.648-1.751,28.585-4.954l-6.518-4.546
c-7.004,2.052-14.408,3.158-22.067,3.158c-43.343,0-78.605-35.262-78.605-78.605c0-32.587,19.933-60.604,48.246-72.503
c0.605-0.254,0.605-0.254,0,0l48.901,44.386l-26.995,54.239l29.651,26.416l-1.028-8.381l-0.427,0.769l0.427-0.769l-2.656-21.652
l26.874-53.809L80.567,24.773c6.394-1.685,13.104-2.587,20.02-2.587c43.343,0,78.605,35.262,78.605,78.605
c0,32.035-19.265,59.652-46.817,71.883l5.155,4.601c28.385-13.766,48.004-42.876,48.004-76.484
C185.534,53.95,147.427,15.843,100.587,15.843z"/>
</g>
<path style="fill:#FFFFFF;" d="M137.53,177.275l-5.155-4.601c-0.785,0.348-0.785,0.348,0,0l-50.419-45.002l27.016-54.397
L79.224,46.49l-0.111,0.238l4.563,31.512v0l-25.961,52.71l64.938,45.287l6.518,4.546l25.202,17.576l2.804-3.548L137.53,177.275z"
/>
</g>
<g>
<path style="fill:#FFFFFF;" d="M57.285,130.792"/>
<g>
<circle style="fill:#FFFFFF;" cx="102" cy="100" r="81.667"/>
<rect x="120.103" y="145.331" transform="matrix(-0.4854 0.8743 -0.8743 -0.4854 306.1181 110.4958)" style="fill:#FFFFFF;" width="0.879" height="0"/>
<path style="fill:#FF9900;" d="M100.587,15.843c-9.32,0-18.292,1.513-26.689,4.299L48.676,2.633l-2.814,3.538l19.23,17.454
C35.928,37.093,15.64,66.615,15.64,100.79c0,46.84,38.107,84.947,84.947,84.947c10.025,0,19.648-1.751,28.585-4.954l-6.518-4.546
c-7.004,2.052-14.408,3.158-22.067,3.158c-43.343,0-78.605-35.262-78.605-78.605c0-32.587,19.933-60.604,48.246-72.503
c0.605-0.254,0.605-0.254,0,0l48.901,44.386l-26.995,54.239l29.651,26.416l-1.028-8.381l-0.427,0.769l0.427-0.769l-2.656-21.652
l26.874-53.809L80.567,24.773c6.394-1.685,13.104-2.587,20.02-2.587c43.343,0,78.605,35.262,78.605,78.605
c0,32.035-19.265,59.652-46.817,71.883l5.155,4.601c28.385-13.766,48.004-42.876,48.004-76.484
C185.534,53.95,147.427,15.843,100.587,15.843z"/>
</g>
<path style="fill:#FF8500;" d="M137.53,177.275l-5.155-4.601c-0.785,0.348-0.785,0.348,0,0l-50.419-45.002l27.016-54.397
L79.224,46.49l-0.111,0.238l4.563,31.512v0l-25.961,52.71l64.938,45.287l6.518,4.546l25.202,17.576l2.804-3.548L137.53,177.275z"
/>
</g>
</g>
<g id="&#x30EC;&#x30A4;&#x30E4;&#x30FC;_3&#x306E;&#x30B3;&#x30D4;&#x30FC;" style="display:none;">
<g style="display:inline;">
<path style="fill:#FFFFFF;" d="M57.285,130.792"/>
<g>
<rect x="120.103" y="145.331" transform="matrix(-0.4854 0.8743 -0.8743 -0.4854 306.1181 110.4958)" style="fill:#FFFFFF;" width="0.879" height="0"/>
<path style="fill:#FFFFFF;" d="M100.587,15.843c-9.32,0-18.292,1.513-26.689,4.299L48.676,2.633l-2.814,3.538l19.23,17.454
C35.928,37.093,15.64,66.615,15.64,100.79c0,46.84,38.107,84.947,84.947,84.947c10.025,0,19.648-1.751,28.585-4.954l-6.518-4.546
c-7.004,2.052-14.408,3.158-22.067,3.158c-43.343,0-78.605-35.262-78.605-78.605c0-32.587,19.933-60.604,48.246-72.503
c0.605-0.254,0.605-0.254,0,0l48.901,44.386l-26.995,54.239l29.651,26.416l-1.028-8.381l-0.427,0.769l0.427-0.769l-2.656-21.652
l26.874-53.809L80.567,24.773c6.394-1.685,13.104-2.587,20.02-2.587c43.343,0,78.605,35.262,78.605,78.605
c0,32.035-19.265,59.652-46.817,71.883l5.155,4.601c28.385-13.766,48.004-42.876,48.004-76.484
C185.534,53.95,147.427,15.843,100.587,15.843z"/>
</g>
<path style="fill:#FFFFFF;" d="M137.53,177.275l-5.155-4.601c-0.785,0.348-0.785,0.348,0,0l-50.419-45.002l27.016-54.397
L79.224,46.49l-0.111,0.238l4.563,31.512v0l-25.961,52.71l64.938,45.287l6.518,4.546l25.202,17.576l2.804-3.548L137.53,177.275z"
/>
</g>
<g style="display:inline;">
<path style="fill:#FFFFFF;" d="M57.285,130.792"/>
<g>
<rect x="120.103" y="145.331" transform="matrix(-0.4854 0.8743 -0.8743 -0.4854 306.1181 110.4958)" style="fill:#FFFFFF;" width="0.879" height="0"/>
<path style="fill:#FFFFFF;" d="M100.587,15.843c-9.32,0-18.292,1.513-26.689,4.299L48.676,2.633l-2.814,3.538l19.23,17.454
C35.928,37.093,15.64,66.615,15.64,100.79c0,46.84,38.107,84.947,84.947,84.947c10.025,0,19.648-1.751,28.585-4.954l-6.518-4.546
c-7.004,2.052-14.408,3.158-22.067,3.158c-43.343,0-78.605-35.262-78.605-78.605c0-32.587,19.933-60.604,48.246-72.503
c0.605-0.254,0.605-0.254,0,0l48.901,44.386l-26.995,54.239l29.651,26.416l-1.028-8.381l-0.427,0.769l0.427-0.769l-2.656-21.652
l26.874-53.809L80.567,24.773c6.394-1.685,13.104-2.587,20.02-2.587c43.343,0,78.605,35.262,78.605,78.605
c0,32.035-19.265,59.652-46.817,71.883l5.155,4.601c28.385-13.766,48.004-42.876,48.004-76.484
C185.534,53.95,147.427,15.843,100.587,15.843z"/>
</g>
<path style="fill:#FFFFFF;" d="M137.53,177.275l-5.155-4.601c-0.785,0.348-0.785,0.348,0,0l-50.419-45.002l27.016-54.397
L79.224,46.49l-0.111,0.238l4.563,31.512v0l-25.961,52.71l64.938,45.287l6.518,4.546l25.202,17.576l2.804-3.548L137.53,177.275z"
/>
</g>
<g style="display:inline;">
<path style="fill:#B2B4B6;" d="M57.285,130.792"/>
<g>
<circle style="fill:#FFFFFF;" cx="102" cy="100" r="81.667"/>
<rect x="120.103" y="145.331" transform="matrix(-0.4854 0.8743 -0.8743 -0.4854 306.1181 110.4958)" style="fill:#B2B4B6;" width="0.879" height="0"/>
<path style="fill:#B2B4B6;" d="M100.587,15.843c-9.32,0-18.292,1.513-26.689,4.299L48.676,2.633l-2.814,3.538l19.23,17.454
C35.928,37.093,15.64,66.615,15.64,100.79c0,46.84,38.107,84.947,84.947,84.947c10.025,0,19.648-1.751,28.585-4.954l-6.518-4.546
c-7.004,2.052-14.408,3.158-22.067,3.158c-43.343,0-78.605-35.262-78.605-78.605c0-32.587,19.933-60.604,48.246-72.503
c0.605-0.254,0.605-0.254,0,0l48.901,44.386l-26.995,54.239l29.651,26.416l-1.028-8.381l-0.427,0.769l0.427-0.769l-2.656-21.652
l26.874-53.809L80.567,24.773c6.394-1.685,13.104-2.587,20.02-2.587c43.343,0,78.605,35.262,78.605,78.605
c0,32.035-19.265,59.652-46.817,71.883l5.155,4.601c28.385-13.766,48.004-42.876,48.004-76.484
C185.534,53.95,147.427,15.843,100.587,15.843z"/>
</g>
<path style="fill:#8C8D8E;" d="M137.53,177.275l-5.155-4.601c-0.785,0.348-0.785,0.348,0,0l-50.419-45.002l27.016-54.397
L79.224,46.49l-0.111,0.238l4.563,31.512v0l-25.961,52.71l64.938,45.287l6.518,4.546l25.202,17.576l2.804-3.548L137.53,177.275z"
/>
</g>
</g>
<g id="&#x30EC;&#x30A4;&#x30E4;&#x30FC;_3&#x306E;&#x30B3;&#x30D4;&#x30FC;_2" style="display:none;">
<g style="display:inline;">
<path style="fill:#FFFFFF;" d="M57.285,130.792"/>
<g>
<rect x="120.103" y="145.331" transform="matrix(-0.4854 0.8743 -0.8743 -0.4854 306.1181 110.4958)" style="fill:#FFFFFF;" width="0.879" height="0"/>
<path style="fill:#FFFFFF;" d="M100.587,15.843c-9.32,0-18.292,1.513-26.689,4.299L48.676,2.633l-2.814,3.538l19.23,17.454
C35.928,37.093,15.64,66.615,15.64,100.79c0,46.84,38.107,84.947,84.947,84.947c10.025,0,19.648-1.751,28.585-4.954l-6.518-4.546
c-7.004,2.052-14.408,3.158-22.067,3.158c-43.343,0-78.605-35.262-78.605-78.605c0-32.587,19.933-60.604,48.246-72.503
c0.605-0.254,0.605-0.254,0,0l48.901,44.386l-26.995,54.239l29.651,26.416l-1.028-8.381l-0.427,0.769l0.427-0.769l-2.656-21.652
l26.874-53.809L80.567,24.773c6.394-1.685,13.104-2.587,20.02-2.587c43.343,0,78.605,35.262,78.605,78.605
c0,32.035-19.265,59.652-46.817,71.883l5.155,4.601c28.385-13.766,48.004-42.876,48.004-76.484
C185.534,53.95,147.427,15.843,100.587,15.843z"/>
</g>
<path style="fill:#FFFFFF;" d="M137.53,177.275l-5.155-4.601c-0.785,0.348-0.785,0.348,0,0l-50.419-45.002l27.016-54.397
L79.224,46.49l-0.111,0.238l4.563,31.512v0l-25.961,52.71l64.938,45.287l6.518,4.546l25.202,17.576l2.804-3.548L137.53,177.275z"
/>
</g>
<g style="display:inline;">
<path style="fill:#FFFFFF;" d="M57.285,130.792"/>
<g>
<rect x="120.103" y="145.331" transform="matrix(-0.4854 0.8743 -0.8743 -0.4854 306.1181 110.4958)" style="fill:#FFFFFF;" width="0.879" height="0"/>
<path style="fill:#FFFFFF;" d="M100.587,15.843c-9.32,0-18.292,1.513-26.689,4.299L48.676,2.633l-2.814,3.538l19.23,17.454
C35.928,37.093,15.64,66.615,15.64,100.79c0,46.84,38.107,84.947,84.947,84.947c10.025,0,19.648-1.751,28.585-4.954l-6.518-4.546
c-7.004,2.052-14.408,3.158-22.067,3.158c-43.343,0-78.605-35.262-78.605-78.605c0-32.587,19.933-60.604,48.246-72.503
c0.605-0.254,0.605-0.254,0,0l48.901,44.386l-26.995,54.239l29.651,26.416l-1.028-8.381l-0.427,0.769l0.427-0.769l-2.656-21.652
l26.874-53.809L80.567,24.773c6.394-1.685,13.104-2.587,20.02-2.587c43.343,0,78.605,35.262,78.605,78.605
c0,32.035-19.265,59.652-46.817,71.883l5.155,4.601c28.385-13.766,48.004-42.876,48.004-76.484
C185.534,53.95,147.427,15.843,100.587,15.843z"/>
</g>
<path style="fill:#FFFFFF;" d="M137.53,177.275l-5.155-4.601c-0.785,0.348-0.785,0.348,0,0l-50.419-45.002l27.016-54.397
L79.224,46.49l-0.111,0.238l4.563,31.512v0l-25.961,52.71l64.938,45.287l6.518,4.546l25.202,17.576l2.804-3.548L137.53,177.275z"
/>
</g>
<g style="display:inline;">
<path style="fill:#B2B4B6;" d="M57.285,130.792"/>
<g>
<circle style="fill:#FFFFFF;" cx="102" cy="100" r="81.667"/>
<rect x="120.103" y="145.331" transform="matrix(-0.4854 0.8743 -0.8743 -0.4854 306.1181 110.4958)" style="fill:#B2B4B6;" width="0.879" height="0"/>
<path style="fill:#D9DADB;" d="M100.587,15.843c-9.32,0-18.292,1.513-26.689,4.299L48.676,2.633l-2.814,3.538l19.23,17.454
C35.928,37.093,15.64,66.615,15.64,100.79c0,46.84,38.107,84.947,84.947,84.947c10.025,0,19.648-1.751,28.585-4.954l-6.518-4.546
c-7.004,2.052-14.408,3.158-22.067,3.158c-43.343,0-78.605-35.262-78.605-78.605c0-32.587,19.933-60.604,48.246-72.503
c0.605-0.254,0.605-0.254,0,0l48.901,44.386l-26.995,54.239l29.651,26.416l-1.028-8.381l-0.427,0.769l0.427-0.769l-2.656-21.652
l26.874-53.809L80.567,24.773c6.394-1.685,13.104-2.587,20.02-2.587c43.343,0,78.605,35.262,78.605,78.605
c0,32.035-19.265,59.652-46.817,71.883l5.155,4.601c28.385-13.766,48.004-42.876,48.004-76.484
C185.534,53.95,147.427,15.843,100.587,15.843z"/>
</g>
<path style="fill:#B2B4B6;" d="M137.53,177.275l-5.155-4.601c-0.785,0.348-0.785,0.348,0,0l-50.419-45.002l27.016-54.397
L79.224,46.49l-0.111,0.238l4.563,31.512v0l-25.961,52.71l64.938,45.287l6.518,4.546l25.202,17.576l2.804-3.548L137.53,177.275z"
/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 73 KiB

Some files were not shown because too many files have changed in this diff Show More