From eae268f51b5fc28fe966de8a1bc6870ed5996267 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sun, 21 Jun 2026 19:00:45 +0800 Subject: [PATCH 1/2] feat(tx): persistent pending-transaction tracking with local notifications Add a PendingTransactionService (DI singleton) that tracks broadcast transactions to a terminal state (HALT/FAULT) and raises a local notification on confirm/fail. Unlike SendingPage's in-page poll, tracking lives for the app process and is persisted in the existing Settings store, so it survives navigation, backgrounding and relaunch. Wires Plugin.LocalNotification, POST_NOTIFICATIONS, startup permission request + resume, and enqueues on send (SendPage/SendNFTPage). Builds for net10.0-android (0 errors). --- OneGateApp/App.xaml.cs | 5 +- OneGateApp/MauiProgram.cs | 3 + OneGateApp/OneGateApp.csproj | 1 + OneGateApp/Pages/SendNFTPage.xaml.cs | 1 + OneGateApp/Pages/SendPage.xaml.cs | 1 + .../Platforms/Android/AndroidManifest.xml | 1 + OneGateApp/Properties/Strings.Designer.cs | 9 ++ OneGateApp/Properties/Strings.resx | 3 + OneGateApp/Properties/Strings.zh-Hans.resx | 3 + OneGateApp/Properties/Strings.zh-Hant.resx | 3 + .../Services/PendingTransactionService.cs | 143 ++++++++++++++++++ 11 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 OneGateApp/Services/PendingTransactionService.cs diff --git a/OneGateApp/App.xaml.cs b/OneGateApp/App.xaml.cs index f550270..ae4b456 100644 --- a/OneGateApp/App.xaml.cs +++ b/OneGateApp/App.xaml.cs @@ -1,4 +1,5 @@ -using Neo.Wallets; +using Microsoft.Extensions.DependencyInjection; +using Neo.Wallets; using NeoOrder.OneGate.Data; using NeoOrder.OneGate.Models.AppLinks; using NeoOrder.OneGate.Pages; @@ -37,6 +38,8 @@ public App(IServiceProvider serviceProvider, ApplicationDbContext dbContext, IWa } httpClient.DefaultRequestHeaders.AcceptLanguage.Clear(); httpClient.DefaultRequestHeaders.AcceptLanguage.ParseAdd(CultureInfo.CurrentUICulture.Name); + + _ = serviceProvider.GetRequiredService().StartAsync(); } internal bool ProcessAppLinkUri(Uri uri) diff --git a/OneGateApp/MauiProgram.cs b/OneGateApp/MauiProgram.cs index b8cf40e..a157b9f 100644 --- a/OneGateApp/MauiProgram.cs +++ b/OneGateApp/MauiProgram.cs @@ -9,6 +9,7 @@ using NeoOrder.OneGate.Resources; using NeoOrder.OneGate.Services; using NeoOrder.OneGate.Services.RPC; +using Plugin.LocalNotification; using Plugin.Maui.ScreenSecurity; using ZXing.Net.Maui.Controls; @@ -35,6 +36,7 @@ public static MauiApp CreateMauiApp() .UseMauiCommunityToolkit(ConfigureMauiCommunityToolkit) .UseScreenSecurity() .UseBarcodeReader() + .UseLocalNotification() .RegisterServices() .ConfigureMauiHandlers(handlers => { @@ -107,6 +109,7 @@ static MauiAppBuilder RegisterServices(this MauiAppBuilder builder) builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); return builder; diff --git a/OneGateApp/OneGateApp.csproj b/OneGateApp/OneGateApp.csproj index 90589c7..b91a9c6 100644 --- a/OneGateApp/OneGateApp.csproj +++ b/OneGateApp/OneGateApp.csproj @@ -67,6 +67,7 @@ + diff --git a/OneGateApp/Pages/SendNFTPage.xaml.cs b/OneGateApp/Pages/SendNFTPage.xaml.cs index f44575f..2ada7d6 100644 --- a/OneGateApp/Pages/SendNFTPage.xaml.cs +++ b/OneGateApp/Pages/SendNFTPage.xaml.cs @@ -113,6 +113,7 @@ async void OnSubmitted(object sender, EventArgs e) return; } GlobalStates.Invalidate(); + serviceProvider.GetServiceOrCreateInstance().Enqueue(tx.Hash); await Shell.Current.GoToAsync("//wallet/sending", new Dictionary { ["tx"] = tx, diff --git a/OneGateApp/Pages/SendPage.xaml.cs b/OneGateApp/Pages/SendPage.xaml.cs index 62df271..9a203d5 100644 --- a/OneGateApp/Pages/SendPage.xaml.cs +++ b/OneGateApp/Pages/SendPage.xaml.cs @@ -289,6 +289,7 @@ async void OnSubmitted(object sender, EventArgs e) return; } GlobalStates.Invalidate(); + serviceProvider.GetServiceOrCreateInstance().Enqueue(tx.Hash); await Shell.Current.GoToAsync("//wallet/sending", new Dictionary { ["tx"] = tx, diff --git a/OneGateApp/Platforms/Android/AndroidManifest.xml b/OneGateApp/Platforms/Android/AndroidManifest.xml index 1c77607..2f45106 100644 --- a/OneGateApp/Platforms/Android/AndroidManifest.xml +++ b/OneGateApp/Platforms/Android/AndroidManifest.xml @@ -7,6 +7,7 @@ + diff --git a/OneGateApp/Properties/Strings.Designer.cs b/OneGateApp/Properties/Strings.Designer.cs index 9b455dd..560b60e 100644 --- a/OneGateApp/Properties/Strings.Designer.cs +++ b/OneGateApp/Properties/Strings.Designer.cs @@ -2625,5 +2625,14 @@ internal static string WifPrivateKeyWarning { return ResourceManager.GetString("WifPrivateKeyWarning", resourceCulture); } } + + /// + /// Looks up a localized string similar to Transaction failed. + /// + internal static string TransactionFailed { + get { + return ResourceManager.GetString("TransactionFailed", resourceCulture); + } + } } } diff --git a/OneGateApp/Properties/Strings.resx b/OneGateApp/Properties/Strings.resx index e0fbfd0..3bb1482 100644 --- a/OneGateApp/Properties/Strings.resx +++ b/OneGateApp/Properties/Strings.resx @@ -989,4 +989,7 @@ It is recommended to store the NEP-2 key and password separately and back them u Unavailable + + Transaction failed + diff --git a/OneGateApp/Properties/Strings.zh-Hans.resx b/OneGateApp/Properties/Strings.zh-Hans.resx index 068ed29..202a2b0 100644 --- a/OneGateApp/Properties/Strings.zh-Hans.resx +++ b/OneGateApp/Properties/Strings.zh-Hans.resx @@ -989,4 +989,7 @@ 不可用 + + 交易失败 + diff --git a/OneGateApp/Properties/Strings.zh-Hant.resx b/OneGateApp/Properties/Strings.zh-Hant.resx index e985d08..76caa0e 100644 --- a/OneGateApp/Properties/Strings.zh-Hant.resx +++ b/OneGateApp/Properties/Strings.zh-Hant.resx @@ -989,4 +989,7 @@ 不可用 + + 交易失敗 + \ No newline at end of file diff --git a/OneGateApp/Services/PendingTransactionService.cs b/OneGateApp/Services/PendingTransactionService.cs new file mode 100644 index 0000000..634d033 --- /dev/null +++ b/OneGateApp/Services/PendingTransactionService.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.DependencyInjection; +using Neo; +using Neo.VM; +using NeoOrder.OneGate.Data; +using NeoOrder.OneGate.Properties; +using NeoOrder.OneGate.Services.RPC; +using Plugin.LocalNotification; +using Plugin.LocalNotification.Core.Models; +using System.Text.Json.Nodes; + +namespace NeoOrder.OneGate.Services; + +/// +/// Tracks broadcast transactions until they reach a terminal state and raises a local +/// notification on confirm/fail. Unlike the in-page poll on SendingPage, tracking lives +/// for the app process and is persisted, so it survives navigation, backgrounding and relaunch. +/// +public sealed class PendingTransactionService(IServiceScopeFactory scopeFactory, RpcClient rpcClient) +{ + const string StorageKey = "transactions/pending"; + static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15); + const int MaxAttempts = 40; // ~10 minutes before we stop polling a stuck hash + + readonly SemaphoreSlim mutex = new(1, 1); + readonly List pending = []; + Task? loop; + + /// Request notification permission and resume tracking persisted pending transactions. + public async Task StartAsync() + { + try { await LocalNotificationCenter.Current.RequestNotificationPermission(); } + catch { /* permission is best-effort; tracking still works without it */ } + + List? saved = await LoadAsync(); + if (saved is not { Count: > 0 }) return; + await mutex.WaitAsync(); + try + { + foreach (PendingTransaction p in saved) + if (!pending.Any(x => x.Hash == p.Hash)) + pending.Add(p); + } + finally { mutex.Release(); } + EnsureLoop(); + } + + public void Enqueue(UInt256 hash) => _ = EnqueueAsync(hash.ToString()); + + async Task EnqueueAsync(string hash) + { + await mutex.WaitAsync(); + try + { + if (pending.Any(x => x.Hash == hash)) return; + pending.Add(new PendingTransaction { Hash = hash, CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); + await SaveAsync(); + } + finally { mutex.Release(); } + EnsureLoop(); + } + + void EnsureLoop() + { + if (loop is { IsCompleted: false }) return; + loop = Task.Run(PollLoopAsync); + } + + async Task PollLoopAsync() + { + while (true) + { + await Task.Delay(PollInterval); + + PendingTransaction[] snapshot; + await mutex.WaitAsync(); + try { snapshot = [.. pending]; } + finally { mutex.Release(); } + if (snapshot.Length == 0) return; + + foreach (PendingTransaction p in snapshot) + { + p.Attempts++; + bool? succeeded = await TryResolveAsync(p.Hash); + if (succeeded is null && p.Attempts < MaxAttempts) continue; + + await mutex.WaitAsync(); + try { pending.RemoveAll(x => x.Hash == p.Hash); await SaveAsync(); } + finally { mutex.Release(); } + + if (succeeded is not null) Notify(p.Hash, succeeded.Value); + } + } + } + + // null = not yet in a block; true = HALT; false = FAULT (reverted). + async Task TryResolveAsync(string hash) + { + try + { + JsonObject tx = await rpcClient.RpcSendAsync("getrawtransaction", hash, true); + if (tx["blocktime"]?.GetValue() is null) return null; + JsonObject log = await rpcClient.RpcSendAsync("getapplicationlog", hash); + JsonNode? execution = log["executions"] is JsonArray executions && executions.Count > 0 ? executions[0] : null; + return execution?["vmstate"]?.GetValue() == nameof(VMState.HALT); + } + catch (RpcException) + { + return null; + } + } + + static void Notify(string hash, bool succeeded) + { + string shortHash = hash.Length <= 16 ? hash : $"{hash[..10]}…{hash[^6..]}"; + LocalNotificationCenter.Current.Show(new NotificationRequest + { + NotificationId = hash.GetHashCode() & int.MaxValue, + Title = succeeded ? Strings.TransactionSucceeded : Strings.TransactionFailed, + Description = shortHash, + }); + } + + async Task?> LoadAsync() + { + using IServiceScope scope = scopeFactory.CreateScope(); + ApplicationDbContext db = scope.ServiceProvider.GetRequiredService(); + return await db.Settings.GetAsync>(StorageKey); + } + + async Task SaveAsync() + { + using IServiceScope scope = scopeFactory.CreateScope(); + ApplicationDbContext db = scope.ServiceProvider.GetRequiredService(); + await db.Settings.PutAsync(StorageKey, pending); + } +} + +public sealed class PendingTransaction +{ + public required string Hash { get; set; } + public long CreatedAt { get; set; } + public int Attempts { get; set; } +} From 3e999ca0fc8d755577642531a77d55be9cb2e21e Mon Sep 17 00:00:00 2001 From: Jimmy Date: Mon, 22 Jun 2026 02:29:46 +0800 Subject: [PATCH 2/2] Harden pending transaction tracking --- OneGateApp/Properties/Strings.de.resx | 5 +- OneGateApp/Properties/Strings.es.resx | 5 +- OneGateApp/Properties/Strings.fr.resx | 5 +- OneGateApp/Properties/Strings.id.resx | 5 +- OneGateApp/Properties/Strings.it.resx | 5 +- OneGateApp/Properties/Strings.ja.resx | 5 +- OneGateApp/Properties/Strings.ko.resx | 5 +- OneGateApp/Properties/Strings.nl.resx | 5 +- OneGateApp/Properties/Strings.pt-BR.resx | 5 +- OneGateApp/Properties/Strings.ru.resx | 5 +- OneGateApp/Properties/Strings.tr.resx | 5 +- OneGateApp/Properties/Strings.vi.resx | 5 +- .../Services/PendingTransactionService.cs | 125 +++++++++++++----- 13 files changed, 138 insertions(+), 47 deletions(-) diff --git a/OneGateApp/Properties/Strings.de.resx b/OneGateApp/Properties/Strings.de.resx index 9501220..a4a6f8b 100644 --- a/OneGateApp/Properties/Strings.de.resx +++ b/OneGateApp/Properties/Strings.de.resx @@ -989,4 +989,7 @@ Es wird empfohlen, den NEP-2-Schlüssel und das Passwort getrennt aufzubewahren Nicht verfügbar - \ No newline at end of file + + Transaktion fehlgeschlagen + + diff --git a/OneGateApp/Properties/Strings.es.resx b/OneGateApp/Properties/Strings.es.resx index 9192f36..0e3c07d 100644 --- a/OneGateApp/Properties/Strings.es.resx +++ b/OneGateApp/Properties/Strings.es.resx @@ -989,4 +989,7 @@ Se recomienda almacenar la clave NEP-2 y la contraseña por separado y realizar No disponible - \ No newline at end of file + + Transacción fallida + + diff --git a/OneGateApp/Properties/Strings.fr.resx b/OneGateApp/Properties/Strings.fr.resx index 36505fb..b71f3bc 100644 --- a/OneGateApp/Properties/Strings.fr.resx +++ b/OneGateApp/Properties/Strings.fr.resx @@ -989,4 +989,7 @@ Il est recommandé de conserver séparément la clé NEP-2 et le mot de passe, e Indisponible - \ No newline at end of file + + Transaction échouée + + diff --git a/OneGateApp/Properties/Strings.id.resx b/OneGateApp/Properties/Strings.id.resx index 8cf03e3..928e922 100644 --- a/OneGateApp/Properties/Strings.id.resx +++ b/OneGateApp/Properties/Strings.id.resx @@ -989,4 +989,7 @@ Disarankan untuk menyimpan kunci NEP-2 dan kata sandinya secara terpisah serta m Tidak tersedia - \ No newline at end of file + + Transaksi gagal + + diff --git a/OneGateApp/Properties/Strings.it.resx b/OneGateApp/Properties/Strings.it.resx index 90f59ca..a562b5f 100644 --- a/OneGateApp/Properties/Strings.it.resx +++ b/OneGateApp/Properties/Strings.it.resx @@ -989,4 +989,7 @@ Si consiglia di conservare separatamente la chiave NEP-2 e la password e di eseg Non disponibile - \ No newline at end of file + + Transazione non riuscita + + diff --git a/OneGateApp/Properties/Strings.ja.resx b/OneGateApp/Properties/Strings.ja.resx index b9eab0d..be30bc9 100644 --- a/OneGateApp/Properties/Strings.ja.resx +++ b/OneGateApp/Properties/Strings.ja.resx @@ -989,4 +989,7 @@ NEP-2 とパスワードは別々に保管し、それぞれを安全にバッ 利用不可 - \ No newline at end of file + + トランザクション失敗 + + diff --git a/OneGateApp/Properties/Strings.ko.resx b/OneGateApp/Properties/Strings.ko.resx index d71a57b..b43a8c1 100644 --- a/OneGateApp/Properties/Strings.ko.resx +++ b/OneGateApp/Properties/Strings.ko.resx @@ -989,4 +989,7 @@ NEP-2와 비밀번호는 별도로 보관하고 각각 안전하게 백업하는 사용할 수 없음 - \ No newline at end of file + + 트랜잭션 실패 + + diff --git a/OneGateApp/Properties/Strings.nl.resx b/OneGateApp/Properties/Strings.nl.resx index 7a9105c..02fb71c 100644 --- a/OneGateApp/Properties/Strings.nl.resx +++ b/OneGateApp/Properties/Strings.nl.resx @@ -989,4 +989,7 @@ Het wordt aanbevolen om de NEP-2-sleutel en het wachtwoord apart op te slaan en Niet beschikbaar - \ No newline at end of file + + Transactie mislukt + + diff --git a/OneGateApp/Properties/Strings.pt-BR.resx b/OneGateApp/Properties/Strings.pt-BR.resx index 352f2a7..11f9821 100644 --- a/OneGateApp/Properties/Strings.pt-BR.resx +++ b/OneGateApp/Properties/Strings.pt-BR.resx @@ -989,4 +989,7 @@ Recomenda-se armazenar a chave NEP-2 e a senha separadamente e fazer backups seg Indisponível - \ No newline at end of file + + Transação falhou + + diff --git a/OneGateApp/Properties/Strings.ru.resx b/OneGateApp/Properties/Strings.ru.resx index 4149470..638a2eb 100644 --- a/OneGateApp/Properties/Strings.ru.resx +++ b/OneGateApp/Properties/Strings.ru.resx @@ -989,4 +989,7 @@ Недоступно - \ No newline at end of file + + Транзакция не удалась + + diff --git a/OneGateApp/Properties/Strings.tr.resx b/OneGateApp/Properties/Strings.tr.resx index 7c3145c..7cb032a 100644 --- a/OneGateApp/Properties/Strings.tr.resx +++ b/OneGateApp/Properties/Strings.tr.resx @@ -989,4 +989,7 @@ NEP-2 anahtarı ile şifreyi ayrı ayrı saklamanız ve güvenli şekilde yedekl Kullanılamıyor - \ No newline at end of file + + İşlem başarısız + + diff --git a/OneGateApp/Properties/Strings.vi.resx b/OneGateApp/Properties/Strings.vi.resx index e101e22..934ab39 100644 --- a/OneGateApp/Properties/Strings.vi.resx +++ b/OneGateApp/Properties/Strings.vi.resx @@ -989,4 +989,7 @@ Nên lưu trữ riêng khóa NEP-2 và mật khẩu, đồng thời sao lưu ch Không khả dụng - \ No newline at end of file + + Giao dịch thất bại + + diff --git a/OneGateApp/Services/PendingTransactionService.cs b/OneGateApp/Services/PendingTransactionService.cs index 634d033..290f8c6 100644 --- a/OneGateApp/Services/PendingTransactionService.cs +++ b/OneGateApp/Services/PendingTransactionService.cs @@ -6,6 +6,10 @@ using NeoOrder.OneGate.Services.RPC; using Plugin.LocalNotification; using Plugin.LocalNotification.Core.Models; +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; using System.Text.Json.Nodes; namespace NeoOrder.OneGate.Services; @@ -19,29 +23,37 @@ public sealed class PendingTransactionService(IServiceScopeFactory scopeFactory, { const string StorageKey = "transactions/pending"; static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15); - const int MaxAttempts = 40; // ~10 minutes before we stop polling a stuck hash + static readonly TimeSpan MaxPendingAge = TimeSpan.FromMinutes(10); readonly SemaphoreSlim mutex = new(1, 1); + readonly object loopLock = new(); readonly List pending = []; Task? loop; /// Request notification permission and resume tracking persisted pending transactions. public async Task StartAsync() { - try { await LocalNotificationCenter.Current.RequestNotificationPermission(); } - catch { /* permission is best-effort; tracking still works without it */ } - - List? saved = await LoadAsync(); - if (saved is not { Count: > 0 }) return; - await mutex.WaitAsync(); try { - foreach (PendingTransaction p in saved) - if (!pending.Any(x => x.Hash == p.Hash)) - pending.Add(p); + try { await LocalNotificationCenter.Current.RequestNotificationPermission(); } + catch { /* permission is best-effort; tracking still works without it */ } + + List? saved = await LoadAsync(); + if (saved is not { Count: > 0 }) return; + await mutex.WaitAsync(); + try + { + foreach (PendingTransaction p in saved) + if (!pending.Any(x => x.Hash == p.Hash)) + pending.Add(p); + } + finally { mutex.Release(); } + EnsureLoop(); + } + catch + { + // Pending transaction tracking must never make app startup fail. } - finally { mutex.Release(); } - EnsureLoop(); } public void Enqueue(UInt256 hash) => _ = EnqueueAsync(hash.ToString()); @@ -61,51 +73,72 @@ async Task EnqueueAsync(string hash) void EnsureLoop() { - if (loop is { IsCompleted: false }) return; - loop = Task.Run(PollLoopAsync); + lock (loopLock) + { + if (loop is { IsCompleted: false }) return; + loop = Task.Run(PollLoopAsync); + } } async Task PollLoopAsync() { - while (true) + try { - await Task.Delay(PollInterval); - - PendingTransaction[] snapshot; - await mutex.WaitAsync(); - try { snapshot = [.. pending]; } - finally { mutex.Release(); } - if (snapshot.Length == 0) return; - - foreach (PendingTransaction p in snapshot) + while (true) { - p.Attempts++; - bool? succeeded = await TryResolveAsync(p.Hash); - if (succeeded is null && p.Attempts < MaxAttempts) continue; + await Task.Delay(PollInterval); + PendingTransaction[] snapshot; await mutex.WaitAsync(); - try { pending.RemoveAll(x => x.Hash == p.Hash); await SaveAsync(); } + try { snapshot = [.. pending]; } finally { mutex.Release(); } + if (snapshot.Length == 0) return; + + foreach (PendingTransaction p in snapshot) + { + bool? succeeded = await TryResolveAsync(p.Hash); + if (succeeded is null && !IsExpired(p)) continue; - if (succeeded is not null) Notify(p.Hash, succeeded.Value); + await mutex.WaitAsync(); + try { pending.RemoveAll(x => x.Hash == p.Hash); await SaveAsync(); } + finally { mutex.Release(); } + + if (succeeded is not null) Notify(p.Hash, succeeded.Value); + } } } + catch + { + // Polling is background best-effort work; do not surface unobserved task exceptions. + } } // null = not yet in a block; true = HALT; false = FAULT (reverted). async Task TryResolveAsync(string hash) { + JsonObject tx; try { - JsonObject tx = await rpcClient.RpcSendAsync("getrawtransaction", hash, true); + tx = await rpcClient.RpcSendAsync("getrawtransaction", hash, true); if (tx["blocktime"]?.GetValue() is null) return null; + } + catch (Exception ex) when (IsExpectedRpcOrJsonException(ex)) + { + return null; + } + + try + { JsonObject log = await rpcClient.RpcSendAsync("getapplicationlog", hash); JsonNode? execution = log["executions"] is JsonArray executions && executions.Count > 0 ? executions[0] : null; - return execution?["vmstate"]?.GetValue() == nameof(VMState.HALT); + JsonNode? vmState = execution?["vmstate"]; + return vmState is null || vmState.GetValue() == nameof(VMState.HALT); } - catch (RpcException) + catch (Exception ex) when (IsExpectedRpcOrJsonException(ex)) { - return null; + // The transaction is in a block but the application log is unavailable or malformed. + // Treat it as confirmed to avoid a false failure notification. + return true; } } @@ -114,7 +147,7 @@ static void Notify(string hash, bool succeeded) string shortHash = hash.Length <= 16 ? hash : $"{hash[..10]}…{hash[^6..]}"; LocalNotificationCenter.Current.Show(new NotificationRequest { - NotificationId = hash.GetHashCode() & int.MaxValue, + NotificationId = CreateNotificationId(hash), Title = succeeded ? Strings.TransactionSucceeded : Strings.TransactionFailed, Description = shortHash, }); @@ -133,11 +166,33 @@ async Task SaveAsync() ApplicationDbContext db = scope.ServiceProvider.GetRequiredService(); await db.Settings.PutAsync(StorageKey, pending); } + + static bool IsExpired(PendingTransaction transaction) + { + try + { + DateTimeOffset createdAt = DateTimeOffset.FromUnixTimeMilliseconds(transaction.CreatedAt); + return DateTimeOffset.UtcNow - createdAt >= MaxPendingAge; + } + catch (ArgumentOutOfRangeException) + { + return true; + } + } + + static bool IsExpectedRpcOrJsonException(Exception ex) => + ex is RpcException or HttpRequestException or JsonException or InvalidOperationException or FormatException; + + static int CreateNotificationId(string hash) + { + byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(hash)); + int id = BinaryPrimitives.ReadInt32LittleEndian(digest) & int.MaxValue; + return id == 0 ? 1 : id; + } } public sealed class PendingTransaction { public required string Hash { get; set; } public long CreatedAt { get; set; } - public int Attempts { get; set; } }