Automatically set default payment methods for the store

Triggered on
- store select/change
- Onchain wallet becoming availble
- Lightning node becoming availble

Conditions:
- User needs to be permissioned to manage the store
- App onchain wallet is available and the store doesn't have an onchain payment method configured
- App Lightning node is available and the store doesn't have a Lightning node configured

Closes #43.

Improve wallet settings
This commit is contained in:
Dennis Reimann 2024-07-11 16:56:33 +02:00 committed by Andrew Camilleri
parent 85643b8b2e
commit 882c22bfd3
8 changed files with 119 additions and 77 deletions

View File

@ -9,6 +9,8 @@ namespace BTCPayApp.Core.Attempt2;
public class LightningNodeManager : BaseHostedService
{
public const string PaymentMethodId = "BTC-LN";
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly ILogger<LightningNodeManager> _logger;
private readonly OnChainWalletManager _onChainWalletManager;
@ -22,6 +24,9 @@ public class LightningNodeManager : BaseHostedService
private bool IsOnchainConfigured => _onChainWalletManager.WalletConfig is not null;
private bool IsOnchainLightningDerivationConfigured => _onChainWalletManager.WalletConfig?.Derivations.ContainsKey(WalletDerivation.LightningScripts) is true;
public bool CanConfigureLightningNode => IsHubConnected && IsOnchainConfigured && !IsOnchainLightningDerivationConfigured && State == LightningNodeState.NotConfigured;
public string? ConnectionString => IsOnchainLightningDerivationConfigured
? $"type=app;group={_onChainWalletManager.WalletConfig!.Derivations[WalletDerivation.LightningScripts].Identifier}".ToLower()
: null;
public LightningNodeState State
{

View File

@ -14,6 +14,8 @@ using TxOut = NBitcoin.TxOut;
namespace BTCPayApp.Core.Attempt2;
public class OnChainWalletManager : BaseHostedService
{
public const string PaymentMethodId = "BTC-CHAIN";
private readonly IConfigProvider _configProvider;
private readonly BTCPayAppServerClient _btcPayAppServerClient;
private readonly BTCPayConnectionManager _btcPayConnectionManager;
@ -75,7 +77,7 @@ public class OnChainWalletManager : BaseHostedService
}
private bool IsHubConnected => _btcPayConnectionManager.ConnectionState is HubConnectionState.Connected;
private bool IsConfigured => WalletConfig is not null;
public bool IsConfigured => WalletConfig is not null;
private async Task OnStateChanged(object? sender, (OnChainWalletState Old, OnChainWalletState New) e)
{

View File

@ -4,7 +4,6 @@ using BTCPayApp.Core.AspNetRip;
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Helpers;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
@ -34,6 +33,8 @@ public class AuthStateProvider : AuthenticationStateProvider, IAccountManager, I
public AsyncEventHandler<BTCPayAccount?>? OnBeforeAccountChange { get; set; }
public AsyncEventHandler<BTCPayAccount?>? OnAfterAccountChange { get; set; }
public AsyncEventHandler<AppUserStoreInfo?>? OnBeforeStoreChange { get; set; }
public AsyncEventHandler<AppUserStoreInfo?>? OnAfterStoreChange { get; set; }
public AuthStateProvider(
IConfigProvider config,
@ -145,7 +146,7 @@ public class AuthStateProvider : AuthenticationStateProvider, IAccountManager, I
public async Task<bool> IsAuthorized(string policy, object? resource = null)
{
var authState = await GetAuthenticationStateAsync();
var result = await _authService.AuthorizeAsync(authState.User, resource,Policies.CanViewStoreSettings);
var result = await _authService.AuthorizeAsync(authState.User, resource, policy);
return result.Succeeded;
}
@ -162,6 +163,7 @@ public class AuthStateProvider : AuthenticationStateProvider, IAccountManager, I
var store = GetUserStore(storeId);
if (store == null) return new FormResult(false, $"Store with ID '{storeId}' does not exist or belong to the user.");
OnBeforeStoreChange?.Invoke(this, GetCurrentStore());
string? message = null;
// create associated POS app if there is none
@ -183,6 +185,7 @@ public class AuthStateProvider : AuthenticationStateProvider, IAccountManager, I
_account!.CurrentStoreId = storeId;
await UpdateAccount(_account);
OnAfterStoreChange?.Invoke(this, store);
return new FormResult(true, string.IsNullOrEmpty(message) ? null : [message]);
}

View File

@ -29,4 +29,6 @@ public interface IAccountManager
public Task RemoveAccount(BTCPayAccount account);
public AsyncEventHandler<BTCPayAccount?>? OnBeforeAccountChange { get; set; }
public AsyncEventHandler<BTCPayAccount?>? OnAfterAccountChange { get; set; }
public AsyncEventHandler<AppUserStoreInfo?>? OnBeforeStoreChange { get; set; }
public AsyncEventHandler<AppUserStoreInfo?>? OnAfterStoreChange { get; set; }
}

View File

@ -9,7 +9,6 @@
@using BTCPayServer.Client.Models
@using Newtonsoft.Json.Linq
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
@inject OnChainWalletManager OnChainWalletManager
@inject LightningNodeManager LightningNodeManager
@inject IState<RootState> State
@inject ILogger<IndexPage> Logger
@ -82,7 +81,7 @@
private LDKNode? Node => LightningNodeManager.Node;
private string? ConfiguredConnectionString;
private string ConnectionString => $"type=app;group={OnChainWalletManager.WalletConfig?.Derivations[WalletDerivation.LightningScripts].Identifier}".ToLower();
private string ConnectionString => LightningNodeManager.ConnectionString;
protected override async Task OnInitializedAsync()
{
@ -124,7 +123,7 @@
var acc = AccountManager.GetAccount();
if (acc?.CurrentStoreId != null)
{
var pm = await AccountManager.GetClient().UpdateStorePaymentMethod(acc.CurrentStoreId, "BTC-LN", new UpdatePaymentMethodRequest()
var pm = await AccountManager.GetClient().UpdateStorePaymentMethod(acc.CurrentStoreId, "BTC-LN", new UpdatePaymentMethodRequest
{
Enabled = true,
Config = new JObject

View File

@ -1,12 +1,12 @@
@attribute [Route(Routes.WalletSettings)]
@layout BaseLayout
@using BTCPayApp.CommonServer.Models
@using BTCPayApp.UI.Features
@using Microsoft.AspNetCore.SignalR.Client
@using BTCPayApp.Core.Attempt2
@using BTCPayApp.Core.Auth
@using Microsoft.Extensions.Logging
@using BTCPayApp.UI.Components.Layout
@using BTCPayApp.Core.Helpers
@using BTCPayServer.Client.Models
@using Newtonsoft.Json.Linq
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
@ -14,22 +14,18 @@
@inject IState<RootState> State
@inject ILogger<IndexPage> Logger
@inject IAccountManager AccountManager
<PageTitle>Wallet Setup</PageTitle>
<PageTitle>Onchain Wallet</PageTitle>
<SectionContent SectionId="_Layout.Top">
<Titlebar Back>
<h1>Wallet Setup</h1>
<h1>Onchain Wallet</h1>
</Titlebar>
</SectionContent>
<section class="container">
@if (CanConfigureWallet)
@if (OnChainWalletManager.IsConfigured)
{
<button class="btn btn-primary" @onclick="GenerateWallet">Generate Wallet</button>
}
@if (OnChainWalletManager.State == OnChainWalletState.Loaded)
{
var config = OnChainWalletManager.WalletConfig;
<h3>@config.Network Wallet</h3>
var config = OnChainWalletManager.WalletConfig!;
<h3>Network: @config.Network</h3>
<p>Fingerprint: <code>@config.Fingerprint</code></p>
<button class="btn btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#seed" aria-expanded="false" aria-controls="seed">
Show seed
@ -48,66 +44,49 @@
<h3 class="mt-4">Derivations</h3>
@foreach (var d in config.Derivations)
{
var identifier = d.Value.Identifier;
var descriptor = d.Value.Descriptor;
var isStorePaymentMethod = IsStorePaymentMethodId(identifier);
<div class="box mb-3">
<h4>@(d.Value.Name)</h4>
@if (!string.IsNullOrEmpty(d.Value.Identifier))
@if (!string.IsNullOrEmpty(identifier))
{
<div class="form-floating">
<TruncateCenter Text="@d.Value.Identifier" Padding="15" Copy="true" Elastic="true" class="form-control-plaintext" />
<TruncateCenter Text="@identifier" Padding="15" Copy="true" Elastic="true" class="form-control-plaintext" />
<label>Identifier</label>
</div>
}
@if (!string.IsNullOrEmpty(d.Value.Descriptor))
@if (!string.IsNullOrEmpty(descriptor))
{
<div class="form-floating">
<TruncateCenter Text="@d.Value.Descriptor" Padding="15" Copy="true" Elastic="true" class="form-control-plaintext" />
<TruncateCenter Text="@descriptor" Padding="15" Copy="true" Elastic="true" class="form-control-plaintext" />
<label>Descriptor</label>
</div>
}
@if (d.Value.Descriptor is not null && d.Value.Identifier is not null && (StorePaymentMethodIdentifier is null || !d.Value.Identifier.Contains(StorePaymentMethodIdentifier)))
{
<button class="btn btn-primary" @onclick="() => SetStorePaymentMethod(d.Key)">Set as Store Payment Method</button>
@if (isStorePaymentMethod is false)
{
<button class="btn btn-primary" @onclick="() => SetStorePaymentMethod(d.Key)">
Set as onchain payment method for store "@Store!.Name"
</button>
}
}
</div>
}
}
else
{
<ul>
<li>Connection state: @State.Value.ConnectionState</li>
<li>Onchain wallet state: @State.Value.OnchainWalletState</li>
</ul>
}
</section>
@code {
private bool CanConfigureWallet =>
State.Value.OnchainWalletState == OnChainWalletState.NotConfigured &&
State.Value.ConnectionState == HubConnectionState.Connected;
private string? StorePaymentMethodIdentifier = null;
private async Task SetStorePaymentMethod(string key)
{
try
{
if (!OnChainWalletManager.WalletConfig.Derivations.TryGetValue(key, out var derivation) || derivation.Descriptor is null)
{
return;
}
var xpub = derivation.Descriptor.ExtractFromDescriptor(OnChainWalletManager.Network);
var identifierSUffix = derivation.Identifier.Split(':').Last();
var acc = AccountManager.GetAccount();
if (acc?.CurrentStoreId is null)
return;
var pm = await AccountManager.GetClient().UpdateStorePaymentMethod(acc.CurrentStoreId, "BTC-CHAIN", new UpdatePaymentMethodRequest()
{
Enabled = true,
Config = derivation.Descriptor
});
}
catch (Exception ex)
{
Logger.LogError(ex, "Error setting store payment method");
}
}
private AppUserStoreInfo? Store => AccountManager.GetCurrentStore();
private string? _storePaymentMethodIdentifier;
protected override async Task OnInitializedAsync()
{
@ -118,32 +97,46 @@
private async Task GetStorePaymentMethod()
{
var storeId = AccountManager.GetCurrentStore()?.Id;
var storeId = Store?.Id;
var pmId = OnChainWalletManager.PaymentMethodId;
if (string.IsNullOrEmpty(storeId) || CanConfigureWallet) return;
try
{
var pm = await AccountManager.GetClient().GetStorePaymentMethod(storeId, "BTC-CHAIN", true);
var pm = await AccountManager.GetClient().GetStorePaymentMethod(storeId, pmId, true);
if (pm?.Config is JObject configObj && configObj.TryGetValue("accountDerivation", out var derivationSchemeToken) && derivationSchemeToken.Value<string>() is {} derivationScheme)
{
StorePaymentMethodIdentifier = derivationScheme;
_storePaymentMethodIdentifier = derivationScheme;
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Payment method BTC-CHAIN unset for store {StoreId}", storeId);
Logger.LogDebug(ex, "Payment method {PaymentMethodId} unset for store {StoreId}", pmId, storeId);
}
}
private async Task GenerateWallet()
private async Task SetStorePaymentMethod(string key)
{
var pmId = OnChainWalletManager.PaymentMethodId;
var storeId = AccountManager.GetCurrentStore()?.Id;
try
{
await OnChainWalletManager.Generate();
if (storeId is null || OnChainWalletManager.WalletConfig?.Derivations.TryGetValue(key, out var derivation) is not true || derivation.Descriptor is null) return;
await AccountManager.GetClient().UpdateStorePaymentMethod(storeId, pmId, new UpdatePaymentMethodRequest
{
Enabled = true,
Config = derivation.Descriptor
});
}
catch (Exception ex)
{
Logger.LogError(ex, "Error configuring wallet");
Logger.LogError(ex, "Error setting payment method {PaymentMethodId} for store {StoreId}", pmId, storeId);
}
}
private bool? IsStorePaymentMethodId(string identifier)
{
return !string.IsNullOrEmpty(identifier) && !string.IsNullOrEmpty(_storePaymentMethodIdentifier)
? identifier.Contains(_storePaymentMethodIdentifier)
: null;
}
}

View File

@ -25,12 +25,6 @@
</Titlebar>
</SectionContent>
<section class="container">
<ul>
<li>Connection state: @State.Value.ConnectionState</li>
<li>Onchain wallet state: @State.Value.OnchainWalletState</li>
</ul>
@if (loading)
{
<p>Loading...</p>

View File

@ -1,10 +1,14 @@
using BTCPayApp.Core.Attempt2;
using BTCPayApp.Core.Auth;
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Data;
using BTCPayApp.UI.Features;
using Fluxor;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Components;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using Newtonsoft.Json.Linq;
namespace BTCPayApp.UI;
@ -54,16 +58,24 @@ public class StateMiddleware(
return Task.CompletedTask;
};
lightningNodeService.StateChanged += (sender, args) =>
{
dispatcher.Dispatch(new RootState.LightningNodeStateUpdatedAction(lightningNodeService.State));
return Task.CompletedTask;
};
onChainWalletManager.StateChanged += (sender, args) =>
onChainWalletManager.StateChanged += async (sender, args) =>
{
dispatcher.Dispatch(new RootState.OnChainWalletStateUpdatedAction(onChainWalletManager.State));
return Task.CompletedTask;
if (onChainWalletManager.State == OnChainWalletState.Loaded)
await TryApplyingAppPaymentMethodsToCurrentStore(true, false);
};
lightningNodeService.StateChanged += async (sender, args) =>
{
dispatcher.Dispatch(new RootState.LightningNodeStateUpdatedAction(lightningNodeService.State));
if (lightningNodeService.State == LightningNodeState.Loaded)
await TryApplyingAppPaymentMethodsToCurrentStore(false, true);
};
accountManager.OnAfterStoreChange += async (sender, storeInfo) =>
{
await TryApplyingAppPaymentMethodsToCurrentStore(true, true);
};
btcpayAppServerClient.OnNotifyServerEvent += async (sender, serverEvent) =>
@ -105,4 +117,36 @@ public class StateMiddleware(
}
};
}
private async Task<(GenericPaymentMethodData? onchain, GenericPaymentMethodData? lightning)?> TryApplyingAppPaymentMethodsToCurrentStore(bool applyOnchain, bool applyLighting)
{
var storeId = accountManager.GetCurrentStore()?.Id;
if (// is a store present?
string.IsNullOrEmpty(storeId) ||
// is user permitted? (store owner)
!await accountManager.IsAuthorized(Policies.CanModifyStoreSettings, storeId) ||
// is the onchain wallet configured?
onChainWalletManager.WalletConfig?.Derivations.TryGetValue(WalletDerivation.NativeSegwit, out var onchainDerivation) is not true || string.IsNullOrEmpty(onchainDerivation.Descriptor)) return null;
// check the store's payment methods
var pms = await accountManager.GetClient().GetStorePaymentMethods(storeId);
var onchain = pms.FirstOrDefault(pm => pm.PaymentMethodId == OnChainWalletManager.PaymentMethodId);
if (onchain is null && applyOnchain)
{
onchain = await accountManager.GetClient().UpdateStorePaymentMethod(storeId, OnChainWalletManager.PaymentMethodId, new UpdatePaymentMethodRequest
{
Enabled = true,
Config = onchainDerivation.Descriptor
});
}
var lightning = pms.FirstOrDefault(pm => pm.PaymentMethodId == LightningNodeManager.PaymentMethodId);
if (lightning is null && !string.IsNullOrEmpty(lightningNodeService.ConnectionString) && applyLighting)
{
lightning = await accountManager.GetClient().UpdateStorePaymentMethod(storeId, LightningNodeManager.PaymentMethodId, new UpdatePaymentMethodRequest
{
Enabled = true,
Config = new JObject { ["connectionString"] = lightningNodeService.ConnectionString }
});
}
return (onchain, lightning);
}
}