diff --git a/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.cs b/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.cs new file mode 100644 index 000000000..34ec9d625 --- /dev/null +++ b/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using NBitcoin; + +namespace BTCPayServer.Client +{ + public partial class BTCPayServerClient + { + public virtual async Task ShowOnChainWalletOverview(string storeId, string cryptoCode, + CancellationToken token = default) + { + var response = + await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/Onchain/{cryptoCode}/wallet"), token); + return await HandleResponse(response); + } + + public virtual async Task GetOnChainWalletReceiveAddress(string storeId, string cryptoCode, bool forceGenerate = false, + CancellationToken token = default) + { + var response = + await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/Onchain/{cryptoCode}/wallet/address", new Dictionary() + { + {"forceGenerate", forceGenerate} + }), token); + return await HandleResponse(response); + } + + public virtual async Task UnReserveOnChainWalletReceiveAddress(string storeId, string cryptoCode, + CancellationToken token = default) + { + var response = + await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/Onchain/{cryptoCode}/wallet/address",method:HttpMethod.Delete), token); + await HandleResponse(response); + } + + public virtual async Task> ShowOnChainWalletTransactions( + string storeId, string cryptoCode, TransactionStatus[] statusFilter = null, + CancellationToken token = default) + { + var query = new Dictionary(); + if (statusFilter?.Any() is true) + { + query.Add(nameof(statusFilter), statusFilter); + } + var response = + await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/Onchain/{cryptoCode}/wallet/transactions", query), token); + return await HandleResponse>(response); + } + + public virtual async Task GetOnChainWalletTransaction( + string storeId, string cryptoCode, string transactionId, + CancellationToken token = default) + { + var response = + await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/Onchain/{cryptoCode}/wallet/transactions/{transactionId}"), token); + return await HandleResponse(response); + } + + public virtual async Task> GetOnChainWalletUTXOs(string storeId, + string cryptoCode, + CancellationToken token = default) + { + var response = + await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/Onchain/{cryptoCode}/wallet/utxos"), token); + return await HandleResponse>(response); + } + + public virtual async Task CreateOnChainTransaction(string storeId, + string cryptoCode, CreateOnChainTransactionRequest request, + CancellationToken token = default) + { + if (!request.ProceedWithBroadcast) + { + throw new ArgumentOutOfRangeException(nameof(request.ProceedWithBroadcast), + "Please use CreateOnChainTransactionButDoNotBroadcast when wanting to only create the transaction"); + } + var response = + await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/Onchain/{cryptoCode}/wallet/transactions", null, request, HttpMethod.Post), token); + return await HandleResponse(response); + } + + public virtual async Task CreateOnChainTransactionButDoNotBroadcast(string storeId, + string cryptoCode, CreateOnChainTransactionRequest request, Network network, + CancellationToken token = default) + { + if (request.ProceedWithBroadcast) + { + throw new ArgumentOutOfRangeException(nameof(request.ProceedWithBroadcast), + "Please use CreateOnChainTransaction when wanting to also broadcast the transaction"); + } + var response = + await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/Onchain/{cryptoCode}/wallet/transactions", null, request, HttpMethod.Post), token); + return Transaction.Parse(await HandleResponse(response), network); + } + } +} diff --git a/BTCPayServer.Client/Models/CreateInvoiceRequest.cs b/BTCPayServer.Client/Models/CreateInvoiceRequest.cs index 11221fcc2..739b7c857 100644 --- a/BTCPayServer.Client/Models/CreateInvoiceRequest.cs +++ b/BTCPayServer.Client/Models/CreateInvoiceRequest.cs @@ -9,7 +9,7 @@ namespace BTCPayServer.Client.Models { public class CreateInvoiceRequest { - [JsonProperty(ItemConverterType = typeof(NumericStringJsonConverter))] + [JsonConverter(typeof(NumericStringJsonConverter))] public decimal Amount { get; set; } public string Currency { get; set; } public JObject Metadata { get; set; } diff --git a/BTCPayServer.Client/Models/CreateOnChainTransactionRequest.cs b/BTCPayServer.Client/Models/CreateOnChainTransactionRequest.cs new file mode 100644 index 000000000..3c2d8f671 --- /dev/null +++ b/BTCPayServer.Client/Models/CreateOnChainTransactionRequest.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using BTCPayServer.JsonConverters; +using NBitcoin; +using NBitcoin.JsonConverters; +using Newtonsoft.Json; + +namespace BTCPayServer.Client.Models +{ + public class CreateOnChainTransactionRequest + { + + public class CreateOnChainTransactionRequestDestination + { + public string Destination { get; set; } + [JsonConverter(typeof(NumericStringJsonConverter))] + public decimal? Amount { get; set; } + public bool SubtractFromAmount { get; set; } + } + [JsonConverter(typeof(FeeRateJsonConverter))] + public FeeRate FeeRate { get; set; } + public bool ProceedWithPayjoin { get; set; }= true; + public bool ProceedWithBroadcast { get; set; } = true; + public bool NoChange { get; set; } = false; + [JsonProperty(ItemConverterType = typeof(OutpointJsonConverter))] + public List SelectedInputs { get; set; } = null; + public List Destinations { get; set; } + [JsonProperty("rbf")] + public bool? RBF { get; set; } = null; + } +} diff --git a/BTCPayServer.Client/Models/LabelData.cs b/BTCPayServer.Client/Models/LabelData.cs new file mode 100644 index 000000000..6b71102b3 --- /dev/null +++ b/BTCPayServer.Client/Models/LabelData.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Client.Models +{ + public class LabelData + { + public string Type { get; set; } + public string Text { get; set; } + + [JsonExtensionData] public Dictionary AdditionalData { get; set; } + } +} \ No newline at end of file diff --git a/BTCPayServer.Client/Models/OnChainWalletAddressData.cs b/BTCPayServer.Client/Models/OnChainWalletAddressData.cs new file mode 100644 index 000000000..814d85e42 --- /dev/null +++ b/BTCPayServer.Client/Models/OnChainWalletAddressData.cs @@ -0,0 +1,13 @@ +using NBitcoin; +using NBitcoin.JsonConverters; +using Newtonsoft.Json; + +namespace BTCPayServer.Client.Models +{ + public class OnChainWalletAddressData + { + public string Address { get; set; } + [JsonConverter(typeof(KeyPathJsonConverter))] + public KeyPath KeyPath { get; set; } + } +} diff --git a/BTCPayServer.Client/Models/OnChainWalletOverviewData.cs b/BTCPayServer.Client/Models/OnChainWalletOverviewData.cs new file mode 100644 index 000000000..6f2fa51b8 --- /dev/null +++ b/BTCPayServer.Client/Models/OnChainWalletOverviewData.cs @@ -0,0 +1,12 @@ +using BTCPayServer.JsonConverters; +using Newtonsoft.Json; + +namespace BTCPayServer.Client.Models +{ + public class OnChainWalletOverviewData + { + + [JsonConverter(typeof(NumericStringJsonConverter))] + public decimal Balance { get; set; } + } +} diff --git a/BTCPayServer.Client/Models/OnChainWalletTransactionData.cs b/BTCPayServer.Client/Models/OnChainWalletTransactionData.cs new file mode 100644 index 000000000..ed8aa7a1d --- /dev/null +++ b/BTCPayServer.Client/Models/OnChainWalletTransactionData.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using BTCPayServer.JsonConverters; +using NBitcoin; +using NBitcoin.JsonConverters; +using Newtonsoft.Json; + +namespace BTCPayServer.Client.Models +{ + public class OnChainWalletTransactionData + { + [JsonConverter(typeof(UInt256JsonConverter))] + public uint256 TransactionHash { get; set; } + + public string Comment { get; set; } + public Dictionary Labels { get; set; } = new Dictionary(); + + [JsonConverter(typeof(NumericStringJsonConverter))] + public decimal Amount { get; set; } + + [JsonConverter(typeof(UInt256JsonConverter))] + public uint256 BlockHash { get; set; } + + public int? BlockHeight { get; set; } + + public int Confirmations { get; set; } + + [JsonConverter(typeof(DateTimeToUnixTimeConverter))] + public DateTimeOffset Timestamp { get; set; } + + [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + public TransactionStatus Status { get; set; } + } +} diff --git a/BTCPayServer.Client/Models/OnChainWalletUTXOData.cs b/BTCPayServer.Client/Models/OnChainWalletUTXOData.cs new file mode 100644 index 000000000..a10ffd1e1 --- /dev/null +++ b/BTCPayServer.Client/Models/OnChainWalletUTXOData.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using BTCPayServer.JsonConverters; +using NBitcoin; +using NBitcoin.JsonConverters; +using Newtonsoft.Json; + +namespace BTCPayServer.Client.Models +{ + public class OnChainWalletUTXOData + { + public string Comment { get; set; } + [JsonConverter(typeof(NumericStringJsonConverter))] + public decimal Amount { get; set; } + [JsonConverter(typeof(OutpointJsonConverter))] + public OutPoint Outpoint { get; set; } + public string Link { get; set; } + + public Dictionary Labels { get; set; } + } +} diff --git a/BTCPayServer.Client/Models/TransactionStatus.cs b/BTCPayServer.Client/Models/TransactionStatus.cs new file mode 100644 index 000000000..3fef01758 --- /dev/null +++ b/BTCPayServer.Client/Models/TransactionStatus.cs @@ -0,0 +1,9 @@ +namespace BTCPayServer.Client.Models +{ + public enum TransactionStatus + { + Unconfirmed, + Confirmed, + Replaced + } +} \ No newline at end of file diff --git a/BTCPayServer.Common/Altcoins/Liquid/ElementsLikeBtcPayNetwork.cs b/BTCPayServer.Common/Altcoins/Liquid/ElementsLikeBtcPayNetwork.cs index 711e718e4..f62fb7522 100644 --- a/BTCPayServer.Common/Altcoins/Liquid/ElementsLikeBtcPayNetwork.cs +++ b/BTCPayServer.Common/Altcoins/Liquid/ElementsLikeBtcPayNetwork.cs @@ -24,31 +24,15 @@ namespace BTCPayServer }); } - public override GetTransactionsResponse FilterValidTransactions(GetTransactionsResponse response) + public override List FilterValidTransactions(List transactionInformationSet) { - TransactionInformationSet Filter(TransactionInformationSet transactionInformationSet) - { - return new TransactionInformationSet() - { - Transactions = - transactionInformationSet.Transactions.FindAll(information => - information.Outputs.Any(output => - output.Value is AssetMoney assetMoney && assetMoney.AssetId == AssetId) || - information.Inputs.Any(output => - output.Value is AssetMoney assetMoney && assetMoney.AssetId == AssetId)) - }; - } - - return new GetTransactionsResponse() - { - Height = response.Height, - ConfirmedTransactions = Filter(response.ConfirmedTransactions), - ReplacedTransactions = Filter(response.ReplacedTransactions), - UnconfirmedTransactions = Filter(response.UnconfirmedTransactions) - }; + return transactionInformationSet.FindAll(information => + information.Outputs.Any(output => + output.Value is AssetMoney assetMoney && assetMoney.AssetId == AssetId) || + information.Inputs.Any(output => + output.Value is AssetMoney assetMoney && assetMoney.AssetId == AssetId)); } - public override string GenerateBIP21(string cryptoInfoAddress, Money cryptoInfoDue) { //precision 0: 10 = 0.00000010 diff --git a/BTCPayServer.Common/BTCPayNetwork.cs b/BTCPayServer.Common/BTCPayNetwork.cs index d93ed015a..76c5c8dca 100644 --- a/BTCPayServer.Common/BTCPayNetwork.cs +++ b/BTCPayServer.Common/BTCPayNetwork.cs @@ -126,9 +126,9 @@ namespace BTCPayServer return $"{UriScheme}:{cryptoInfoAddress}?amount={cryptoInfoDue.ToString(false, true)}"; } - public virtual GetTransactionsResponse FilterValidTransactions(GetTransactionsResponse response) + public virtual List FilterValidTransactions(List transactionInformationSet) { - return response; + return transactionInformationSet; } } diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index b3b77d412..6a4648835 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; @@ -19,6 +20,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NBitcoin; using NBitcoin.OpenAsset; +using NBitcoin.Payment; using NBitpayClient; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -1183,7 +1185,6 @@ namespace BTCPayServer.Tests } } - [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] public async Task NotificationAPITests() @@ -1280,8 +1281,6 @@ namespace BTCPayServer.Tests }); } - - [Fact(Timeout = 60 * 2 * 1000)] [Trait("Lightning", "Lightning")] [Trait("Integration", "Integration")] @@ -1395,7 +1394,223 @@ namespace BTCPayServer.Tests await nonAdminUserClient.UpdateStoreLightningNetworkPaymentMethod(nonAdminUser.StoreId, "BTC", method); } - + + [Fact(Timeout = 60 * 2 * 1000)] + [Trait("Integration", "Integration")] + public async Task WalletAPITests() + { + using var tester = ServerTester.Create(); + await tester.StartAsync(); + + var user = tester.NewAccount(); + await user.GrantAccessAsync(true); + + var client = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings); + var viewOnlyClient = await user.CreateClient(Policies.CanViewStoreSettings); + var walletId = await user.RegisterDerivationSchemeAsync("BTC", ScriptPubKeyType.Segwit, true); + + //view only clients can't do jack shit with this API + await AssertHttpError(403, async () => + { + await viewOnlyClient.ShowOnChainWalletOverview(walletId.StoreId, walletId.CryptoCode ); + }); + var overview = await client.ShowOnChainWalletOverview(walletId.StoreId, walletId.CryptoCode ); + Assert.Equal(0m, overview.Balance); + + await AssertHttpError(403, async () => + { + await viewOnlyClient.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode ); + }); + var address = await client.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode ); + var address2 = await client.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode ); + var address3 = await client.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode, true ); + Assert.Equal(address.Address, address2.Address); + Assert.NotEqual(address.Address, address3.Address); + await AssertHttpError(403, async () => + { + await viewOnlyClient.GetOnChainWalletUTXOs(walletId.StoreId, walletId.CryptoCode); + }); + Assert.Empty(await client.GetOnChainWalletUTXOs(walletId.StoreId, walletId.CryptoCode)); + uint256 txhash = null; + await tester.WaitForEvent(async () => + { + txhash = await tester.ExplorerNode.SendToAddressAsync( + BitcoinAddress.Create(address3.Address, tester.ExplorerClient.Network.NBitcoinNetwork), + new Money(0.01m, MoneyUnit.BTC)); + }); + await tester.ExplorerNode.GenerateAsync(1); + + var address4 = await client.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode, false ); + Assert.NotEqual(address3.Address, address4.Address); + await client.UnReserveOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode); + var address5 = await client.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode, true ); + Assert.Equal(address5.Address, address4.Address); + + + var utxo = Assert.Single(await client.GetOnChainWalletUTXOs(walletId.StoreId, walletId.CryptoCode)); + Assert.Equal(0.01m, utxo.Amount); + Assert.Equal(txhash, utxo.Outpoint.Hash); + overview = await client.ShowOnChainWalletOverview(walletId.StoreId, walletId.CryptoCode ); + Assert.Equal(0.01m, overview.Balance); + + //the simplest request: + var nodeAddress = await tester.ExplorerNode.GetNewAddressAsync(); + var createTxRequest = new CreateOnChainTransactionRequest() + { + Destinations = + new List() + { + new CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination() + { + Destination = nodeAddress.ToString(), Amount = 0.001m + } + }, + FeeRate = new FeeRate(5m) //only because regtest may fail but not required + }; + await AssertHttpError(403, async () => + { + await viewOnlyClient.CreateOnChainTransaction(walletId.StoreId, walletId.CryptoCode, createTxRequest ); + }); + await Assert.ThrowsAsync(async () => + { + await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode, + createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork); + }); + await Assert.ThrowsAsync(async () => + { + createTxRequest.ProceedWithBroadcast = false; + await client.CreateOnChainTransaction(walletId.StoreId, walletId.CryptoCode, + createTxRequest); + }); + Transaction tx; + + tx = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode, + createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork); + + + Assert.NotNull(tx); + Assert.Contains(tx.Outputs, txout => txout.IsTo(nodeAddress) && txout.Value.ToDecimal(MoneyUnit.BTC) == 0.001m); + Assert.True((await tester.ExplorerNode.TestMempoolAcceptAsync(tx)).IsAllowed); + + // no change test + createTxRequest.NoChange = true; + tx = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode, + createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork); + Assert.NotNull(tx); + Assert.True(Assert.Single(tx.Outputs).IsTo(nodeAddress) ); + Assert.True((await tester.ExplorerNode.TestMempoolAcceptAsync(tx)).IsAllowed); + + createTxRequest.NoChange = false; + //coin selection + await AssertValidationError(new []{nameof(createTxRequest.SelectedInputs)}, async () => + { + createTxRequest.SelectedInputs = new List(); + tx = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode, + createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork); + }); + createTxRequest.SelectedInputs = new List() + { + utxo.Outpoint + }; + tx = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode, + createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork); + createTxRequest.SelectedInputs = null; + + //destination testing + await AssertValidationError(new []{ "Destinations"}, async () => + { + createTxRequest.Destinations[0].Amount = utxo.Amount; + tx = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode, + createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork); + }); + + createTxRequest.Destinations[0].SubtractFromAmount = true; + tx = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode, + createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork); + + + await AssertValidationError(new []{ "Destinations[0]"}, async () => + { + createTxRequest.Destinations[0].Amount = 0m; + tx = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode, + createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork); + }); + + //dest can be a bip21 + + //cant use bip with subtractfromamount + createTxRequest.Destinations[0].Amount = null; + createTxRequest.Destinations[0].Destination = $"bitcoin:{nodeAddress}?amount=0.001"; + await AssertValidationError(new []{ "Destinations[0]"}, async () => + { + tx = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode, + createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork); + }); + //if amt specified, it overrides bip21 amount + createTxRequest.Destinations[0].Amount = 0.0001m; + createTxRequest.Destinations[0].SubtractFromAmount = false; + tx = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode, + createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork); + Assert.Contains(tx.Outputs, txout => txout.Value.GetValue(tester.NetworkProvider.GetNetwork("BTC")) ==0.0001m ); + + //fee rate test + createTxRequest.FeeRate = FeeRate.Zero; + await AssertValidationError(new []{ "FeeRate"}, async () => + { + tx = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode, + createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork); + }); + + + createTxRequest.FeeRate = null; + + createTxRequest.Destinations[0].Amount = 0.001m; + createTxRequest.Destinations[0].Destination = nodeAddress.ToString(); + createTxRequest.Destinations[0].SubtractFromAmount = false; + await AssertHttpError(403, async () => + { + await viewOnlyClient.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode, + createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork); + }); + createTxRequest.ProceedWithBroadcast = true; + var txdata= + await client.CreateOnChainTransaction(walletId.StoreId, walletId.CryptoCode, + createTxRequest); + Assert.Equal(TransactionStatus.Unconfirmed, txdata.Status); + Assert.Null(txdata.BlockHeight); + Assert.Null(txdata.BlockHash); + Assert.NotNull(await tester.ExplorerClient.GetTransactionAsync(txdata.TransactionHash)); + + await AssertHttpError(403, async () => + { + await viewOnlyClient.GetOnChainWalletTransaction(walletId.StoreId, walletId.CryptoCode, txdata.TransactionHash.ToString()); + }); + await client.GetOnChainWalletTransaction(walletId.StoreId, walletId.CryptoCode, txdata.TransactionHash.ToString()); + + await AssertHttpError(403, async () => + { + await viewOnlyClient.ShowOnChainWalletTransactions(walletId.StoreId, walletId.CryptoCode); + }); + Assert.True(Assert.Single( + await client.ShowOnChainWalletTransactions(walletId.StoreId, walletId.CryptoCode, + new[] {TransactionStatus.Confirmed})).TransactionHash == utxo.Outpoint.Hash); + Assert.Contains( + await client.ShowOnChainWalletTransactions(walletId.StoreId, walletId.CryptoCode, + new[] {TransactionStatus.Unconfirmed}), data => data.TransactionHash == txdata.TransactionHash); + Assert.Contains( + await client.ShowOnChainWalletTransactions(walletId.StoreId, walletId.CryptoCode), data => data.TransactionHash == txdata.TransactionHash); + await tester.WaitForEvent(async () => + { + + await tester.ExplorerNode.GenerateAsync(1); + }, bevent => bevent.CryptoCode.Equals("BTC", StringComparison.Ordinal)); + + Assert.Contains( + await client.ShowOnChainWalletTransactions(walletId.StoreId, walletId.CryptoCode, + new[] {TransactionStatus.Confirmed}), data => data.TransactionHash == txdata.TransactionHash); + + } + [Fact(Timeout = TestTimeout)] [Trait("Fast", "Fast")] public void NumericJsonConverterTests() @@ -1411,7 +1626,6 @@ namespace BTCPayServer.Tests Assert.True(jsonConverter.CanConvert(typeof(double))); Assert.True(jsonConverter.CanConvert(typeof(double?))); Assert.False(jsonConverter.CanConvert(typeof(float))); - Assert.False(jsonConverter.CanConvert(typeof(int))); Assert.False(jsonConverter.CanConvert(typeof(string))); var numberJson = "1"; diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs index 6d5ec4fbc..8c457e48c 100644 --- a/BTCPayServer.Tests/PayJoinTests.cs +++ b/BTCPayServer.Tests/PayJoinTests.cs @@ -840,17 +840,24 @@ retry: settings.PaymentId == paymentMethodId); ReceivedCoin[] senderCoins = null; + ReceivedCoin coin = null; + ReceivedCoin coin2 = null; + ReceivedCoin coin3 = null; + ReceivedCoin coin4 = null; + ReceivedCoin coin5 = null; + ReceivedCoin coin6 = null; await TestUtils.EventuallyAsync(async () => { senderCoins = await btcPayWallet.GetUnspentCoins(senderUser.DerivationScheme); Assert.Contains(senderCoins, coin => coin.Value.GetValue(btcPayNetwork) == 0.026m); + coin = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.021m); + coin2 = Assert.Single(senderCoins, coin => coin.Value.GetValue(btcPayNetwork) == 0.022m); + coin3 = Assert.Single(senderCoins, coin => coin.Value.GetValue(btcPayNetwork) == 0.023m); + coin4 = Assert.Single(senderCoins, coin => coin.Value.GetValue(btcPayNetwork) == 0.024m); + coin5 = Assert.Single(senderCoins, coin => coin.Value.GetValue(btcPayNetwork) == 0.025m); + coin6 = Assert.Single(senderCoins, coin => coin.Value.GetValue(btcPayNetwork) == 0.026m); }); - var coin = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.021m); - var coin2 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.022m); - var coin3 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.023m); - var coin4 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.024m); - var coin5 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.025m); - var coin6 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.026m); + var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings(); signingKeySettings.RootFingerprint = diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 85e57c2ee..ec9bf0b3c 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -183,6 +183,7 @@ namespace BTCPayServer.Tests { ScriptPubKeyType = segwit, SavePrivateKeys = importKeysToNBX, + ImportKeysToRPC = importKeysToNBX }); await store.UpdateWallet( new WalletSetupViewModel diff --git a/BTCPayServer/Controllers/GreenField/StoreOnChainWalletsController.cs b/BTCPayServer/Controllers/GreenField/StoreOnChainWalletsController.cs new file mode 100644 index 000000000..6205e705c --- /dev/null +++ b/BTCPayServer/Controllers/GreenField/StoreOnChainWalletsController.cs @@ -0,0 +1,539 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.BIP78.Sender; +using BTCPayServer.Client; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.HostedServices; +using BTCPayServer.Models.WalletViewModels; +using BTCPayServer.Payments.PayJoin.Sender; +using BTCPayServer.Services; +using BTCPayServer.Services.Wallets; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using NBitcoin; +using NBitcoin.Payment; +using NBXplorer; +using NBXplorer.Models; +using Newtonsoft.Json.Linq; +using StoreData = BTCPayServer.Data.StoreData; + +namespace BTCPayServer.Controllers.GreenField +{ + [ApiController] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [EnableCors(CorsPolicies.All)] + public class StoreOnChainWalletsController : Controller + { + private StoreData Store => HttpContext.GetStoreData(); + private readonly IAuthorizationService _authorizationService; + private readonly BTCPayWalletProvider _btcPayWalletProvider; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly WalletRepository _walletRepository; + private readonly ExplorerClientProvider _explorerClientProvider; + private readonly CssThemeManager _cssThemeManager; + private readonly NBXplorerDashboard _nbXplorerDashboard; + private readonly WalletsController _walletsController; + private readonly PayjoinClient _payjoinClient; + private readonly DelayedTransactionBroadcaster _delayedTransactionBroadcaster; + private readonly EventAggregator _eventAggregator; + private readonly WalletReceiveService _walletReceiveService; + + public StoreOnChainWalletsController( + IAuthorizationService authorizationService, + BTCPayWalletProvider btcPayWalletProvider, + BTCPayNetworkProvider btcPayNetworkProvider, + WalletRepository walletRepository, + ExplorerClientProvider explorerClientProvider, + CssThemeManager cssThemeManager, + NBXplorerDashboard nbXplorerDashboard, + WalletsController walletsController, + PayjoinClient payjoinClient, + DelayedTransactionBroadcaster delayedTransactionBroadcaster, + EventAggregator eventAggregator, + WalletReceiveService walletReceiveService) + { + _authorizationService = authorizationService; + _btcPayWalletProvider = btcPayWalletProvider; + _btcPayNetworkProvider = btcPayNetworkProvider; + _walletRepository = walletRepository; + _explorerClientProvider = explorerClientProvider; + _cssThemeManager = cssThemeManager; + _nbXplorerDashboard = nbXplorerDashboard; + _walletsController = walletsController; + _payjoinClient = payjoinClient; + _delayedTransactionBroadcaster = delayedTransactionBroadcaster; + _eventAggregator = eventAggregator; + _walletReceiveService = walletReceiveService; + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet")] + public async Task ShowOnChainWalletOverview(string storeId, string cryptoCode) + { + if (IsInvalidWalletRequest(cryptoCode, out BTCPayNetwork network, + out DerivationSchemeSettings derivationScheme, out IActionResult actionResult)) return actionResult; + + var wallet = _btcPayWalletProvider.GetWallet(network); + return Ok(new OnChainWalletOverviewData() + { + Balance = await wallet.GetBalance(derivationScheme.AccountDerivation) + }); + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/address")] + public async Task GetOnChainWalletReceiveAddress(string storeId, string cryptoCode, bool forceGenerate = false) + { + if (IsInvalidWalletRequest(cryptoCode, out BTCPayNetwork network, + out DerivationSchemeSettings derivationScheme, out IActionResult actionResult)) return actionResult; + + var kpi = await _walletReceiveService.GetOrGenerate(new WalletId(storeId, cryptoCode), forceGenerate); + if (kpi is null) + { + return BadRequest(); + } + return Ok(new OnChainWalletAddressData() + { + Address = kpi.Address.ToString(), + KeyPath = kpi.KeyPath + }); + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpDelete("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/address")] + public async Task UnReserveOnChainWalletReceiveAddress(string storeId, string cryptoCode) + { + if (IsInvalidWalletRequest(cryptoCode, out BTCPayNetwork network, + out DerivationSchemeSettings derivationScheme, out IActionResult actionResult)) return actionResult; + + var addr = await _walletReceiveService.UnReserveAddress(new WalletId(storeId, cryptoCode)); + if (addr is null) + { + return NotFound(); + } + return Ok(); + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions")] + public async Task ShowOnChainWalletTransactions(string storeId, string cryptoCode, + [FromQuery]TransactionStatus[] statusFilter = null) + { + if (IsInvalidWalletRequest(cryptoCode, out BTCPayNetwork network, + out DerivationSchemeSettings derivationScheme, out IActionResult actionResult)) return actionResult; + + var wallet = _btcPayWalletProvider.GetWallet(network); + var walletId = new WalletId(storeId, cryptoCode); + var walletBlobAsync = await _walletRepository.GetWalletInfo(walletId); + var walletTransactionsInfoAsync = await _walletRepository.GetWalletTransactionsInfo(walletId); + + var txs = await wallet.FetchTransactions(derivationScheme.AccountDerivation); + var filteredFlatList = new List(); + if (statusFilter is null || !statusFilter.Any() || statusFilter.Contains(TransactionStatus.Confirmed)) + { + filteredFlatList.AddRange(txs.ConfirmedTransactions.Transactions); + } + + if (statusFilter is null || !statusFilter.Any() || statusFilter.Contains(TransactionStatus.Unconfirmed)) + { + filteredFlatList.AddRange(txs.UnconfirmedTransactions.Transactions); + } + + if (statusFilter is null || !statusFilter.Any() ||statusFilter.Contains(TransactionStatus.Replaced)) + { + filteredFlatList.AddRange(txs.ReplacedTransactions.Transactions); + } + + var result = filteredFlatList.Select(information => + { + walletTransactionsInfoAsync.TryGetValue(information.TransactionId.ToString(), out var transactionInfo); + return ToModel(transactionInfo, information, wallet); + }).ToList(); + return Ok(result); + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions/{transactionId}")] + public async Task GetOnChainWalletTransaction(string storeId, string cryptoCode, + string transactionId) + { + if (IsInvalidWalletRequest(cryptoCode, out BTCPayNetwork network, + out DerivationSchemeSettings derivationScheme, out IActionResult actionResult)) return actionResult; + + var wallet = _btcPayWalletProvider.GetWallet(network); + var tx = await wallet.FetchTransaction(derivationScheme.AccountDerivation, uint256.Parse(transactionId)); + if (tx is null) + { + return NotFound(); + } + + var walletId = new WalletId(storeId, cryptoCode); + var walletTransactionsInfoAsync = + (await _walletRepository.GetWalletTransactionsInfo(walletId, new[] {transactionId})).Values + .FirstOrDefault(); + + return Ok(ToModel(walletTransactionsInfoAsync, tx, wallet)); + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/utxos")] + public async Task GetOnChainWalletUTXOs(string storeId, string cryptoCode) + { + if (IsInvalidWalletRequest(cryptoCode, out BTCPayNetwork network, + out DerivationSchemeSettings derivationScheme, out IActionResult actionResult)) return actionResult; + + var wallet = _btcPayWalletProvider.GetWallet(network); + + var walletId = new WalletId(storeId, cryptoCode); + var walletTransactionsInfoAsync = await _walletRepository.GetWalletTransactionsInfo(walletId); + var utxos = await wallet.GetUnspentCoins(derivationScheme.AccountDerivation); + return Ok(utxos.Select(coin => + { + walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info); + return new OnChainWalletUTXOData() + { + Outpoint = coin.OutPoint, + Amount = coin.Value.GetValue(network), + Comment = info?.Comment, + Labels = info?.Labels, + Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, + coin.OutPoint.Hash.ToString()) + }; + }).ToList() + ); + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpPost("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions")] + public async Task CreateOnChainTransaction(string storeId, string cryptoCode, + [FromBody] CreateOnChainTransactionRequest request) + { + if (IsInvalidWalletRequest(cryptoCode, out BTCPayNetwork network, + out DerivationSchemeSettings derivationScheme, out IActionResult actionResult)) return actionResult; + if (network.ReadonlyWallet) + { + return this.CreateAPIError("not-available", + $"{cryptoCode} sending services are not currently available"); + } + + //This API is only meant for hot wallet usage for now. We can expand later when we allow PSBT manipulation. + if (!(await CanUseHotWallet()).HotWallet) + { + return Unauthorized(); + } + + var explorerClient = _explorerClientProvider.GetExplorerClient(cryptoCode); + var wallet = _btcPayWalletProvider.GetWallet(network); + + var utxos = await wallet.GetUnspentCoins(derivationScheme.AccountDerivation); + if (request.SelectedInputs != null || !utxos.Any()) + { + utxos = utxos.Where(coin => request.SelectedInputs?.Contains(coin.OutPoint) ?? true) + .ToArray(); + if (utxos.Any() is false) + { + //no valid utxos selected + request.AddModelError(transactionRequest => transactionRequest.SelectedInputs, + "There are no available utxos based on your request", this); + } + } + + var balanceAvailable = utxos.Sum(coin => coin.Value.GetValue(network)); + + var subtractFeesOutputsCount = new List(); + var subtractFees = request.Destinations.Any(o => o.SubtractFromAmount); + int? payjoinOutputIndex = null; + var sum = 0m; + var outputs = new List(); + for (var index = 0; index < request.Destinations.Count; index++) + { + var destination = request.Destinations[index]; + + if (destination.SubtractFromAmount) + { + subtractFeesOutputsCount.Add(index); + } + + BitcoinUrlBuilder bip21 = null; + var amount = destination.Amount; + if (amount.GetValueOrDefault(0) <= 0) + { + amount = null; + } + var address = string.Empty; + try + { + destination.Destination = destination.Destination.Replace(network.UriScheme+":", "bitcoin:", StringComparison.InvariantCultureIgnoreCase); + bip21 = new BitcoinUrlBuilder(destination.Destination, network.NBitcoinNetwork); + amount ??= bip21.Amount.GetValue(network); + address = bip21.Address.ToString(); + if (destination.SubtractFromAmount) + { + request.AddModelError(transactionRequest => transactionRequest.Destinations[index], + "You cannot use a BIP21 destination along with SubtractFromAmount", this); + } + } + catch (FormatException) + { + try + { + address = BitcoinAddress.Create(destination.Destination, network.NBitcoinNetwork).ToString(); + } + catch (Exception e) + { + request.AddModelError(transactionRequest => transactionRequest.Destinations[index], + "Destination must be a BIP21 payment link or an address", this); + } + } + + if (amount is null || amount <= 0) + { + request.AddModelError(transactionRequest => transactionRequest.Destinations[index], + "Amount must be specified or destination must be a BIP21 payment link, and greater than 0", this); + } + if (request.ProceedWithPayjoin && bip21?.UnknowParameters?.ContainsKey("pj") is true) + { + payjoinOutputIndex = index; + } + + outputs.Add(new WalletSendModel.TransactionOutput() + { + DestinationAddress = address, + Amount = amount, + SubtractFeesFromOutput = destination.SubtractFromAmount + }); + sum += destination.Amount ?? 0; + } + + if (subtractFeesOutputsCount.Count > 1) + { + foreach (var subtractFeesOutput in subtractFeesOutputsCount) + { + request.AddModelError(model => model.Destinations[subtractFeesOutput].SubtractFromAmount, + "You can only subtract fees from one destination", this); + } + } + + if (balanceAvailable < sum) + { + request.AddModelError(transactionRequest => transactionRequest.Destinations, + "You are attempting to send more than is available", this); + } + else if (balanceAvailable == sum && !subtractFees) + { + request.AddModelError(transactionRequest => transactionRequest.Destinations, + "You are sending your entire balance, you should subtract the fees from a destination", this); + } + + var minRelayFee = this._nbXplorerDashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ?? + new FeeRate(1.0m); + if (request.FeeRate != null && request.FeeRate < minRelayFee) + { + ModelState.AddModelError(nameof(request.FeeRate), + "The fee rate specified is lower than the current minimum relay fee"); + } + + if (!ModelState.IsValid) + { + return this.CreateValidationError(ModelState); + } + + CreatePSBTResponse psbt; + try + { + psbt = await _walletsController.CreatePSBT(network, derivationScheme, + new WalletSendModel() + { + SelectedInputs = request.SelectedInputs?.Select(point => point.ToString()), + Outputs = outputs, + AlwaysIncludeNonWitnessUTXO = true, + InputSelection = request.SelectedInputs?.Any() is true, + AllowFeeBump = + !request.RBF.HasValue ? WalletSendModel.ThreeStateBool.Maybe : + request.RBF.Value ? WalletSendModel.ThreeStateBool.Yes : + WalletSendModel.ThreeStateBool.No, + FeeSatoshiPerByte = request.FeeRate?.SatoshiPerByte, + NoChange = request.NoChange + }, + CancellationToken.None); + } + catch (NBXplorerException ex) + { + return this.CreateAPIError(ex.Error.Code, ex.Error.Message); + } + catch (NotSupportedException) + { + return this.CreateAPIError("not-available", "You need to update your version of NBXplorer"); + } + + derivationScheme.RebaseKeyPaths(psbt.PSBT); + + var signingContext = new SigningContextModel() + { + PayJoinBIP21 = + payjoinOutputIndex is null + ? null + : request.Destinations.ElementAt(payjoinOutputIndex.Value).Destination, + EnforceLowR = psbt.Suggestions?.ShouldEnforceLowR, + ChangeAddress = psbt.ChangeAddress?.ToString() + }; + + var signingKeyStr = await explorerClient + .GetMetadataAsync(derivationScheme.AccountDerivation, + WellknownMetadataKeys.MasterHDKey); + if (signingKeyStr is null) + { + return this.CreateAPIError("not-available", + $"{cryptoCode} sending services are not currently available"); + } + + var signingKey = ExtKey.Parse(signingKeyStr, network.NBitcoinNetwork); + + var signingKeySettings = derivationScheme.GetSigningAccountKeySettings(); + signingKeySettings.RootFingerprint ??= signingKey.GetPublicKey().GetHDFingerPrint(); + RootedKeyPath rootedKeyPath = signingKeySettings.GetRootedKeyPath(); + psbt.PSBT.RebaseKeyPaths(signingKeySettings.AccountKey, rootedKeyPath); + var accountKey = signingKey.Derive(rootedKeyPath.KeyPath); + + var changed = psbt.PSBT.PSBTChanged(() => psbt.PSBT.SignAll(derivationScheme.AccountDerivation, accountKey, + rootedKeyPath, new SigningOptions() {EnforceLowR = !(signingContext?.EnforceLowR is false)})); + + if (!changed) + { + return this.CreateAPIError("psbt-signing-error", + "Impossible to sign the transaction. Probable cause: Incorrect account key path in wallet settings, PSBT already signed."); + } + + psbt.PSBT.Finalize(); + var transaction = psbt.PSBT.ExtractTransaction(); + var transactionHash = transaction.GetHash(); + BroadcastResult broadcastResult; + if (!string.IsNullOrEmpty(signingContext.PayJoinBIP21)) + { + signingContext.OriginalPSBT = psbt.PSBT.ToBase64(); + try + { + await _delayedTransactionBroadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), + transaction, network); + var payjoinPSBT = await _payjoinClient.RequestPayjoin( + new BitcoinUrlBuilder(signingContext.PayJoinBIP21, network.NBitcoinNetwork), new PayjoinWallet(derivationScheme), + psbt.PSBT, CancellationToken.None); + payjoinPSBT = psbt.PSBT.SignAll(derivationScheme.AccountDerivation, accountKey, rootedKeyPath, + new SigningOptions() {EnforceLowR = !(signingContext?.EnforceLowR is false)}); + payjoinPSBT.Finalize(); + var payjoinTransaction = payjoinPSBT.ExtractTransaction(); + var hash = payjoinTransaction.GetHash(); + _eventAggregator.Publish(new UpdateTransactionLabel(new WalletId(Store.Id, cryptoCode), hash, + UpdateTransactionLabel.PayjoinLabelTemplate())); + broadcastResult = await explorerClient.BroadcastAsync(payjoinTransaction); + if (broadcastResult.Success) + { + return await GetOnChainWalletTransaction(storeId, cryptoCode, hash.ToString()); + } + } + catch (PayjoinException) + { + //not a critical thing, payjoin is great if possible, fine if not + } + } + + if (!request.ProceedWithBroadcast) + { + return Ok(new JValue(transaction.ToHex())); + } + + broadcastResult = await explorerClient.BroadcastAsync(transaction); + if (broadcastResult.Success) + { + return await GetOnChainWalletTransaction(storeId, cryptoCode, transactionHash.ToString()); + } + else + { + return this.CreateAPIError("broadcast-error", broadcastResult.RPCMessage); + } + } + + private async Task<(bool HotWallet, bool RPCImport)> CanUseHotWallet() + { + return await _authorizationService.CanUseHotWallet(_cssThemeManager.Policies, User); + } + + private async Task GetWallet(DerivationSchemeSettings derivationScheme) + { + if (!derivationScheme.IsHotWallet) + return null; + + var result = await _explorerClientProvider.GetExplorerClient(derivationScheme.Network.CryptoCode) + .GetMetadataAsync(derivationScheme.AccountDerivation, + WellknownMetadataKeys.MasterHDKey); + return string.IsNullOrEmpty(result) ? null : ExtKey.Parse(result, derivationScheme.Network.NBitcoinNetwork); + } + + private bool IsInvalidWalletRequest(string cryptoCode, out BTCPayNetwork network, + out DerivationSchemeSettings derivationScheme, out IActionResult actionResult) + { + derivationScheme = null; + actionResult = null; + network = _btcPayNetworkProvider.GetNetwork(cryptoCode); + if (network is null) + { + actionResult = NotFound(); + return true; + } + + + if (!network.WalletSupported || !_btcPayWalletProvider.IsAvailable(network)) + { + actionResult = this.CreateAPIError("not-available", + $"{cryptoCode} services are not currently available"); + return true; + } + + derivationScheme = GetDerivationSchemeSettings(cryptoCode); + if (derivationScheme?.AccountDerivation is null) + { + actionResult = NotFound(); + return true; + } + + return false; + } + + private DerivationSchemeSettings GetDerivationSchemeSettings(string cryptoCode) + { + var paymentMethod = Store + .GetSupportedPaymentMethods(_btcPayNetworkProvider) + .OfType() + .FirstOrDefault(p => + p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike && + p.PaymentId.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase)); + return paymentMethod; + } + + private OnChainWalletTransactionData ToModel(WalletTransactionInfo walletTransactionsInfoAsync, + TransactionInformation tx, + BTCPayWallet wallet) + { + return new OnChainWalletTransactionData() + { + TransactionHash = tx.TransactionId, + Comment = walletTransactionsInfoAsync?.Comment?? string.Empty, + Labels = walletTransactionsInfoAsync?.Labels?? new Dictionary(), + Amount = tx.BalanceChange.GetValue(wallet.Network), + BlockHash = tx.BlockHash, + BlockHeight = tx.Height, + Confirmations = tx.Confirmations, + Timestamp = tx.Timestamp, + Status = tx.Confirmations > 0 ? TransactionStatus.Confirmed : + tx.ReplacedBy != null ? TransactionStatus.Replaced : TransactionStatus.Unconfirmed + }; + } + } +} diff --git a/BTCPayServer/Controllers/StoresController.Onchain.cs b/BTCPayServer/Controllers/StoresController.Onchain.cs index f2808dfd9..fa0c66bca 100644 --- a/BTCPayServer/Controllers/StoresController.Onchain.cs +++ b/BTCPayServer/Controllers/StoresController.Onchain.cs @@ -579,13 +579,8 @@ namespace BTCPayServer.Controllers private async Task<(bool HotWallet, bool RPCImport)> CanUseHotWallet() { - var isAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)) - .Succeeded; - if (isAdmin) - return (true, true); var policies = await _settingsRepository.GetSettingAsync(); - var hotWallet = policies?.AllowHotWalletForAll is true; - return (hotWallet, hotWallet && policies.AllowHotWalletRPCImportForAll is true); + return await _authorizationService.CanUseHotWallet(policies, User); } private async Task ReadAllText(IFormFile file) diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index 3f948c007..5b9b074c2 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -8,6 +8,7 @@ using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Models; using BTCPayServer.Client; +using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.HostedServices; using BTCPayServer.ModelBinders; @@ -31,6 +32,7 @@ using NBXplorer; using NBXplorer.DerivationStrategy; using NBXplorer.Models; using Newtonsoft.Json; +using StoreData = BTCPayServer.Data.StoreData; namespace BTCPayServer.Controllers { @@ -50,7 +52,7 @@ namespace BTCPayServer.Controllers private readonly IAuthorizationService _authorizationService; private readonly IFeeProviderFactory _feeRateProvider; private readonly BTCPayWalletProvider _walletProvider; - private readonly WalletReceiveStateService _WalletReceiveStateService; + private readonly WalletReceiveService _walletReceiveService; private readonly EventAggregator _EventAggregator; private readonly SettingsRepository _settingsRepository; private readonly DelayedTransactionBroadcaster _broadcaster; @@ -75,7 +77,7 @@ namespace BTCPayServer.Controllers ExplorerClientProvider explorerProvider, IFeeProviderFactory feeRateProvider, BTCPayWalletProvider walletProvider, - WalletReceiveStateService walletReceiveStateService, + WalletReceiveService walletReceiveService, EventAggregator eventAggregator, SettingsRepository settingsRepository, DelayedTransactionBroadcaster broadcaster, @@ -97,7 +99,7 @@ namespace BTCPayServer.Controllers ExplorerClientProvider = explorerProvider; _feeRateProvider = feeRateProvider; _walletProvider = walletProvider; - _WalletReceiveStateService = walletReceiveStateService; + _walletReceiveService = walletReceiveService; _EventAggregator = eventAggregator; _settingsRepository = settingsRepository; _broadcaster = broadcaster; @@ -361,7 +363,7 @@ namespace BTCPayServer.Controllers if (network == null) return NotFound(); - var address = _WalletReceiveStateService.Get(walletId)?.Address; + var address = _walletReceiveService.Get(walletId)?.Address; return View(new WalletReceiveViewModel() { CryptoCode = walletId.CryptoCode, @@ -383,29 +385,22 @@ namespace BTCPayServer.Controllers var network = this.NetworkProvider.GetNetwork(walletId?.CryptoCode); if (network == null) return NotFound(); - var wallet = _walletProvider.GetWallet(network); switch (command) { case "unreserve-current-address": - KeyPathInformation cachedAddress = _WalletReceiveStateService.Get(walletId); - if (cachedAddress == null) + var address = await _walletReceiveService.UnReserveAddress(walletId); + if (!string.IsNullOrEmpty(address)) { - break; + TempData.SetStatusMessageModel(new StatusMessageModel() + { + AllowDismiss = true, + Message = $"Address {address} was unreserved.", + Severity = StatusMessageModel.StatusSeverity.Success, + }); } - var address = cachedAddress.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork); - ExplorerClientProvider.GetExplorerClient(network) - .CancelReservation(cachedAddress.DerivationStrategy, new[] { cachedAddress.KeyPath }); - this.TempData.SetStatusMessageModel(new StatusMessageModel() - { - AllowDismiss = true, - Message = $"Address {address} was unreserved.", - Severity = StatusMessageModel.StatusSeverity.Success, - }); - _WalletReceiveStateService.Remove(walletId); break; case "generate-new-address": - var reserve = (await wallet.ReserveAddressAsync(paymentMethod.AccountDerivation)); - _WalletReceiveStateService.Set(walletId, reserve); + await _walletReceiveService.GetOrGenerate(walletId, true); break; } return RedirectToAction(nameof(WalletReceive), new { walletId }); @@ -413,11 +408,8 @@ namespace BTCPayServer.Controllers private async Task CanUseHotWallet() { - var isAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)).Succeeded; - if (isAdmin) - return true; var policies = await _settingsRepository.GetSettingAsync(); - return policies?.AllowHotWalletForAll is true; + return (await _authorizationService.CanUseHotWallet(policies, User)).HotWallet; } [HttpGet] @@ -667,7 +659,7 @@ namespace BTCPayServer.Controllers if (!ModelState.IsValid) return View(vm); - + DerivationSchemeSettings derivationScheme = GetDerivationSchemeSettings(walletId); CreatePSBTResponse psbt = null; @@ -907,7 +899,7 @@ namespace BTCPayServer.Controllers return View(nameof(SignWithSeed), viewModel); } - var changed = PSBTChanged(psbt, () => psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath, new SigningOptions() + var changed = psbt.PSBTChanged( () => psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath, new SigningOptions() { EnforceLowR = !(viewModel.SigningContext?.EnforceLowR is false) })); @@ -926,15 +918,6 @@ namespace BTCPayServer.Controllers }); } - - private bool PSBTChanged(PSBT psbt, Action act) - { - var before = psbt.ToBase64(); - act(); - var after = psbt.ToBase64(); - return before != after; - } - private string ValueToString(Money v, BTCPayNetworkBase network) { return v.ToString() + " " + network.CryptoCode; @@ -1041,11 +1024,7 @@ namespace BTCPayServer.Controllers internal DerivationSchemeSettings GetDerivationSchemeSettings(WalletId walletId) { - var paymentMethod = CurrentStore - .GetSupportedPaymentMethods(NetworkProvider) - .OfType() - .FirstOrDefault(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike && p.PaymentId.CryptoCode == walletId.CryptoCode); - return paymentMethod; + return CurrentStore.GetDerivationSchemeSettings(NetworkProvider, walletId.CryptoCode); } private static async Task GetBalanceString(BTCPayWallet wallet, DerivationStrategyBase derivationStrategy) diff --git a/BTCPayServer/Data/WalletTransactionDataExtensions.cs b/BTCPayServer/Data/WalletTransactionDataExtensions.cs index fbdf5d64f..e2ed6c03b 100644 --- a/BTCPayServer/Data/WalletTransactionDataExtensions.cs +++ b/BTCPayServer/Data/WalletTransactionDataExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using BTCPayServer.Client.Models; using BTCPayServer.Services.Labels; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -12,7 +13,7 @@ namespace BTCPayServer.Data { public string Comment { get; set; } = string.Empty; [JsonIgnore] - public Dictionary Labels { get; set; } = new Dictionary(); + public Dictionary Labels { get; set; } = new Dictionary(); } public static class WalletTransactionDataExtensions { diff --git a/BTCPayServer/Extensions/AuthorizationExtensions.cs b/BTCPayServer/Extensions/AuthorizationExtensions.cs new file mode 100644 index 000000000..57dd46583 --- /dev/null +++ b/BTCPayServer/Extensions/AuthorizationExtensions.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; +using System.Threading.Tasks; +using BTCPayServer.Client; +using BTCPayServer.Security.Bitpay; +using BTCPayServer.Security.GreenField; +using BTCPayServer.Services; +using Microsoft.AspNetCore.Authorization; + +namespace BTCPayServer +{ + public static class AuthorizationExtensions + { + public static async Task<(bool HotWallet, bool RPCImport)> CanUseHotWallet( + this IAuthorizationService authorizationService, + PoliciesSettings policiesSettings, + ClaimsPrincipal user) + { + return (await authorizationService.AuthorizeAsync(user, Policies.CanModifyServerSettings)) + .Succeeded ? (true, true) : (policiesSettings?.AllowHotWalletForAll is true, policiesSettings?.AllowHotWalletRPCImportForAll is true); + } + } +} diff --git a/BTCPayServer/Extensions/PSBTExtensions.cs b/BTCPayServer/Extensions/PSBTExtensions.cs new file mode 100644 index 000000000..4c3129530 --- /dev/null +++ b/BTCPayServer/Extensions/PSBTExtensions.cs @@ -0,0 +1,16 @@ +using System; +using NBitcoin; + +namespace BTCPayServer +{ + public static class PSBTExtensions + { + public static bool PSBTChanged(this PSBT psbt, Action act) + { + var before = psbt.ToBase64(); + act(); + var after = psbt.ToBase64(); + return before != after; + } + } +} diff --git a/BTCPayServer/Extensions/StoreExtensions.cs b/BTCPayServer/Extensions/StoreExtensions.cs new file mode 100644 index 000000000..bc3058786 --- /dev/null +++ b/BTCPayServer/Extensions/StoreExtensions.cs @@ -0,0 +1,18 @@ +using System.Linq; +using BTCPayServer.Data; + +namespace BTCPayServer +{ + public static class StoreExtensions + { + public static DerivationSchemeSettings GetDerivationSchemeSettings(this StoreData store, BTCPayNetworkProvider networkProvider, string cryptoCode) + { + var paymentMethod = store + .GetSupportedPaymentMethods(networkProvider) + .OfType() + .FirstOrDefault(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike && p.PaymentId.CryptoCode == cryptoCode); + return paymentMethod; + } + + } +} diff --git a/BTCPayServer/HostedServices/WalletReceiveCacheUpdater.cs b/BTCPayServer/HostedServices/WalletReceiveCacheUpdater.cs deleted file mode 100644 index 54415ae71..000000000 --- a/BTCPayServer/HostedServices/WalletReceiveCacheUpdater.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using BTCPayServer.Events; -using BTCPayServer.Services.Wallets; -using Microsoft.Extensions.Hosting; -using NBXplorer; - -namespace BTCPayServer.HostedServices -{ - public class WalletReceiveCacheUpdater : IHostedService - { - private readonly EventAggregator _EventAggregator; - private readonly WalletReceiveStateService _WalletReceiveStateService; - - private readonly CompositeDisposable _Leases = new CompositeDisposable(); - - public WalletReceiveCacheUpdater(EventAggregator eventAggregator, - WalletReceiveStateService walletReceiveStateService) - { - _EventAggregator = eventAggregator; - _WalletReceiveStateService = walletReceiveStateService; - } - - public Task StartAsync(CancellationToken cancellationToken) - { - _Leases.Add(_EventAggregator.Subscribe(evt => - _WalletReceiveStateService.Remove(evt.WalletId))); - - _Leases.Add(_EventAggregator.Subscribe(evt => - { - var matching = _WalletReceiveStateService - .GetByDerivation(evt.CryptoCode, evt.NewTransactionEvent.DerivationStrategy).Where(pair => - evt.NewTransactionEvent.Outputs.Any(output => output.ScriptPubKey == pair.Value.ScriptPubKey)); - - foreach (var keyValuePair in matching) - { - _WalletReceiveStateService.Remove(keyValuePair.Key); - } - })); - - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _Leases.Dispose(); - return Task.CompletedTask; - } - } -} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index f94037cc7..e15a88ce6 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -293,7 +293,8 @@ namespace BTCPayServer.Hosting services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddSingleton( provider => provider.GetService()); services.TryAddSingleton(CurrencyNameTable.Instance); services.TryAddSingleton(o => new NBXplorerFeeProviderFactory(o.GetRequiredService()) { @@ -346,7 +347,6 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/BTCPayServer/Services/Labels/Label.cs b/BTCPayServer/Services/Labels/Label.cs index c686d86a3..898aa0420 100644 --- a/BTCPayServer/Services/Labels/Label.cs +++ b/BTCPayServer/Services/Labels/Label.cs @@ -1,16 +1,13 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using BTCPayServer.Client.Models; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace BTCPayServer.Services.Labels { - public abstract class Label + + public abstract class Label: LabelData { - public string Type { get; set; } - public string Text { get; set; } static void FixLegacy(JObject jObj, ReferenceLabel refLabel) { if (refLabel.Reference is null) @@ -80,8 +77,6 @@ namespace BTCPayServer.Services.Labels return new RawLabel(str); } } - [JsonExtensionData] - public IDictionary AdditionalData { get; set; } } public class RawLabel : Label diff --git a/BTCPayServer/Services/Labels/LabelFactory.cs b/BTCPayServer/Services/Labels/LabelFactory.cs index 82ec701af..e5307f8b2 100644 --- a/BTCPayServer/Services/Labels/LabelFactory.cs +++ b/BTCPayServer/Services/Labels/LabelFactory.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Amazon.Util.Internal.PlatformServices; +using BTCPayServer.Client.Models; using BTCPayServer.Data; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -37,7 +38,7 @@ namespace BTCPayServer.Services.Labels } const string DefaultColor = "#000"; - private ColoredLabel CreateLabel(Label uncoloredLabel, string color, HttpRequest request) + private ColoredLabel CreateLabel(LabelData uncoloredLabel, string color, HttpRequest request) { if (uncoloredLabel == null) throw new ArgumentNullException(nameof(uncoloredLabel)); diff --git a/BTCPayServer/Services/WalletRepository.cs b/BTCPayServer/Services/WalletRepository.cs index 6c3041447..b88b380eb 100644 --- a/BTCPayServer/Services/WalletRepository.cs +++ b/BTCPayServer/Services/WalletRepository.cs @@ -38,7 +38,7 @@ namespace BTCPayServer.Services } } - public async Task> GetWalletTransactionsInfo(WalletId walletId) + public async Task> GetWalletTransactionsInfo(WalletId walletId, string[] transactionIds = null) { if (walletId == null) throw new ArgumentNullException(nameof(walletId)); @@ -46,6 +46,7 @@ namespace BTCPayServer.Services { return (await ctx.WalletTransactions .Where(w => w.WalletDataId == walletId.ToString()) + .Where(data => transactionIds == null || transactionIds.Contains(data.TransactionId)) .Select(w => w) .ToArrayAsync()) .ToDictionary(w => w.TransactionId, w => w.GetBlobInfo()); diff --git a/BTCPayServer/Services/Wallets/BTCPayWallet.cs b/BTCPayServer/Services/Wallets/BTCPayWallet.cs index 286857d84..ddfa7fbb5 100644 --- a/BTCPayServer/Services/Wallets/BTCPayWallet.cs +++ b/BTCPayServer/Services/Wallets/BTCPayWallet.cs @@ -202,7 +202,40 @@ namespace BTCPayServer.Services.Wallets public async Task FetchTransactions(DerivationStrategyBase derivationStrategyBase) { - return _Network.FilterValidTransactions(await _Client.GetTransactionsAsync(derivationStrategyBase)); + return FilterValidTransactions(await _Client.GetTransactionsAsync(derivationStrategyBase)); + } + + private GetTransactionsResponse FilterValidTransactions(GetTransactionsResponse response) + { + return new GetTransactionsResponse() + { + Height = response.Height, + UnconfirmedTransactions = + new TransactionInformationSet() + { + Transactions = _Network.FilterValidTransactions(response.UnconfirmedTransactions.Transactions) + }, + ConfirmedTransactions = + new TransactionInformationSet() + { + Transactions = _Network.FilterValidTransactions(response.ConfirmedTransactions.Transactions) + }, + ReplacedTransactions = new TransactionInformationSet() + { + Transactions = _Network.FilterValidTransactions(response.ReplacedTransactions.Transactions) + } + }; + } + + public async Task FetchTransaction(DerivationStrategyBase derivationStrategyBase, uint256 transactionId) + { + var tx = await _Client.GetTransactionAsync(derivationStrategyBase, transactionId); + if (tx is null || !_Network.FilterValidTransactions(new List() {tx}).Any()) + { + return null; + } + + return tx; } public Task BroadcastTransactionsAsync(List transactions) diff --git a/BTCPayServer/Services/Wallets/WalletReceiveService.cs b/BTCPayServer/Services/Wallets/WalletReceiveService.cs new file mode 100644 index 000000000..baa5bdfa4 --- /dev/null +++ b/BTCPayServer/Services/Wallets/WalletReceiveService.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Events; +using BTCPayServer.Services.Stores; +using Microsoft.Extensions.Hosting; +using NBitcoin; +using NBXplorer; +using NBXplorer.DerivationStrategy; +using NBXplorer.Models; + +namespace BTCPayServer.Services.Wallets +{ + public class WalletReceiveService : IHostedService + { + private readonly CompositeDisposable _leases = new CompositeDisposable(); + private readonly EventAggregator _eventAggregator; + private readonly ExplorerClientProvider _explorerClientProvider; + private readonly BTCPayWalletProvider _btcPayWalletProvider; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly StoreRepository _storeRepository; + + private readonly ConcurrentDictionary _walletReceiveState = + new ConcurrentDictionary(); + + public WalletReceiveService(EventAggregator eventAggregator, ExplorerClientProvider explorerClientProvider, + BTCPayWalletProvider btcPayWalletProvider, BTCPayNetworkProvider btcPayNetworkProvider, + StoreRepository storeRepository) + { + _eventAggregator = eventAggregator; + _explorerClientProvider = explorerClientProvider; + _btcPayWalletProvider = btcPayWalletProvider; + _btcPayNetworkProvider = btcPayNetworkProvider; + _storeRepository = storeRepository; + } + + public async Task UnReserveAddress(WalletId walletId) + { + var kpi = Get(walletId); + if (kpi is null) + { + return null; + } + + var explorerClient = _explorerClientProvider.GetExplorerClient(walletId.CryptoCode); + if (explorerClient is null) + { + return null; + } + + await explorerClient.CancelReservationAsync(kpi.DerivationStrategy, new[] {kpi.KeyPath}); + return kpi.Address.ToString(); + } + + public async Task GetOrGenerate(WalletId walletId, bool forceGenerate = false) + { + var existing = Get(walletId); + if (existing != null && !forceGenerate) + { + return existing; + } + + var wallet = _btcPayWalletProvider.GetWallet(walletId.CryptoCode); + var store = await _storeRepository.FindStore(walletId.StoreId); + var derivationScheme = store?.GetDerivationSchemeSettings(_btcPayNetworkProvider, walletId.CryptoCode); + if (wallet is null || derivationScheme is null) + { + return null; + } + + var reserve = (await wallet.ReserveAddressAsync(derivationScheme.AccountDerivation)); + Set(walletId, reserve); + return reserve; + } + + public void Remove(WalletId walletId) + { + _walletReceiveState.TryRemove(walletId, out _); + } + + public KeyPathInformation Get(WalletId walletId) + { + if (_walletReceiveState.ContainsKey(walletId)) + { + return _walletReceiveState[walletId]; + } + + return null; + } + + private void Set(WalletId walletId, KeyPathInformation information) + { + _walletReceiveState.AddOrReplace(walletId, information); + } + + public IEnumerable> GetByDerivation(string cryptoCode, + DerivationStrategyBase derivationStrategyBase) + { + return _walletReceiveState.Where(pair => + pair.Key.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCulture) && + pair.Value.DerivationStrategy == derivationStrategyBase); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _leases.Add(_eventAggregator.Subscribe(evt => + Remove(evt.WalletId))); + + _leases.Add(_eventAggregator.Subscribe(evt => + { + var matching = GetByDerivation(evt.CryptoCode, evt.NewTransactionEvent.DerivationStrategy).Where(pair => + evt.NewTransactionEvent.Outputs.Any(output => output.ScriptPubKey == pair.Value.ScriptPubKey)); + + foreach (var keyValuePair in matching) + { + Remove(keyValuePair.Key); + } + })); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _leases.Dispose(); + return Task.CompletedTask; + } + } +} diff --git a/BTCPayServer/Services/Wallets/WalletReceiveStateService.cs b/BTCPayServer/Services/Wallets/WalletReceiveStateService.cs deleted file mode 100644 index bd1969eb7..000000000 --- a/BTCPayServer/Services/Wallets/WalletReceiveStateService.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using NBitcoin; -using NBXplorer.DerivationStrategy; -using NBXplorer.Models; - -namespace BTCPayServer.Services.Wallets -{ - public class WalletReceiveStateService - { - private readonly ConcurrentDictionary _walletReceiveState = - new ConcurrentDictionary(); - - public void Remove(WalletId walletId) - { - _walletReceiveState.TryRemove(walletId, out _); - } - - public KeyPathInformation Get(WalletId walletId) - { - if (_walletReceiveState.ContainsKey(walletId)) - { - return _walletReceiveState[walletId]; - } - - return null; - } - - public void Set(WalletId walletId, KeyPathInformation information) - { - _walletReceiveState.AddOrReplace(walletId, information); - } - - public IEnumerable> GetByDerivation(string cryptoCode, - DerivationStrategyBase derivationStrategyBase) - { - return _walletReceiveState.Where(pair => - pair.Key.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCulture) && - pair.Value.DerivationStrategy == derivationStrategyBase); - } - } -} diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.json new file mode 100644 index 000000000..d7ea284e2 --- /dev/null +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.json @@ -0,0 +1,661 @@ +{ + "paths": { + "/api/v1/stores/{storeId}/payment-methods/OnChain/{cryptoCode}/wallet": { + "get": { + "tags": [ + "Store Wallet (On Chain)" + ], + "summary": "Get store on-chain wallet overview", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "cryptoCode", + "in": "path", + "required": true, + "description": "The crypto code of the payment method to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "View information about the specified wallet", + "operationId": "StoreOnChainWallets_ShowOnChainWalletOverview", + "responses": { + "200": { + "description": "specified wallet", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnChainWalletOverviewData" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet" + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/payment-methods/OnChain/{cryptoCode}/wallet/address": { + "get": { + "tags": [ + "Store Wallet (On Chain)" + ], + "summary": "Get store on-chain wallet address", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "cryptoCode", + "in": "path", + "required": true, + "description": "The crypto code of the payment method to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "forceGenerate", + "in": "query", + "required": false, + "description": "Whether to generate a new address for this request even if the previous one was not used", + "schema": { + "type": "string" + } + } + ], + "description": "Get or generate address for wallet", + "operationId": "StoreOnChainWallets_GetOnChainWalletReceiveAddress", + "responses": { + "200": { + "description": "reserved address", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnChainWalletAddressData" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet" + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + }, + "delete": { + "tags": [ + "Store Wallet (On Chain)" + ], + "summary": "UnReserve last store on-chain wallet address", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "cryptoCode", + "in": "path", + "required": true, + "description": "The crypto code of the payment method to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "UnReserve address", + "operationId": "StoreOnChainWallets_UnReserveOnChainWalletReceiveAddress", + "responses": { + "200": { + "description": "address unreserved" + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet or there was no address reserved" + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/payment-methods/OnChain/{cryptoCode}/wallet/transactions": { + "get": { + "tags": [ + "Store Wallet (On Chain)" + ], + "summary": "Get store on-chain wallet transactions", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "cryptoCode", + "in": "path", + "required": true, + "description": "The crypto code of the wallet to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "statusFilter", + "in": "query", + "required": false, + "description": "statuses to filter the transactions with", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TransactionStatus" + } + } + } + ], + "description": "Get store on-chain wallet transactions", + "operationId": "StoreOnChainWallets_ShowOnChainWalletTransactions", + "responses": { + "200": { + "description": "transactions list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OnChainWalletTransactionData" + } + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet" + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + }, + "post": { + "tags": [ + "Store Wallet (On Chain)" + ], + "summary": "Create store on-chain wallet transaction", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "cryptoCode", + "in": "path", + "required": true, + "description": "The crypto code of the wallet", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOnChainTransactionRequest" + } + } + } + }, + "description": "Create store on-chain wallet transaction", + "operationId": "StoreOnChainWallets_CreateOnChainTransaction", + "responses": { + "200": { + "description": "the tx", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "description": "The unbroadcasted transaction in hex format", + "type": "string" + }, + { + "$ref": "#/components/schemas/OnChainWalletTransactionData" + } + ] + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet" + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/payment-methods/OnChain/{cryptoCode}/wallet/transactions/{transactionId}": { + "get": { + "tags": [ + "Store Wallet (On Chain)" + ], + "summary": "Get store on-chain wallet transactions", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "cryptoCode", + "in": "path", + "required": true, + "description": "The crypto code of the wallet to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "transactionId", + "in": "path", + "required": true, + "description": "The transaction id to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "Get store on-chain wallet transaction", + "operationId": "StoreOnChainWallets_GetOnChainWalletTransaction", + "responses": { + "200": { + "description": "transaction", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnChainWalletTransactionData" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet" + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/payment-methods/OnChain/{cryptoCode}/wallet/utxos": { + "get": { + "tags": [ + "Store Wallet (On Chain)" + ], + "summary": "Get store on-chain wallet UTXOS", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "cryptoCode", + "in": "path", + "required": true, + "description": "The crypto code of the wallet to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "Get store on-chain wallet utxos", + "operationId": "StoreOnChainWallets_GetOnChainWalletUTXOs", + "responses": { + "200": { + "description": "utxo list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OnChainWalletUTXOData" + } + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet" + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + } + }, + "components": { + "schemas": { + "OnChainWalletOverviewData": { + "type": "object", + "additionalProperties": false, + "properties": { + "balance": { + "type": "string", + "format": "decimal", + "description": "The current balance of the wallet" + } + } + }, + "OnChainWalletAddressData": { + "type": "object", + "additionalProperties": false, + "properties": { + "address": { + "type": "string", + "description": "The bitcoin address" + }, + "keyPath": { + "type": "string", + "format": "keypath", + "description": "the derivation path in relation to the HD account" + } + } + }, + "OnChainPaymentMethodPreviewResultAddressItem": { + "type": "object", + "additionalProperties": false, + "properties": { + "keyPath": { + "type": "string", + "description": "The key path relative to the account key path." + }, + "address": { + "type": "string", + "description": "The address generated at the key path" + } + } + }, + "TransactionStatus": { + "type": "string", + "description": "", + "x-enumNames": [ + "Unconfirmed", + "Confirmed", + "Replaced" + ], + "enum": [ + "Unconfirmed", + "Confirmed", + "Replaced" + ] + }, + "LabelData": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "string", + "description": "The type of label" + }, + "text": { + "type": "string", + "description": "Information about this label" + } + } + }, + "OnChainWalletTransactionData": { + "type": "object", + "additionalProperties": false, + "properties": { + "transactionHash": { + "type": "string", + "nullable": true, + "description": "The transaction id" + }, + "comment": { + "type": "string", + "description": "A comment linked to the transaction" + }, + "amount": { + "type": "string", + "format": "decimal", + "description": "The amount the wallet balance changed with this transaction" + }, + "blockHash": { + "type": "string", + "nullable": true, + "description": "The hash of the block that confirmed this transaction. Null if still unconfirmed." + }, + "blockHeight": { + "type": "string", + "nullable": true, + "description": "The height of the block that confirmed this transaction. Null if still unconfirmed." + }, + "confirmations": { + "type": "string", + "nullable": true, + "description": "The number of confirmations for this transaction" + }, + "timestamp": { + "type": "number", + "format": "int64", + "description": "The time of the transaction" + }, + "status": { + "$ref": "#/components/schemas/TransactionStatus", + "description": "The status for this transaction" + }, + "labels": { + "description": "Labels linked to this transaction", + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/LabelData" + } + } + } + }, + "OnChainWalletUTXOData": { + "type": "object", + "additionalProperties": false, + "properties": { + "comment": { + "type": "string", + "description": "A comment linked to this utxo" + }, + "amount": { + "type": "string", + "description": "the value of this utxo" + }, + "link": { + "type": "string", + "format": "url", + "description": "a link to the configured blockchain explorer to view the utxo" + }, + "outpoint": { + "type": "string", + "format": "{txid}:{outputIndex}", + "description": "outpoint of this utxo" + } + } + }, + "CreateOnChainTransactionRequestDestination": { + "type": "object", + "additionalProperties": false, + "properties": { + "destination": { + "type": "string", + "description": "A wallet address or a BIP21 payment link" + }, + "amount": { + "type": "string", + "format": "decimal", + "nullable": true, + "description": "The amount to send. If `destination` is a BIP21 link, the amount must be the same or null." + }, + "subtractFromAmount": { + "type": "boolean", + "description": "Whether to subtract from the provided amount. Must be false if `destination` is a BIP21 link" + } + } + }, + "CreateOnChainTransactionRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "destinations": { + "nullable": false, + "description": "What and where to send money", + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateOnChainTransactionRequestDestination" + } + }, + "feeRate": { + "type": "string", + "format": "decimal or long (sats/byte)", + "description": "A wallet address or a BIP21 payment link" + }, + "proceedWithPayjoin": { + "type": "boolean", + "default": true, + "nullable": true, + "description": "Whether to attempt to do a BIP78 payjoin if one of the destinations is a BIP21 with payjoin enabled" + }, + "proceedWithBroadcast": { + "type": "boolean", + "default": true, + "nullable": true, + "description": "Whether to broadcast the transaction after creating it or to simply return the transaction in hex format." + }, + "noChange": { + "type": "boolean", + "default": false, + "nullable": true, + "description": "Whether to send all the spent coins to the destinations (THIS CAN COST YOU SIGNIFICANT AMOUNTS OF MONEY, LEAVE FALSE UNLESS YOU KNOW WHAT YOU ARE DOING)." + }, + "rbf": { + "type": "boolean", + "nullable": true, + "description": "Whether to enable RBF for the transaction. Leave blank to have it random (beneficial to privacy)" + }, + "selectedInputs": { + "nullable": true, + "description": "Restrict the creation of the transactions from the outpoints provided ONLY (coin selection)", + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "tags": [ + { + "name": "Store Wallet (On Chain)" + } + ] +}