diff --git a/src/shared/Angor.Shared.Tests/Services/NetworkServiceTests.cs b/src/shared/Angor.Shared.Tests/Services/NetworkServiceTests.cs new file mode 100644 index 000000000..2b397b0f2 --- /dev/null +++ b/src/shared/Angor.Shared.Tests/Services/NetworkServiceTests.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Text.Json; +using Angor.Shared; +using Angor.Shared.Models; +using Angor.Shared.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Angor.Test.Services; + +public class NetworkServiceTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CheckServices_WhenSettingsChangeDuringRequest_PreservesCurrentNetworkAndIndexer(bool switchNetwork) + { + string network = "Main"; + SettingsInfo stored = new() + { + Indexers = new List + { + new() { Url = "https://old.example", IsPrimary = true } + } + }; + Mock storage = new(); + storage.Setup(value => value.GetNetwork()).Returns(() => network); + // Local storage returns snapshots, not a shared in-memory object. + storage.Setup(value => value.GetSettings()).Returns(() => + JsonSerializer.Deserialize(JsonSerializer.Serialize(stored))!); + storage.Setup(value => value.SetSettings(It.IsAny())) + .Callback(value => stored = value); + DelayedHandler handler = new(); + using HttpClient client = new(handler); + Mock factory = new(); + factory.Setup(value => value.CreateClient(It.IsAny())).Returns(client); + NetworkService service = new(storage.Object, factory.Object, + NullLogger.Instance, Mock.Of()); + + Task checking = service.CheckServices(true); + await handler.Started.Task; + network = switchNetwork ? "Angornet" : "Main"; + stored = new SettingsInfo + { + Indexers = new List + { + new() { Url = "https://selected.example", IsPrimary = true }, + new() { Url = "https://old.example", IsPrimary = false } + } + }; + handler.Response.SetResult(new HttpResponseMessage(HttpStatusCode.OK)); + await checking; + + Assert.Equal(switchNetwork ? "Angornet" : "Main", network); + Assert.Equal("https://selected.example", service.GetPrimaryIndexer().Url); + Assert.Equal(2, stored.Indexers.Count); + if (switchNetwork) + storage.Verify(value => value.SetSettings(It.IsAny()), Times.Never); + else + Assert.Equal(UrlStatus.Online, stored.Indexers[1].Status); + } + + private sealed class DelayedHandler : HttpMessageHandler + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Response { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Started.TrySetResult(); + return Response.Task; + } + } +} diff --git a/src/shared/Angor.Shared.Tests/WalletWordsAsyncTests.cs b/src/shared/Angor.Shared.Tests/WalletWordsAsyncTests.cs new file mode 100644 index 000000000..e3dc75c74 --- /dev/null +++ b/src/shared/Angor.Shared.Tests/WalletWordsAsyncTests.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using System.Text; +using Angor.Shared; +using Angor.Shared.Models; +using Moq; +using NBitcoin; + +namespace Angor.Test; + +public class WalletWordsAsyncTests +{ + private const string MnemonicWords = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + [Theory] + [InlineData("")] + [InlineData("TREZOR")] + [InlineData("é㍍ガバヴァぱばぐゞちぢ十人十色")] + public async Task AsyncDerivation_MatchesNBitcoinAndPrimesSigningCache(string passphrase) + { + using WalletWords words = new() { Words = MnemonicWords, Passphrase = passphrase }; + ExtKey expected = new HdOperations().GetExtendedKey(MnemonicWords, passphrase); + ExtKey actual = await words.GetOrDeriveExtKeyAsync((mnemonic, password) => + { + byte[] seed = Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(mnemonic.Normalize(NormalizationForm.FormKD)), + Encoding.UTF8.GetBytes(("mnemonic" + password).Normalize(NormalizationForm.FormKD)), + 2048, HashAlgorithmName.SHA512, 64); + try + { + return Task.FromResult(ExtKey.CreateFromSeed(seed)); + } + finally + { + CryptographicOperations.ZeroMemory(seed); + } + }); + + Assert.Equal(expected.GetWif(Network.Main).ToString(), actual.GetWif(Network.Main).ToString()); + Assert.Same(actual, words.GetOrDeriveExtKey(Mock.Of())); + Assert.Same(actual, await words.GetOrDeriveExtKeyAsync((_, _) => throw new Exception("Cache was not used"))); + Assert.DoesNotContain(actual.GetWif(Network.Main).ToString(), words.ConvertToString()); + } + + [Fact] + public async Task AsyncDerivation_WhenDisposedWhileWaiting_DoesNotRetainKey() + { + WalletWords words = new() { Words = MnemonicWords }; + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task pending = words.GetOrDeriveExtKeyAsync((_, _) => completion.Task); + words.Dispose(); + completion.SetResult(new HdOperations().GetExtendedKey(MnemonicWords)); + await Assert.ThrowsAsync(() => pending); + Assert.Null(words.CachedExtKey); + } +} diff --git a/src/shared/Angor.Shared/Models/WalletWords.cs b/src/shared/Angor.Shared/Models/WalletWords.cs index 5b23a6fff..872a1ffca 100644 --- a/src/shared/Angor.Shared/Models/WalletWords.cs +++ b/src/shared/Angor.Shared/Models/WalletWords.cs @@ -101,6 +101,22 @@ public ExtKey GetOrDeriveExtKey(IHdOperations hdOperations) return cachedExtKey; } + /// + /// Prime the same master-key cache using a platform's asynchronous BIP-39 derivation. + /// Browser callers use Web Crypto so PBKDF2 does not block the UI thread. + /// + public async Task GetOrDeriveExtKeyAsync(Func> derive) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (cachedExtKey != null) + return cachedExtKey; + + ExtKey key = await derive(Words, Passphrase); + ObjectDisposedException.ThrowIf(disposed, this); + cachedExtKey ??= key; + return cachedExtKey; + } + public string ConvertToString() { return JsonSerializer.Serialize(this); diff --git a/src/shared/Angor.Shared/Services/NetworkService.cs b/src/shared/Angor.Shared/Services/NetworkService.cs index 205f222ca..09abd000c 100644 --- a/src/shared/Angor.Shared/Services/NetworkService.cs +++ b/src/shared/Angor.Shared/Services/NetworkService.cs @@ -99,6 +99,7 @@ public void AddSettingsIfNotExist() public async Task CheckServices(bool force = false) { + string checkedNetwork = _networkStorage.GetNetwork(); var settings = _networkStorage.GetSettings(); foreach (var indexerUrl in settings.Indexers) @@ -173,7 +174,32 @@ public async Task CheckServices(bool force = false) } client.DefaultRequestHeaders.Accept.Remove(nostrHeaderMediaType); - _networkStorage.SetSettings(settings); + // Connection checks can outlive a network switch or a Hub handoff. + // Merge only status information into the latest settings, never the + // old primary selections or lists captured before the HTTP requests. + if (_networkStorage.GetNetwork() != checkedNetwork) + return; + + SettingsInfo currentSettings = _networkStorage.GetSettings(); + MergeConnectionStatuses(currentSettings.Indexers, settings.Indexers); + MergeConnectionStatuses(currentSettings.Relays, settings.Relays); + _networkStorage.SetSettings(currentSettings); + OnStatusChanged?.Invoke(); + } + + private static void MergeConnectionStatuses(List current, List checkedUrls) + { + foreach (SettingsUrl entry in current) + { + SettingsUrl? result = checkedUrls.FirstOrDefault(candidate => candidate.Url == entry.Url); + if (result == null || result.LastCheck < entry.LastCheck) + continue; + + entry.LastCheck = result.LastCheck; + entry.Status = result.Status; + if (string.IsNullOrEmpty(entry.Name)) + entry.Name = result.Name; + } } public SettingsUrl GetPrimaryIndexer() diff --git a/src/shared/Angor.Shared/Services/NostrCommunicationFactory.cs b/src/shared/Angor.Shared/Services/NostrCommunicationFactory.cs index 28f9ebf7e..48e007d2e 100644 --- a/src/shared/Angor.Shared/Services/NostrCommunicationFactory.cs +++ b/src/shared/Angor.Shared/Services/NostrCommunicationFactory.cs @@ -15,6 +15,7 @@ public class NostrCommunicationFactory : IDisposable , INostrCommunicationFactor private NostrMultiWebsocketClient? _nostrMultiWebsocketClient; private NostrMultiWebsocketClient? _nostrMultiWebsocketClientDiscovery; private readonly List _serviceSubscriptions; + private readonly ConcurrentDictionary _connectingRelays = new(); private ConcurrentDictionary> _eoseCalledOnSubscriptionClients; private ConcurrentDictionary> _okCalledOnSubscriptionClients; @@ -30,7 +31,7 @@ public NostrCommunicationFactory(ILogger clientLogger, ILo _okCalledOnSubscriptionClients = new(); } - private ConcurrentDictionary GetAllConnectedRelayNames(bool includeDiscoveryRelays = false) + private ConcurrentDictionary GetAllConnectedRelayNames(bool includeDiscoveryRelays = false, bool includeConnectingRelays = false) { var allRelays = new ConcurrentDictionary(); @@ -38,11 +39,10 @@ private ConcurrentDictionary GetAllConnectedRelayNames(bool includ { foreach (var client in _nostrMultiWebsocketClient.Clients) { - // Only track relays whose websocket is actually running. A relay that never - // completed the WS upgrade will never send EOSE, and its DisconnectionHappened - // already fired before any subscription was monitored — so including it here - // would block the "all relays sent EOSE" completion check forever. - if (client.Communicator.IsRunning) + // A connecting relay may hold the only copy of a requested event. + // Wait for its initial attempt, but exclude relays that already failed. + if (client.Communicator.IsRunning || + (includeConnectingRelays && _connectingRelays.ContainsKey(client.Communicator.Name))) allRelays.TryAdd(client.Communicator.Name, 0); } } @@ -51,7 +51,8 @@ private ConcurrentDictionary GetAllConnectedRelayNames(bool includ { foreach (var client in _nostrMultiWebsocketClientDiscovery.Clients) { - if (client.Communicator.IsRunning) + if (client.Communicator.IsRunning || + (includeConnectingRelays && _connectingRelays.ContainsKey(client.Communicator.Name))) allRelays.TryAdd(client.Communicator.Name, 0); } } @@ -99,7 +100,7 @@ public INostrClient GetOrCreateDiscoveryClients(INetworkService networkService) _nostrMultiWebsocketClientDiscovery!.RegisterClient(client); - communicator.StartOrFail(); + _ = StartRelayAsync(communicator); } return _nostrMultiWebsocketClientDiscovery; @@ -173,7 +174,24 @@ private void ConnectToAllRelaysInTheSettings(INetworkService networkService) _nostrMultiWebsocketClient!.RegisterClient(client); - communicator.StartOrFail(); + _ = StartRelayAsync(communicator); + } + } + + private async Task StartRelayAsync(INostrCommunicator communicator) + { + _connectingRelays.TryAdd(communicator.Name, 0); + try + { + await communicator.StartOrFail(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Initial connection failed for relay {RelayName}", communicator.Name); + } + finally + { + _connectingRelays.TryRemove(communicator.Name, out _); } } @@ -199,7 +217,7 @@ public bool EoseEventReceivedOnAllRelays(string subscription) public bool MonitoringEoseReceivedOnSubscription(string subscription, bool includeDiscoveryRelays = false) { _logger.LogDebug($"Started monitoring subscription {subscription}"); - var relayNames = GetAllConnectedRelayNames(includeDiscoveryRelays); + var relayNames = GetAllConnectedRelayNames(includeDiscoveryRelays, includeConnectingRelays: true); if (_eoseCalledOnSubscriptionClients.TryAdd(subscription, relayNames)) return true; @@ -264,6 +282,7 @@ public INostrCommunicator CreateCommunicator(string uri, string relayName) _serviceSubscriptions.Add(nostrCommunicator.DisconnectionHappened.Subscribe(e => { + _connectingRelays.TryRemove(relayName, out _); if (e.Exception != null) _logger.LogWarning( "Relay {relayName} disconnected, type: {Type}, reason: {Reason}", @@ -322,6 +341,7 @@ public void Dispose() { _serviceSubscriptions.ForEach(subscription => subscription.Dispose()); _serviceSubscriptions.Clear(); + _connectingRelays.Clear(); _nostrMultiWebsocketClient?.Dispose(); _nostrMultiWebsocketClient = null; _eoseCalledOnSubscriptionClients = new(); diff --git a/src/shared/Angor.Shared/WalletOperations.cs b/src/shared/Angor.Shared/WalletOperations.cs index f62e9de63..596885ccb 100644 --- a/src/shared/Angor.Shared/WalletOperations.cs +++ b/src/shared/Angor.Shared/WalletOperations.cs @@ -647,9 +647,15 @@ public async Task UpdateAccountInfoWithNewAddressesAsync(AccountInfo accountInfo { _logger.LogInformation($"fetching balance for account = {accountExtPubKey.ToString(network.BitcoinNetwork)} start index = {scanIndex} isChange = {isChange} gap = {gap}"); - var newAddressesToCheck = Enumerable.Range(0, gap) - .Select(_ => GenerateAddressFromPubKey(scanIndex + _, network, isChange, accountExtPubKey)) - .ToList(); + var newAddressesToCheck = new List(gap); + for (int offset = 0; offset < gap; offset++) + { + newAddressesToCheck.Add(GenerateAddressFromPubKey(scanIndex + offset, network, isChange, accountExtPubKey)); + // Browser WASM shares the UI thread. Let input and painting run + // between address derivations instead of blocking for a full batch. + if (OperatingSystem.IsBrowser()) + await Task.Delay(1); + } //check all new addresses for balance or a history addressesNotEmpty = await _indexerService.GetAdressBalancesAsync(newAddressesToCheck, true); diff --git a/src/webapp/Angor.Client/Pages/InvestView.razor b/src/webapp/Angor.Client/Pages/InvestView.razor index 712ffc549..c6c2740ff 100644 --- a/src/webapp/Angor.Client/Pages/InvestView.razor +++ b/src/webapp/Angor.Client/Pages/InvestView.razor @@ -38,6 +38,7 @@ @inject NostrConversionHelper NostrHelper @inject IInvestorTransactionActions _InvestorTransactionActions @inject ISerializer serializer +@inject WalletKeyService walletKeyService @inject IEncryptionService encryption @inject IAddressPollingService _addressPollingService @inject IWalletStorage _walletStorage @@ -49,28 +50,87 @@ - -
- - Angor - Angor Invest - - - @network.Name - - - Settings - - -
+ + + +@if (showConnectionModal && connectionSettings != null) +{ + +} + @if (loadingProject) { -
-
-

Loading project...

+
+
+

Loading your project

+

Preparing your payment details...

- Taking too long? Check your relay and indexer settings. + Taking too long? Check your .

return; @@ -84,8 +144,8 @@ @(loadError ?? "The project was not found.")
return; @@ -102,84 +162,50 @@ return; } -
+
- -
- @if (!string.IsNullOrEmpty(project.Metadata?.Banner)) - { - - } -
-
- @if (!string.IsNullOrEmpty(project.Metadata?.Picture)) - { - - } -
-

@(project.Metadata?.Name ?? project.ProjectInfo.ProjectIdentifier)

- @if (!string.IsNullOrEmpty(project.Metadata?.About)) - { -

@TruncateAbout(project.Metadata.About)

- } - - View full project details on angor.io - - -
+
+
+
+ Back + @(project.Metadata?.Name ?? project.ProjectInfo.ProjectIdentifier)
- - -
-
- Raised - @fundingProgressPercent% -
-
-
-
-
- - @Money.Satoshis(totalRaisedSats).ToUnit(MoneyUnit.BTC) - of @Money.Satoshis(project.ProjectInfo.TargetAmount).ToUnit(MoneyUnit.BTC) @network.CoinTicker - - @totalInvestors investors -
+
+
+ Total Raised @Money.Satoshis(totalRaisedSats).ToUnit(MoneyUnit.BTC) @network.CoinTicker + Progress @fundingProgressPercent%
- -
- Starts: @project.ProjectInfo.StartDate.ToString("dd MMM yyyy") - Expires: @project.ProjectInfo.ExpiryDate.ToString("dd MMM yyyy") - Stages: @((project.ProjectInfo.AllowDynamicStages ? SelectedPattern?.StageCount : project.ProjectInfo.Stages?.Count) ?? 0) - Penalty: @project.ProjectInfo.PenaltyDays days +
@if (invested) { -
-
- -
-

Investment Confirmed

-

- Your investment was published to the Bitcoin network. - To manage or recover your funds later, import your recovery phrase into the Angor desktop app. -

- @if (!string.IsNullOrEmpty(investedTransactionId)) - { - - View Transaction - - } - - +
+
+ +

@PaymentNoun Confirmed

+

Your @PaymentNoun.ToLowerInvariant() was published to the Bitcoin network.

+
+ @if (!string.IsNullOrEmpty(investedTransactionId)) + { + + View Transaction + + } + +
+
+

Keep your recovery phrase safe

+ Import it into the Angor desktop app to manage or recover your funds later. +
+ +
} else if (project is InvestorProject pendingProject && pendingProject.WaitingForFounderResponse()) @@ -243,41 +269,50 @@ } else { -
+
+ @if (project.ProjectInfo.ProjectType != ProjectType.Subscribe) + { +
Target Amount@Money.Satoshis(project.ProjectInfo.TargetAmount).ToUnit(MoneyUnit.BTC) @network.CoinTicker
+ } + @if (!project.ProjectInfo.AllowDynamicStages) + { +
Total Raised@Money.Satoshis(totalRaisedSats).ToUnit(MoneyUnit.BTC) @network.CoinTicker
+
Progress@fundingProgressPercent%
+ } +
+
-
-
-
- -
Investment Amount
+
+
+
+ ₿ +

@PaymentNoun Amount

-
-
- -
- +
+ +
+ - @network.CoinTicker + @network.CoinTicker
- Minimum investment: 0.001 @network.CoinTicker +

Minimum investment: 0.001 @network.CoinTicker

-
+
@foreach (var preset in AmountPresets) { }
@@ -309,87 +344,119 @@ }
-
- - - @if (ShowPatternSelector && AvailablePatterns?.Any() == true) - { -
-
-
-
Funding Pattern
+ @if (ShowPatternSelector && AvailablePatterns?.Any() == true) + { +
+
+ +

Funding Pattern

-
-

Choose a funding pattern for your investment:

-
- @foreach (var pattern in AvailablePatterns) - { - - } -
+

Select which installment schedule to use:

+ @if (handoffPlanNeedsSelection) + { +

The selected plan is not offered by this project. Choose an available funding pattern below.

+ } +
+ @foreach (var pattern in AvailablePatterns) + { + + }
-
- } + } +
- -
-
-
-
Release Schedule
-
-
-

Your investment will be released in stages:

- @if (StagesBreakdown?.Any() == true) +
+

@(project.ProjectInfo.AllowDynamicStages ? "Payment Schedule" : "Release Schedule")

+

Your @(project.ProjectInfo.AllowDynamicStages ? "payments" : "investment will be released in stages"):

+
+ @if (StagesBreakdown?.Any() == true) + { + int stageIndex = 0; + @foreach (var stage in StagesBreakdown) { - int stageIndex = 0; - @foreach (var stage in StagesBreakdown) - { - stageIndex++; -
-
-
- Stage @stageIndex - - @(project?.ProjectInfo?.Stages?.ElementAtOrDefault(stageIndex - 1)?.AmountToRelease.ToString("F1"))% - -
-
- @stage.StageDateTime.FormatDate() - @stage.AmountBtc.ToString("0.00000000") @network.CoinTicker -
-
+ stageIndex++; +
+
+ @(project.ProjectInfo.AllowDynamicStages ? "Payment" : "Stage") @stageIndex + @stage.StageDateTime.FormatDate()
- } - } - else - { -

Enter an amount to see the stage breakdown.

+
+ @if (!project.ProjectInfo.AllowDynamicStages) + { + @(project.ProjectInfo.Stages?.ElementAtOrDefault(stageIndex - 1)?.AmountToRelease.ToString("F1"))% + } + @stage.AmountBtc.ToString("0.00000000") @network.CoinTicker +
+
} -
+ } + else + { +

Enter an amount to see the stage breakdown.

+ }
+
+

Transaction Details

+
+
Project ID
@ProjectId
+
Amount to @PaymentAction@Investment.InvestmentAmountBtc @network.CoinTicker
+
Miner FeeCalculated with invoice
+
Angor FeeIncluded in invoice total
+
+
+
+ + + + } + + + +
+ +@if (showHelpModal) +{ -
-
-
-
How It Works
+