2021-02-11 11:48:54 +01:00
using System ;
2021-02-17 18:43:12 +01:00
using System.IO ;
2021-02-11 11:48:54 +01:00
using System.Linq ;
2021-06-17 18:27:17 +09:00
using System.Text ;
2024-04-04 10:47:28 +02:00
using System.Text.RegularExpressions ;
2021-11-11 06:30:19 +01:00
using System.Threading ;
2021-02-11 11:48:54 +01:00
using System.Threading.Tasks ;
2022-02-21 15:46:43 +01:00
using BTCPayServer.Abstractions.Constants ;
2021-02-11 11:48:54 +01:00
using BTCPayServer.Abstractions.Extensions ;
using BTCPayServer.Abstractions.Models ;
2024-03-14 10:25:40 +01:00
using BTCPayServer.Client ;
2021-02-11 11:48:54 +01:00
using BTCPayServer.Data ;
using BTCPayServer.Events ;
using BTCPayServer.Models.StoreViewModels ;
using BTCPayServer.Payments ;
2024-04-04 16:31:04 +09:00
using BTCPayServer.Payments.Bitcoin ;
2024-03-14 10:25:40 +01:00
using Microsoft.AspNetCore.Authorization ;
2021-02-17 18:43:12 +01:00
using Microsoft.AspNetCore.Http ;
2021-02-11 11:48:54 +01:00
using Microsoft.AspNetCore.Mvc ;
using NBitcoin ;
2021-11-11 06:30:19 +01:00
using NBitcoin.DataEncoders ;
2021-03-01 12:43:25 +01:00
using NBXplorer ;
2021-02-11 11:48:54 +01:00
using NBXplorer.DerivationStrategy ;
using NBXplorer.Models ;
2024-04-04 16:31:04 +09:00
using Newtonsoft.Json.Linq ;
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
namespace BTCPayServer.Controllers ;
public partial class UIStoresController
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
[HttpGet("{storeId}/onchain/{cryptoCode}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public ActionResult SetupWallet ( WalletSetupViewModel vm )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
var checkResult = IsAvailable ( vm . CryptoCode , out var store , out _ ) ;
if ( checkResult ! = null )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
return checkResult ;
}
var derivation = GetExistingDerivationStrategy ( vm . CryptoCode , store ) ;
vm . DerivationScheme = derivation ? . AccountDerivation . ToString ( ) ;
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
return View ( vm ) ;
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
[HttpGet("{storeId}/onchain/{cryptoCode}/import/{method?}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task < IActionResult > ImportWallet ( WalletSetupViewModel vm )
{
var checkResult = IsAvailable ( vm . CryptoCode , out _ , out var network ) ;
if ( checkResult ! = null )
{
return checkResult ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
var ( hotWallet , rpcImport ) = await CanUseHotWallet ( ) ;
vm . Network = network ;
vm . CanUseHotWallet = hotWallet ;
vm . CanUseRPCImport = rpcImport ;
vm . SupportTaproot = network . NBitcoinNetwork . Consensus . SupportTaproot ;
vm . SupportSegwit = network . NBitcoinNetwork . Consensus . SupportSegwit ;
if ( vm . Method = = null )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
vm . Method = WalletSetupMethod . ImportOptions ;
}
else if ( vm . Method = = WalletSetupMethod . Seed )
{
vm . SetupRequest = new WalletSetupRequest ( ) ;
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
return View ( vm . ViewName , vm ) ;
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
[HttpPost("{storeId}/onchain/{cryptoCode}/modify")]
[HttpPost("{storeId}/onchain/{cryptoCode}/import/{method}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task < IActionResult > UpdateWallet ( WalletSetupViewModel vm )
{
var checkResult = IsAvailable ( vm . CryptoCode , out var store , out var network ) ;
if ( checkResult ! = null )
{
return checkResult ;
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
vm . Network = network ;
DerivationSchemeSettings strategy = null ;
PaymentMethodId paymentMethodId = PaymentTypes . CHAIN . GetPaymentMethodId ( network . CryptoCode ) ;
BitcoinLikePaymentHandler handler = ( BitcoinLikePaymentHandler ) _handlers [ paymentMethodId ] ;
var wallet = _walletProvider . GetWallet ( network ) ;
if ( wallet = = null )
{
return NotFound ( ) ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
if ( vm . WalletFile ! = null )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
string fileContent = null ;
try
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
fileContent = await ReadAllText ( vm . WalletFile ) ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
catch
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
// ignored
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
if ( fileContent is null | | ! _onChainWalletParsers . TryParseWalletFile ( fileContent , network , out strategy , out _ ) )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
ModelState . AddModelError ( nameof ( vm . WalletFile ) , $"Import failed, make sure you import a compatible wallet format" ) ;
return View ( vm . ViewName , vm ) ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
}
else if ( ! string . IsNullOrEmpty ( vm . WalletFileContent ) )
{
if ( ! _onChainWalletParsers . TryParseWalletFile ( vm . WalletFileContent , network , out strategy , out var error ) )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
ModelState . AddModelError ( nameof ( vm . WalletFileContent ) , $"QR import failed: {error}" ) ;
return View ( vm . ViewName , vm ) ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
}
else if ( ! string . IsNullOrEmpty ( vm . DerivationScheme ) )
{
try
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
strategy = ParseDerivationStrategy ( vm . DerivationScheme , network ) ;
strategy . Source = "ManualDerivationScheme" ;
if ( ! string . IsNullOrEmpty ( vm . AccountKey ) )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
var accountKey = new BitcoinExtPubKey ( vm . AccountKey , network . NBitcoinNetwork ) ;
var accountSettings =
strategy . AccountKeySettings . FirstOrDefault ( a = > a . AccountKey = = accountKey ) ;
if ( accountSettings ! = null )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
accountSettings . AccountKeyPath =
vm . KeyPath = = null ? null : KeyPath . Parse ( vm . KeyPath ) ;
accountSettings . RootFingerprint = string . IsNullOrEmpty ( vm . RootFingerprint )
? null
: new HDFingerprint ( Encoders . Hex . DecodeData ( vm . RootFingerprint ) ) ;
2021-02-11 11:48:54 +01:00
}
}
2024-04-04 11:00:18 +02:00
vm . DerivationScheme = strategy . AccountDerivation . ToString ( ) ;
ModelState . Remove ( nameof ( vm . DerivationScheme ) ) ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
catch ( Exception ex )
2021-06-17 16:02:47 +09:00
{
2024-04-04 11:00:18 +02:00
ModelState . AddModelError ( nameof ( vm . DerivationScheme ) , $"Invalid wallet format: {ex.Message}" ) ;
return View ( vm . ViewName , vm ) ;
2021-06-17 16:02:47 +09:00
}
2024-04-04 11:00:18 +02:00
}
else if ( ! string . IsNullOrEmpty ( vm . Config ) )
{
try
2021-02-18 15:58:35 +01:00
{
2024-04-04 11:00:18 +02:00
strategy = handler . ParsePaymentMethodConfig ( JToken . Parse ( UnprotectString ( vm . Config ) ) ) ;
2021-02-18 15:58:35 +01:00
}
2024-04-04 11:00:18 +02:00
catch
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
ModelState . AddModelError ( nameof ( vm . Config ) , "Config file was not in the correct format" ) ;
return View ( vm . ViewName , vm ) ;
2021-02-11 11:48:54 +01:00
}
}
2024-04-04 11:00:18 +02:00
if ( strategy is null )
2021-06-17 18:27:17 +09:00
{
2024-04-04 11:00:18 +02:00
ModelState . AddModelError ( nameof ( vm . DerivationScheme ) , "Please provide your extended public key" ) ;
return View ( vm . ViewName , vm ) ;
2021-06-17 18:27:17 +09:00
}
2024-04-04 11:00:18 +02:00
vm . Config = ProtectString ( JToken . FromObject ( strategy , handler . Serializer ) . ToString ( ) ) ;
ModelState . Remove ( nameof ( vm . Config ) ) ;
var storeBlob = store . GetStoreBlob ( ) ;
if ( vm . Confirmation )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
try
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
await wallet . TrackAsync ( strategy . AccountDerivation ) ;
store . SetPaymentMethodConfig ( _handlers [ paymentMethodId ] , strategy ) ;
storeBlob . SetExcluded ( paymentMethodId , false ) ;
storeBlob . PayJoinEnabled = strategy . IsHotWallet & & ! ( vm . SetupRequest ? . PayJoinEnabled is false ) ;
store . SetStoreBlob ( storeBlob ) ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
catch
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
ModelState . AddModelError ( nameof ( vm . DerivationScheme ) , "Invalid derivation scheme" ) ;
return View ( vm . ViewName , vm ) ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
await _storeRepo . UpdateStore ( store ) ;
_eventAggregator . Publish ( new WalletChangedEvent { WalletId = new WalletId ( vm . StoreId , vm . CryptoCode ) } ) ;
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
TempData [ WellKnownTempData . SuccessMessage ] = $"Wallet settings for {network.CryptoCode} have been updated." ;
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
// This is success case when derivation scheme is added to the store
return RedirectToAction ( nameof ( WalletSettings ) , new { storeId = vm . StoreId , cryptoCode = vm . CryptoCode } ) ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
return ConfirmAddresses ( vm , strategy , network . NBXplorerNetwork ) ;
}
2024-03-14 10:25:40 +01:00
2024-04-04 11:00:18 +02:00
private string ProtectString ( string str )
{
return Convert . ToBase64String ( _dataProtector . Protect ( Encoding . UTF8 . GetBytes ( str ) ) ) ;
}
private string UnprotectString ( string str )
{
return Encoding . UTF8 . GetString ( _dataProtector . Unprotect ( Convert . FromBase64String ( str ) ) ) ;
}
2024-03-14 10:25:40 +01:00
2024-04-04 11:00:18 +02:00
[HttpGet("{storeId}/onchain/{cryptoCode}/generate/{method?}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task < IActionResult > GenerateWallet ( WalletSetupViewModel vm )
{
var checkResult = IsAvailable ( vm . CryptoCode , out _ , out var network ) ;
if ( checkResult ! = null )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
return checkResult ;
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
var isHotWallet = vm . Method = = WalletSetupMethod . HotWallet ;
var ( hotWallet , rpcImport ) = await CanUseHotWallet ( ) ;
if ( isHotWallet & & ! hotWallet )
{
return NotFound ( ) ;
}
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
vm . CanUseHotWallet = hotWallet ;
vm . CanUseRPCImport = rpcImport ;
vm . SupportTaproot = network . NBitcoinNetwork . Consensus . SupportTaproot ;
vm . SupportSegwit = network . NBitcoinNetwork . Consensus . SupportSegwit ;
vm . Network = network ;
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
if ( vm . Method = = null )
{
vm . Method = WalletSetupMethod . GenerateOptions ;
}
else
{
var canUsePayJoin = hotWallet & & isHotWallet & & network . SupportPayJoin ;
vm . SetupRequest = new WalletSetupRequest
2021-06-17 16:02:47 +09:00
{
2024-04-04 11:00:18 +02:00
SavePrivateKeys = isHotWallet ,
CanUsePayJoin = canUsePayJoin ,
PayJoinEnabled = canUsePayJoin
} ;
}
2021-06-17 16:02:47 +09:00
2024-04-04 11:00:18 +02:00
return View ( vm . ViewName , vm ) ;
}
2021-06-17 16:02:47 +09:00
2024-04-04 11:00:18 +02:00
internal GenerateWalletResponse GenerateWalletResponse ;
2021-06-17 16:02:47 +09:00
2024-04-04 11:00:18 +02:00
[HttpPost("{storeId}/onchain/{cryptoCode}/generate/{method}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task < IActionResult > GenerateWallet ( string storeId , string cryptoCode , WalletSetupMethod method , WalletSetupRequest request )
{
var checkResult = IsAvailable ( cryptoCode , out _ , out var network ) ;
if ( checkResult ! = null )
{
return checkResult ;
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
var ( hotWallet , rpcImport ) = await CanUseHotWallet ( ) ;
if ( ! hotWallet & & request . SavePrivateKeys | | ! rpcImport & & request . ImportKeysToRPC )
{
return NotFound ( ) ;
}
var handler = _handlers . GetBitcoinHandler ( cryptoCode ) ;
var client = _explorerProvider . GetExplorerClient ( cryptoCode ) ;
var isImport = method = = WalletSetupMethod . Seed ;
var vm = new WalletSetupViewModel
{
StoreId = storeId ,
CryptoCode = cryptoCode ,
Method = method ,
SetupRequest = request ,
Confirmation = ! isImport ,
Network = network ,
Source = isImport ? "SeedImported" : "NBXplorerGenerated" ,
IsHotWallet = isImport ? request . SavePrivateKeys : method = = WalletSetupMethod . HotWallet ,
DerivationSchemeFormat = "BTCPay" ,
CanUseHotWallet = hotWallet ,
CanUseRPCImport = rpcImport ,
SupportTaproot = network . NBitcoinNetwork . Consensus . SupportTaproot ,
SupportSegwit = network . NBitcoinNetwork . Consensus . SupportSegwit
} ;
if ( isImport & & string . IsNullOrEmpty ( request . ExistingMnemonic ) )
{
ModelState . AddModelError ( nameof ( request . ExistingMnemonic ) , "Please provide your existing seed" ) ;
return View ( vm . ViewName , vm ) ;
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
GenerateWalletResponse response ;
try
{
response = await client . GenerateWalletAsync ( request ) ;
if ( response = = null )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
throw new Exception ( "Node unavailable" ) ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
}
catch ( Exception e )
{
2021-02-11 11:48:54 +01:00
TempData . SetStatusMessageModel ( new StatusMessageModel
{
2024-04-04 11:00:18 +02:00
Severity = StatusMessageModel . StatusSeverity . Error ,
Html = $"There was an error generating your wallet: {e.Message}"
2021-02-11 11:48:54 +01:00
} ) ;
2024-04-04 11:00:18 +02:00
return View ( vm . ViewName , vm ) ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
var derivationSchemeSettings = new DerivationSchemeSettings ( response . DerivationScheme , network ) ;
if ( method = = WalletSetupMethod . Seed )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
derivationSchemeSettings . Source = "ImportedSeed" ;
derivationSchemeSettings . IsHotWallet = request . SavePrivateKeys ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
else
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
derivationSchemeSettings . Source = "NBXplorerGenerated" ;
derivationSchemeSettings . IsHotWallet = method = = WalletSetupMethod . HotWallet ;
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
var accountSettings = derivationSchemeSettings . GetSigningAccountKeySettings ( ) ;
accountSettings . AccountKeyPath = response . AccountKeyPath . KeyPath ;
accountSettings . RootFingerprint = response . AccountKeyPath . MasterFingerprint ;
derivationSchemeSettings . AccountOriginal = response . DerivationScheme . ToString ( ) ;
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
// Set wallet properties from generate response
vm . RootFingerprint = response . AccountKeyPath . MasterFingerprint . ToString ( ) ;
vm . AccountKey = response . AccountHDKey . Neuter ( ) . ToWif ( ) ;
vm . KeyPath = response . AccountKeyPath . KeyPath . ToString ( ) ;
vm . Config = ProtectString ( JToken . FromObject ( derivationSchemeSettings , handler . Serializer ) . ToString ( ) ) ;
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
var result = await UpdateWallet ( vm ) ;
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
if ( ! ModelState . IsValid | | result is not RedirectToActionResult )
return result ;
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
if ( ! isImport )
2021-10-29 08:25:43 +02:00
{
2024-04-04 11:00:18 +02:00
TempData . SetStatusMessageModel ( new StatusMessageModel
2022-01-19 12:58:02 +01:00
{
2024-04-04 11:00:18 +02:00
Severity = StatusMessageModel . StatusSeverity . Success ,
Html = "<span class='text-centered'>Your wallet has been generated.</span>"
} ) ;
var seedVm = new RecoverySeedBackupViewModel
2021-11-11 06:30:19 +01:00
{
2024-04-04 11:00:18 +02:00
CryptoCode = cryptoCode ,
Mnemonic = response . Mnemonic ,
Passphrase = response . Passphrase ,
IsStored = request . SavePrivateKeys ,
ReturnUrl = Url . Action ( nameof ( GenerateWalletConfirm ) , new { storeId , cryptoCode } )
} ;
if ( _btcPayEnv . IsDeveloping )
2021-10-29 08:25:43 +02:00
{
2024-04-04 11:00:18 +02:00
GenerateWalletResponse = response ;
2021-10-29 08:25:43 +02:00
}
2024-04-04 11:00:18 +02:00
return this . RedirectToRecoverySeedBackup ( seedVm ) ;
}
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
TempData . SetStatusMessageModel ( new StatusMessageModel
{
Severity = StatusMessageModel . StatusSeverity . Warning ,
Html = "Please check your addresses and confirm."
} ) ;
return result ;
}
2021-11-11 06:30:19 +01:00
2024-04-04 11:00:18 +02:00
// The purpose of this action is to show the user a success message, which confirms
// that the store settings have been updated after generating a new wallet.
[HttpGet("{storeId}/onchain/{cryptoCode}/generate/confirm")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public ActionResult GenerateWalletConfirm ( string storeId , string cryptoCode )
{
var checkResult = IsAvailable ( cryptoCode , out _ , out var network ) ;
if ( checkResult ! = null )
{
return checkResult ;
}
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
TempData [ WellKnownTempData . SuccessMessage ] = $"Wallet settings for {network.CryptoCode} have been updated." ;
2021-11-11 06:30:19 +01:00
2024-04-04 11:00:18 +02:00
var walletId = new WalletId ( storeId , cryptoCode ) ;
return RedirectToAction ( nameof ( UIWalletsController . WalletTransactions ) , "UIWallets" , new { walletId } ) ;
}
2024-04-04 16:31:04 +09:00
2024-04-04 11:00:18 +02:00
[HttpGet("{storeId}/onchain/{cryptoCode}/settings")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task < IActionResult > WalletSettings ( string storeId , string cryptoCode )
{
var checkResult = IsAvailable ( cryptoCode , out var store , out var network ) ;
if ( checkResult ! = null )
{
return checkResult ;
}
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
var derivation = GetExistingDerivationStrategy ( cryptoCode , store ) ;
if ( derivation = = null )
{
return NotFound ( ) ;
}
2021-10-29 08:25:43 +02:00
2024-04-04 11:00:18 +02:00
var storeBlob = store . GetStoreBlob ( ) ;
var excludeFilters = storeBlob . GetExcludedPaymentMethods ( ) ;
( bool canUseHotWallet , bool rpcImport ) = await CanUseHotWallet ( ) ;
var client = _explorerProvider . GetExplorerClient ( network ) ;
2023-01-06 14:18:07 +01:00
2024-04-04 11:00:18 +02:00
var handler = _handlers . GetBitcoinHandler ( cryptoCode ) ;
var vm = new WalletSettingsViewModel
{
StoreId = storeId ,
CryptoCode = cryptoCode ,
WalletId = new WalletId ( storeId , cryptoCode ) ,
Enabled = ! excludeFilters . Match ( handler . PaymentMethodId ) ,
Network = network ,
IsHotWallet = derivation . IsHotWallet ,
Source = derivation . Source ,
RootFingerprint = derivation . GetSigningAccountKeySettings ( ) . RootFingerprint . ToString ( ) ,
DerivationScheme = derivation . AccountDerivation . ToString ( ) ,
DerivationSchemeInput = derivation . AccountOriginal ,
KeyPath = derivation . GetSigningAccountKeySettings ( ) . AccountKeyPath ? . ToString ( ) ,
UriScheme = network . NBitcoinNetwork . UriScheme ,
Label = derivation . Label ,
SelectedSigningKey = derivation . SigningKey . ToString ( ) ,
NBXSeedAvailable = derivation . IsHotWallet & &
canUseHotWallet & &
! string . IsNullOrEmpty ( await client . GetMetadataAsync < string > ( derivation . AccountDerivation ,
WellknownMetadataKeys . MasterHDKey ) ) ,
AccountKeys = derivation . AccountKeySettings
. Select ( e = > new WalletSettingsAccountKeyViewModel
2021-11-11 06:30:19 +01:00
{
2024-04-04 11:00:18 +02:00
AccountKey = e . AccountKey . ToString ( ) ,
MasterFingerprint = e . RootFingerprint is { } fp ? fp . ToString ( ) : null ,
AccountKeyPath = e . AccountKeyPath = = null ? "" : $"m/{e.AccountKeyPath}"
} ) . ToList ( ) ,
Config = ProtectString ( JToken . FromObject ( derivation , handler . Serializer ) . ToString ( ) ) ,
PayJoinEnabled = storeBlob . PayJoinEnabled ,
MonitoringExpiration = ( int ) storeBlob . MonitoringExpiration . TotalMinutes ,
SpeedPolicy = store . SpeedPolicy ,
ShowRecommendedFee = storeBlob . ShowRecommendedFee ,
RecommendedFeeBlockTarget = storeBlob . RecommendedFeeBlockTarget ,
CanUseHotWallet = canUseHotWallet ,
CanUseRPCImport = rpcImport ,
CanUsePayJoin = canUseHotWallet & & network . SupportPayJoin & & derivation . IsHotWallet ,
StoreName = store . StoreName ,
} ;
ViewData [ "ReplaceDescription" ] = WalletReplaceWarning ( derivation . IsHotWallet ) ;
ViewData [ "RemoveDescription" ] = WalletRemoveWarning ( derivation . IsHotWallet , network . CryptoCode ) ;
return View ( vm ) ;
}
2021-11-11 06:30:19 +01:00
2024-04-04 11:00:18 +02:00
[HttpPost("{storeId}/onchain/{cryptoCode}/settings/wallet")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task < IActionResult > UpdateWalletSettings ( WalletSettingsViewModel vm )
{
var checkResult = IsAvailable ( vm . CryptoCode , out var store , out var network ) ;
if ( checkResult ! = null )
{
return checkResult ;
2021-11-11 06:30:19 +01:00
}
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
var derivation = GetExistingDerivationStrategy ( vm . CryptoCode , store ) ;
if ( derivation = = null )
2021-11-11 06:30:19 +01:00
{
2024-04-04 11:00:18 +02:00
return NotFound ( ) ;
}
var handler = _handlers . GetBitcoinHandler ( vm . CryptoCode ) ;
var storeBlob = store . GetStoreBlob ( ) ;
var excludeFilters = storeBlob . GetExcludedPaymentMethods ( ) ;
var currentlyEnabled = ! excludeFilters . Match ( handler . PaymentMethodId ) ;
bool enabledChanged = currentlyEnabled ! = vm . Enabled ;
bool needUpdate = enabledChanged ;
string errorMessage = null ;
if ( enabledChanged )
{
storeBlob . SetExcluded ( handler . PaymentMethodId , ! vm . Enabled ) ;
store . SetStoreBlob ( storeBlob ) ;
}
2021-11-11 06:30:19 +01:00
2024-04-04 11:00:18 +02:00
if ( derivation . Label ! = vm . Label )
{
needUpdate = true ;
derivation . Label = vm . Label ;
}
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
var signingKey = string . IsNullOrEmpty ( vm . SelectedSigningKey )
? null
: new BitcoinExtPubKey ( vm . SelectedSigningKey , network . NBitcoinNetwork ) ;
if ( derivation . SigningKey ! = signingKey & & signingKey ! = null )
{
needUpdate = true ;
derivation . SigningKey = signingKey ;
}
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
for ( int i = 0 ; i < derivation . AccountKeySettings . Length ; i + + )
{
KeyPath accountKeyPath ;
HDFingerprint ? rootFingerprint ;
2021-10-29 08:25:43 +02:00
2024-04-04 11:00:18 +02:00
try
2021-10-29 08:25:43 +02:00
{
2024-04-04 11:00:18 +02:00
accountKeyPath = string . IsNullOrWhiteSpace ( vm . AccountKeys [ i ] . AccountKeyPath )
? null
: new KeyPath ( vm . AccountKeys [ i ] . AccountKeyPath ) ;
2021-10-29 08:25:43 +02:00
2024-04-04 11:00:18 +02:00
if ( accountKeyPath ! = null & & derivation . AccountKeySettings [ i ] . AccountKeyPath ! = accountKeyPath )
2024-04-04 16:31:04 +09:00
{
2024-04-04 11:00:18 +02:00
needUpdate = true ;
derivation . AccountKeySettings [ i ] . AccountKeyPath = accountKeyPath ;
2021-10-29 08:25:43 +02:00
}
}
2024-04-04 11:00:18 +02:00
catch ( Exception ex )
2021-10-29 08:25:43 +02:00
{
2024-04-04 11:00:18 +02:00
errorMessage = $"{ex.Message}: {vm.AccountKeys[i].AccountKeyPath}" ;
2021-11-11 06:30:19 +01:00
}
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
try
2021-11-11 06:30:19 +01:00
{
2024-04-04 11:00:18 +02:00
rootFingerprint = string . IsNullOrWhiteSpace ( vm . AccountKeys [ i ] . MasterFingerprint )
? null
: new HDFingerprint ( Encoders . Hex . DecodeData ( vm . AccountKeys [ i ] . MasterFingerprint ) ) ;
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
if ( rootFingerprint ! = null & & derivation . AccountKeySettings [ i ] . RootFingerprint ! = rootFingerprint )
2021-11-11 06:30:19 +01:00
{
2024-04-04 11:00:18 +02:00
needUpdate = true ;
derivation . AccountKeySettings [ i ] . RootFingerprint = rootFingerprint ;
}
2021-11-11 06:30:19 +01:00
}
2024-04-04 11:00:18 +02:00
catch ( Exception ex )
2021-11-11 06:30:19 +01:00
{
2024-04-04 11:00:18 +02:00
errorMessage = $"{ex.Message}: {vm.AccountKeys[i].MasterFingerprint}" ;
}
}
2021-11-11 06:30:19 +01:00
2024-04-04 11:00:18 +02:00
if ( store . SpeedPolicy ! = vm . SpeedPolicy )
{
store . SpeedPolicy = vm . SpeedPolicy ;
needUpdate = true ;
2021-10-29 08:25:43 +02:00
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
if ( needUpdate )
2021-03-01 12:43:25 +01:00
{
2024-04-04 11:00:18 +02:00
store . SetPaymentMethodConfig ( handler , derivation ) ;
2021-03-01 12:43:25 +01:00
2024-04-04 11:00:18 +02:00
await _storeRepo . UpdateStore ( store ) ;
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
if ( string . IsNullOrEmpty ( errorMessage ) )
2021-03-01 12:43:25 +01:00
{
2024-04-04 11:00:18 +02:00
var successMessage = "Wallet settings successfully updated." ;
if ( enabledChanged )
{
_eventAggregator . Publish ( new WalletChangedEvent { WalletId = new WalletId ( vm . StoreId , vm . CryptoCode ) } ) ;
successMessage + = $" {vm.CryptoCode} on-chain payments are now {(vm.Enabled ? " enabled " : " disabled ")} for this store." ;
}
2021-03-01 12:43:25 +01:00
2024-04-04 11:00:18 +02:00
TempData [ WellKnownTempData . SuccessMessage ] = successMessage ;
2021-03-01 12:43:25 +01:00
}
2024-04-04 11:00:18 +02:00
else
2021-03-01 12:43:25 +01:00
{
2024-04-04 11:00:18 +02:00
TempData [ WellKnownTempData . ErrorMessage ] = errorMessage ;
2021-03-01 12:43:25 +01:00
}
}
2024-04-04 11:00:18 +02:00
return RedirectToAction ( nameof ( WalletSettings ) , new { vm . StoreId , vm . CryptoCode } ) ;
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
[HttpPost("{storeId}/onchain/{cryptoCode}/settings/payment")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task < IActionResult > UpdatePaymentSettings ( WalletSettingsViewModel vm )
{
var checkResult = IsAvailable ( vm . CryptoCode , out var store , out var network ) ;
if ( checkResult ! = null )
{
return checkResult ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
var derivation = GetExistingDerivationStrategy ( vm . CryptoCode , store ) ;
if ( derivation = = null )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
return NotFound ( ) ;
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
bool needUpdate = false ;
var blob = store . GetStoreBlob ( ) ;
var payjoinChanged = blob . PayJoinEnabled ! = vm . PayJoinEnabled ;
blob . MonitoringExpiration = TimeSpan . FromMinutes ( vm . MonitoringExpiration ) ;
blob . ShowRecommendedFee = vm . ShowRecommendedFee ;
blob . RecommendedFeeBlockTarget = vm . RecommendedFeeBlockTarget ;
blob . PayJoinEnabled = vm . PayJoinEnabled ;
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
if ( store . SetStoreBlob ( blob ) )
{
needUpdate = true ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
if ( needUpdate )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
await _storeRepo . UpdateStore ( store ) ;
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
TempData [ WellKnownTempData . SuccessMessage ] = "Payment settings successfully updated" ;
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
if ( payjoinChanged & & blob . PayJoinEnabled & & network . SupportPayJoin )
{
var config = store . GetPaymentMethodConfig < DerivationSchemeSettings > ( PaymentTypes . CHAIN . GetPaymentMethodId ( network . CryptoCode ) , _handlers ) ;
if ( config ? . IsHotWallet is not true )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
TempData . Remove ( WellKnownTempData . SuccessMessage ) ;
TempData . SetStatusMessageModel ( new StatusMessageModel
{
Severity = StatusMessageModel . StatusSeverity . Warning ,
Html = "The payment settings were updated successfully. However, PayJoin will not work, as this isn't a <a href='https://docs.btcpayserver.org/HotWallet/' class='alert-link' target='_blank'>hot wallet</a>."
} ) ;
2021-02-11 11:48:54 +01:00
}
}
2024-04-04 11:00:18 +02:00
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
return RedirectToAction ( nameof ( WalletSettings ) , new { vm . StoreId , vm . CryptoCode } ) ;
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
[HttpGet("{storeId}/onchain/{cryptoCode}/seed")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task < IActionResult > WalletSeed ( string storeId , string cryptoCode , CancellationToken cancellationToken = default )
{
var checkResult = IsAvailable ( cryptoCode , out var store , out var network ) ;
if ( checkResult ! = null )
{
return checkResult ;
2021-02-11 11:48:54 +01:00
}
2024-04-04 11:00:18 +02:00
var derivation = GetExistingDerivationStrategy ( cryptoCode , store ) ;
if ( derivation = = null )
2021-02-11 11:48:54 +01:00
{
2024-04-04 11:00:18 +02:00
return NotFound ( ) ;
}
2021-02-11 11:48:54 +01:00
2024-04-04 11:00:18 +02:00
( bool canUseHotWallet , bool _ ) = await CanUseHotWallet ( ) ;
if ( ! canUseHotWallet )
{
return NotFound ( ) ;
2021-02-11 11:48:54 +01:00
}
2021-02-17 18:43:12 +01:00
2024-04-04 11:00:18 +02:00
var client = _explorerProvider . GetExplorerClient ( network ) ;
if ( await GetSeed ( client , derivation ) ! = null )
2021-02-17 18:43:12 +01:00
{
2024-04-04 11:00:18 +02:00
var mnemonic = await client . GetMetadataAsync < string > ( derivation . AccountDerivation ,
WellknownMetadataKeys . Mnemonic , cancellationToken ) ;
var recoveryVm = new RecoverySeedBackupViewModel
{
CryptoCode = cryptoCode ,
Mnemonic = mnemonic ,
IsStored = true ,
RequireConfirm = false ,
ReturnUrl = Url . Action ( nameof ( WalletSettings ) , new { storeId , cryptoCode } )
} ;
return this . RedirectToRecoverySeedBackup ( recoveryVm ) ;
2021-02-17 18:43:12 +01:00
}
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
TempData . SetStatusMessageModel ( new StatusMessageModel
2021-11-11 06:30:19 +01:00
{
2024-04-04 11:00:18 +02:00
Severity = StatusMessageModel . StatusSeverity . Error ,
Message = "The seed was not found"
} ) ;
return RedirectToAction ( nameof ( WalletSettings ) ) ;
}
[HttpGet("{storeId}/onchain/{cryptoCode}/replace")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public ActionResult ReplaceWallet ( string storeId , string cryptoCode )
{
var checkResult = IsAvailable ( cryptoCode , out var store , out var network ) ;
if ( checkResult ! = null )
{
return checkResult ;
2021-11-11 06:30:19 +01:00
}
2021-02-17 18:43:12 +01:00
2024-04-04 11:00:18 +02:00
var derivation = GetExistingDerivationStrategy ( cryptoCode , store ) ;
return View ( "Confirm" , new ConfirmModel
2021-02-17 18:43:12 +01:00
{
2024-04-04 11:00:18 +02:00
Title = $"Replace {network.CryptoCode} wallet" ,
Description = WalletReplaceWarning ( derivation . IsHotWallet ) ,
Action = "Setup new wallet"
} ) ;
}
[HttpPost("{storeId}/onchain/{cryptoCode}/replace")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult ConfirmReplaceWallet ( string storeId , string cryptoCode )
{
var checkResult = IsAvailable ( cryptoCode , out var store , out _ ) ;
if ( checkResult ! = null )
{
return checkResult ;
2021-02-17 18:43:12 +01:00
}
2024-04-04 11:00:18 +02:00
var derivation = GetExistingDerivationStrategy ( cryptoCode , store ) ;
if ( derivation = = null )
2021-02-17 18:43:12 +01:00
{
2024-04-04 11:00:18 +02:00
return NotFound ( ) ;
2021-02-17 18:43:12 +01:00
}
2021-09-07 04:55:53 +02:00
2024-04-04 11:00:18 +02:00
return RedirectToAction ( nameof ( SetupWallet ) , new { storeId , cryptoCode } ) ;
}
[HttpGet("{storeId}/onchain/{cryptoCode}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public ActionResult DeleteWallet ( string storeId , string cryptoCode )
{
var checkResult = IsAvailable ( cryptoCode , out var store , out var network ) ;
if ( checkResult ! = null )
2021-09-07 04:55:53 +02:00
{
2024-04-04 11:00:18 +02:00
return checkResult ;
2021-09-07 04:55:53 +02:00
}
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
var derivation = GetExistingDerivationStrategy ( cryptoCode , store ) ;
return View ( "Confirm" , new ConfirmModel
{
Title = $"Remove {network.CryptoCode} wallet" ,
Description = WalletRemoveWarning ( derivation . IsHotWallet , network . CryptoCode ) ,
Action = "Remove"
} ) ;
}
[HttpPost("{storeId}/onchain/{cryptoCode}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task < IActionResult > ConfirmDeleteWallet ( string storeId , string cryptoCode )
{
var checkResult = IsAvailable ( cryptoCode , out var store , out var network ) ;
if ( checkResult ! = null )
2021-09-07 04:55:53 +02:00
{
2024-04-04 11:00:18 +02:00
return checkResult ;
2021-09-07 04:55:53 +02:00
}
2021-12-31 16:59:02 +09:00
2024-04-04 11:00:18 +02:00
var derivation = GetExistingDerivationStrategy ( cryptoCode , store ) ;
if ( derivation = = null )
2021-09-07 04:55:53 +02:00
{
2024-04-04 11:00:18 +02:00
return NotFound ( ) ;
2021-09-07 04:55:53 +02:00
}
2024-04-04 11:00:18 +02:00
store . SetPaymentMethodConfig ( PaymentTypes . CHAIN . GetPaymentMethodId ( network . CryptoCode ) , null ) ;
await _storeRepo . UpdateStore ( store ) ;
_eventAggregator . Publish ( new WalletChangedEvent { WalletId = new WalletId ( storeId , cryptoCode ) } ) ;
TempData [ WellKnownTempData . SuccessMessage ] =
$"On-Chain payment for {network.CryptoCode} has been removed." ;
return RedirectToAction ( nameof ( GeneralSettings ) , new { storeId } ) ;
}
private IActionResult ConfirmAddresses ( WalletSetupViewModel vm , DerivationSchemeSettings strategy , NBXplorerNetwork network )
{
vm . DerivationScheme = strategy . AccountDerivation . ToString ( ) ;
var deposit = new KeyPathTemplates ( null ) . GetKeyPathTemplate ( DerivationFeature . Deposit ) ;
if ( ! string . IsNullOrEmpty ( vm . DerivationScheme ) )
2024-04-04 10:47:28 +02:00
{
2024-04-04 11:00:18 +02:00
var line = strategy . AccountDerivation . GetLineFor ( deposit ) ;
for ( uint i = 0 ; i < 10 ; i + + )
2024-04-04 10:47:28 +02:00
{
2024-04-04 11:00:18 +02:00
var keyPath = deposit . GetKeyPath ( i ) ;
var rootedKeyPath = vm . GetAccountKeypath ( ) ? . Derive ( keyPath ) ;
var derivation = line . Derive ( i ) ;
var address = network . CreateAddress ( strategy . AccountDerivation ,
line . KeyPathTemplate . GetKeyPath ( i ) ,
derivation . ScriptPubKey ) . ToString ( ) ;
vm . AddressSamples . Add ( ( keyPath . ToString ( ) , address , rootedKeyPath ) ) ;
2024-04-04 10:47:28 +02:00
}
2024-04-04 11:00:18 +02:00
}
vm . Confirmation = true ;
ModelState . Remove ( nameof ( vm . Config ) ) ; // Remove the cached value
return View ( "ImportWallet/ConfirmAddresses" , vm ) ;
}
private ActionResult IsAvailable ( string cryptoCode , out StoreData store , out BTCPayNetwork network )
{
store = HttpContext . GetStoreData ( ) ;
network = cryptoCode = = null ? null : _explorerProvider . GetNetwork ( cryptoCode ) ;
return store = = null | | network = = null ? NotFound ( ) : null ;
}
private DerivationSchemeSettings GetExistingDerivationStrategy ( string cryptoCode , StoreData store )
{
return store . GetPaymentMethodConfig < DerivationSchemeSettings > ( PaymentTypes . CHAIN . GetPaymentMethodId ( cryptoCode ) , _handlers ) ;
}
private async Task < string > GetSeed ( ExplorerClient client , DerivationSchemeSettings derivation )
{
return derivation . IsHotWallet & &
await client . GetMetadataAsync < string > ( derivation . AccountDerivation , WellknownMetadataKeys . MasterHDKey ) is { } seed & &
! string . IsNullOrEmpty ( seed ) ? seed : null ;
}
private async Task < ( bool HotWallet , bool RPCImport ) > CanUseHotWallet ( )
{
return await _authorizationService . CanUseHotWallet ( _policiesSettings , User ) ;
}
private async Task < string > ReadAllText ( IFormFile file )
{
using var stream = new StreamReader ( file . OpenReadStream ( ) ) ;
return await stream . ReadToEndAsync ( ) ;
}
private string WalletWarning ( bool isHotWallet , string info )
{
var walletType = isHotWallet ? "hot" : "watch-only" ;
var additionalText = isHotWallet
? ""
: " or imported it into an external wallet. If you no longer have access to your private key (recovery seed), immediately replace the wallet" ;
return
$"<p class=\" text - danger fw - bold \ ">Please note that this is a <strong>{_html.Encode(walletType)} wallet</strong>!</p>" +
$"<p class=\" text - danger fw - bold \ ">Do not proceed if you have not backed up the wallet{_html.Encode(additionalText)}.</p>" +
$"<p class=\" text - start mb - 0 \ ">This action will erase the current wallet data from the server. {_html.Encode(info)}</p>" ;
}
private string WalletReplaceWarning ( bool isHotWallet )
{
return WalletWarning ( isHotWallet ,
"The current wallet will be replaced once you finish the setup of the new wallet. " +
"If you cancel the setup, the current wallet will stay active." ) ;
}
2024-04-04 10:47:28 +02:00
2024-04-04 11:00:18 +02:00
private string WalletRemoveWarning ( bool isHotWallet , string cryptoCode )
{
return WalletWarning ( isHotWallet ,
$"The store won't be able to receive {cryptoCode} onchain payments until a new wallet is set up." ) ;
}
private DerivationSchemeSettings ParseDerivationStrategy ( string derivationScheme , BTCPayNetwork network )
{
var parser = new DerivationSchemeParser ( network ) ;
var isOD = Regex . Match ( derivationScheme , @"\(.*?\)" ) ;
if ( isOD . Success )
{
var derivationSchemeSettings = new DerivationSchemeSettings ( ) ;
var result = parser . ParseOutputDescriptor ( derivationScheme ) ;
derivationSchemeSettings . AccountOriginal = derivationScheme . Trim ( ) ;
derivationSchemeSettings . AccountDerivation = result . Item1 ;
derivationSchemeSettings . AccountKeySettings = result . Item2 ? . Select ( ( path , i ) = > new AccountKeySettings ( )
{
RootFingerprint = path ? . MasterFingerprint ,
AccountKeyPath = path ? . KeyPath ,
AccountKey = result . Item1 . GetExtPubKeys ( ) . ElementAt ( i ) . GetWif ( parser . Network )
} ) . ToArray ( ) ? ? new AccountKeySettings [ result . Item1 . GetExtPubKeys ( ) . Count ( ) ] ;
return derivationSchemeSettings ;
2024-04-04 10:47:28 +02:00
}
2024-04-04 11:00:18 +02:00
var strategy = parser . Parse ( derivationScheme ) ;
return new DerivationSchemeSettings ( strategy , network ) ;
2021-02-11 11:48:54 +01:00
}
}