Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions OneGateApp/AppShell.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,6 @@ static AppShell()
Routing.RegisterRoute("wallet/receive", typeof(ReceivePage));
Routing.RegisterRoute("wallet/send", typeof(SendPage));
Routing.RegisterRoute("wallet/sending", typeof(SendingPage));
Routing.RegisterRoute("wallet/transactions", typeof(TransactionHistoryPage));
}
}
13 changes: 13 additions & 0 deletions OneGateApp/Models/TransactionHistoryItem.cs
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);
}
57 changes: 57 additions & 0 deletions OneGateApp/Pages/TransactionHistoryPage.xaml
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>
285 changes: 285 additions & 0 deletions OneGateApp/Pages/TransactionHistoryPage.xaml.cs
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;
}
Comment thread
Jim8y marked this conversation as resolved.

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; }
}
}
2 changes: 1 addition & 1 deletion OneGateApp/Pages/WalletPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
</Border>
<Label Text="{x:Static og:Strings.Records}" FontAttributes="Bold" FontSize="12" HorizontalTextAlignment="Center" />
<VerticalStackLayout.GestureRecognizers>
<TapGestureRecognizer Command="{x:Static og:Commands.OpenUrl}" CommandParameter="{Binding ScriptHash, StringFormat='https://explorer.onegate.space/accountprofile/{0}'}" />
<TapGestureRecognizer Command="{x:Static og:Commands.GotoPage}" CommandParameter="//wallet/transactions" />
</VerticalStackLayout.GestureRecognizers>
</VerticalStackLayout>
</Grid>
Expand Down
Loading