Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/shared/Angor.Shared.Tests/Services/NetworkServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<SettingsUrl>
{
new() { Url = "https://old.example", IsPrimary = true }
}
};
Mock<INetworkStorage> 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<SettingsInfo>(JsonSerializer.Serialize(stored))!);
storage.Setup(value => value.SetSettings(It.IsAny<SettingsInfo>()))
.Callback<SettingsInfo>(value => stored = value);
DelayedHandler handler = new();
using HttpClient client = new(handler);
Mock<IHttpClientFactory> factory = new();
factory.Setup(value => value.CreateClient(It.IsAny<string>())).Returns(client);
NetworkService service = new(storage.Object, factory.Object,
NullLogger<NetworkService>.Instance, Mock.Of<INetworkConfiguration>());

Task checking = service.CheckServices(true);
await handler.Started.Task;
network = switchNetwork ? "Angornet" : "Main";
stored = new SettingsInfo
{
Indexers = new List<SettingsUrl>
{
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<SettingsInfo>()), 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<HttpResponseMessage> Response { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Started.TrySetResult();
return Response.Task;
}
}
}
55 changes: 55 additions & 0 deletions src/shared/Angor.Shared.Tests/WalletWordsAsyncTests.cs
Original file line number Diff line number Diff line change
@@ -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<IHdOperations>()));
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<ExtKey> completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
Task<ExtKey> pending = words.GetOrDeriveExtKeyAsync((_, _) => completion.Task);
words.Dispose();
completion.SetResult(new HdOperations().GetExtendedKey(MnemonicWords));
await Assert.ThrowsAsync<ObjectDisposedException>(() => pending);
Assert.Null(words.CachedExtKey);
}
}
16 changes: 16 additions & 0 deletions src/shared/Angor.Shared/Models/WalletWords.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,22 @@ public ExtKey GetOrDeriveExtKey(IHdOperations hdOperations)
return cachedExtKey;
}

/// <summary>
/// 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.
/// </summary>
public async Task<ExtKey> GetOrDeriveExtKeyAsync(Func<string, string?, Task<ExtKey>> 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);
Expand Down
28 changes: 27 additions & 1 deletion src/shared/Angor.Shared/Services/NetworkService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<SettingsUrl> current, List<SettingsUrl> 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()
Expand Down
40 changes: 30 additions & 10 deletions src/shared/Angor.Shared/Services/NostrCommunicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class NostrCommunicationFactory : IDisposable , INostrCommunicationFactor
private NostrMultiWebsocketClient? _nostrMultiWebsocketClient;
private NostrMultiWebsocketClient? _nostrMultiWebsocketClientDiscovery;
private readonly List<IDisposable> _serviceSubscriptions;
private readonly ConcurrentDictionary<string, byte> _connectingRelays = new();

private ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _eoseCalledOnSubscriptionClients;
private ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _okCalledOnSubscriptionClients;
Expand All @@ -30,19 +31,18 @@ public NostrCommunicationFactory(ILogger<NostrWebsocketClient> clientLogger, ILo
_okCalledOnSubscriptionClients = new();
}

private ConcurrentDictionary<string, byte> GetAllConnectedRelayNames(bool includeDiscoveryRelays = false)
private ConcurrentDictionary<string, byte> GetAllConnectedRelayNames(bool includeDiscoveryRelays = false, bool includeConnectingRelays = false)
{
var allRelays = new ConcurrentDictionary<string, byte>();

if (_nostrMultiWebsocketClient != null)
{
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);
}
}
Expand All @@ -51,7 +51,8 @@ private ConcurrentDictionary<string, byte> 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);
}
}
Expand Down Expand Up @@ -99,7 +100,7 @@ public INostrClient GetOrCreateDiscoveryClients(INetworkService networkService)

_nostrMultiWebsocketClientDiscovery!.RegisterClient(client);

communicator.StartOrFail();
_ = StartRelayAsync(communicator);
}

return _nostrMultiWebsocketClientDiscovery;
Expand Down Expand Up @@ -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 _);
}
}

Expand All @@ -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;

Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -322,6 +341,7 @@ public void Dispose()
{
_serviceSubscriptions.ForEach(subscription => subscription.Dispose());
_serviceSubscriptions.Clear();
_connectingRelays.Clear();
_nostrMultiWebsocketClient?.Dispose();
_nostrMultiWebsocketClient = null;
_eoseCalledOnSubscriptionClients = new();
Expand Down
12 changes: 9 additions & 3 deletions src/shared/Angor.Shared/WalletOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AddressInfo>(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);
Expand Down
Loading
Loading