From 2728bf161e0da0fd9468662598ae297d981bcc38 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Thu, 24 Sep 2026 21:14:55 +0800 Subject: [PATCH] List transactions newest first on both chains --- apps/ios/Koins/Views/Components.swift | 20 +++++---- .../Koins/Views/TransactionDetailView.swift | 22 +++++++++- apps/ios/Koins/Wallet/MoneroService.swift | 42 +++++++++++++++++++ apps/ios/Koins/Wallet/XRPWalletService.swift | 3 +- 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/apps/ios/Koins/Views/Components.swift b/apps/ios/Koins/Views/Components.swift index 56510dc..b7d06a2 100644 --- a/apps/ios/Koins/Views/Components.swift +++ b/apps/ios/Koins/Views/Components.swift @@ -174,12 +174,18 @@ struct TransactionRow: View { var record: TransactionRecord var symbol: String - private var formattedDate: String { - guard let timestamp = record.timestamp else { return "Pending" } - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - return formatter.string(from: timestamp) + private var subtitle: String { + if let timestamp = record.timestamp { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return record.confirmed ? formatter.string(from: timestamp) : "Pending confirmation" + } + // Monero transfers carry no timestamp; the block height is the honest label. + if let height = record.height, height > 0 { + return record.confirmed ? "Block \(height)" : "In the transaction pool" + } + return record.confirmed ? "Confirmed" : "Pending" } var body: some View { @@ -197,7 +203,7 @@ struct TransactionRow: View { Text(record.direction == .incoming ? "Received" : "Sent") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Theme.text) - Text(record.confirmed ? formattedDate : "Pending confirmation") + Text(subtitle) .font(.system(size: 11)) .foregroundStyle(Theme.muted) } diff --git a/apps/ios/Koins/Views/TransactionDetailView.swift b/apps/ios/Koins/Views/TransactionDetailView.swift index 9339935..dcdcf1d 100644 --- a/apps/ios/Koins/Views/TransactionDetailView.swift +++ b/apps/ios/Koins/Views/TransactionDetailView.swift @@ -8,6 +8,11 @@ struct TransactionDetailView: View { var chain: Chain var onDone: () -> Void + @State private var resolvedDate: Date? + @State private var isResolvingDate = false + + private var displayedDate: Date? { record.timestamp ?? resolvedDate } + private var dateFormatter: DateFormatter { let formatter = DateFormatter() formatter.dateStyle = .medium @@ -59,8 +64,12 @@ struct TransactionDetailView: View { Card { VStack(alignment: .leading, spacing: 12) { - if let timestamp = record.timestamp { - DetailRow(label: "Date", value: dateFormatter.string(from: timestamp)) + if let date = displayedDate { + DetailRow(label: "Date", value: dateFormatter.string(from: date)) + } else if isResolvingDate { + DetailRow(label: "Date", value: "Looking up the block time…") + } else if let height = record.height, height > 0 { + DetailRow(label: "Block height", value: String(height)) } if let fee = record.fee, !fee.isEmpty, record.direction == .outgoing { DetailRow(label: "Network fee", value: "\(fee) \(symbol)") @@ -108,6 +117,15 @@ struct TransactionDetailView: View { } } .preferredColorScheme(.dark) + .task { + guard record.timestamp == nil, + chain == .xmr, + let height = record.height, height > 0, + resolvedDate == nil else { return } + isResolvingDate = true + resolvedDate = try? await MoneroService.shared.blockTimestamp(height: height) + isResolvingDate = false + } } } diff --git a/apps/ios/Koins/Wallet/MoneroService.swift b/apps/ios/Koins/Wallet/MoneroService.swift index db7a380..834ee1d 100644 --- a/apps/ios/Koins/Wallet/MoneroService.swift +++ b/apps/ios/Koins/Wallet/MoneroService.swift @@ -168,6 +168,33 @@ final class MoneroService { return response["mnemonic"] as? String ?? "" } + /// Block time for a height. wallet2 keeps no timestamp for transfers, so the + /// only way to date a Monero transaction is to ask the daemon for the block + /// it landed in. + func blockTimestamp(height: UInt64) async throws -> Date? { + guard let url = URL(string: "http://\(Self.daemonHost):18081/json_rpc") else { return nil } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 15 + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": "koins", + "method": "get_block_header_by_height", + "params": ["height": height], + ]) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let result = json["result"] as? [String: Any], + let header = result["block_header"] as? [String: Any], + let timestamp = (header["timestamp"] as? NSNumber)?.doubleValue, timestamp > 0 else { + return nil + } + return Date(timeIntervalSince1970: timestamp) + } + // MARK: - Reads func balance() async throws -> Balance { @@ -201,6 +228,21 @@ final class MoneroService { note: (entry["note"] as? String).flatMap { $0.isEmpty ? nil : $0 }, weight: (entry["weight"] as? NSNumber)?.uint64Value) } + .sorted { newestFirst($0, $1) } + } + + /// Newest first. wallet2 gives transfers no timestamp, but the block height is + /// chronological and always present, so that is the ordering key; anything + /// still in the pool (height 0) belongs at the top. + private func newestFirst(_ left: TransactionRecord, _ right: TransactionRecord) -> Bool { + func key(_ record: TransactionRecord) -> UInt64 { + guard let height = record.height, height > 0 else { return .max } + return height + } + let leftKey = key(left) + let rightKey = key(right) + if leftKey != rightKey { return leftKey > rightKey } + return (left.timestamp ?? .distantFuture) > (right.timestamp ?? .distantFuture) } // MARK: - Accounts diff --git a/apps/ios/Koins/Wallet/XRPWalletService.swift b/apps/ios/Koins/Wallet/XRPWalletService.swift index 99f758c..fb5cd75 100644 --- a/apps/ios/Koins/Wallet/XRPWalletService.swift +++ b/apps/ios/Koins/Wallet/XRPWalletService.swift @@ -92,7 +92,8 @@ final class XRPWalletService { ledgerIndex: (entry["ledger_index"] as? NSNumber)?.uint64Value, sequence: (tx["Sequence"] as? NSNumber)?.uint64Value)) } - return records + // Newest first, matching the Monero list. + return records.sorted { ($0.timestamp ?? .distantFuture) > ($1.timestamp ?? .distantFuture) } } // MARK: - Sends