GreenField: Generate Store OnChain Wallet (#2708)

* GreenField: Generate Store OnChain Wallet

* Greenfield: Do not generate wallet if already configured
This commit is contained in:
Andrew Camilleri 2021-07-27 16:53:44 +02:00 committed by GitHub
parent 80483ba76f
commit c59798e9c4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 505 additions and 12 deletions

View file

@ -78,5 +78,17 @@ namespace BTCPayServer.Client
method: HttpMethod.Get), token);
return await HandleResponse<OnChainPaymentMethodPreviewResultData>(response);
}
public virtual async Task<OnChainPaymentMethodDataWithSensitiveData> GenerateOnChainWallet(string storeId,
string cryptoCode, GenerateOnChainWalletRequest request,
CancellationToken token = default)
{
var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/Onchain/{cryptoCode}/generate",
bodyPayload: request,
method: HttpMethod.Post), token);
return await HandleResponse<OnChainPaymentMethodDataWithSensitiveData>(response);
}
}
}

View file

@ -0,0 +1,31 @@
using System;
using NBitcoin;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Client.JsonConverters
{
public class MnemonicJsonConverter : JsonConverter<Mnemonic>
{
public override Mnemonic ReadJson(JsonReader reader, Type objectType, Mnemonic existingValue, bool hasExistingValue,
JsonSerializer serializer)
{
return reader.TokenType switch
{
JsonToken.String => new Mnemonic((string)reader.Value),
JsonToken.Null => null,
_ => throw new JsonObjectException(reader.Path, "Mnemonic must be a json string")
};
}
public override void WriteJson(JsonWriter writer, Mnemonic value, JsonSerializer serializer)
{
if (value != null)
writer.WriteValue(value.ToString());
else
{
writer.WriteNull();
}
}
}
}

View file

@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using NBitcoin;
using Newtonsoft.Json;
public class WordcountJsonConverter : JsonConverter
{
static WordcountJsonConverter()
{
_Wordcount = new Dictionary<long, WordCount>()
{
{18, WordCount.Eighteen},
{15, WordCount.Fifteen},
{12, WordCount.Twelve},
{24, WordCount.TwentyFour},
{21, WordCount.TwentyOne}
};
_WordcountReverse = _Wordcount.ToDictionary(kv => kv.Value, kv => kv.Key);
}
public override bool CanConvert(Type objectType)
{
return typeof(NBitcoin.WordCount).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo()) ||
typeof(NBitcoin.WordCount?).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo());
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null)
return default;
if (reader.TokenType != JsonToken.Integer)
throw new NBitcoin.JsonConverters.JsonObjectException(
$"Unexpected json token type, expected Integer, actual {reader.TokenType}", reader);
if (!_Wordcount.TryGetValue((long)reader.Value, out var result))
throw new NBitcoin.JsonConverters.JsonObjectException(
$"Invalid WordCount, possible values {string.Join(", ", _Wordcount.Keys.ToArray())} (default: 12)",
reader);
return result;
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
if (value is WordCount wc)
writer.WriteValue(_WordcountReverse[wc]);
}
readonly static Dictionary<long, WordCount> _Wordcount = new Dictionary<long, WordCount>()
{
{18, WordCount.Eighteen},
{15, WordCount.Fifteen},
{12, WordCount.Twelve},
{24, WordCount.TwentyFour},
{21, WordCount.TwentyOne}
};
readonly static Dictionary<WordCount, long> _WordcountReverse;
}

View file

@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using NBitcoin;
using Newtonsoft.Json;
public class WordlistJsonConverter : JsonConverter
{
static WordlistJsonConverter()
{
_Wordlists = new Dictionary<string, Wordlist>(StringComparer.OrdinalIgnoreCase)
{
{"English", Wordlist.English},
{"Japanese", Wordlist.Japanese},
{"Spanish", Wordlist.Spanish},
{"ChineseSimplified", Wordlist.ChineseSimplified},
{"ChineseTraditional", Wordlist.ChineseTraditional},
{"French", Wordlist.French},
{"PortugueseBrazil", Wordlist.PortugueseBrazil},
{"Czech", Wordlist.Czech}
};
_WordlistsReverse = _Wordlists.ToDictionary(kv => kv.Value, kv => kv.Key);
}
public override bool CanConvert(Type objectType)
{
return typeof(Wordlist).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo());
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null)
return null;
if (reader.TokenType != JsonToken.String)
throw new NBitcoin.JsonConverters.JsonObjectException(
$"Unexpected json token type, expected String, actual {reader.TokenType}", reader);
if (!_Wordlists.TryGetValue((string)reader.Value, out var result))
throw new NBitcoin.JsonConverters.JsonObjectException(
$"Invalid wordlist, possible values {string.Join(", ", _Wordlists.Keys.ToArray())} (default: English)",
reader);
return result;
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
if (value is Wordlist wl)
writer.WriteValue(_WordlistsReverse[wl]);
}
readonly static Dictionary<string, Wordlist> _Wordlists;
readonly static Dictionary<Wordlist, string> _WordlistsReverse;
}

View file

@ -0,0 +1,25 @@
using BTCPayServer.Client.JsonConverters;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client
{
public class GenerateOnChainWalletRequest
{
public int AccountNumber { get; set; } = 0;
[JsonConverter(typeof(MnemonicJsonConverter))]
public Mnemonic ExistingMnemonic { get; set; }
[JsonConverter(typeof(WordlistJsonConverter))]
public NBitcoin.Wordlist WordList { get; set; }
[JsonConverter(typeof(WordcountJsonConverter))]
public NBitcoin.WordCount? WordCount { get; set; } = NBitcoin.WordCount.Twelve;
[JsonConverter(typeof(StringEnumConverter))]
public NBitcoin.ScriptPubKeyType ScriptPubKeyType { get; set; } = ScriptPubKeyType.Segwit;
public string Passphrase { get; set; }
public bool ImportKeysToRPC { get; set; }
public bool SavePrivateKeys { get; set; }
}
}

View file

@ -1,3 +1,5 @@
using NBitcoin;
namespace BTCPayServer.Client.Models
{
public class OnChainPaymentMethodData : OnChainPaymentMethodBaseData
@ -17,9 +19,11 @@ namespace BTCPayServer.Client.Models
}
public OnChainPaymentMethodData(string cryptoCode, string derivationScheme, bool enabled)
public OnChainPaymentMethodData(string cryptoCode, string derivationScheme, bool enabled, string label, RootedKeyPath accountKeyPath)
{
Enabled = enabled;
Label = label;
AccountKeyPath = accountKeyPath;
CryptoCode = cryptoCode;
DerivationScheme = derivationScheme;
}

View file

@ -0,0 +1,23 @@
using BTCPayServer.Client.JsonConverters;
using NBitcoin;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models
{
public class OnChainPaymentMethodDataWithSensitiveData : OnChainPaymentMethodData
{
public OnChainPaymentMethodDataWithSensitiveData()
{
}
public OnChainPaymentMethodDataWithSensitiveData(string cryptoCode, string derivationScheme, bool enabled,
string label, RootedKeyPath accountKeyPath, Mnemonic mnemonic) : base(cryptoCode, derivationScheme, enabled,
label, accountKeyPath)
{
Mnemonic = mnemonic;
}
[JsonConverter(typeof(MnemonicJsonConverter))]
public Mnemonic Mnemonic { get; set; }
}
}

View file

@ -1427,8 +1427,12 @@ namespace BTCPayServer.Tests
using var tester = ServerTester.Create();
await tester.StartAsync();
var user = tester.NewAccount();
var user2 = tester.NewAccount();
await user.GrantAccessAsync(true);
await user2.GrantAccessAsync(false);
var client = await user.CreateClient(Policies.CanModifyStoreSettings);
var client2 = await user2.CreateClient(Policies.CanModifyStoreSettings);
var viewOnlyClient = await user.CreateClient(Policies.CanViewStoreSettings);
var store = await client.CreateStore(new CreateStoreRequest() { Name = "test store" });
@ -1438,8 +1442,9 @@ namespace BTCPayServer.Tests
{
await viewOnlyClient.UpdateStoreOnChainPaymentMethod(store.Id, "BTC", new OnChainPaymentMethodData() { });
});
var xpriv = new Mnemonic("all all all all all all all all all all all all").DeriveExtKey()
.Derive(KeyPath.Parse("m/84'/0'/0'"));
.Derive(KeyPath.Parse("m/84'/1'/0'"));
var xpub = xpriv.Neuter().ToString(Network.RegTest);
var firstAddress = xpriv.Derive(KeyPath.Parse("0/0")).Neuter().GetPublicKey().GetAddress(ScriptPubKeyType.Segwit, Network.RegTest).ToString();
await AssertHttpError(404, async () =>
@ -1475,6 +1480,60 @@ namespace BTCPayServer.Tests
{
await client.GetStoreOnChainPaymentMethod(store.Id, "BTC");
});
await AssertHttpError(403, async () =>
{
await viewOnlyClient.GenerateOnChainWallet(store.Id, "BTC", new GenerateOnChainWalletRequest() { });
});
await AssertValidationError(new []{"SavePrivateKeys", "ImportKeysToRPC"}, async () =>
{
await client2.GenerateOnChainWallet(user2.StoreId, "BTC", new GenerateOnChainWalletRequest()
{
SavePrivateKeys = true,
ImportKeysToRPC = true
});
});
var allMnemonic = new Mnemonic("all all all all all all all all all all all all");
await client.RemoveStoreOnChainPaymentMethod(store.Id, "BTC");
var generateResponse = await client.GenerateOnChainWallet(store.Id, "BTC",
new GenerateOnChainWalletRequest() {ExistingMnemonic = allMnemonic,});
Assert.Equal(generateResponse.Mnemonic.ToString(), allMnemonic.ToString());
Assert.Equal(generateResponse.DerivationScheme, xpub);
await AssertAPIError("already-configured", async () =>
{
await client.GenerateOnChainWallet(store.Id, "BTC",
new GenerateOnChainWalletRequest() {ExistingMnemonic = allMnemonic,});
});
await client.RemoveStoreOnChainPaymentMethod(store.Id, "BTC");
generateResponse = await client.GenerateOnChainWallet(store.Id, "BTC",
new GenerateOnChainWalletRequest() {});
Assert.NotEqual(generateResponse.Mnemonic.ToString(), allMnemonic.ToString());
Assert.Equal(generateResponse.Mnemonic.DeriveExtKey().Derive(KeyPath.Parse("m/84'/1'/0'")).Neuter().ToString(Network.RegTest), generateResponse.DerivationScheme);
await client.RemoveStoreOnChainPaymentMethod(store.Id, "BTC");
generateResponse = await client.GenerateOnChainWallet(store.Id, "BTC",
new GenerateOnChainWalletRequest() { ExistingMnemonic = allMnemonic, AccountNumber = 1});
Assert.Equal(generateResponse.Mnemonic.ToString(), allMnemonic.ToString());
Assert.Equal(new Mnemonic("all all all all all all all all all all all all").DeriveExtKey()
.Derive(KeyPath.Parse("m/84'/1'/1'")).Neuter().ToString(Network.RegTest), generateResponse.DerivationScheme);
await client.RemoveStoreOnChainPaymentMethod(store.Id, "BTC");
generateResponse = await client.GenerateOnChainWallet(store.Id, "BTC",
new GenerateOnChainWalletRequest() { WordList = Wordlist.Japanese, WordCount = WordCount.TwentyFour});
Assert.Equal(24,generateResponse.Mnemonic.Words.Length);
Assert.Equal(Wordlist.Japanese,generateResponse.Mnemonic.WordList);
}
[Fact(Timeout = 60 * 2 * 1000)]
@ -1883,7 +1942,7 @@ namespace BTCPayServer.Tests
var randK = new Mnemonic(Wordlist.English, WordCount.Twelve).DeriveExtKey().Neuter().ToString(Network.RegTest);
await adminClient.UpdateStoreOnChainPaymentMethod(admin.StoreId, "BTC",
new OnChainPaymentMethodData("BTC", randK, true));
new OnChainPaymentMethodData("BTC", randK, true, "testing", null));
void VerifyOnChain(Dictionary<string, GenericPaymentMethodData> dictionary)
{

View file

@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using NBitcoin;
using NBXplorer.Models;
using YamlDotNet.Core.Tokens;
using InvoiceData = BTCPayServer.Client.Models.InvoiceData;
using Language = BTCPayServer.Client.Models.Language;
@ -883,5 +884,21 @@ namespace BTCPayServer.Controllers.GreenField
{
return Task.FromResult(GetFromActionResult(_storePaymentMethodsController.GetStorePaymentMethods(storeId, enabled)));
}
public override async Task<OnChainPaymentMethodDataWithSensitiveData> GenerateOnChainWallet(string storeId, string cryptoCode, GenerateOnChainWalletRequest request,
CancellationToken token = default)
{
return GetFromActionResult<OnChainPaymentMethodDataWithSensitiveData>(await _chainPaymentMethodsController.GenerateOnChainWallet(storeId, cryptoCode, new GenerateWalletRequest()
{
Passphrase = request.Passphrase,
AccountNumber = request.AccountNumber,
ExistingMnemonic = request.ExistingMnemonic?.ToString(),
WordCount = request.WordCount,
WordList = request.WordList,
SavePrivateKeys = request.SavePrivateKeys,
ScriptPubKeyType = request.ScriptPubKeyType,
ImportKeysToRPC = request.ImportKeysToRPC
}));
}
}
}

View file

@ -0,0 +1,103 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Payments;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NBXplorer.Models;
namespace BTCPayServer.Controllers.GreenField
{
public partial class StoreOnChainPaymentMethodsController
{
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/generate")]
public async Task<IActionResult> GenerateOnChainWallet(string storeId, string cryptoCode,
GenerateWalletRequest request)
{
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
if (network is null)
{
return NotFound();
}
if (!_walletProvider.IsAvailable(network))
{
return this.CreateAPIError("not-available",
$"{cryptoCode} services are not currently available");
}
var method = GetExistingBtcLikePaymentMethod(cryptoCode);
if (method != null)
{
return this.CreateAPIError("already-configured",
$"{cryptoCode} wallet is already configured for this store");
}
var canUseHotWallet = await CanUseHotWallet();
if (request.SavePrivateKeys && !canUseHotWallet.HotWallet)
{
ModelState.AddModelError(nameof(request.SavePrivateKeys),
"This instance forbids non-admins from having a hot wallet for your store.");
}
if (request.ImportKeysToRPC && !canUseHotWallet.RPCImport)
{
ModelState.AddModelError(nameof(request.ImportKeysToRPC),
"This instance forbids non-admins from having importing the wallet addresses/keys to the underlying node.");
}
if (!ModelState.IsValid)
{
return this.CreateValidationError(ModelState);
}
var client = _explorerClientProvider.GetExplorerClient(network);
GenerateWalletResponse response;
try
{
response = await client.GenerateWalletAsync(request);
if (response == null)
{
return this.CreateAPIError("not-available",
$"{cryptoCode} services are not currently available");
}
}
catch (Exception e)
{
return this.CreateAPIError("not-available",
$"{cryptoCode} error: {e.Message}");
}
var derivationSchemeSettings = new DerivationSchemeSettings(response.DerivationScheme, network);
derivationSchemeSettings.Source =
string.IsNullOrEmpty(request.ExistingMnemonic) ? "NBXplorerGenerated" : "ImportedSeed";
derivationSchemeSettings.IsHotWallet = request.SavePrivateKeys;
var accountSettings = derivationSchemeSettings.GetSigningAccountKeySettings();
accountSettings.AccountKeyPath = response.AccountKeyPath.KeyPath;
accountSettings.RootFingerprint = response.AccountKeyPath.MasterFingerprint;
derivationSchemeSettings.AccountOriginal = response.DerivationScheme.ToString();
var store = Store;
var storeBlob = store.GetStoreBlob();
store.SetSupportedPaymentMethod(new PaymentMethodId(cryptoCode, PaymentTypes.BTCLike),
derivationSchemeSettings);
store.SetStoreBlob(storeBlob);
await _storeRepository.UpdateStore(store);
var rawResult = GetExistingBtcLikePaymentMethod(cryptoCode, store);
var result = new OnChainPaymentMethodDataWithSensitiveData(rawResult.CryptoCode, rawResult.DerivationScheme,
rawResult.Enabled, rawResult.Label, rawResult.AccountKeyPath, response.GetMnemonic());
return Ok(result);
}
private async Task<(bool HotWallet, bool RPCImport)> CanUseHotWallet()
{
return await _authorizationService.CanUseHotWallet(await _settingsRepository.GetPolicies(), User);
}
}
}

View file

@ -2,9 +2,11 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
@ -12,27 +14,36 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers.GreenField
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public class StoreOnChainPaymentMethodsController : ControllerBase
public partial class StoreOnChainPaymentMethodsController : ControllerBase
{
private StoreData Store => HttpContext.GetStoreData();
private readonly StoreRepository _storeRepository;
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly BTCPayWalletProvider _walletProvider;
private readonly IAuthorizationService _authorizationService;
private readonly ISettingsRepository _settingsRepository;
private readonly ExplorerClientProvider _explorerClientProvider;
public StoreOnChainPaymentMethodsController(
StoreRepository storeRepository,
BTCPayNetworkProvider btcPayNetworkProvider,
BTCPayWalletProvider walletProvider)
BTCPayWalletProvider walletProvider,
IAuthorizationService authorizationService,
ExplorerClientProvider explorerClientProvider, ISettingsRepository settingsRepository)
{
_storeRepository = storeRepository;
_btcPayNetworkProvider = btcPayNetworkProvider;
_walletProvider = walletProvider;
_authorizationService = authorizationService;
_explorerClientProvider = explorerClientProvider;
_settingsRepository = settingsRepository;
}
public static IEnumerable<OnChainPaymentMethodData> GetOnChainPaymentMethods(StoreData store,
@ -46,7 +57,7 @@ namespace BTCPayServer.Controllers.GreenField
.OfType<DerivationSchemeSettings>()
.Select(strategy =>
new OnChainPaymentMethodData(strategy.PaymentId.CryptoCode,
strategy.AccountDerivation.ToString(), !excludedPaymentMethods.Match(strategy.PaymentId)))
strategy.AccountDerivation.ToString(), !excludedPaymentMethods.Match(strategy.PaymentId), strategy.Label, strategy.GetSigningAccountKeySettings().GetRootedKeyPath()))
.Where((result) => enabled is null || enabled == result.Enabled)
.ToList();
}
@ -279,11 +290,8 @@ namespace BTCPayServer.Controllers.GreenField
return paymentMethod == null
? null
: new OnChainPaymentMethodData(paymentMethod.PaymentId.CryptoCode,
paymentMethod.AccountDerivation.ToString(), !excluded)
{
Label = paymentMethod.Label,
AccountKeyPath = paymentMethod.GetSigningAccountKeySettings().GetRootedKeyPath()
};
paymentMethod.AccountDerivation.ToString(), !excluded, paymentMethod.Label,
paymentMethod.GetSigningAccountKeySettings().GetRootedKeyPath());
}
}
}

View file

@ -405,6 +405,23 @@
"$ref": "#/components/schemas/OnChainPaymentMethodData"
}
},
"OnChainPaymentMethodDataWithSensitiveData": {
"allOf": [
{
"$ref": "#/components/schemas/OnChainPaymentMethodData"
},
{
"type": "object",
"properties": {
"mnemonic": {
"type": "string",
"description": "The mnemonic used to generate the wallet",
"nullable": false
}
}
}
]
},
"OnChainPaymentMethodBaseData": {
"type": "object",
"additionalProperties": false,
@ -467,6 +484,86 @@
"description": "The address generated at the key path"
}
}
},
"GenerateOnChainWalletRequest": {
"type": "object",
"additionalProperties": false,
"properties": {
"existingMnemonic": {
"type": "string",
"description": "An existing BIP39 mnemonic seed to generate the wallet with"
},
"passphrase": {
"type": "string",
"description": "A passphrase for the BIP39 mnemonic seed"
},
"accountNumber": {
"type": "number",
"default": 0,
"description": "The account to derive from the BIP39 mnemonic seed"
},
"savePrivateKeys": {
"type": "boolean",
"default": false,
"description": "Whether to store the seed inside BTCPay Server to enable some additional services. IF `false` AND `existingMnemonic` IS NOT SPECIFIED, BE SURE TO SECURELY STORE THE SEED IN THE RESPONSE!"
},
"importKeysToRPC": {
"type": "boolean",
"default": false,
"description": "Whether to import all addresses generated via BTCPay Server into the underlying node wallet. (Private keys will also be imported if `savePrivateKeys` is set to true."
},
"wordList": {
"type": "string",
"description": "If `existingMnemonic` is not set, a mnemonic is generated using the specified wordList.",
"default": "English",
"x-enumNames": [
"English",
"Japanese",
"Spanish",
"ChineseSimplified",
"ChineseTraditional",
"French",
"PortugueseBrazil",
"Czech"
],
"enum": [
"English",
"Japanese",
"Spanish",
"ChineseSimplified",
"ChineseTraditional",
"French",
"PortugueseBrazil",
"Czech"
]
},
"wordCount": {
"type": "number",
"description": "If `existingMnemonic` is not set, a mnemonic is generated using the specified wordCount.",
"default": 12,
"x-enumNames": [
12,15,18,21,24
],
"enum": [
12,15,18,21,24
]
},
"scriptPubKeyType": {
"type": "string",
"description": "the type of wallet to generate",
"default": "Segwit",
"x-enumNames": [
"Legacy",
"Segwit",
"SegwitP2SH"
],
"enum": [
"Legacy",
"Segwit",
"SegwitP2SH"
]
}
}
}
}
},