GreenField API: Wallet API (#2246)

* GreenField: Wallet API

* more work

* wip

* rough fiunish of transaction sending api

* Allow to create tx without broadcasting and small fixes

* Refactor Wallet Receive feature ad add greenfield api for address reserve for wallet

* add wallet api client

* add docs

* fix json converter tags

* fixes and add wallet tests

* fix tests

* fix rebase

* fixes

* just pass the tests already

* ugggh

* small cleanup

* revert int support in numeric string converter and make block id as native number in json

* fix LN endpoint

* try fix flaky test

* Revert "try fix flaky test"

This reverts commit 2e0d256325b892f7741325dcbab01196f74d182a.

* try fix other flaky test

* return proepr error if fee rate could not be fetched

* try fix test again

* reduce fee related logic for wallet api

* try reduce code changes for pr scope

* change auth logic for initial release of wallet api
This commit is contained in:
Andrew Camilleri
2021-03-11 13:34:52 +01:00
committed by GitHub
parent 1f7992e5da
commit cdfdad3e3d
30 changed files with 1936 additions and 191 deletions

View File

@@ -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<OnChainWalletOverviewData> 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<OnChainWalletOverviewData>(response);
}
public virtual async Task<OnChainWalletAddressData> 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<string, object>()
{
{"forceGenerate", forceGenerate}
}), token);
return await HandleResponse<OnChainWalletAddressData>(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<IEnumerable<OnChainWalletTransactionData>> ShowOnChainWalletTransactions(
string storeId, string cryptoCode, TransactionStatus[] statusFilter = null,
CancellationToken token = default)
{
var query = new Dictionary<string, object>();
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<IEnumerable<OnChainWalletTransactionData>>(response);
}
public virtual async Task<OnChainWalletTransactionData> 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<OnChainWalletTransactionData>(response);
}
public virtual async Task<IEnumerable<OnChainWalletUTXOData>> 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<IEnumerable<OnChainWalletUTXOData>>(response);
}
public virtual async Task<OnChainWalletTransactionData> 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<OnChainWalletTransactionData>(response);
}
public virtual async Task<Transaction> 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<string>(response), network);
}
}
}

View File

@@ -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; }

View File

@@ -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<OutPoint> SelectedInputs { get; set; } = null;
public List<CreateOnChainTransactionRequestDestination> Destinations { get; set; }
[JsonProperty("rbf")]
public bool? RBF { get; set; } = null;
}
}

View File

@@ -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<string, JToken> AdditionalData { get; set; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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<string, LabelData> Labels { get; set; } = new Dictionary<string, LabelData>();
[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; }
}
}

View File

@@ -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<string, LabelData> Labels { get; set; }
}
}

View File

@@ -0,0 +1,9 @@
namespace BTCPayServer.Client.Models
{
public enum TransactionStatus
{
Unconfirmed,
Confirmed,
Replaced
}
}

View File

@@ -24,31 +24,15 @@ namespace BTCPayServer
});
}
public override GetTransactionsResponse FilterValidTransactions(GetTransactionsResponse response)
public override List<TransactionInformation> FilterValidTransactions(List<TransactionInformation> transactionInformationSet)
{
TransactionInformationSet Filter(TransactionInformationSet transactionInformationSet)
{
return new TransactionInformationSet()
{
Transactions =
transactionInformationSet.Transactions.FindAll(information =>
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))
};
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)
};
}
public override string GenerateBIP21(string cryptoInfoAddress, Money cryptoInfoDue)
{
//precision 0: 10 = 0.00000010

View File

@@ -126,9 +126,9 @@ namespace BTCPayServer
return $"{UriScheme}:{cryptoInfoAddress}?amount={cryptoInfoDue.ToString(false, true)}";
}
public virtual GetTransactionsResponse FilterValidTransactions(GetTransactionsResponse response)
public virtual List<TransactionInformation> FilterValidTransactions(List<TransactionInformation> transactionInformationSet)
{
return response;
return transactionInformationSet;
}
}

View File

@@ -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")]
@@ -1396,6 +1395,222 @@ 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<NewOnChainTransactionEvent>(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<CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination>()
{
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<ArgumentOutOfRangeException>(async () =>
{
await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode,
createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork);
});
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(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<OutPoint>();
tx = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode,
createTxRequest, tester.ExplorerClient.Network.NBitcoinNetwork);
});
createTxRequest.SelectedInputs = new List<OutPoint>()
{
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<BTCPayNetwork>("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<NewBlockEvent>(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";

View File

@@ -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 =

View File

@@ -183,6 +183,7 @@ namespace BTCPayServer.Tests
{
ScriptPubKeyType = segwit,
SavePrivateKeys = importKeysToNBX,
ImportKeysToRPC = importKeysToNBX
});
await store.UpdateWallet(
new WalletSetupViewModel

View File

@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<TransactionInformation>();
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<int>();
var subtractFees = request.Destinations.Any(o => o.SubtractFromAmount);
int? payjoinOutputIndex = null;
var sum = 0m;
var outputs = new List<WalletSendModel.TransactionOutput>();
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<string>(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<ExtKey> GetWallet(DerivationSchemeSettings derivationScheme)
{
if (!derivationScheme.IsHotWallet)
return null;
var result = await _explorerClientProvider.GetExplorerClient(derivationScheme.Network.CryptoCode)
.GetMetadataAsync<string>(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<BTCPayNetwork>(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<DerivationSchemeSettings>()
.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<string, LabelData>(),
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
};
}
}
}

View File

@@ -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<PoliciesSettings>();
var hotWallet = policies?.AllowHotWalletForAll is true;
return (hotWallet, hotWallet && policies.AllowHotWalletRPCImportForAll is true);
return await _authorizationService.CanUseHotWallet(policies, User);
}
private async Task<string> ReadAllText(IFormFile file)

View File

@@ -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<BTCPayNetwork>(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;
}
var address = cachedAddress.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork);
ExplorerClientProvider.GetExplorerClient(network)
.CancelReservation(cachedAddress.DerivationStrategy, new[] { cachedAddress.KeyPath });
this.TempData.SetStatusMessageModel(new StatusMessageModel()
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<bool> CanUseHotWallet()
{
var isAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)).Succeeded;
if (isAdmin)
return true;
var policies = await _settingsRepository.GetSettingAsync<PoliciesSettings>();
return policies?.AllowHotWalletForAll is true;
return (await _authorizationService.CanUseHotWallet(policies, User)).HotWallet;
}
[HttpGet]
@@ -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<DerivationSchemeSettings>()
.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<string> GetBalanceString(BTCPayWallet wallet, DerivationStrategyBase derivationStrategy)

View File

@@ -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<string, Label> Labels { get; set; } = new Dictionary<string, Label>();
public Dictionary<string, LabelData> Labels { get; set; } = new Dictionary<string, LabelData>();
}
public static class WalletTransactionDataExtensions
{

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<DerivationSchemeSettings>()
.FirstOrDefault(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike && p.PaymentId.CryptoCode == cryptoCode);
return paymentMethod;
}
}
}

View File

@@ -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<WalletChangedEvent>(evt =>
_WalletReceiveStateService.Remove(evt.WalletId)));
_Leases.Add(_EventAggregator.Subscribe<NewOnChainTransactionEvent>(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;
}
}
}

View File

@@ -293,7 +293,8 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<StoreRepository>();
services.TryAddSingleton<PaymentRequestRepository>();
services.TryAddSingleton<BTCPayWalletProvider>();
services.TryAddSingleton<WalletReceiveStateService>();
services.TryAddSingleton<WalletReceiveService>();
services.AddSingleton<IHostedService>( provider => provider.GetService<WalletReceiveService>());
services.TryAddSingleton<CurrencyNameTable>(CurrencyNameTable.Instance);
services.TryAddSingleton<IFeeProviderFactory>(o => new NBXplorerFeeProviderFactory(o.GetRequiredService<ExplorerClientProvider>())
{
@@ -346,7 +347,6 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, DynamicDnsHostedService>();
services.AddSingleton<IHostedService, TorServicesHostedService>();
services.AddSingleton<IHostedService, PaymentRequestStreamer>();
services.AddSingleton<IHostedService, WalletReceiveCacheUpdater>();
services.AddSingleton<IBackgroundJobClient, BackgroundJobClient>();
services.AddScoped<IAuthorizationHandler, CookieAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, BitpayAuthorizationHandler>();

View File

@@ -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<string, JToken> AdditionalData { get; set; }
}
public class RawLabel : Label

View File

@@ -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));

View File

@@ -38,7 +38,7 @@ namespace BTCPayServer.Services
}
}
public async Task<Dictionary<string, WalletTransactionInfo>> GetWalletTransactionsInfo(WalletId walletId)
public async Task<Dictionary<string, WalletTransactionInfo>> 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());

View File

@@ -202,7 +202,40 @@ namespace BTCPayServer.Services.Wallets
public async Task<GetTransactionsResponse> 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<TransactionInformation> FetchTransaction(DerivationStrategyBase derivationStrategyBase, uint256 transactionId)
{
var tx = await _Client.GetTransactionAsync(derivationStrategyBase, transactionId);
if (tx is null || !_Network.FilterValidTransactions(new List<TransactionInformation>() {tx}).Any())
{
return null;
}
return tx;
}
public Task<BroadcastResult[]> BroadcastTransactionsAsync(List<Transaction> transactions)

View File

@@ -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<WalletId, KeyPathInformation> _walletReceiveState =
new ConcurrentDictionary<WalletId, KeyPathInformation>();
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<string> 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<KeyPathInformation> 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<KeyValuePair<WalletId, KeyPathInformation>> 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<WalletChangedEvent>(evt =>
Remove(evt.WalletId)));
_leases.Add(_eventAggregator.Subscribe<NewOnChainTransactionEvent>(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;
}
}
}

View File

@@ -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<WalletId, KeyPathInformation> _walletReceiveState =
new ConcurrentDictionary<WalletId, KeyPathInformation>();
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<KeyValuePair<WalletId, KeyPathInformation>> GetByDerivation(string cryptoCode,
DerivationStrategyBase derivationStrategyBase)
{
return _walletReceiveState.Where(pair =>
pair.Key.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCulture) &&
pair.Value.DerivationStrategy == derivationStrategyBase);
}
}
}

View File

@@ -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)"
}
]
}