-
Notifications
You must be signed in to change notification settings - Fork 2
Add in-app account transaction history #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Jim8y
wants to merge
14
commits into
neoorder:master
Choose a base branch
from
Jim8y:codex/rpc-transaction-history
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 3 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
3863dfd
Add RPC-backed transaction history
Jim8y c6ec0fc
Address transaction history review feedback
Jim8y f99dda5
Merge branch 'master' into codex/rpc-transaction-history
erikzhang 7d880da
Load full transaction history range
Jim8y 2b6aefa
Merge branch 'master' into codex/rpc-transaction-history
erikzhang 615ce49
Fix transfer history start timestamp
Jim8y 3d36f77
Use indexer account history
Jim8y 827bf1e
Limit transaction history fetches
Jim8y 690e176
Merge remote-tracking branch 'origin/master' into codex/rpc-transacti…
Jim8y a9a4928
Merge branch 'master' into codex/rpc-transaction-history
erikzhang e14036b
Include incoming transfers in account history
Jim8y 25abe27
Merge branch 'master' into codex/rpc-transaction-history
erikzhang 6995afb
Improve transaction history loading
Jim8y 43ad613
Merge remote-tracking branch 'origin/master' into codex/rpc-transacti…
Jim8y File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| namespace NeoOrder.OneGate.Models; | ||
|
|
||
| public sealed class TransactionHistoryItem | ||
| { | ||
| public required string Title { get; init; } | ||
| public required string AmountText { get; init; } | ||
| public required string DirectionText { get; init; } | ||
| public required string TimeText { get; init; } | ||
| public required string CounterpartyText { get; init; } | ||
| public required string TransactionHash { get; init; } | ||
| public string? BlockText { get; init; } | ||
| public bool HasBlockText => !string.IsNullOrWhiteSpace(BlockText); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| <?xml version="1.0" encoding="utf-8" ?> | ||
| <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" | ||
| xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" | ||
| xmlns:og="http://schemas.neoorder.org/onegate/controls" | ||
| x:Class="NeoOrder.OneGate.Pages.TransactionHistoryPage" | ||
| Title="{x:Static og:Strings.Records}" | ||
| BindingContext="{Binding Source={RelativeSource Self}}" | ||
| Shell.TabBarIsVisible="False"> | ||
| <RefreshView Margin="20,8" IsRefreshing="{Binding IsRefreshing}" Command="{Binding RefreshCommand}"> | ||
| <CollectionView ItemsSource="{Binding Transactions}" MinimumHeightRequest="200"> | ||
| <CollectionView.EmptyView> | ||
| <Grid RowDefinitions="*,auto,*"> | ||
| <VerticalStackLayout Grid.Row="1" Spacing="18" HorizontalOptions="Center" MaximumWidthRequest="420"> | ||
| <ActivityIndicator IsRunning="{Binding IsLoading}" IsVisible="{Binding IsLoading}" WidthRequest="34" HeightRequest="34" HorizontalOptions="Center" /> | ||
| <Label IsVisible="{Binding IsLoading}" FontAttributes="Bold" HorizontalOptions="Center"> | ||
| <Label.FormattedText> | ||
| <FormattedString> | ||
| <Span Text="{x:Static og:Strings.Loading}" /> | ||
| <Span Text="…" /> | ||
| </FormattedString> | ||
| </Label.FormattedText> | ||
| </Label> | ||
| <VerticalStackLayout Spacing="10" IsVisible="{Binding HasLoadError}"> | ||
| <Label Text="{x:Static og:Strings.TransactionHistoryLoadFailed}" FontAttributes="Bold" FontSize="20" HorizontalTextAlignment="Center" /> | ||
| <Label StyleClass="Secondary" Text="{Binding LoadErrorMessage}" HorizontalTextAlignment="Center" /> | ||
| <Button Text="{x:Static og:Strings.TransactionHistoryRetry}" Padding="34,13" HorizontalOptions="Center" Command="{Binding RefreshCommand}" /> | ||
| </VerticalStackLayout> | ||
| <VerticalStackLayout Spacing="10" IsVisible="{Binding IsEmpty}"> | ||
| <Label Text="{x:Static og:Strings.TransactionHistoryEmpty}" FontAttributes="Bold" FontSize="20" HorizontalTextAlignment="Center" /> | ||
| <Label StyleClass="Secondary" Text="{x:Static og:Strings.TransactionHistoryEmptyText}" HorizontalTextAlignment="Center" /> | ||
| </VerticalStackLayout> | ||
| </VerticalStackLayout> | ||
| </Grid> | ||
| </CollectionView.EmptyView> | ||
| <CollectionView.ItemsLayout> | ||
| <LinearItemsLayout Orientation="Vertical" ItemSpacing="10" /> | ||
| </CollectionView.ItemsLayout> | ||
| <CollectionView.ItemTemplate> | ||
| <DataTemplate x:DataType="og:TransactionHistoryItem"> | ||
| <Border StyleClass="Card" Padding="14"> | ||
| <Grid RowDefinitions="auto,auto,auto" ColumnDefinitions="*,auto" ColumnSpacing="12" RowSpacing="5"> | ||
| <Label Grid.Row="0" Grid.Column="0" Text="{Binding Title}" FontAttributes="Bold" FontSize="16" LineBreakMode="TailTruncation" /> | ||
| <Label Grid.Row="0" Grid.Column="1" Text="{Binding AmountText}" FontAttributes="Bold" FontSize="16" HorizontalOptions="End" /> | ||
| <Label Grid.Row="1" Grid.Column="0" StyleClass="Secondary" Text="{Binding CounterpartyText}" FontSize="12" LineBreakMode="MiddleTruncation" /> | ||
| <Label Grid.Row="1" Grid.Column="1" StyleClass="Secondary" Text="{Binding DirectionText}" FontSize="12" HorizontalOptions="End" /> | ||
| <HorizontalStackLayout Grid.Row="2" Grid.ColumnSpan="2" Spacing="10"> | ||
| <Label StyleClass="Secondary" Text="{Binding TimeText}" FontSize="12" /> | ||
| <Label StyleClass="Secondary" Text="{Binding BlockText}" FontSize="12" IsVisible="{Binding HasBlockText}" /> | ||
| <Label StyleClass="Secondary" Text="{Binding TransactionHash}" FontSize="12" LineBreakMode="MiddleTruncation" /> | ||
| </HorizontalStackLayout> | ||
| </Grid> | ||
| </Border> | ||
| </DataTemplate> | ||
| </CollectionView.ItemTemplate> | ||
| </CollectionView> | ||
| </RefreshView> | ||
| </ContentPage> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,285 @@ | ||
| using Neo; | ||
| using Neo.Wallets; | ||
| using NeoOrder.OneGate.Models; | ||
| using NeoOrder.OneGate.Properties; | ||
| using NeoOrder.OneGate.Services; | ||
| using NeoOrder.OneGate.Services.RPC; | ||
| using System.Diagnostics; | ||
| using System.Globalization; | ||
| using System.Numerics; | ||
| using System.Text.Json.Nodes; | ||
| using System.Windows.Input; | ||
|
|
||
| namespace NeoOrder.OneGate.Pages; | ||
|
|
||
| public partial class TransactionHistoryPage : ContentPage | ||
| { | ||
| const int DisplayLimit = 50; | ||
|
|
||
| readonly IWalletProvider walletProvider; | ||
| readonly ProtocolSettings protocolSettings; | ||
| readonly RpcClient rpcClient; | ||
| readonly Dictionary<UInt160, TokenInfo?> nep17TokenCache = []; | ||
| readonly Dictionary<UInt160, Nep11TokenInfo?> nep11TokenCache = []; | ||
| bool hasLoaded; | ||
|
|
||
| public IReadOnlyList<TransactionHistoryItem> Transactions { get; set { field = value; OnPropertyChanged(); OnPropertyChanged(nameof(IsEmpty)); } } = []; | ||
| public bool IsLoading { get; set { field = value; OnPropertyChanged(); OnPropertyChanged(nameof(IsEmpty)); } } | ||
| public bool IsRefreshing { get; set { field = value; OnPropertyChanged(); } } | ||
| public bool HasLoadError { get; set { field = value; OnPropertyChanged(); OnPropertyChanged(nameof(IsEmpty)); } } | ||
| public string LoadErrorMessage { get; set { field = value; OnPropertyChanged(); } } = ""; | ||
| public bool IsEmpty => !IsLoading && !HasLoadError && Transactions.Count == 0; | ||
| public ICommand RefreshCommand { get; } | ||
|
|
||
| public TransactionHistoryPage(IWalletProvider walletProvider, ProtocolSettings protocolSettings, RpcClient rpcClient) | ||
| { | ||
| this.walletProvider = walletProvider; | ||
| this.protocolSettings = protocolSettings; | ||
| this.rpcClient = rpcClient; | ||
| RefreshCommand = new Command(async () => await LoadTransactionsAsync(true), () => !IsLoading); | ||
| InitializeComponent(); | ||
| } | ||
|
|
||
| protected override void OnAppearing() | ||
| { | ||
| base.OnAppearing(); | ||
| if (!hasLoaded) | ||
| _ = LoadTransactionsAsync(false); | ||
| } | ||
|
|
||
| async Task LoadTransactionsAsync(bool isRefresh) | ||
| { | ||
| if (IsLoading) return; | ||
| IsLoading = true; | ||
| IsRefreshing = isRefresh; | ||
| HasLoadError = false; | ||
| ((Command)RefreshCommand).ChangeCanExecute(); | ||
| try | ||
| { | ||
| WalletAccount account = walletProvider.GetWallet()!.GetDefaultAccount()!; | ||
| string address = account.ScriptHash.ToAddress(protocolSettings.AddressVersion); | ||
| List<RawTransfer> transfers = []; | ||
| transfers.AddRange(await LoadNep17TransfersAsync(address)); | ||
| transfers.AddRange(await LoadNep11TransfersAsync(address)); | ||
|
|
||
| List<TransactionHistoryItem> items = []; | ||
| foreach (RawTransfer transfer in transfers | ||
| .OrderByDescending(p => p.Timestamp) | ||
| .ThenByDescending(p => p.BlockIndex ?? 0) | ||
| .Take(DisplayLimit)) | ||
| { | ||
| items.Add(await CreateItemAsync(transfer)); | ||
| } | ||
|
|
||
| Transactions = items; | ||
| hasLoaded = true; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| LoadErrorMessage = ex.Message; | ||
| HasLoadError = true; | ||
| } | ||
| finally | ||
| { | ||
| IsLoading = false; | ||
| IsRefreshing = false; | ||
| ((Command)RefreshCommand).ChangeCanExecute(); | ||
| } | ||
| } | ||
|
|
||
| async Task<IEnumerable<RawTransfer>> LoadNep17TransfersAsync(string address) | ||
| { | ||
| JsonObject result = await rpcClient.RpcSendAsync<JsonObject>("getnep17transfers", address); | ||
| return EnumerateTransfers(result, isNep11: false); | ||
| } | ||
|
|
||
| async Task<IEnumerable<RawTransfer>> LoadNep11TransfersAsync(string address) | ||
| { | ||
| JsonObject result = await rpcClient.RpcSendAsync<JsonObject>("getnep11transfers", address); | ||
| return EnumerateTransfers(result, isNep11: true); | ||
| } | ||
|
|
||
| static IEnumerable<RawTransfer> EnumerateTransfers(JsonObject result, bool isNep11) | ||
| { | ||
| foreach (RawTransfer transfer in EnumerateDirection(result, "received", isIncoming: true, isNep11)) | ||
| yield return transfer; | ||
| foreach (RawTransfer transfer in EnumerateDirection(result, "sent", isIncoming: false, isNep11)) | ||
| yield return transfer; | ||
| } | ||
|
|
||
| static IEnumerable<RawTransfer> EnumerateDirection(JsonObject result, string key, bool isIncoming, bool isNep11) | ||
| { | ||
| if (result[key] is not JsonArray transfers) | ||
| yield break; | ||
|
|
||
| foreach (JsonNode? node in transfers) | ||
| { | ||
| if (node is not JsonObject transfer) | ||
| continue; | ||
|
|
||
| string? assetHash = ReadString(transfer, "assethash", "assetHash"); | ||
| string? txHash = ReadString(transfer, "txhash", "txHash"); | ||
| if (string.IsNullOrWhiteSpace(assetHash) || string.IsNullOrWhiteSpace(txHash)) | ||
| continue; | ||
|
|
||
| yield return new RawTransfer | ||
| { | ||
| IsIncoming = isIncoming, | ||
| IsNep11 = isNep11, | ||
| AssetHash = UInt160.Parse(assetHash), | ||
| Amount = ReadString(transfer, "amount"), | ||
| TokenId = ReadString(transfer, "tokenid", "tokenId"), | ||
| Counterparty = ReadString(transfer, "transferaddress", "transferAddress") ?? "", | ||
| TransactionHash = txHash, | ||
| Timestamp = ReadInt64(transfer, "timestamp"), | ||
| BlockIndex = ReadUInt32(transfer, "blockindex", "blockIndex") | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| async Task<TransactionHistoryItem> CreateItemAsync(RawTransfer transfer) | ||
| { | ||
| return transfer.IsNep11 | ||
| ? await CreateNep11ItemAsync(transfer) | ||
| : await CreateNep17ItemAsync(transfer); | ||
| } | ||
|
|
||
| async Task<TransactionHistoryItem> CreateNep17ItemAsync(RawTransfer transfer) | ||
| { | ||
| TokenInfo? token = await GetNep17TokenAsync(transfer.AssetHash); | ||
| string symbol = token?.Symbol ?? ShortHash(transfer.AssetHash.ToString()); | ||
| string amount = FormatNep17Amount(transfer.Amount, token?.Decimals ?? 0); | ||
| string sign = transfer.IsIncoming ? "+" : "-"; | ||
| return CreateItem(transfer, $"{DirectionVerb(transfer)} {symbol}", $"{sign}{amount} {symbol}"); | ||
| } | ||
|
|
||
| async Task<TransactionHistoryItem> CreateNep11ItemAsync(RawTransfer transfer) | ||
| { | ||
| Nep11TokenInfo? token = await GetNep11TokenAsync(transfer.AssetHash); | ||
| string symbol = token?.Symbol ?? ShortHash(transfer.AssetHash.ToString()); | ||
| string tokenId = string.IsNullOrWhiteSpace(transfer.TokenId) ? "" : $" #{ShortText(transfer.TokenId)}"; | ||
| return CreateItem(transfer, $"{DirectionVerb(transfer)} {symbol}", $"{symbol}{tokenId}"); | ||
| } | ||
|
|
||
| TransactionHistoryItem CreateItem(RawTransfer transfer, string title, string amountText) | ||
| { | ||
| string timeText = transfer.Timestamp > 0 | ||
| ? DateTimeOffset.FromUnixTimeMilliseconds(transfer.Timestamp).LocalDateTime.ToString("g", CultureInfo.CurrentCulture) | ||
| : Strings.Time; | ||
| return new TransactionHistoryItem | ||
| { | ||
| Title = title, | ||
| AmountText = amountText, | ||
| DirectionText = transfer.IsIncoming ? Strings.Receive : Strings.Send, | ||
| CounterpartyText = string.IsNullOrWhiteSpace(transfer.Counterparty) ? Strings.Unavailable : transfer.Counterparty, | ||
| TimeText = timeText, | ||
| BlockText = transfer.BlockIndex is null ? null : $"#{transfer.BlockIndex}", | ||
| TransactionHash = ShortHash(transfer.TransactionHash) | ||
| }; | ||
| } | ||
|
|
||
| async Task<TokenInfo?> GetNep17TokenAsync(UInt160 assetHash) | ||
| { | ||
| if (nep17TokenCache.TryGetValue(assetHash, out TokenInfo? cached)) | ||
| return cached; | ||
|
|
||
| try | ||
| { | ||
| cached = await rpcClient.GetTokenInfo(assetHash); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| Debug.WriteLine(ex); | ||
| cached = null; | ||
| } | ||
| nep17TokenCache[assetHash] = cached; | ||
| return cached; | ||
| } | ||
|
|
||
| async Task<Nep11TokenInfo?> GetNep11TokenAsync(UInt160 assetHash) | ||
| { | ||
| if (nep11TokenCache.TryGetValue(assetHash, out Nep11TokenInfo? cached)) | ||
| return cached; | ||
|
|
||
| try | ||
| { | ||
| cached = await rpcClient.GetNep11TokenInfo(assetHash); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| Debug.WriteLine(ex); | ||
| cached = null; | ||
| } | ||
| nep11TokenCache[assetHash] = cached; | ||
| return cached; | ||
| } | ||
|
|
||
| static string FormatNep17Amount(string? value, byte decimals) | ||
| { | ||
| return BigInteger.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out BigInteger amount) | ||
| ? new BigDecimal(amount, decimals).ToString() | ||
| : value ?? "0"; | ||
| } | ||
|
|
||
| static string DirectionVerb(RawTransfer transfer) | ||
| { | ||
| return transfer.IsIncoming ? Strings.Receive : Strings.Send; | ||
| } | ||
|
|
||
| static string ShortHash(string value) | ||
| { | ||
| return value.Length <= 14 ? value : $"{value[..6]}...{value[^6..]}"; | ||
| } | ||
|
|
||
| static string ShortText(string value) | ||
| { | ||
| return value.Length <= 12 ? value : $"{value[..6]}...{value[^4..]}"; | ||
| } | ||
|
|
||
| static string? ReadString(JsonObject obj, params string[] keys) | ||
| { | ||
| foreach (string key in keys) | ||
| { | ||
| if (obj[key] is JsonValue value) | ||
| return ReadJsonScalar(value); | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| static long ReadInt64(JsonObject obj, string key) | ||
| { | ||
| return obj[key] is JsonValue value && long.TryParse(ReadJsonScalar(value), NumberStyles.Integer, CultureInfo.InvariantCulture, out long result) | ||
| ? result | ||
| : 0; | ||
| } | ||
|
|
||
| static uint? ReadUInt32(JsonObject obj, params string[] keys) | ||
| { | ||
| foreach (string key in keys) | ||
| { | ||
| if (obj[key] is JsonValue value && uint.TryParse(ReadJsonScalar(value), NumberStyles.Integer, CultureInfo.InvariantCulture, out uint result)) | ||
| return result; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| static string ReadJsonScalar(JsonValue value) | ||
| { | ||
| return value.TryGetValue(out string? text) | ||
| ? text ?? string.Empty | ||
| : value.ToString(); | ||
| } | ||
|
|
||
| sealed class RawTransfer | ||
| { | ||
| public required bool IsIncoming { get; init; } | ||
| public required bool IsNep11 { get; init; } | ||
| public required UInt160 AssetHash { get; init; } | ||
| public string? Amount { get; init; } | ||
| public string? TokenId { get; init; } | ||
| public required string Counterparty { get; init; } | ||
| public required string TransactionHash { get; init; } | ||
| public required long Timestamp { get; init; } | ||
| public uint? BlockIndex { get; init; } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.