27 Commits
Author SHA1 Message Date
TZGyn 3ff5385a9b Show live swap status, explorer links, and full kyc.rip route details 2026-10-26 21:31:41 +08:00
TZGyn 2f70931cba Move the chain switcher into the settings drawer 2026-10-11 20:18:05 +08:00
TZGyn e67ed9e063 Show Monero accounts as their own section below the wallet card 2026-10-11 19:22:21 +08:00
TZGyn c9679e35c4 Give Monero sync its own section and consolidate the wallet buttons 2026-10-10 20:18:41 +08:00
TZGyn 7e739e1b77 Show Monero accounts as a stack of cards you can swipe through 2026-10-07 21:27:57 +08:00
TZGyn 4fa1584dfc Swap XMR and XRP through kyc.rip without leaving the app 2026-10-07 21:01:34 +08:00
TZGyn 31429e6923 Present sheets as a full bottom drawer 2026-10-03 23:42:46 +08:00
TZGyn 782143e87b Offer only USD, MYR and JPY in the currency settings 2026-10-01 22:08:46 +08:00
TZGyn 994ba5ed6b Give each Monero account a swipeable card with its balance and QR 2026-10-01 22:05:19 +08:00
TZGyn e3846fe881 Write the inverted icon variant to Branding, not the icon set 2026-09-29 21:20:58 +08:00
TZGyn edfefb6210 Give the app icon more padding 2026-09-29 21:16:24 +08:00
TZGyn 58d862161e Show each wallet's balance in USD 2026-09-29 21:02:45 +08:00
TZGyn 0b1b253d30 Make the whole transaction row tappable 2026-09-28 22:23:54 +08:00
TZGyn 78e1cefad4 Require Face ID to open the app and to send 2026-09-27 23:35:26 +08:00
TZGyn b989b0f791 Cache wallet balance and activity so screens show it instantly 2026-09-25 21:58:34 +08:00
TZGyn 2728bf161e List transactions newest first on both chains 2026-09-24 21:14:55 +08:00
TZGyn 51dc007107 Use the official Monero mark for the app icon 2026-09-23 21:08:23 +08:00
TZGyn 27dc122632 Fix the QR scanner showing a black preview 2026-09-22 22:45:25 +08:00
TZGyn e9720391b7 Add a black and white Monero app icon 2026-09-22 22:28:46 +08:00
TZGyn c0903f4f02 Fill in payments by scanning a QR code or picking one from photos 2026-09-20 02:00:52 +08:00
TZGyn 399502cf72 Stop a slow chain refresh from showing the wrong wallet balance 2026-09-20 01:30:42 +08:00
TZGyn e53c6aa7a9 Show each address QR in a bottom drawer 2026-09-20 00:57:51 +08:00
TZGyn 021672b47c Show full transaction details for Monero and XRP 2026-09-20 00:43:09 +08:00
TZGyn caca4321cc Add Monero subaccounts and subaddresses with a QR per address 2026-09-20 00:11:53 +08:00
TZGyn 1a23c730c6 Keep the Monero wallet out of iCloud backups 2026-09-19 22:54:58 +08:00
TZGyn 807388ea6b Keep Monero scan progress across app restarts 2026-09-19 22:17:00 +08:00
TZGyn f4037a188a Sign the iOS app for real devices and stop bundling the static archives 2026-09-19 19:25:01 +08:00
33 changed files with 3543 additions and 131 deletions
+32
View File
@@ -0,0 +1,32 @@
# Branding
The app icon is the **official Monero mark**, monochrome.
`logo-official.svg` is `meta/safari-pinned-tab.svg` from
[monero-project/monero-site](https://github.com/monero-project/monero-site) —
the Monero Project's own traced, single-colour version of the logo, which is
exactly what a black-and-white icon needs. `logo-official.png` is that file
rasterised to 1024×1024:
```sh
qlmanage -t -s 1024 -o /tmp Branding/logo-official.svg
```
iOS cannot use SVG for app icons, so `make-icons.swift` produces the PNG the
asset catalog needs — the mark as published (black badge, white M) inset to 90%
so the platform's squircle mask does not clip it:
```sh
cd apps/ios
swift Branding/make-icons.swift Koins/Assets.xcassets/AppIcon.appiconset
```
That writes the opaque 1024×1024 app icon (no alpha channel — the App Store
rejects icons with alpha) and `Branding/logo-inverted.png`, the same mark
inverted for dark surfaces (white badge, black M, black ground).
The asset catalog uses the single-size format, so Xcode derives every size the
device needs from the 1024.
Note: the Monero name and mark belong to the Monero Project — check their brand
guidance before shipping this publicly.
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M3370 6894 c-410 -21 -760 -95 -1116 -235 -1129 -445 -1939 -1467
-2114 -2666 -69 -479 -37 -963 97 -1436 l36 -127 508 0 509 0 0 1427 0 1428
1105 -1105 1105 -1105 1105 1105 1105 1105 0 -1428 0 -1427 509 0 508 0 36
127 c134 473 166 957 97 1436 -212 1456 -1350 2616 -2800 2856 -207 34 -511
54 -690 45z"/>
<path d="M2028 2635 l-3 -900 -713 -5 -713 -5 79 -117 c526 -777 1341 -1305
2252 -1457 200 -34 338 -45 570 -45 305 0 533 27 805 93 824 202 1538 700
2016 1409 l80 117 -716 3 -715 2 0 903 0 902 -735 -735 -735 -735 -735 735
-735 735 -2 -900z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+134
View File
@@ -0,0 +1,134 @@
// Regenerates the app icon PNGs from the official Monero mark.
//
// cd apps/ios
// swift Branding/make-icons.swift Koins/Assets.xcassets/AppIcon.appiconset
//
// Input: Branding/logo-official.png — the monochrome mark published in
// monero-project/monero-site (meta/safari-pinned-tab.svg), rasterised.
// It is a black badge with the M knocked out, drawn on white.
// Output: AppIcon-1024.png - the inverse: white badge, black M, on black
// (reads well on the app's dark UI)
// AppIcon-light-1024.png - the mark as published: black badge, white M,
// on white
//
// The artwork is inset to 90% of the canvas so the iOS squircle mask does not
// clip the badge's edges. iOS cannot use SVG for app icons, which is why this
// rasterises instead of linking the SVG.
import AppKit
import CoreGraphics
import Foundation
struct Bitmap {
let width: Int
let height: Int
var pixels: [UInt8] // RGBA, premultiplied
func luminance(_ offset: Int) -> Int {
(Int(pixels[offset]) * 299 + Int(pixels[offset + 1]) * 587 + Int(pixels[offset + 2]) * 114) / 1000
}
}
func loadBitmap(_ path: String) -> Bitmap? {
guard let image = NSImage(contentsOfFile: path),
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
let width = cgImage.width
let height = cgImage.height
var pixels = [UInt8](repeating: 0, count: width * height * 4)
guard let context = CGContext(data: &pixels,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
return Bitmap(width: width, height: height, pixels: pixels)
}
func makeImage(_ bitmap: Bitmap) -> CGImage? {
guard let provider = CGDataProvider(data: Data(bitmap.pixels) as CFData) else { return nil }
return CGImage(width: bitmap.width,
height: bitmap.height,
bitsPerComponent: 8,
bitsPerPixel: 32,
bytesPerRow: bitmap.width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
provider: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent)
}
func inverted(_ bitmap: Bitmap) -> Bitmap {
var pixels = bitmap.pixels
for offset in stride(from: 0, to: pixels.count, by: 4) {
let value = UInt8(255 - bitmap.luminance(offset))
pixels[offset] = value
pixels[offset + 1] = value
pixels[offset + 2] = value
}
return Bitmap(width: bitmap.width, height: bitmap.height, pixels: pixels)
}
/// Composes the mark onto a square background, inset so the platform mask has
/// room, and writes an opaque PNG (App Store icons must not carry alpha).
func writeIcon(_ source: Bitmap, size: Int, inset: CGFloat, background: NSColor, to path: String) {
guard let image = makeImage(source) else { return }
var pixels = [UInt8](repeating: 0, count: size * size * 4)
guard let context = CGContext(data: &pixels,
width: size,
height: size,
bitsPerComponent: 8,
bytesPerRow: size * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return }
context.setFillColor(background.cgColor)
context.fill(CGRect(x: 0, y: 0, width: size, height: size))
let side = CGFloat(size) * inset
let offset = (CGFloat(size) - side) / 2
context.interpolationQuality = .high
context.draw(image, in: CGRect(x: offset, y: offset, width: side, height: side))
var rgb = [UInt8](repeating: 0, count: size * size * 3)
for index in 0..<(size * size) {
rgb[index * 3] = pixels[index * 4]
rgb[index * 3 + 1] = pixels[index * 4 + 1]
rgb[index * 3 + 2] = pixels[index * 4 + 2]
}
guard let provider = CGDataProvider(data: Data(rgb) as CFData),
let flattened = CGImage(width: size,
height: size,
bitsPerComponent: 8,
bitsPerPixel: 24,
bytesPerRow: size * 3,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue),
provider: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent) else { return }
let representation = NSBitmapImageRep(cgImage: flattened)
guard let data = representation.representation(using: .png, properties: [:]) else { return }
try? data.write(to: URL(fileURLWithPath: path))
print("wrote \(path)")
}
let arguments = CommandLine.arguments
let outputDirectory = arguments.count > 1 ? arguments[1] : "/tmp"
let sourcePath = arguments.count > 2 ? arguments[2] : "Branding/logo-official.png"
try? FileManager.default.createDirectory(atPath: outputDirectory, withIntermediateDirectories: true)
guard let source = loadBitmap(sourcePath) else {
print("cannot read source artwork at \(sourcePath)")
exit(1)
}
let inset: CGFloat = 0.78
// Primary: the mark exactly as Monero publishes it (black badge, white M).
writeIcon(source, size: 1024, inset: inset, background: .white,
to: "\(outputDirectory)/AppIcon-1024.png")
// Alternate: inverted, for dark surfaces (white badge, black M, black ground).
writeIcon(inverted(source), size: 1024, inset: inset, background: .black,
to: "Branding/logo-inverted.png")
+259 -8
View File
@@ -14,6 +14,21 @@ final class AppModel: ObservableObject {
@Published var statusMessage: String?
@Published var moneroReady = false
@Published var moneroAddress: String?
@Published var moneroAccounts: [MoneroAccount] = []
@Published var prices = PriceService.Prices(currency: "USD")
/// Whether the app is behind the biometric lock screen right now.
@Published var locked = false
/// Face ID/Touch ID lock. On by default when the device has biometrics;
/// persisted across launches.
var faceIDEnabled: Bool {
get { UserDefaults.standard.object(forKey: "koins.faceID") as? Bool ?? true }
set {
UserDefaults.standard.set(newValue, forKey: "koins.faceID")
objectWillChange.send()
}
}
/// Rough time remaining for the Monero sync, derived from observed block rate.
@Published var syncEta: String?
@@ -21,6 +36,10 @@ final class AppModel: ObservableObject {
private var refreshTick = 0
private var refreshTask: Task<Void, Never>?
private var lastSample: (height: UInt64, date: Date)?
private var lastCheckpoint = Date.distantPast
/// Invalidates in-flight refreshes: switching chains (or starting a newer
/// refresh) must stop an older request from writing its result to the UI.
private var refreshToken = UUID()
var wallet: WalletRecord? {
chain == .xrp ? xrpWallet : xmrWallet
@@ -36,6 +55,150 @@ final class AppModel: ObservableObject {
var symbol: String { chain.symbol }
// MARK: - Fiat display
/// The fiat currency the wallet shows; persisted, and changing it refetches
/// the price cache.
var fiatCurrency: String {
get { UserDefaults.standard.string(forKey: "koins.fiatCurrency") ?? "USD" }
set {
UserDefaults.standard.set(newValue, forKey: "koins.fiatCurrency")
objectWillChange.send()
Task { await refreshPrices() }
}
}
/// The current chain's balance in the chosen fiat currency, when a price is
/// known.
var fiatBalance: String? {
fiatValue(of: balance.total, chain: chain)
}
/// Converts a chain amount into the chosen fiat currency, when a price is
/// known.
func fiatValue(of amount: String, chain: Chain) -> String? {
let price = chain == .xrp ? prices.xrp : prices.xmr
guard let price, let value = Decimal(string: amount) else { return nil }
let fiat = value * Decimal(price)
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = fiatCurrency
formatter.locale = Locale(identifier: "en_US")
return formatter.string(from: fiat as NSDecimalNumber)
}
private func refreshPrices() async {
prices = await PriceService.shared.prices(currency: fiatCurrency)
}
// MARK: - Biometric lock
// MARK: - Swaps (kyc.rip)
@Published var swapOrders: [SwapOrder] = []
func loadSwapOrders() {
swapOrders = WalletStore.swapOrders()
}
func estimateSwap(amount: String, from: Chain, to: Chain) async throws -> SwapEstimate {
guard let amountNumber = Double(amount.trimmingCharacters(in: .whitespaces)),
amountNumber > 0 else {
throw SwapError.api("Enter a valid amount")
}
return try await SwapService.shared.estimate(from: from.rawValue, to: to.rawValue, amount: amountNumber)
}
func createSwap(route: SwapRoute, estimateID: String, amountFrom: String,
from: Chain, to: Chain) async throws -> SwapOrder {
guard let amountNumber = Double(amountFrom.trimmingCharacters(in: .whitespaces)), amountNumber > 0 else {
throw SwapError.api("Enter a valid amount")
}
guard let destination = (to == .xrp ? xrpWallet?.address : xmrWallet?.address), !destination.isEmpty else {
throw SwapError.api("No \(to.title) wallet to receive into")
}
let order = try await SwapService.shared.createOrder(route: route,
estimateID: estimateID,
from: from.rawValue,
to: to.rawValue,
addressTo: destination,
amountFrom: amountNumber)
WalletStore.saveSwapOrder(order)
loadSwapOrders()
return order
}
/// Sends the swap deposit using the same payment path as a normal send.
func sendSwapDeposit(_ order: SwapOrder, accountIndex: Int = 0) async throws {
try await send(to: order.depositAddress,
amount: String(order.depositAmount),
tag: order.depositMemo ?? "",
priority: .defaultPriority,
accountIndex: accountIndex)
}
func refreshSwapStatus(_ order: SwapOrder) async -> SwapOrder {
guard let updated = try? await SwapService.shared.orderStatus(order) else { return order }
var order = order
order.status = updated.status
order.txIn = updated.txIn ?? order.txIn
order.txOut = updated.txOut ?? order.txOut
if order.txIn?.isEmpty == true { order.txIn = nil }
if order.txOut?.isEmpty == true { order.txOut = nil }
order.supportUrl = updated.supportUrl ?? order.supportUrl
if let index = swapOrders.firstIndex(where: { $0.id == order.id }) {
swapOrders[index] = order
}
WalletStore.saveSwapOrder(order)
return order
}
/// Persists a local change to an order (for example, the deposit was sent).
func saveSwapOrder(_ order: SwapOrder) {
WalletStore.saveSwapOrder(order)
loadSwapOrders()
}
func removeSwapOrder(id: String) {
WalletStore.removeSwapOrder(id: id)
loadSwapOrders()
}
func authenticate() async {
if await Biometrics.authenticate(reason: "Unlock Koins") {
locked = false
}
}
/// Requires biometrics for sensitive actions when the lock is enabled; a
/// device without biometrics is not asked (the wallet is still usable).
func requireAuth(reason: String) async throws {
guard faceIDEnabled, Biometrics.isAvailable else { return }
let ok = await Biometrics.authenticate(reason: reason)
if !ok { throw XRPError.rpc("Authentication failed") }
}
// MARK: - Cache
/// Restores the last-known state for the chain on screen instantly; the
/// refresh that follows replaces it with live data.
private func showCached(_ forChain: Chain) {
guard let cached = WalletStore.cachedState(forChain) else { return }
balance = cached.balance
transactions = cached.transactions
if let accounts = cached.accounts { moneroAccounts = accounts }
}
private func persistState(for forChain: Chain) {
guard (forChain == .xrp ? xrpWallet : xmrWallet) != nil else { return }
WalletStore.saveCachedState(
ChainCache(balance: balance,
transactions: transactions,
accounts: forChain == .xmr ? moneroAccounts : nil,
savedAt: Date()),
chain: forChain)
}
// MARK: - Bootstrap
func bootstrap() async {
@@ -49,11 +212,21 @@ final class AppModel: ObservableObject {
if let xmrWallet {
await openMonero(afterRestore: false, wallet: xmrWallet)
}
if xrpWallet != nil || moneroReady {
// Restore the chain the user was last on, as long as that wallet exists.
let savedChain = UserDefaults.standard.string(forKey: "koins.chain").flatMap(Chain.init(rawValue:))
if let savedChain, (savedChain == .xrp ? xrpWallet : xmrWallet) != nil {
chain = savedChain
} else if xrpWallet != nil || moneroReady {
chain = xrpWallet != nil ? .xrp : .xmr
}
if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable {
locked = true
}
loadSwapOrders()
showCached(chain)
await refresh(force: true)
startAutoRefresh()
Task { await refreshPrices() }
}
private func adoptMoneroWallet(named name: String) async {
@@ -91,11 +264,15 @@ final class AppModel: ObservableObject {
func select(_ newChain: Chain) async {
chain = newChain
UserDefaults.standard.set(newChain.rawValue, forKey: "koins.chain")
refreshToken = UUID()
errorMessage = nil
statusMessage = nil
balance = Balance(total: "0", unlocked: "", funded: false)
transactions = []
moneroAccounts = []
syncStatus = nil
showCached(newChain)
if newChain == .xmr, let xmrWallet, !moneroOpened {
await openMonero(afterRestore: false, wallet: xmrWallet)
@@ -198,20 +375,30 @@ final class AppModel: ObservableObject {
func refresh(force: Bool = false) async {
guard hasWallet else { return }
let token = UUID()
refreshToken = token
let requestChain = chain
errorMessage = nil
await refreshPrices()
if chain == .xrp, let wallet = xrpWallet {
do {
async let balanceCall = XRPWalletService.shared.balance(address: wallet.address)
async let txCall = XRPWalletService.shared.transactions(address: wallet.address)
balance = try await balanceCall
transactions = try await txCall
let fetchedBalance = try await balanceCall
let fetchedTransactions = try await txCall
guard !isStale(token, requestChain) else { return }
balance = fetchedBalance
transactions = fetchedTransactions
persistState(for: requestChain)
} catch {
guard !isStale(token, requestChain) else { return }
errorMessage = error.localizedDescription
}
// The Monero wallet keeps scanning while XRP is on screen, so keep its
// progress current here too.
if moneroReady, let status = try? await MoneroService.shared.syncStatus() {
updateSyncStatus(status)
await checkpointWalletIfSyncing()
}
return
}
@@ -220,19 +407,37 @@ final class AppModel: ObservableObject {
do {
// Sync progress is cheap and polled every tick; the balance and transfer
// list touch the wallet's data files, so they are refreshed less often.
updateSyncStatus(try await MoneroService.shared.syncStatus())
let status = try await MoneroService.shared.syncStatus()
if !isStale(token, requestChain) {
updateSyncStatus(status)
}
refreshTick += 1
await checkpointWalletIfSyncing()
if force || refreshTick == 1 || refreshTick % 4 == 0 {
async let balanceCall = MoneroService.shared.balance()
async let txCall = MoneroService.shared.transactions()
balance = try await balanceCall
transactions = try await txCall
async let accountsCall = MoneroService.shared.accounts()
let fetchedBalance = try await balanceCall
let fetchedTransactions = try await txCall
let fetchedAccounts = try await accountsCall
guard !isStale(token, requestChain) else { return }
balance = fetchedBalance
transactions = fetchedTransactions
moneroAccounts = fetchedAccounts
persistState(for: requestChain)
}
} catch {
guard !isStale(token, requestChain) else { return }
errorMessage = error.localizedDescription
}
}
/// True when a newer refresh has started or the user moved to another chain
/// while this request was in flight.
private func isStale(_ token: UUID, _ requestChain: Chain) -> Bool {
token != refreshToken || requestChain != chain
}
/// Tracks how fast blocks are being scanned so the UI can show an estimate.
private func updateSyncStatus(_ status: SyncStatus) {
syncStatus = status
@@ -277,6 +482,7 @@ final class AppModel: ObservableObject {
}
func revealSeed() async throws -> String {
try await requireAuth(reason: "Reveal your recovery phrase")
if chain == .xrp {
guard let seed = WalletStore.secret(.xrp) else {
throw XRPError.rpc("No stored secret for this wallet")
@@ -289,7 +495,9 @@ final class AppModel: ObservableObject {
// MARK: - Sends
func send(to: String, amount: String, tag: String, priority: TransactionPriority) async throws {
func send(to: String, amount: String, tag: String, priority: TransactionPriority,
accountIndex: Int = 0) async throws {
try await requireAuth(reason: "Authorize this payment")
let destinationTag = UInt32(tag.trimmingCharacters(in: .whitespaces))
if chain == .xrp {
guard let seed = WalletStore.secret(.xrp) else {
@@ -304,7 +512,10 @@ final class AppModel: ObservableObject {
await refresh(force: true)
return
}
_ = try await MoneroService.shared.send(address: to, amountXMR: amount, priority: priority)
_ = try await MoneroService.shared.send(address: to,
amountXMR: amount,
priority: priority,
accountIndex: accountIndex)
statusMessage = "Payment sent"
await refresh(force: true)
}
@@ -334,4 +545,44 @@ final class AppModel: ObservableObject {
errorMessage = nil
statusMessage = nil
}
/// Called when the app leaves the foreground: iOS can suspend or kill it at
/// any point, so flush the wallet while there is still time.
func persistWallet() async {
guard moneroReady else { return }
await MoneroService.shared.save()
}
func addAccount(label: String = "") async throws {
_ = try await MoneroService.shared.createAccount(label: label)
await refresh(force: true)
}
func addSubaddress(accountIndex: Int, label: String = "") async throws {
_ = try await MoneroService.shared.createSubaddress(accountIndex: accountIndex, label: label)
await refresh(force: true)
}
/// The account a payment should leave from by default: the first one with
/// spendable funds, otherwise the primary account.
var defaultMoneroAccountIndex: Int {
moneroAccounts.first { (Decimal(string: $0.unlocked) ?? 0) > 0 }?.index ?? 0
}
/// Refreshes the account/subaddress tree independently of the active chain.
func loadAccounts() async {
guard moneroReady else { return }
if let accounts = try? await MoneroService.shared.accounts() {
moneroAccounts = accounts
}
}
/// monero-cpp only writes the wallet file from save(), and its sync loop
/// never does, so without this a restart re-scans from the beginning.
private func checkpointWalletIfSyncing() async {
guard moneroReady, syncStatus?.syncing == true else { return }
guard Date().timeIntervalSince(lastCheckpoint) >= 60 else { return }
lastCheckpoint = Date()
await MoneroService.shared.save()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+124 -12
View File
@@ -14,7 +14,22 @@ static std::string walletPath(NSString *name) {
return std::string([dir stringByAppendingPathComponent:name].UTF8String);
}
// Keep wallet files out of iCloud/iTunes backups: the seed in the Keychain is
// device-only and the file is rebuildable from it, so backing it up would only
// consume the user's quota (it peaks near 120 MB while a wallet restored from
// genesis scans). Must run after every write, since wallet2 rewrites these
// files on each save.
static void excludeWalletFilesFromBackup(const std::string &walletPathString) {
NSString *base = [NSString stringWithUTF8String:walletPathString.c_str()];
if (base.length == 0) return;
for (NSString *suffix in @[ @"", @".keys", @".address.txt", @".mms" ]) {
NSURL *url = [NSURL fileURLWithPath:[base stringByAppendingString:suffix]];
[url setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil];
}
}
static monero_wallet_full *g_wallet = nullptr;
static std::string g_walletPath;
static NSString *g_daemonURL = @"xmr-node.cakewallet.com:18081";
static NSArray<NSString *> *g_fallbackDaemons = nil;
static std::atomic<bool> s_cachedConnected(false);
@@ -82,6 +97,8 @@ static void connectDaemon(monero_wallet_full *w) {
connectDaemon(g_wallet);
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
try { resp[@"mnemonic"] = [NSString stringWithUTF8String:g_wallet->get_seed().c_str()]; } catch (...) {}
g_walletPath = path;
excludeWalletFilesFromBackup(g_walletPath);
} else {
monero_wallet_config config;
config.m_path = path;
@@ -94,6 +111,8 @@ static void connectDaemon(monero_wallet_full *w) {
connectDaemon(w);
resp[@"address"] = [NSString stringWithUTF8String:w->get_address(0, 0).c_str()];
resp[@"mnemonic"] = [NSString stringWithUTF8String:w->get_seed().c_str()];
g_walletPath = path;
excludeWalletFilesFromBackup(g_walletPath);
}
} else if ([op isEqualToString:@"openWallet"] || [op isEqualToString:@"restoreWallet"]) {
if (g_wallet) {
@@ -118,6 +137,8 @@ static void connectDaemon(monero_wallet_full *w) {
g_wallet = monero_wallet_full::create_wallet(config, nullptr);
}
connectDaemon(g_wallet);
g_walletPath = walletPath(req[@"name"]);
excludeWalletFilesFromBackup(g_walletPath);
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
} else if ([op isEqualToString:@"getSeed"]) {
if (g_wallet) {
@@ -145,6 +166,18 @@ static void connectDaemon(monero_wallet_full *w) {
delete g_wallet;
g_wallet = nullptr;
}
g_walletPath.clear();
} else if ([op isEqualToString:@"saveWallet"]) {
// monero-cpp never persists while syncing, so scan progress survives a
// restart only if this is called explicitly. save() pauses the sync
// thread while it writes.
if (g_wallet) {
g_wallet->save();
excludeWalletFilesFromBackup(g_walletPath);
resp[@"saved"] = @YES;
} else {
resp[@"error"] = @"No wallet open";
}
} else if ([op isEqualToString:@"getBalance"]) {
if (g_wallet) {
uint64_t balance = 0, unlocked = 0;
@@ -176,21 +209,100 @@ static void connectDaemon(monero_wallet_full *w) {
const auto &tx = transfer->m_tx;
const auto isIn = transfer->is_incoming();
boost::optional<uint64_t> txHeight = tx ? tx->get_height() : boost::none;
[entries addObject:@{
@"hash" : [NSString stringWithUTF8String:((tx && tx->m_hash) ? *tx->m_hash : "").c_str()],
@"amount" : @((transfer->m_amount ? *transfer->m_amount : 0)).stringValue,
@"timestamp" : @((tx && tx->m_received_timestamp) ? *tx->m_received_timestamp : 0),
@"direction" : (isIn && *isIn) ? @"in" : @"out",
@"confirmed" : @((tx && tx->m_is_confirmed) ? *tx->m_is_confirmed : false),
@"height" : @(txHeight ? *txHeight : 0),
@"fee" : @((tx && tx->m_fee) ? *tx->m_fee : 0).stringValue,
}];
NSMutableDictionary *entry = [NSMutableDictionary dictionary];
entry[@"hash"] = [NSString stringWithUTF8String:((tx && tx->m_hash) ? *tx->m_hash : "").c_str()];
entry[@"amount"] = @((transfer->m_amount ? *transfer->m_amount : 0)).stringValue;
entry[@"timestamp"] = @((tx && tx->m_received_timestamp) ? *tx->m_received_timestamp : 0);
entry[@"direction"] = (isIn && *isIn) ? @"in" : @"out";
entry[@"confirmed"] = @((tx && tx->m_is_confirmed) ? *tx->m_is_confirmed : false);
entry[@"height"] = @(txHeight ? *txHeight : 0);
entry[@"fee"] = @((tx && tx->m_fee) ? *tx->m_fee : 0).stringValue;
// Extra detail for the transaction view.
if (tx) {
if (tx->m_payment_id) entry[@"paymentId"] = [NSString stringWithUTF8String:tx->m_payment_id->c_str()];
if (tx->m_num_confirmations) entry[@"confirmations"] = @(*tx->m_num_confirmations);
if (tx->m_weight) entry[@"weight"] = @(*tx->m_weight);
if (tx->m_note) entry[@"note"] = [NSString stringWithUTF8String:tx->m_note->c_str()];
if (tx->m_unlock_time) entry[@"unlockTime"] = @(*tx->m_unlock_time);
}
if (transfer->m_account_index) entry[@"accountIndex"] = @(*transfer->m_account_index);
if (isIn && *isIn) {
auto incoming = std::dynamic_pointer_cast<monero_incoming_transfer>(transfer);
if (incoming) {
if (incoming->m_subaddress_index) entry[@"subaddressIndex"] = @(*incoming->m_subaddress_index);
if (incoming->m_address) entry[@"counterparty"] = [NSString stringWithUTF8String:incoming->m_address->c_str()];
}
} else {
auto outgoing = std::dynamic_pointer_cast<monero_outgoing_transfer>(transfer);
if (outgoing) {
if (!outgoing->m_addresses.empty()) entry[@"counterparty"] = [NSString stringWithUTF8String:outgoing->m_addresses.front().c_str()];
if (!outgoing->m_subaddress_indices.empty()) entry[@"subaddressIndex"] = @(outgoing->m_subaddress_indices.front());
}
}
[entries addObject:entry];
}
} catch (const std::exception &e) {
os_log_error(OS_LOG_DEFAULT, "[koins] getTransactions: %{public}s", e.what());
}
resp[@"transactions"] = entries;
}
} else if ([op isEqualToString:@"getAccounts"]) {
NSMutableArray *accounts = [NSMutableArray new];
if (!g_wallet) {
resp[@"accounts"] = accounts;
} else {
for (const auto &account : g_wallet->get_accounts(true, "")) {
NSMutableDictionary *accountDict = [NSMutableDictionary new];
accountDict[@"index"] = @(account.m_index ? *account.m_index : 0);
accountDict[@"address"] = [NSString stringWithUTF8String:(account.m_primary_address ? *account.m_primary_address : "").c_str()];
accountDict[@"balance"] = @((account.m_balance ? *account.m_balance : 0)).stringValue;
accountDict[@"unlocked"] = @((account.m_unlocked_balance ? *account.m_unlocked_balance : 0)).stringValue;
NSMutableArray *subaddresses = [NSMutableArray new];
for (const auto &sub : account.m_subaddresses) {
[subaddresses addObject:@{
@"accountIndex" : @(sub.m_account_index ? *sub.m_account_index : 0),
@"index" : @(sub.m_index ? *sub.m_index : 0),
@"address" : [NSString stringWithUTF8String:(sub.m_address ? *sub.m_address : "").c_str()],
@"label" : [NSString stringWithUTF8String:(sub.m_label ? *sub.m_label : "").c_str()],
@"balance" : @((sub.m_balance ? *sub.m_balance : 0)).stringValue,
@"unlocked" : @((sub.m_unlocked_balance ? *sub.m_unlocked_balance : 0)).stringValue,
@"isUsed" : @((sub.m_is_used ? *sub.m_is_used : false)),
}];
}
accountDict[@"subaddresses"] = subaddresses;
[accounts addObject:accountDict];
}
resp[@"accounts"] = accounts;
}
} else if ([op isEqualToString:@"createAccount"]) {
if (!g_wallet) {
resp[@"error"] = @"No wallet open";
} else {
monero_account account = g_wallet->create_account(std::string([req[@"label"] UTF8String]));
g_wallet->save();
excludeWalletFilesFromBackup(g_walletPath);
resp[@"index"] = @(account.m_index ? *account.m_index : 0);
resp[@"address"] = [NSString stringWithUTF8String:(account.m_primary_address ? *account.m_primary_address : "").c_str()];
resp[@"balance"] = @((account.m_balance ? *account.m_balance : 0)).stringValue;
resp[@"unlocked"] = @((account.m_unlocked_balance ? *account.m_unlocked_balance : 0)).stringValue;
}
} else if ([op isEqualToString:@"createSubaddress"]) {
if (!g_wallet) {
resp[@"error"] = @"No wallet open";
} else {
uint32_t accountIndex = (uint32_t)[req[@"accountIndex"] integerValue];
monero_subaddress sub = g_wallet->create_subaddress(accountIndex, std::string([req[@"label"] UTF8String]));
g_wallet->save();
excludeWalletFilesFromBackup(g_walletPath);
resp[@"accountIndex"] = @(sub.m_account_index ? *sub.m_account_index : 0);
resp[@"index"] = @(sub.m_index ? *sub.m_index : 0);
resp[@"address"] = [NSString stringWithUTF8String:(sub.m_address ? *sub.m_address : "").c_str()];
resp[@"label"] = [NSString stringWithUTF8String:(sub.m_label ? *sub.m_label : "").c_str()];
}
} else if ([op isEqualToString:@"startSync"]) {
if (g_wallet) {
connectDaemon(g_wallet);
@@ -248,11 +360,11 @@ static void connectDaemon(monero_wallet_full *w) {
destination->m_amount = (uint64_t)[req[@"amount"] longLongValue];
config.m_destinations = { destination };
config.m_priority = (monero_tx_priority)[req[@"priority"] integerValue];
config.m_account_index = (uint32_t)[req[@"accountIndex"] integerValue];
// create_txs relays the transaction when m_relay is set, so it must
// not be submitted a second time.
config.m_relay = true;
auto txs = g_wallet->create_txs(config);
const auto &txSet = txs[0]->m_tx_set;
std::string signedHex = (txSet && txSet->m_signed_tx_hex) ? *txSet->m_signed_tx_hex : "";
g_wallet->submit_txs(signedHex);
resp[@"txHash"] = [NSString stringWithUTF8String:(txs[0]->m_hash ? *txs[0]->m_hash : "").c_str()];
resp[@"fee"] = @((txs[0]->m_fee ? *txs[0]->m_fee : 0)).stringValue;
}
+4
View File
@@ -23,6 +23,10 @@
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Scan a QR code to fill in a payment address.</string>
<key>NSFaceIDUsageDescription</key>
<string>Unlock Koins and approve payments.</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
+68 -3
View File
@@ -11,6 +11,26 @@ enum Chain: String, Codable, CaseIterable {
}
}
/// Web block explorers used for "view on the web" links.
enum BlockExplorer {
static func transactionURL(chain: Chain, hash: String) -> URL? {
guard !hash.isEmpty else { return nil }
switch chain {
case .xmr: return URL(string: "https://xmrchain.net/tx/\(hash)")
case .xrp: return URL(string: "https://livenet.xrpl.org/transactions/\(hash)")
}
}
/// Same lookup for a raw ticker ("XMR"/"XRP"), as used by the swap API.
static func transactionURL(ticker: String, hash: String) -> URL? {
switch ticker.uppercased() {
case "XMR": return transactionURL(chain: .xmr, hash: hash)
case "XRP": return transactionURL(chain: .xrp, hash: hash)
default: return nil
}
}
}
struct WalletRecord: Codable, Identifiable, Equatable {
let id: String
var name: String
@@ -18,12 +38,12 @@ struct WalletRecord: Codable, Identifiable, Equatable {
var createdAt: Date
}
enum Direction {
enum Direction: String, Codable {
case incoming
case outgoing
}
struct TransactionRecord: Identifiable {
struct TransactionRecord: Identifiable, Codable {
let id: String
let amount: String
let timestamp: Date?
@@ -32,14 +52,33 @@ struct TransactionRecord: Identifiable {
var fee: String?
var tag: Int?
var counterparty: String?
var confirmations: UInt64?
var height: UInt64?
var paymentId: String?
var accountIndex: Int?
var subaddressIndex: Int?
var note: String?
var weight: UInt64?
var ledgerIndex: UInt64?
var sequence: UInt64?
}
struct Balance {
struct Balance: Codable {
var total: String
var unlocked: String
var funded: Bool
}
/// Persisted per chain so a switch or relaunch shows the last-known state
/// instantly while the network catches up.
struct ChainCache: Codable {
var balance: Balance
var transactions: [TransactionRecord]
var accounts: [MoneroAccount]?
var savedAt: Date
}
struct SyncStatus {
var height: UInt64
var daemonHeight: UInt64
@@ -66,4 +105,30 @@ enum TransactionPriority: Int {
case .high: return "High"
}
}
}
struct MoneroSubaddress: Identifiable, Codable {
let accountIndex: Int
let index: Int
let address: String
let label: String
let balance: String
let unlocked: String
let isUsed: Bool
var id: String { "\(accountIndex)/\(index)" }
}
struct MoneroAccount: Identifiable, Codable {
let index: Int
let address: String
let balance: String
let unlocked: String
var subaddresses: [MoneroSubaddress]
var id: Int { index }
var title: String {
index == 0 ? "Primary account" : "Account \(index + 1)"
}
}
+110 -15
View File
@@ -9,20 +9,34 @@ enum SelfTest {
/// Writes to both stdout and the unified log so results are visible however
/// the app was launched.
private static var report: [String] = []
private static func emit(_ line: String) {
print(line)
os_log("%{public}@", log: log, type: .default, line)
report.append(line)
}
/// Writes the collected lines into the app container so the result can be
/// read back off the device (console output is not always reachable).
private static func flushReport() {
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
let url = documents.appendingPathComponent("koins-selftest.txt")
try? report.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
}
static func runIfRequested() {
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTest") {
print("KOINS_SELFTEST begin")
emit("KOINS_SELFTEST begin")
run()
print("KOINS_SELFTEST end")
emit("KOINS_SELFTEST end")
}
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestNetwork") {
runNetwork()
}
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestSwap") {
runSwapCheckIfRequested()
}
}
/// End-to-end check of the live network paths (XRP RPC + native Monero
@@ -80,6 +94,16 @@ enum SelfTest {
}
let balance = try await MoneroService.shared.balance()
emit("KOINS_NET xmr balance \(balance.total) unlocked \(balance.unlocked)")
let accounts = try await MoneroService.shared.accounts()
emit("KOINS_NET xmr accounts \(accounts.count) subaddresses \(accounts.first?.subaddresses.count ?? 0)")
let account = try await MoneroService.shared.createAccount(label: "Savings")
emit("KOINS_NET xmr new account index \(account.index) address \(account.address.prefix(12))")
let subaddress = try await MoneroService.shared.createSubaddress(accountIndex: 0, label: "From Alice")
emit("KOINS_NET xmr new subaddress index \(subaddress.index) address \(subaddress.address.prefix(12))")
let reloaded = try await MoneroService.shared.accounts()
emit("KOINS_NET xmr accounts after \(reloaded.count) subaddresses \(reloaded.first?.subaddresses.count ?? 0)")
await MoneroService.shared.stopSync()
await MoneroService.shared.removeWallet(name: "koins-net-selftest")
} catch {
@@ -88,6 +112,24 @@ enum SelfTest {
restoreWallets()
emit("KOINS_NET end")
flushReport()
}
}
/// Temporary: exercise the kyc.rip estimate path with real values.
static func runSwapCheckIfRequested() {
guard ProcessInfo.processInfo.arguments.contains("-koinsSelfTestSwap") else { return }
Task.detached {
do {
let estimate = try await SwapService.shared.estimate(from: "xmr", to: "xrp", amount: 1)
emit("KOINS_SWAP amountTo \(estimate.amountTo)")
for route in estimate.routes.prefix(3) {
emit("KOINS_SWAP route \(route.provider) (\(route.engine)) -> \(route.amountTo) eta \(route.eta ?? -1)")
}
} catch {
emit("KOINS_SWAP FAIL \(error)")
}
flushReport()
}
}
@@ -97,9 +139,12 @@ enum SelfTest {
func expect(_ name: String, _ actual: String, _ wanted: String) {
let ok = actual.lowercased() == wanted.lowercased()
if !ok { failures += 1 }
let line = "KOINS_SELFTEST \(name): \(ok ? "PASS" : "FAIL") \(actual)"
print(line)
os_log("%{public}@", log: log, type: .default, line)
emit("KOINS_SELFTEST \(name): \(ok ? "PASS" : "FAIL") \(actual)")
}
func expectTrue(_ name: String, _ value: Bool) {
if !value { failures += 1 }
emit("KOINS_SELFTEST \(name): \(value ? "PASS" : "FAIL")")
}
expect("ripemd160", Hex.encode(RIPEMD160.hash(Array("abc".utf8))),
@@ -114,7 +159,7 @@ enum SelfTest {
expect("ed25519 address", keypair.address, edAddress)
} else {
failures += 1
print("KOINS_SELFTEST ed25519: FAIL keypair threw")
emit("KOINS_SELFTEST ed25519: FAIL keypair threw")
}
// secp256k1 vector: exercises the OpenSSL-backed derivation on device.
@@ -128,7 +173,7 @@ enum SelfTest {
expect("secp256k1 address", keypair.address, secpAddress)
} catch {
failures += 1
print("KOINS_SELFTEST secp256k1: FAIL \(error)")
emit("KOINS_SELFTEST secp256k1: FAIL \(error)")
}
// Serialization + hashing reference vector.
@@ -189,12 +234,12 @@ enum SelfTest {
expect("bip32 level \(level)", Hex.encode(child.privateKey), expected)
} else {
failures += 1
print("KOINS_SELFTEST bip32 level \(level): FAIL derivation returned nil")
emit("KOINS_SELFTEST bip32 level \(level): FAIL derivation returned nil")
}
}
} else {
failures += 1
print("KOINS_SELFTEST bip32 master: FAIL")
emit("KOINS_SELFTEST bip32 master: FAIL")
}
let mnemonicCases: [(String, String, String)] = [
@@ -212,10 +257,61 @@ enum SelfTest {
expect("bip44 address #\(position)", keypair.address, vector.2)
} catch {
failures += 1
print("KOINS_SELFTEST bip44 #\(position): FAIL \(error)")
emit("KOINS_SELFTEST bip44 #\(position): FAIL \(error)")
}
}
// QR round trip: generate an image, read it back, parse the payment URI.
let xmrAddress = "46cRPeU77sBVGSVXPyCjvJUdC9AjqvETWb6giUGskdnWN8ZifindmrH7mcztKU52iKgvWXY4dyPujg9LYUY1Q7dvDiwHNCE"
let xrpAddress = "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C"
func roundTrip(_ name: String, _ text: String, checks: [(String, String)]) {
guard let image = QRCodeImage.make(text) else {
failures += 1
emit("KOINS_SELFTEST \(name): FAIL could not render QR image")
return
}
guard let decoded = QRCodeReader.decodeText(in: image) else {
failures += 1
emit("KOINS_SELFTEST \(name): FAIL vision could not read the QR image (\(Int(image.size.width))px)")
return
}
guard let payload = QRCodeReader.parse(decoded) else {
failures += 1
emit("KOINS_SELFTEST \(name): FAIL parse of '\(decoded)'")
return
}
for (label, expected) in checks {
let actual: String
switch label {
case "address": actual = payload.address
case "amount": actual = payload.amount ?? ""
case "tag": actual = payload.tag.map(String.init) ?? ""
case "paymentId": actual = payload.paymentId ?? ""
case "memo": actual = payload.memo ?? ""
default: actual = ""
}
expect("\(name) \(label)", actual, expected)
}
}
roundTrip("qr monero uri",
"monero:\(xmrAddress)?tx_amount=1.5&tx_payment_id=abc123&tx_description=Rent",
checks: [("address", xmrAddress), ("amount", "1.5"), ("paymentId", "abc123"), ("memo", "Rent")])
roundTrip("qr monero plain", xmrAddress, checks: [("address", xmrAddress)])
roundTrip("qr xrp uri",
"ripple:\(xrpAddress)?amount=12.5&dt=99",
checks: [("address", xrpAddress), ("amount", "12.5"), ("tag", "99")])
roundTrip("qr xrp plain", xrpAddress, checks: [("address", xrpAddress)])
expectTrue("qr monero detected", AddressFormat.isMonero(xmrAddress))
expectTrue("qr xrp detected", AddressFormat.isXRP(xrpAddress))
expectTrue("qr rejects junk", QRCodeReader.parse("hello world")?.address == "hello world")
expectTrue("qr not monero", !AddressFormat.isMonero(xrpAddress))
expectTrue("qr not xrp", !AddressFormat.isXRP(xmrAddress))
emit("biometrics available=\(Biometrics.isAvailable) label=\(Biometrics.label)")
// secp256k1 signing must produce a low-S DER signature.
if let keypair = try? XRPKey.keypair(fromSeed: secpSeed),
let message = Hex.decode(signingHex),
@@ -227,15 +323,14 @@ enum SelfTest {
let s = DER.signatureScalar(signature) {
expect("secp256k1 signature is low-S", s.lessThanOrEqual(half) ? "yes" : "no", "yes")
}
print("KOINS_SELFTEST secp256k1 signature len: \(signature.count)")
emit("KOINS_SELFTEST secp256k1 signature len: \(signature.count)")
} else {
failures += 1
print("KOINS_SELFTEST secp256k1 sign: FAIL")
emit("KOINS_SELFTEST secp256k1 sign: FAIL")
}
let summary = "KOINS_SELFTEST summary: \(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")"
print(summary)
os_log("%{public}@", log: log, type: .default, summary)
emit("KOINS_SELFTEST summary: \(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")")
flushReport()
}
}
+25
View File
@@ -0,0 +1,25 @@
import Foundation
import LocalAuthentication
/// Face ID / Touch ID gating for the app lock and for sensitive actions.
enum Biometrics {
static var isAvailable: Bool {
LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
}
static var label: String {
let context = LAContext()
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
return context.biometryType == .faceID ? "Face ID" : "Touch ID"
}
/// Biometrics with the system passcode fallback (the alert offers "Use
/// Passcode" after a failed face scan).
static func authenticate(reason: String) async -> Bool {
guard isAvailable else { return false }
let context = LAContext()
context.localizedCancelTitle = "Cancel"
return (try? await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason)) ?? false
}
}
+106
View File
@@ -0,0 +1,106 @@
import Foundation
/// Fiat prices for the two tickers the wallet supports, fetched from a public
/// price API. Cached briefly in memory and in UserDefaults so screens do not
/// refetch on every refresh, and so the currency conversion shows up instantly
/// on a cold start.
final class PriceService {
static let shared = PriceService()
struct Prices: Codable {
var xrp: Double?
var xmr: Double?
var currency: String
var fetchedAt: Date?
}
private static let cacheLifetime: TimeInterval = 60
private static let defaultsKey = "koins.prices"
private var memory: Prices?
private var inFlight: Task<Prices, Never>?
private init() {
if let data = UserDefaults.standard.data(forKey: Self.defaultsKey),
let saved = try? JSONDecoder().decode(Prices.self, from: data) {
memory = saved
}
}
func prices(currency: String) async -> Prices {
if let memory,
memory.currency == currency,
let fetchedAt = memory.fetchedAt,
Date().timeIntervalSince(fetchedAt) < Self.cacheLifetime {
return memory
}
if let inFlight { return await inFlight.value }
let task = Task<Prices, Never> { [weak self] in
guard let self else { return Prices(currency: currency, fetchedAt: nil) }
let fetched = await self.fetch(currency: currency)
self.memory = fetched
if let data = try? JSONEncoder().encode(fetched) {
UserDefaults.standard.set(data, forKey: Self.defaultsKey)
}
return fetched
}
inFlight = task
let result = await task.value
inFlight = nil
return result
}
private func fetch(currency: String) async -> Prices {
if let prices = try? await fromCoinGecko(currency: currency) { return prices }
return (try? await fromKraken(currency: currency)) ?? Prices(currency: currency, fetchedAt: nil)
}
private func fromCoinGecko(currency: String) async throws -> Prices {
let code = currency.lowercased()
guard let url = URL(string: "https://api.coingecko.com/api/v3/simple/price?ids=monero,ripple&vs_currencies=\(code)") else {
throw XRPError.network("Invalid price endpoint")
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw XRPError.network("CoinGecko request failed")
}
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
return Prices(
xrp: (json["ripple"] as? [String: Any]).flatMap { ($0[code] as? NSNumber)?.doubleValue },
xmr: (json["monero"] as? [String: Any]).flatMap { ($0[code] as? NSNumber)?.doubleValue },
currency: currency,
fetchedAt: Date())
}
/// Kraken only lists USD and EUR for both assets; anything else just yields
/// no price rather than a wrong one.
private func fromKraken(currency: String) async throws -> Prices {
let supported: [String: (xrp: String, xmr: String)] = [
"USD": ("XXRPZUSD", "XXMRZUSD"),
"EUR": ("XXRPZEUR", "XXMRZEUR"),
]
guard let pairs = supported[currency] else {
throw XRPError.network("Kraken has no \(currency) pairs")
}
guard let url = URL(string: "https://api.kraken.com/0/public/Ticker?pair=\(pairs.xrp),\(pairs.xmr)") else {
throw XRPError.network("Invalid price endpoint")
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw XRPError.network("Kraken request failed")
}
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
let result = json["result"] as? [String: Any] ?? [:]
func lastTrade(_ key: String) -> Double? {
(result[key] as? [String: Any])
.flatMap { entry in (entry["c"] as? [Any])?.first }
.flatMap { ($0 as? String).flatMap(Double.init) }
}
return Prices(xrp: lastTrade(pairs.xrp),
xmr: lastTrade(pairs.xmr),
currency: currency,
fetchedAt: Date())
}
}
+135
View File
@@ -0,0 +1,135 @@
import CoreImage
import CoreImage.CIFilterBuiltins
import Foundation
import UIKit
import Vision
/// Renders a QR image (used by the UI and by the self-test, which round-trips
/// an image through the reader).
enum QRCodeImage {
static func make(_ text: String, scale: CGFloat = 10) -> UIImage? {
guard !text.isEmpty else { return nil }
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(text.utf8)
filter.correctionLevel = "M"
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
let context = CIContext()
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
}
/// A payment request decoded from a QR code: the address plus whatever else the
/// code carried.
struct QRPayload {
var address: String
var amount: String?
var tag: UInt32?
var paymentId: String?
var memo: String?
}
enum QRCodeReader {
/// Reads the first QR code found in an image (used for photo-library import).
static func decodeText(in data: Data) -> String? {
guard let image = UIImage(data: data), let cgImage = image.cgImage else { return nil }
return decodeText(in: cgImage)
}
static func decodeText(in image: UIImage) -> String? {
guard let cgImage = image.cgImage else { return nil }
return decodeText(in: cgImage)
}
private static func decodeText(in cgImage: CGImage) -> String? {
if let text = decodeWithVision(cgImage) { return text }
// Vision's barcode detector does not work on the iOS simulator, so fall
// back to CoreImage, which does (and adds redundancy on device).
return decodeWithCoreImage(cgImage)
}
private static func decodeWithVision(_ cgImage: CGImage) -> String? {
let request = VNDetectBarcodesRequest()
request.symbologies = [.qr]
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up)
try? handler.perform([request])
for observation in request.results ?? [] {
if let payload = observation.payloadStringValue, !payload.isEmpty {
return payload
}
}
return nil
}
private static func decodeWithCoreImage(_ cgImage: CGImage) -> String? {
let detector = CIDetector(ofType: CIDetectorTypeQRCode,
context: nil,
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
let features = detector?.features(in: CIImage(cgImage: cgImage)) ?? []
for feature in features {
if let code = feature as? CIQRCodeFeature, let message = code.messageString, !message.isEmpty {
return message
}
}
return nil
}
/// Understands plain addresses as well as the payment URIs wallets emit:
/// `monero:` (tx_amount, tx_payment_id, tx_description) and `ripple:`
/// (amount, dt).
static func parse(_ raw: String) -> QRPayload? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
var body = text
var query: [String: String] = [:]
if let separator = text.firstIndex(of: "?") {
body = String(text[text.startIndex..<separator])
let queryText = String(text[text.index(after: separator)...])
for pair in queryText.split(separator: "&") {
let parts = pair.split(separator: "=", maxSplits: 1)
guard let key = parts.first else { continue }
let value = parts.count > 1 ? String(parts[1]) : ""
query[key.lowercased()] = value.removingPercentEncoding ?? value
}
}
let lowered = body.lowercased()
if let range = lowered.range(of: "monero:") {
body = String(body[range.upperBound...])
} else if let range = lowered.range(of: "ripple:") {
body = String(body[range.upperBound...])
}
while body.hasPrefix("/") { body.removeFirst() }
let address = body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !address.isEmpty else { return nil }
var payload = QRPayload(address: address)
payload.amount = query["tx_amount"] ?? query["amount"]
payload.memo = query["tx_description"] ?? query["name"] ?? query["message"]
payload.paymentId = query["tx_payment_id"] ?? query["payment_id"] ?? query["pid"]
if let tag = query["dt"] ?? query["destination_tag"] ?? query["destinationtag"] {
payload.tag = UInt32(tag)
}
return payload
}
}
enum AddressFormat {
private static let moneroAlphabet = Set("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")
/// Monero addresses are 95 characters (primary/subaddress) or 106
/// (integrated), all base58.
static func isMonero(_ address: String) -> Bool {
let text = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard text.count == 95 || text.count == 106 else { return false }
return text.allSatisfy { moneroAlphabet.contains($0) }
}
static func isXRP(_ address: String) -> Bool {
XRPKey.isValidAddress(address.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
+40
View File
@@ -63,9 +63,49 @@ enum WalletStore {
static func removeWallet(_ chain: Chain) {
defaults.removeObject(forKey: walletKey(chain))
defaults.removeObject(forKey: cacheKey(chain))
Keychain.remove(secretKey(chain))
}
// MARK: - Cached state (balance, transactions, accounts)
private static func cacheKey(_ chain: Chain) -> String { "koins.cache.\(chain.rawValue)" }
static func cachedState(_ chain: Chain) -> ChainCache? {
guard let data = defaults.data(forKey: cacheKey(chain)) else { return nil }
return try? JSONDecoder().decode(ChainCache.self, from: data)
}
static func saveCachedState(_ state: ChainCache, chain: Chain) {
guard let data = try? JSONEncoder().encode(state) else { return }
defaults.set(data, forKey: cacheKey(chain))
}
// MARK: - Swap orders (kyc.rip)
private static let swapOrdersKey = "koins.swapOrders"
static func swapOrders() -> [SwapOrder] {
guard let data = defaults.data(forKey: swapOrdersKey),
let orders = try? JSONDecoder().decode([SwapOrder].self, from: data) else { return [] }
return orders
}
static func saveSwapOrder(_ order: SwapOrder) {
var orders = swapOrders()
orders.removeAll { $0.id == order.id }
orders.insert(order, at: 0)
guard let data = try? JSONEncoder().encode(orders) else { return }
defaults.set(data, forKey: swapOrdersKey)
}
static func removeSwapOrder(id: String) {
var orders = swapOrders()
orders.removeAll { $0.id == id }
guard let data = try? JSONEncoder().encode(orders) else { return }
defaults.set(data, forKey: swapOrdersKey)
}
static func secret(_ chain: Chain) -> String? {
Keychain.get(secretKey(chain))
}
+115
View File
@@ -0,0 +1,115 @@
import SwiftUI
/// A stack of account cards, Cake Wallet style: the front card is full size and
/// interactive, the rest peek from behind (scaled down and offset upwards).
/// Drag the front card sideways or down to send it to the back of the deck.
struct AccountStack: View {
var accounts: [MoneroAccount]
/// Converts an account's XMR amount to the chosen fiat currency, if a price
/// is available.
var fiat: (String) -> String?
var onQR: (MoneroAccount) -> Void
@State private var frontIndex = 0
@State private var dragOffset = CGSize.zero
private let maxDepth = 3
var body: some View {
ZStack(alignment: .top) {
ForEach(Array(accounts.enumerated()), id: \.element.id) { index, account in
let depth = depth(of: index)
accountCard(account, depth: depth)
.offset(x: depth == 0 ? dragOffset.width : 0,
y: depth == 0 ? dragOffset.height : 0)
.gesture(depth == 0 ? frontDrag : nil)
.allowsHitTesting(depth == 0)
.zIndex(Double(accounts.count - depth))
}
}
.frame(height: 190 + CGFloat(min(accounts.count - 1, maxDepth)) * 12)
.accessibilityHint("Drag the front card to cycle accounts")
}
// MARK: - Layout
/// How far behind the front an account sits (0 = front).
private func depth(of index: Int) -> Int {
(index - frontIndex + accounts.count) % accounts.count
}
private func accountCard(_ account: MoneroAccount, depth: Int) -> some View {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(account.title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
Spacer()
Text("\(depth + 1) of \(accounts.count)")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
VStack(alignment: .leading, spacing: 3) {
Text("\(account.balance) XMR")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
if let fiat = fiat(account.balance) {
Text(fiat)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
}
if account.unlocked != account.balance {
Text("\(account.unlocked) XMR unlocked")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
HStack(spacing: 8) {
Text(account.address)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.muted)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 6)
Button {
onQR(account)
} label: {
Image(systemName: "qrcode")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 34, height: 34)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
}
// Cards behind the front one peek from the top, slightly smaller.
.scaleEffect(1 - 0.045 * CGFloat(min(depth, maxDepth)))
.offset(y: -CGFloat(min(depth, maxDepth)) * 12)
.opacity(depth > maxDepth ? 0 : 1)
}
// MARK: - Interaction
private var frontDrag: some Gesture {
DragGesture()
.onChanged { value in
dragOffset = value.translation
}
.onEnded { value in
let threshold: CGFloat = 90
let shouldAdvance = abs(value.translation.width) > threshold
|| value.translation.height > threshold
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
dragOffset = .zero
if shouldAdvance {
frontIndex = (frontIndex + 1) % accounts.count
}
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
import SwiftUI
struct AccountsView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
@State private var error: String?
@State private var busy = false
@State private var showAccountPrompt = false
@State private var showSubaddressPrompt = false
@State private var pendingLabel = ""
@State private var subaddressAccount = 0
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Accounts & addresses",
subtitle: "Each address gets its own QR code",
onBack: { route = .home })
PrimaryButton(title: busy ? "Working…" : "New account", disabled: busy) {
showAccountPrompt = true
}
if let error {
MessageBanner(message: error, isError: true)
}
ForEach(model.moneroAccounts) { account in
accountCard(account)
}
if model.moneroAccounts.isEmpty {
Text("No accounts yet. Accounts and addresses appear after the wallet opens.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
.task(id: model.moneroReady) { await model.loadAccounts() }
.alert("Account name", isPresented: $showAccountPrompt) {
TextField("e.g. Savings", text: $pendingLabel)
Button("Create") { createAccount() }
Button("Cancel", role: .cancel) { pendingLabel = "" }
}
.alert("Address label", isPresented: $showSubaddressPrompt) {
TextField("e.g. From Alice", text: $pendingLabel)
Button("Create") { createSubaddress() }
Button("Cancel", role: .cancel) { pendingLabel = "" }
}
}
private func accountCard(_ account: MoneroAccount) -> some View {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(account.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.text)
Text("\(account.balance) XMR")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
Spacer()
}
addressRow(title: "Primary address",
address: account.address,
isUsed: true)
if !account.subaddresses.isEmpty {
Divider().overlay(Theme.border)
ForEach(account.subaddresses.filter { $0.index != 0 }) { subaddress in
addressRow(title: subaddress.label.isEmpty ? "Address \(subaddress.index)" : subaddress.label,
address: subaddress.address,
isUsed: subaddress.isUsed,
balance: subaddress.balance)
}
}
SecondaryButton(title: "New address in this account") {
subaddressAccount = account.index
pendingLabel = ""
showSubaddressPrompt = true
}
}
}
}
private func addressRow(title: String, address: String, isUsed: Bool, balance: String? = nil) -> some View {
Button {
drawers.qr = AddressQR(title: title, address: address)
} label: {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Text(address.isEmpty ? "—" : address)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
if let balance {
Text("\(balance) XMR\(isUsed ? "" : " · unused")")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
Spacer(minLength: 6)
Image(systemName: "qrcode")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 32, height: 32)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(address.isEmpty)
}
private func createAccount() {
busy = true
error = nil
Task {
defer { busy = false }
do {
try await model.addAccount(label: pendingLabel)
pendingLabel = ""
} catch {
self.error = error.localizedDescription
}
}
}
private func createSubaddress() {
busy = true
error = nil
Task {
defer { busy = false }
do {
try await model.addSubaddress(accountIndex: subaddressAccount, label: pendingLabel)
pendingLabel = ""
} catch {
self.error = error.localizedDescription
}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
import SwiftUI
struct AddressQR: Identifiable {
let id = UUID()
let title: String
let address: String
}
/// One address as a scannable QR, shown inside the shared bottom drawer.
struct AddressQRSheet: View {
var title: String
var address: String
var onDone: () -> Void
@State private var copied = false
var body: some View {
VStack(spacing: 16) {
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
.frame(maxWidth: .infinity, alignment: .leading)
QRCodeView(text: address)
.frame(maxWidth: 220)
Text(address)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(Theme.muted)
.multilineTextAlignment(.center)
.lineLimit(4)
.truncationMode(.middle)
.textSelection(.enabled)
SecondaryButton(title: copied ? "Copied" : "Copy address") {
UIPasteboard.general.string = address
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
}
PrimaryButton(title: "Done", action: onDone)
}
.padding(.horizontal, 22)
.preferredColorScheme(.dark)
}
}
+101
View File
@@ -0,0 +1,101 @@
import SwiftUI
import UIKit
extension View {
/// Presents a bottom-anchored drawer: it fills to the screen's bottom edge
/// (no gap), shows a dimmed backdrop, and can be dragged down to dismiss.
func bottomDrawer<Content: View>(isPresented: Binding<Bool>,
@ViewBuilder content: @escaping () -> Content) -> some View {
BottomDrawerHost(host: self, isPresented: isPresented, content: content)
}
}
/// One place for the drawers shown above every screen. Attaching them at the
/// root keeps them anchored to the window instead of to scrolled content.
@MainActor
final class DrawerCoordinator: ObservableObject {
@Published var transaction: TransactionRecord?
@Published var qr: AddressQR?
@Published var settings = false
}
private struct BottomDrawerHost<Host: View, Content: View>: View {
let host: Host
@Binding var isPresented: Bool
@ViewBuilder var content: () -> Content
@State private var dragOffset: CGFloat = 0
private var bottomInset: CGFloat {
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
if let window = windowScene.keyWindow {
return window.safeAreaInsets.bottom
}
}
return 0
}
private var maxHeight: CGFloat {
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
if let window = windowScene.keyWindow {
return window.bounds.height * 0.92
}
}
return 800
}
var body: some View {
ZStack(alignment: .bottom) {
host
if isPresented {
Color.black.opacity(0.5)
.ignoresSafeArea()
.onTapGesture { dismiss() }
.transition(.opacity)
VStack(spacing: 0) {
Capsule()
.fill(Theme.muted.opacity(0.55))
.frame(width: 36, height: 5)
.padding(.top, 8)
content()
.padding(.top, 14)
.padding(.bottom, 16)
}
.frame(maxWidth: .infinity, alignment: .top)
.frame(maxHeight: maxHeight)
.background(Theme.surface)
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous))
// Push the panel's background down to the physical screen edge without
// taking the host content out of its safe area.
.offset(y: dragOffset + bottomInset)
.gesture(dismissDrag)
.transition(.move(edge: .bottom))
}
}
.animation(.spring(response: 0.32, dampingFraction: 0.86), value: isPresented)
}
private func dismiss() {
isPresented = false
dragOffset = 0
}
private var dismissDrag: some Gesture {
DragGesture()
.onChanged { value in
dragOffset = max(0, value.translation.height)
}
.onEnded { value in
if value.translation.height > 120 || value.predictedEndTranslation.height > 200 {
dismiss()
} else {
dragOffset = 0
}
}
}
}
+135 -17
View File
@@ -51,6 +51,26 @@ struct SecondaryButton: View {
}
}
/// A full-width button that opens a web page, styled like SecondaryButton.
struct LinkButton: View {
var title: String
var systemImage: String = "safari"
var url: URL
var body: some View {
Link(destination: url) {
Label(title, systemImage: systemImage)
.font(.system(size: 15, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.surface)
.foregroundStyle(Theme.text)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
struct Card<Content: View>: View {
@ViewBuilder var content: Content
@@ -146,6 +166,54 @@ struct ChainPicker: View {
}
}
/// Picks which Monero account a payment leaves from. Each account has its own
/// balance, so "Max" and the wallet's spend checks need the matching index.
struct MoneroAccountPicker: View {
var accounts: [MoneroAccount]
@Binding var selection: Int
var onSelect: (() -> Void)?
private var selected: MoneroAccount? {
accounts.first(where: { $0.index == selection }) ?? accounts.first
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Send from")
Menu {
ForEach(accounts) { account in
Button {
selection = account.index
onSelect?()
} label: {
Text("\(account.title) · \(account.balance) XMR")
}
}
} label: {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(selected?.title ?? "Primary account")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Theme.text)
Text("\(selected?.unlocked ?? "0") XMR available")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.muted)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
}
struct QRCodeView: View {
var text: String
@@ -166,15 +234,7 @@ struct QRCodeView: View {
}
private static func makeImage(from text: String) -> UIImage? {
guard !text.isEmpty else { return nil }
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(text.utf8)
filter.correctionLevel = "M"
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
let context = CIContext()
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
return UIImage(cgImage: cgImage)
QRCodeImage.make(text)
}
}
@@ -182,12 +242,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 {
@@ -205,7 +271,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)
}
@@ -224,6 +290,9 @@ struct TransactionRow: View {
}
}
.padding(.vertical, 6)
// Buttons only respond where the label has content; the middle of the row
// is a Spacer, so without this the gap does not accept taps.
.contentShape(Rectangle())
}
}
@@ -257,4 +326,53 @@ struct ScreenHeader: View {
Spacer()
}
}
}
}
/// Monero's chain sync as its own section, rather than buried in the wallet card.
struct SyncCard: View {
@EnvironmentObject private var model: AppModel
var body: some View {
Card {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Sync")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
Spacer()
if let status = model.syncStatus { SyncBadge(status: status) }
}
if let status = model.syncStatus {
SyncProgress(status: status, eta: model.syncEta)
} else {
Text("Sync status is not available yet.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") {
Task { await model.startMoneroSync() }
}
}
}
}
}
/// A small circular icon button for utility actions (refresh, delete, …).
struct IconButton: View {
var systemName: String
var destructive = false
var action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(destructive ? Theme.danger : Theme.muted)
.frame(width: 42, height: 42)
.background(Theme.surface)
.overlay(Circle().stroke(Theme.border))
.clipShape(Circle())
}
}
}
+113 -4
View File
@@ -1,7 +1,9 @@
import PhotosUI
import SwiftUI
struct DashboardView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
@State private var showSeed = false
@@ -22,6 +24,11 @@ struct DashboardView: View {
Text("\(model.balance.total) \(model.symbol)")
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
if let fiat = model.fiatBalance {
Text("\(fiat)")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.muted)
}
if model.chain == .xmr, model.balance.unlocked != model.balance.total {
Text("\(model.balance.unlocked) \(model.symbol) spendable now")
.font(.system(size: 11))
@@ -63,9 +70,14 @@ struct DashboardView: View {
Text("Activity")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
ForEach(model.transactions) { record in
TransactionRow(record: record, symbol: model.symbol)
if record.id != model.transactions.last?.id {
ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in
Button {
drawers.transaction = record
} label: {
TransactionRow(record: record, symbol: model.symbol)
}
.buttonStyle(.plain)
if index < model.transactions.count - 1 {
Divider().overlay(Theme.border)
}
}
@@ -112,7 +124,12 @@ struct SendView: View {
@State private var amount = ""
@State private var tag = ""
@State private var priority: TransactionPriority = .defaultPriority
@State private var accountIndex = 0
@State private var accountPicked = false
@State private var localError: String?
@State private var status: String?
@State private var showScanner = false
@State private var photoItem: PhotosPickerItem?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
@@ -120,6 +137,26 @@ struct SendView: View {
subtitle: "Balance \(model.balance.total) \(model.symbol)",
onBack: { route = .home })
HStack(spacing: 10) {
SecondaryButton(title: "Scan QR") { showScanner = true }
PhotosPicker(selection: $photoItem, matching: .images, photoLibrary: .shared()) {
Text("Photo")
.font(.system(size: 15, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.surface)
.foregroundStyle(Theme.text)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
if model.chain == .xmr, model.moneroAccounts.count > 1 {
MoneroAccountPicker(accounts: model.moneroAccounts,
selection: $accountIndex,
onSelect: { accountPicked = true })
}
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address")
KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination)
@@ -152,6 +189,9 @@ struct SendView: View {
if let message = localError ?? model.errorMessage {
MessageBanner(message: message, isError: true)
}
if let message = status {
MessageBanner(message: message, isError: false)
}
if let message = model.statusMessage {
MessageBanner(message: message, isError: false)
}
@@ -161,7 +201,11 @@ struct SendView: View {
localError = nil
model.clearMessages()
do {
try await model.send(to: destination, amount: amount, tag: tag, priority: priority)
try await model.send(to: destination,
amount: amount,
tag: tag,
priority: priority,
accountIndex: accountIndex)
amount = ""
tag = ""
route = .dashboard
@@ -173,6 +217,71 @@ struct SendView: View {
Spacer(minLength: 0)
}
.sheet(isPresented: $showScanner) {
QRScannerView(onScan: { value in
showScanner = false
applyScanned(value)
}, onCancel: {
showScanner = false
})
.ignoresSafeArea()
}
.onChange(of: photoItem) { item in
guard let item else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self) {
if let value = QRCodeReader.decodeText(in: data) {
applyScanned(value)
} else {
localError = "No QR code found in that image"
}
} else {
localError = "That image could not be read"
}
photoItem = nil
}
}
.onAppear {
if !accountPicked { accountIndex = model.defaultMoneroAccountIndex }
}
.onChange(of: model.moneroAccounts.count) { _ in
if !accountPicked { accountIndex = model.defaultMoneroAccountIndex }
}
}
/// Fills the form from a scanned code, accepting both plain addresses and
/// wallet payment URIs (`monero:…?tx_amount=`, `ripple:…?amount=&dt=`).
private func applyScanned(_ value: String) {
localError = nil
status = nil
guard let payload = QRCodeReader.parse(value) else {
localError = "That QR code is not a payment request"
return
}
let isForThisChain = model.chain == .xrp
? AddressFormat.isXRP(payload.address)
: AddressFormat.isMonero(payload.address)
guard isForThisChain else {
localError = model.chain == .xrp
? "That looks like a Monero address. Switch to Monero to use it."
: "That looks like an XRP address. Switch to XRP to use it."
return
}
destination = payload.address
if let scannedAmount = payload.amount, !scannedAmount.isEmpty {
amount = scannedAmount
}
if model.chain == .xrp, let scannedTag = payload.tag {
tag = String(scannedTag)
}
var filled: [String] = ["address"]
if payload.amount?.isEmpty == false { filled.append("amount") }
if payload.tag != nil { filled.append("tag") }
if payload.paymentId?.isEmpty == false { filled.append("payment ID") }
status = "Filled \(filled.joined(separator: ", ")) from the QR code"
}
}
+30
View File
@@ -0,0 +1,30 @@
import SwiftUI
/// Full-screen biometric lock, overlaid on the app whenever `model.locked`.
struct LockScreen: View {
var unlock: () -> Void
var body: some View {
ZStack {
Theme.background.ignoresSafeArea()
VStack(spacing: 16) {
Spacer()
Image(systemName: "lock.shield")
.font(.system(size: 44, weight: .light))
.foregroundStyle(Theme.accent)
Text("Koins is locked")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Theme.text)
Text("Authenticate to continue")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
Spacer()
PrimaryButton(title: "Unlock with \(Biometrics.label)", action: unlock)
Spacer()
}
.padding(24)
}
.preferredColorScheme(.dark)
.task { unlock() }
}
}
+229
View File
@@ -0,0 +1,229 @@
import AVFoundation
import SwiftUI
import UIKit
/// Camera QR scanner, presented as a sheet.
///
/// Notes on the shape of this code, since a black preview is easy to ship:
/// the preview layer is only created once the camera is authorised, which can
/// happen *after* the last layout pass — so its frame is applied at creation
/// time as well as in `viewDidLayoutSubviews`, otherwise it stays zero-sized
/// and renders as a black rectangle.
struct QRScannerView: UIViewControllerRepresentable {
var onScan: (String) -> Void
var onCancel: () -> Void
func makeUIViewController(context: Context) -> ScannerViewController {
let controller = ScannerViewController()
controller.onScan = onScan
controller.onCancel = onCancel
return controller
}
func updateUIViewController(_ controller: ScannerViewController, context: Context) {}
}
final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var onScan: ((String) -> Void)?
var onCancel: (() -> Void)?
private let session = AVCaptureSession()
private let sessionQueue = DispatchQueue(label: "com.koins.qr.session")
private var previewLayer: AVCaptureVideoPreviewLayer?
private var isConfigured = false
private var hasScanned = false
private let statusLabel = UILabel()
private let settingsButton = UIButton(type: .system)
private let viewfinder = UIView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
let cancel = UIButton(type: .system)
cancel.setTitle("Cancel", for: .normal)
cancel.setTitleColor(.white, for: .normal)
cancel.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
cancel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(cancel)
viewfinder.layer.borderColor = UIColor.white.withAlphaComponent(0.6).cgColor
viewfinder.layer.borderWidth = 2
viewfinder.layer.cornerRadius = 18
viewfinder.isUserInteractionEnabled = false
viewfinder.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(viewfinder)
statusLabel.numberOfLines = 0
statusLabel.textAlignment = .center
statusLabel.textColor = .white
statusLabel.font = .systemFont(ofSize: 14)
statusLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(statusLabel)
settingsButton.setTitle("Open Settings", for: .normal)
settingsButton.setTitleColor(.systemOrange, for: .normal)
settingsButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
settingsButton.addTarget(self, action: #selector(openSettings), for: .touchUpInside)
settingsButton.isHidden = true
settingsButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(settingsButton)
NSLayoutConstraint.activate([
cancel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
cancel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
viewfinder.centerXAnchor.constraint(equalTo: view.centerXAnchor),
viewfinder.centerYAnchor.constraint(equalTo: view.centerYAnchor),
viewfinder.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.68),
viewfinder.heightAnchor.constraint(equalTo: viewfinder.widthAnchor),
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
settingsButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 16),
settingsButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
])
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
begin()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Keeps the preview sized even if it was created after the last layout pass.
previewLayer?.frame = view.layer.bounds
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
sessionQueue.async { [session] in
if session.isRunning { session.stopRunning() }
}
}
@objc private func cancelTapped() {
onCancel?()
}
@objc private func openSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
// MARK: - Session
private func begin() {
guard AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) != nil else {
showHelp("This device has no camera. Use “Photo” to read a QR from your library.")
return
}
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
start()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
guard let self else { return }
if granted {
self.start()
} else {
self.showHelp("Camera access is off. Turn it on in Settings, or use “Photo”.")
self.settingsButton.isHidden = false
}
}
}
default:
showHelp("Camera access is off. Turn it on in Settings, or use “Photo”.")
settingsButton.isHidden = false
}
}
private func start() {
statusLabel.isHidden = true
settingsButton.isHidden = true
if !isConfigured { configure() }
guard isConfigured else { return }
sessionQueue.async { [session] in
if !session.isRunning { session.startRunning() }
}
}
private func configure() {
session.beginConfiguration()
session.sessionPreset = .high
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
session.commitConfiguration()
showHelp("The camera could not be started. Use “Photo” instead.")
return
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else {
session.commitConfiguration()
showHelp("The camera could not be started. Use “Photo” instead.")
return
}
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
// Reading a type that is not available raises an Objective-C exception, so
// check first (it is only populated once the session has an input).
if output.availableMetadataObjectTypes.contains(.qr) {
output.metadataObjectTypes = [.qr]
} else {
session.commitConfiguration()
showHelp("This camera cannot read QR codes.")
return
}
session.commitConfiguration()
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
layer.frame = view.layer.bounds
view.layer.insertSublayer(layer, at: 0)
previewLayer = layer
isConfigured = true
view.setNeedsLayout()
NotificationCenter.default.addObserver(self,
selector: #selector(sessionFailed),
name: .AVCaptureSessionRuntimeError,
object: session)
}
@objc private func sessionFailed(_ note: Notification) {
showHelp("The camera stopped unexpectedly. Close and try again.")
}
private func showHelp(_ text: String) {
statusLabel.text = text
statusLabel.isHidden = false
viewfinder.isHidden = true
}
// MARK: - Detection
func metadataOutput(_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection) {
guard !hasScanned else { return }
guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let value = object.stringValue, !value.isEmpty else { return }
hasScanned = true
sessionQueue.async { [session] in
if session.isRunning { session.stopRunning() }
}
UINotificationFeedbackGenerator().notificationOccurred(.success)
onScan?(value)
}
}
+139 -65
View File
@@ -7,6 +7,8 @@ enum Route: Equatable {
case dashboard
case send
case receive
case accounts
case swap
}
struct SeedBox: Identifiable {
@@ -17,8 +19,19 @@ struct SeedBox: Identifiable {
struct RootView: View {
@EnvironmentObject private var model: AppModel
@State private var route: Route = .home
@Environment(\.scenePhase) private var scenePhase
@State private var route: Route = RootView.initialRoute
private static var initialRoute: Route {
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains("-koinsScreenAccounts") { return .accounts }
if arguments.contains("-koinsScreenSend") { return .send }
if arguments.contains("-koinsScreenReceive") { return .receive }
if arguments.contains("-koinsScreenSwap") { return .swap }
return .home
}
@State private var seedBox: SeedBox?
@StateObject private var drawers = DrawerCoordinator()
var body: some View {
ZStack {
@@ -38,6 +51,10 @@ struct RootView: View {
SendView(route: $route)
case .receive:
ReceiveView(route: $route)
case .accounts:
AccountsView(route: $route)
case .swap:
SwapView(route: $route)
}
}
.padding(.horizontal, 20)
@@ -51,16 +68,54 @@ struct RootView: View {
.sheet(item: $seedBox) { box in
SeedRevealView(seed: box.seed, symbol: box.symbol) { seedBox = nil }
}
.bottomDrawer(isPresented: Binding(
get: { drawers.transaction != nil },
set: { if !$0 { drawers.transaction = nil } }
)) {
if let record = drawers.transaction {
TransactionDetailView(record: record,
symbol: model.symbol,
chain: model.chain) { drawers.transaction = nil }
}
}
.bottomDrawer(isPresented: Binding(
get: { drawers.qr != nil },
set: { if !$0 { drawers.qr = nil } }
)) {
if let item = drawers.qr {
AddressQRSheet(title: item.title, address: item.address) { drawers.qr = nil }
}
}
.bottomDrawer(isPresented: $drawers.settings) {
SettingsView()
}
.environmentObject(drawers)
// Above the drawers so a lock always hides whatever was on screen.
.overlay {
if model.locked, model.faceIDEnabled {
LockScreen {
Task { await model.authenticate() }
}
}
}
.onChange(of: model.chain) { _ in
if route == .dashboard { route = .home }
}
.onChange(of: scenePhase) { phase in
if phase == .background, model.faceIDEnabled, Biometrics.isAvailable {
model.locked = true
}
if phase != .active {
Task { await model.persistWallet() }
}
}
}
}
struct HomeView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
@State private var showDeleteConfirm = false
var body: some View {
VStack(spacing: 18) {
@@ -81,48 +136,47 @@ struct HomeView: View {
MessageBanner(message: message, isError: false)
}
}
.confirmationDialog("Delete this wallet?",
isPresented: $showDeleteConfirm,
titleVisibility: .visible) {
Button("Delete wallet", role: .destructive) {
Task {
await model.deleteWallet()
route = .home
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("The keys stored on this device are removed. Make sure your recovery seed is backed up.")
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Koins")
.font(.system(size: 30, weight: .heavy))
.foregroundStyle(Theme.text)
Text("Monero and XRP wallet")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Koins")
.font(.system(size: 30, weight: .heavy))
.foregroundStyle(Theme.text)
Text("Monero and XRP wallet")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
}
Spacer()
Button {
drawers.settings = true
} label: {
Image(systemName: "gearshape")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(Theme.muted)
.frame(width: 36, height: 36)
.background(Theme.surface)
.clipShape(Circle())
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var walletCard: some View {
VStack(spacing: 14) {
ChainPicker(selection: Binding(get: { model.chain },
set: { newValue in Task { await model.select(newValue) } }))
Card {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(model.wallet?.name ?? "Wallet")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
Spacer()
if model.chain == .xmr, let status = model.syncStatus {
SyncBadge(status: status)
VStack(alignment: .leading, spacing: 2) {
Text(model.wallet?.name ?? "Wallet")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
Text(model.chain.title)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Theme.muted)
}
Spacer()
}
VStack(alignment: .leading, spacing: 4) {
@@ -132,6 +186,11 @@ struct HomeView: View {
Text("\(model.balance.total) \(model.symbol)")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
if let fiat = model.fiatBalance {
Text("\(fiat)")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.muted)
}
if model.chain == .xmr, model.balance.unlocked != model.balance.total {
Text("\(model.balance.unlocked) \(model.symbol) unlocked")
.font(.system(size: 11))
@@ -139,48 +198,36 @@ struct HomeView: View {
}
}
AddressRow(address: model.displayAddress)
if model.chain == .xmr, let status = model.syncStatus {
SyncProgress(status: status, eta: model.syncEta)
if model.chain == .xrp {
AddressRow(address: model.displayAddress)
}
}
}
if model.chain == .xmr, !model.moneroAccounts.isEmpty {
// Cake Wallet style: a deck of account cards as its own section, drag
// the front card away to bring the next one up.
AccountStack(accounts: model.moneroAccounts,
fiat: { model.fiatValue(of: $0, chain: .xmr) },
onQR: { drawers.qr = AddressQR(title: $0.title, address: $0.address) })
}
if model.chain == .xmr {
SyncCard()
}
HStack(spacing: 10) {
PrimaryButton(title: "Send") { route = .send }
SecondaryButton(title: "Receive") { route = .receive }
SecondaryButton(title: "Swap") { route = .swap }
}
if model.chain == .xmr {
SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") {
Task { await model.startMoneroSync() }
HStack(spacing: 12) {
IconButton(systemName: "arrow.clockwise") { Task { await model.refresh(force: true) } }
if model.chain == .xmr {
IconButton(systemName: "person.2.fill") { route = .accounts }
}
}
HStack(spacing: 10) {
SecondaryButton(title: "Refresh") { Task { await model.refresh(force: true) } }
SecondaryButton(title: "Delete") { showDeleteConfirm = true }
}
if !model.transactions.isEmpty {
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Activity")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
ForEach(model.transactions) { record in
TransactionRow(record: record, symbol: model.symbol)
if record.id != model.transactions.last?.id {
Divider().overlay(Theme.border)
}
}
}
}
} else if model.chain == .xrp, model.balance.funded == false {
Text("This account is not funded yet. Send XRP to the address above to activate it.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
Spacer()
}
if model.chain == .xrp, model.moneroReady, let status = model.syncStatus, status.syncing {
@@ -202,13 +249,40 @@ struct HomeView: View {
}
}
}
if !model.transactions.isEmpty {
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Activity")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
// A Monero transaction can produce several transfer entries with the
// same hash, so index-based identities keep the rows stable.
ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in
Button {
drawers.transaction = record
} label: {
TransactionRow(record: record, symbol: model.symbol)
}
.buttonStyle(.plain)
if index < model.transactions.count - 1 {
Divider().overlay(Theme.border)
}
}
}
}
} else if model.chain == .xrp, model.balance.funded == false {
Text("This account is not funded yet. Send XRP to the address above to activate it.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
}
private var moneroUnavailableCard: some View {
VStack(spacing: 14) {
ChainPicker(selection: Binding(get: { model.chain },
set: { newValue in Task { await model.select(newValue) } }))
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Monero wallet")
+121
View File
@@ -0,0 +1,121 @@
import SwiftUI
struct SettingsView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@State private var showDeleteConfirm = false
private let currencies = ["USD", "MYR", "JPY"]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Settings",
subtitle: "Security and device options",
onBack: { drawers.settings = false })
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Wallet")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
ChainPicker(selection: Binding(get: { model.chain },
set: { newValue in Task { await model.select(newValue) } }))
}
}
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Currency")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 64), spacing: 8)], spacing: 8) {
ForEach(currencies, id: \.self) { currency in
let selected = model.fiatCurrency == currency
Button {
model.fiatCurrency = currency
} label: {
Text(currency)
.font(.system(size: 13, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(selected ? Theme.accent : Theme.surfaceAlt)
.foregroundStyle(selected ? Color.black : Theme.text)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
Text("Prices come from CoinGecko (with a Kraken fallback for USD and EUR) and refresh about once a minute.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
.padding(.top, 4)
}
}
Card {
Toggle(isOn: Binding(get: { model.faceIDEnabled },
set: { model.faceIDEnabled = $0 })) {
VStack(alignment: .leading, spacing: 3) {
Text("Unlock with \(Biometrics.label)")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.text)
Text("Require \(Biometrics.label) to open the app, reveal the recovery phrase, and send payments.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
.tint(Theme.accent)
.disabled(!Biometrics.isAvailable)
if !Biometrics.isAvailable {
Text("This device has no biometric sensor configured.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Danger zone")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
Button {
showDeleteConfirm = true
} label: {
Text("Delete this wallet")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.danger)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.danger.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
Text("Removes the wallet from this device. Your recovery phrase is the only way to get it back.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, 20)
.padding(.top, 12)
}
.preferredColorScheme(.dark)
.confirmationDialog("Delete this wallet?",
isPresented: $showDeleteConfirm,
titleVisibility: .visible) {
Button("Delete wallet", role: .destructive) {
Task {
await model.deleteWallet()
drawers.settings = false
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("The keys stored on this device are removed. Make sure your recovery phrase is backed up.")
}
}
}
+765
View File
@@ -0,0 +1,765 @@
import SwiftUI
import UIKit
/// Swap between the two wallets via kyc.rip, the public no-KYC aggregator.
struct SwapView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
@State private var from: Chain = .xrp
@State private var amount = ""
@State private var estimate: SwapEstimate?
@State private var selectedRouteID: String?
@State private var estimating = false
@State private var creating = false
@State private var sendingDeposit = false
@State private var createdOrder: SwapOrder?
@State private var localError: String?
@State private var xmrAccountIndex = 0
@State private var xmrAccountPicked = false
@State private var confirmRemoveOrder = false
private var to: Chain { from == .xrp ? .xmr : .xrp }
private var selectedXmrAccount: MoneroAccount? {
model.moneroAccounts.first(where: { $0.index == xmrAccountIndex }) ?? model.moneroAccounts.first
}
private var fromBalance: Balance {
if from == .xmr, let account = selectedXmrAccount {
return Balance(total: account.balance,
unlocked: account.unlocked,
funded: (Decimal(string: account.balance) ?? 0) > 0)
}
if from == model.chain { return model.balance }
return WalletStore.cachedState(from)?.balance ?? Balance(total: "0", unlocked: "", funded: false)
}
private var fromBalanceText: String {
let value = Decimal(string: fromBalance.total) ?? 0
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.usesGroupingSeparator = false
// Monero has 12 decimals; rounding a balance up here would make "Max"
// exceed what the account can spend.
formatter.maximumFractionDigits = from == .xmr ? 12 : 6
return formatter.string(from: value as NSDecimalNumber) ?? "0"
}
/// Quoted routes worth considering, best payout first.
private var routes: [SwapRoute] {
(estimate?.routes ?? []).filter { $0.amountTo > 0 }.sorted { $0.amountTo > $1.amountTo }
}
private var selectedRoute: SwapRoute? {
routes.first { $0.id == selectedRouteID } ?? routes.first
}
/// One-line summary of everything kyc.rip knows about a route.
private func routeFacts(_ route: SwapRoute) -> String {
var facts: [String] = []
if let eta = route.eta { facts.append("~\(eta) min") }
facts.append(route.fixedRate ? "fixed rate" : "floating rate")
if let spread = route.spread { facts.append(String(format: "spread %+.2f%%", spread)) }
if let kyc = route.kycRating { facts.append("KYC \(kyc)") }
if let log = route.logPolicy { facts.append("logs \(log)") }
if let insurance = route.insurance, insurance > 0 { facts.append("insured \(insurance)%") }
if let min = route.minDeposit { facts.append("min \(trim(min)) \(from.symbol)") }
if let max = route.maxDeposit { facts.append("max \(trim(max)) \(from.symbol)") }
return facts.joined(separator: " · ")
}
private func rowFacts(_ route: SwapRoute) -> String {
var facts: [String] = []
if let spread = route.spread { facts.append(String(format: "%+.2f%%", spread)) }
if let kyc = route.kycRating { facts.append("KYC \(kyc)") }
facts.append(route.fixedRate ? "fixed" : "float")
return facts.joined(separator: " · ")
}
private func trim(_ value: Double) -> String {
String(format: "%.6g", value)
}
// MARK: - Swap status
private func statusTitle(_ status: String) -> String {
switch status.uppercased() {
case "WAITING", "NEW": return "Waiting for your deposit"
case "CONFIRMING": return "Deposit detected"
case "EXCHANGING": return "Exchanging"
case "SENDING": return "Sending to your wallet"
case "FINISHED", "SUCCESS": return "Swap complete"
case "EXPIRED": return "Order expired"
case "FAILED": return "Swap failed"
case "REFUNDED": return "Refunded"
case "OVERDUE": return "Deposit overdue"
default: return "Swap status"
}
}
private func statusDetail(_ status: String, order: SwapOrder) -> String {
switch status.uppercased() {
case "WAITING", "NEW":
return "Send your \(order.fromTicker) deposit to the address below."
case "CONFIRMING":
return "Your \(order.fromTicker) arrived. Waiting for network confirmations."
case "EXCHANGING":
return "The provider is converting \(order.fromTicker) to \(order.toTicker)."
case "SENDING":
return "The provider is sending \(order.toTicker) to your wallet."
case "FINISHED", "SUCCESS":
return "\(order.toTicker) was sent to your wallet."
case "EXPIRED":
return "The deposit window closed. If you already sent funds, contact \(order.provider) with the order ID."
case "FAILED":
return "The provider could not complete this swap. Check the order with \(order.provider)."
case "REFUNDED":
return "The provider returned your funds to the refund address."
case "OVERDUE":
return "The deposit arrived after the window closed. Check the order with \(order.provider)."
default:
return "Last known status from \(order.provider)."
}
}
/// Position in the five-step lifecycle, or nil for failure states.
private func statusStep(_ status: String) -> Int? {
switch status.uppercased() {
case "WAITING", "NEW": return 0
case "CONFIRMING": return 1
case "EXCHANGING": return 2
case "SENDING": return 3
case "FINISHED", "SUCCESS": return 4
default: return nil
}
}
private func isTerminal(_ status: String?) -> Bool {
guard let status else { return false }
return ["FINISHED", "SUCCESS", "FAILED", "EXPIRED", "REFUNDED", "OVERDUE"]
.contains(status.uppercased())
}
private func needsDeposit(_ status: String) -> Bool {
["WAITING", "NEW"].contains(status.uppercased())
}
/// Refreshes an open order immediately, then every 20 seconds until it
/// reaches a final state. Cancelled automatically when the view goes away.
private func monitorOrder() async {
guard let order = createdOrder else { return }
if !isTerminal(order.status) {
createdOrder = await model.refreshSwapStatus(order)
}
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 20 * 1_000_000_000)
guard !Task.isCancelled, let current = createdOrder, !isTerminal(current.status) else { return }
createdOrder = await model.refreshSwapStatus(current)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Swap",
subtitle: "XMR ↔ XRP, via kyc.rip — no account, no KYC",
onBack: { route = .home })
if let order = createdOrder {
orderCard(order)
} else {
swapForm
}
if !model.swapOrders.isEmpty {
recentSwaps
}
}
.task {
from = model.chain
if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex }
model.loadSwapOrders()
}
.task(id: createdOrder?.id) {
await monitorOrder()
}
.onChange(of: model.moneroAccounts.count) { _ in
if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex }
}
}
// MARK: - The form
private var swapForm: some View {
VStack(alignment: .leading, spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("From")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text("Balance: \(fromBalanceText) \(from.symbol)")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
HStack(spacing: 10) {
KoinsTextField(placeholder: "Amount", text: $amount, keyboard: .decimalPad)
Button("Max") { amount = fromBalanceText }
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.accent)
}
HStack(spacing: 10) {
Text(from.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.text)
Spacer()
Button {
from = to
amount = ""
estimate = nil
} label: {
Image(systemName: "arrow.up.arrow.down")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 34, height: 34)
.background(Theme.surfaceAlt)
.clipShape(Circle())
}
.accessibilityLabel("Swap direction")
}
if from == .xmr, model.moneroAccounts.count > 1 {
MoneroAccountPicker(accounts: model.moneroAccounts,
selection: $xmrAccountIndex,
onSelect: { xmrAccountPicked = true })
}
Divider().overlay(Theme.border)
HStack {
Text("To")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(to.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.text)
}
if let estimate {
VStack(alignment: .leading, spacing: 4) {
Text("≈ \(String(format: "%.6f", selectedRoute?.amountTo ?? estimate.amountTo)) \(to.symbol)")
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
if let route = selectedRoute {
Text(routeFacts(route))
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
if !routes.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Pick a provider")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.muted)
ForEach(routes.prefix(4)) { route in
routeRow(route)
}
}
.padding(.top, 6)
}
} else {
Text("Enter an amount and get a quote")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
}
if let localError {
MessageBanner(message: localError, isError: true)
}
if let status = model.statusMessage {
MessageBanner(message: status, isError: false)
}
if estimate == nil {
PrimaryButton(title: estimating ? "Getting quote…" : "Get quote", disabled: estimating) {
Task { await loadQuote() }
}
} else {
PrimaryButton(title: creating ? "Creating…" : "Swap", disabled: creating) {
Task { await create() }
}
SecondaryButton(title: "Change amount") { estimate = nil }
}
Spacer(minLength: 0)
}
}
private func routeRow(_ route: SwapRoute) -> some View {
let selected = route.id == selectedRoute?.id
return Button {
selectedRouteID = route.id
} label: {
HStack(spacing: 10) {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(selected ? Theme.accent : Theme.muted)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(route.provider)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.text)
if route.recommended {
Text("Recommended")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(Theme.accent)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Theme.accentSoft)
.clipShape(Capsule())
}
}
Text(rowFacts(route))
.font(.system(size: 10))
.foregroundStyle(Theme.muted)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(String(format: "%.6f", route.amountTo))
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(Theme.text)
Text(route.eta.map { "~\($0) min" } ?? "")
.font(.system(size: 10))
.foregroundStyle(Theme.muted)
}
}
.padding(10)
.background(selected ? Theme.accentSoft : Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(selected ? Theme.accent.opacity(0.6) : Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
}
// MARK: - The order
private func orderCard(_ order: SwapOrder) -> some View {
let status = order.status ?? "WAITING"
let alreadySent = order.depositSent == true
let canSend = needsDeposit(status) && !alreadySent
return VStack(alignment: .leading, spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(statusTitle(status))
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.text)
Spacer()
SwapStatusBadge(status: status)
}
Text(alreadySent && needsDeposit(status)
? "Deposit sent — waiting for \(order.provider) to see it."
: statusDetail(status, order: order))
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
if let step = statusStep(status) {
HStack(spacing: 4) {
ForEach(0..<5, id: \.self) { index in
Capsule()
.fill(index <= step ? Theme.accent : Theme.surfaceAlt)
.frame(height: 4)
}
}
.padding(.vertical, 2)
}
if !isTerminal(status) {
Text("Updates automatically every 20 seconds.")
.font(.system(size: 10))
.foregroundStyle(Theme.muted)
}
if needsDeposit(status) {
Text(verbatim: alreadySent
? "Your \(order.depositAmount) \(order.fromTicker) deposit was sent to this address:"
: "Send exactly \(order.depositAmount) \(order.fromTicker) to this address:")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
.padding(.top, 4)
HStack(spacing: 8) {
Text(order.depositAddress)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
Button {
UIPasteboard.general.string = order.depositAddress
} label: {
Image(systemName: "doc.on.doc")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 34, height: 34)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.disabled(order.depositAddress.isEmpty)
Button {
drawers.qr = AddressQR(title: "Deposit \(order.fromTicker)", address: order.depositAddress)
} label: {
Image(systemName: "qrcode")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 34, height: 34)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.disabled(order.depositAddress.isEmpty)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
if let memo = order.depositMemo, !memo.isEmpty {
HStack {
Text("Tag")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(memo)
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(Theme.text)
}
}
}
HStack {
Text("You get")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text("≈ \(order.toAmount, specifier: "%.6g") \(order.toTicker)")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.success)
}
if let eta = order.eta {
HStack {
Text("Usually takes")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text("~\(eta) min")
.font(.system(size: 13))
.foregroundStyle(Theme.text)
}
}
HStack {
Text("Provider")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(order.provider)
.font(.system(size: 13))
.foregroundStyle(Theme.text)
}
if order.fromAmount > 0 {
HStack {
Text("Rate")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text("1 \(order.fromTicker) ≈ \(order.toAmount / order.fromAmount, specifier: "%.4f") \(order.toTicker)")
.font(.system(size: 13))
.foregroundStyle(Theme.text)
}
}
if let txIn = order.txIn, !txIn.isEmpty {
txHashRow("Deposit tx", hash: txIn, ticker: order.fromTicker)
}
if let txOut = order.txOut, !txOut.isEmpty {
txHashRow("Payout tx", hash: txOut, ticker: order.toTicker)
}
HStack {
Text("Opened")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(order.createdAt, format: Date.FormatStyle(date: .abbreviated, time: .shortened))
.font(.system(size: 12))
.foregroundStyle(Theme.text)
}
HStack {
Text("Order ID")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(order.id)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 160, alignment: .trailing)
Button {
UIPasteboard.general.string = order.id
} label: {
Image(systemName: "doc.on.doc")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.accent)
}
}
if let supportUrl = order.supportUrl, let url = URL(string: supportUrl) {
HStack {
Text("Support")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Link("Contact \(order.provider)", destination: url)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.accent)
}
}
}
}
if canSend, order.fromTicker.uppercased() == "XMR", model.moneroAccounts.count > 1 {
MoneroAccountPicker(accounts: model.moneroAccounts,
selection: $xmrAccountIndex,
onSelect: { xmrAccountPicked = true })
}
if needsDeposit(status) {
if alreadySent {
// The deposit was already broadcast; re-sending would double-pay.
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.success)
Text("Deposit sent")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
} else {
PrimaryButton(title: sendingDeposit ? "Sending…" : "Send deposit", disabled: sendingDeposit) {
Task { await sendDeposit(order) }
}
}
}
SecondaryButton(title: "Check status now") {
Task { createdOrder = await model.refreshSwapStatus(order) }
}
if let orderPage = URL(string: "https://kyc.rip/swap?id=\(order.id)") {
LinkButton(title: "View on kyc.rip", systemImage: "globe", url: orderPage)
}
Button {
confirmRemoveOrder = true
} label: {
Text("Remove this order")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
.frame(maxWidth: .infinity)
}
if let localError {
MessageBanner(message: localError, isError: true)
}
Spacer(minLength: 0)
}
.alert("Remove this swap from history?", isPresented: $confirmRemoveOrder) {
Button("Remove", role: .destructive) {
model.removeSwapOrder(id: order.id)
createdOrder = nil
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Only the local record is removed. If you already sent the deposit, \(order.provider) still has the order.")
}
}
/// A truncated, copyable transaction hash row for the order card, with a
/// link to the right block explorer when the ticker is known.
private func txHashRow(_ label: String, hash: String, ticker: String) -> some View {
HStack {
Text(label)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(hash)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 160, alignment: .trailing)
Button {
UIPasteboard.general.string = hash
} label: {
Image(systemName: "doc.on.doc")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.accent)
}
if let explorer = BlockExplorer.transactionURL(ticker: ticker, hash: hash) {
Link(destination: explorer) {
Image(systemName: "safari")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.accent)
}
}
}
}
private var recentSwaps: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Recent swaps")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
ForEach(model.swapOrders.prefix(6)) { order in
Button {
createdOrder = order
} label: {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text("\(order.fromAmount, specifier: "%.6g") \(order.fromTicker) → \(order.toAmount, specifier: "%.6g") \(order.toTicker)")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.text)
Text(order.createdAt, style: .date)
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
Spacer()
SwapStatusBadge(status: order.status ?? "WAITING")
}
.padding(12)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.contextMenu {
Button(role: .destructive) {
if createdOrder?.id == order.id { createdOrder = nil }
model.removeSwapOrder(id: order.id)
} label: {
Label("Remove from history", systemImage: "trash")
}
}
}
}
}
// MARK: - Actions
private func loadQuote() async {
localError = nil
estimating = true
defer { estimating = false }
do {
estimate = try await model.estimateSwap(amount: amount, from: from, to: to)
if routes.isEmpty {
localError = "No provider offers this swap right now"
estimate = nil
} else {
// Default to the best payout; kyc.rip's "recommended" flag is shown
// but not preselected because it can be rev-share driven.
selectedRouteID = routes.first?.id
}
} catch {
localError = error.localizedDescription
}
}
private func create() async {
guard let route = selectedRoute, let estimate else { return }
localError = nil
// Respect the provider's deposit window so the order can't be created
// with an amount it would refuse.
let value = Double(amount) ?? 0
if let min = route.minDeposit, value < min {
localError = "\(route.provider) needs at least \(trim(min)) \(from.symbol)"
return
}
if let max = route.maxDeposit, value > max {
localError = "\(route.provider) takes at most \(trim(max)) \(from.symbol)"
return
}
creating = true
defer { creating = false }
do {
createdOrder = try await model.createSwap(route: route,
estimateID: estimate.id,
amountFrom: amount,
from: from,
to: to)
} catch {
localError = error.localizedDescription
}
}
private func sendDeposit(_ order: SwapOrder) async {
localError = nil
sendingDeposit = true
defer { sendingDeposit = false }
do {
try await model.sendSwapDeposit(order, accountIndex: xmrAccountIndex)
// Record that the deposit left this device so the button can never be
// pressed twice for the same order.
var sent = order
sent.depositSent = true
model.saveSwapOrder(sent)
createdOrder = sent
await model.refresh(force: true)
createdOrder = await model.refreshSwapStatus(sent)
} catch {
localError = error.localizedDescription
}
}
}
struct SwapStatusBadge: View {
var status: String
private var colour: Color {
switch status.uppercased() {
case "FINISHED", "SUCCESS": return Theme.success
case "WAITING", "NEW", "CONFIRMING", "EXCHANGING", "SENDING": return Theme.accent
case "REFUNDED": return Theme.muted
case "FAILED", "EXPIRED", "OVERDUE": return Theme.danger
default: return Theme.muted
}
}
var body: some View {
Text(status.lowercased().capitalized)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(colour)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(colour.opacity(0.15))
.clipShape(Capsule())
}
}
@@ -0,0 +1,166 @@
import SwiftUI
/// Full detail for one transaction. Monero and XRP fill different fields, so
/// rows are included only when the wallet knows the value.
struct TransactionDetailView: View {
var record: TransactionRecord
var symbol: String
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
formatter.timeStyle = .medium
return formatter
}
/// A Monero incoming transfer only reveals the wallet's own receiving
/// address (the sender is hidden), so it is not labelled "From".
private var counterpartyLabel: String {
if chain == .xmr, record.direction == .incoming { return "Received at" }
return record.direction == .incoming ? "From" : "To"
}
private var statusText: String {
if record.direction == .incoming {
if let confirmations = record.confirmations, confirmations > 0 {
return "Confirmed · \(confirmations) confirmation\(confirmations == 1 ? "" : "s")"
}
return record.confirmed ? "Confirmed" : "Waiting for confirmation"
}
if let confirmations = record.confirmations, confirmations > 0 {
return "Confirmed · \(confirmations) confirmation\(confirmations == 1 ? "" : "s")"
}
return record.confirmed ? "Confirmed" : "Pending"
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: record.direction == .incoming ? "arrow.down.left" : "arrow.up.right")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(record.direction == .incoming ? Theme.success : Theme.accent)
Text(record.direction == .incoming ? "Received" : "Sent")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.text)
}
Text("\(record.direction == .incoming ? "+" : "-")\(record.amount) \(symbol)")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
Text(statusText)
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
Card {
VStack(alignment: .leading, spacing: 12) {
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)")
}
if let counterparty = record.counterparty, !counterparty.isEmpty {
DetailRow(label: counterpartyLabel, value: counterparty, monospaced: true, copyable: true)
}
if let tag = record.tag {
DetailRow(label: "Destination tag", value: String(tag))
}
if let height = record.height ?? record.ledgerIndex {
DetailRow(label: chain == .xmr ? "Block height" : "Ledger index", value: String(height))
}
if let confirmations = record.confirmations {
DetailRow(label: "Confirmations", value: String(confirmations))
}
if let accountIndex = record.accountIndex {
DetailRow(label: "Account", value: accountIndex == 0 ? "Primary (0)" : "Account \(accountIndex + 1) (\(accountIndex))")
}
if let subaddressIndex = record.subaddressIndex {
DetailRow(label: "Address index", value: "\(subaddressIndex)")
}
if let sequence = record.sequence {
DetailRow(label: "Sequence", value: String(sequence))
}
if let paymentId = record.paymentId {
DetailRow(label: "Payment ID", value: paymentId, monospaced: true, copyable: true)
}
if let weight = record.weight {
DetailRow(label: "Weight", value: "\(weight) bytes")
}
if let note = record.note {
DetailRow(label: "Note", value: note)
}
DetailRow(label: chain == .xmr ? "Transaction ID" : "Ledger hash",
value: record.id,
monospaced: true,
copyable: true)
}
}
if let explorer = BlockExplorer.transactionURL(chain: chain, hash: record.id) {
LinkButton(title: "View on explorer", url: explorer)
}
PrimaryButton(title: "Done", action: onDone)
}
.padding(20)
}
.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
}
}
}
struct DetailRow: View {
var label: String
var value: String
var monospaced = false
var copyable = false
@State private var copied = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
Text(label)
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
.frame(width: 108, alignment: .leading)
Text(value)
.font(.system(size: 12, design: monospaced ? .monospaced : .default))
.foregroundStyle(Theme.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
if copyable {
Button {
UIPasteboard.general.string = value
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
} label: {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.accent)
}
}
}
}
}
+106 -3
View File
@@ -158,11 +158,43 @@ final class MoneroService {
_ = try? await invoke(["op": "closeWallet"])
}
/// Writes the wallet file, which is how scan progress survives a restart.
func save() async {
_ = try? await invoke(["op": "saveWallet"])
}
func seed() async throws -> String {
let response = try await invoke(["op": "getSeed"])
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 {
@@ -187,8 +219,77 @@ final class MoneroService {
confirmed: (entry["confirmed"] as? Bool) ?? false,
fee: MoneroAmount.xmr(fromAtomic: entry["fee"] as? String ?? "0"),
tag: nil,
counterparty: nil)
counterparty: entry["counterparty"] as? String,
confirmations: (entry["confirmations"] as? NSNumber)?.uint64Value,
height: (entry["height"] as? NSNumber)?.uint64Value,
paymentId: (entry["paymentId"] as? String).flatMap { $0.isEmpty ? nil : $0 },
accountIndex: (entry["accountIndex"] as? NSNumber)?.intValue,
subaddressIndex: (entry["subaddressIndex"] as? NSNumber)?.intValue,
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
func accounts() async throws -> [MoneroAccount] {
let response = try await invoke(["op": "getAccounts"])
let entries = response["accounts"] as? [[String: Any]] ?? []
return entries.map { entry in
let subEntries = entry["subaddresses"] as? [[String: Any]] ?? []
return MoneroAccount(
index: (entry["index"] as? NSNumber)?.intValue ?? 0,
address: entry["address"] as? String ?? "",
balance: MoneroAmount.xmr(fromAtomic: entry["balance"] as? String ?? "0"),
unlocked: MoneroAmount.xmr(fromAtomic: entry["unlocked"] as? String ?? "0"),
subaddresses: subEntries.map { sub in
MoneroSubaddress(
accountIndex: (sub["accountIndex"] as? NSNumber)?.intValue ?? 0,
index: (sub["index"] as? NSNumber)?.intValue ?? 0,
address: sub["address"] as? String ?? "",
label: sub["label"] as? String ?? "",
balance: MoneroAmount.xmr(fromAtomic: sub["balance"] as? String ?? "0"),
unlocked: MoneroAmount.xmr(fromAtomic: sub["unlocked"] as? String ?? "0"),
isUsed: (sub["isUsed"] as? Bool) ?? false)
})
}
}
func createAccount(label: String) async throws -> MoneroAccount {
let response = try await invoke(["op": "createAccount", "label": label])
return MoneroAccount(index: (response["index"] as? NSNumber)?.intValue ?? 0,
address: response["address"] as? String ?? "",
balance: MoneroAmount.xmr(fromAtomic: response["balance"] as? String ?? "0"),
unlocked: MoneroAmount.xmr(fromAtomic: response["unlocked"] as? String ?? "0"),
subaddresses: [])
}
func createSubaddress(accountIndex: Int, label: String) async throws -> MoneroSubaddress {
let response = try await invoke(["op": "createSubaddress",
"accountIndex": accountIndex,
"label": label])
return MoneroSubaddress(accountIndex: (response["accountIndex"] as? NSNumber)?.intValue ?? accountIndex,
index: (response["index"] as? NSNumber)?.intValue ?? 0,
address: response["address"] as? String ?? "",
label: response["label"] as? String ?? label,
balance: "0",
unlocked: "0",
isUsed: false)
}
// MARK: - Sync
@@ -212,12 +313,14 @@ final class MoneroService {
// MARK: - Sends
func send(address: String, amountXMR: String, priority: TransactionPriority) async throws -> (hash: String, fee: String) {
func send(address: String, amountXMR: String, priority: TransactionPriority,
accountIndex: Int = 0) async throws -> (hash: String, fee: String) {
let atomic = try MoneroAmount.atomic(fromXMR: amountXMR)
let response = try await invoke(["op": "transferTo",
"address": address.trimmingCharacters(in: .whitespacesAndNewlines),
"amount": String(atomic),
"priority": priority.rawValue])
"priority": priority.rawValue,
"accountIndex": accountIndex])
return (response["txHash"] as? String ?? "",
MoneroAmount.xmr(fromAtomic: response["fee"] as? String ?? "0"))
}
+232
View File
@@ -0,0 +1,232 @@
import Foundation
/// A route offered by one of kyc.rip's underlying exchange engines.
struct SwapRoute: Identifiable {
var id: String { "\(provider).\(engine).\(amountTo)" }
var provider: String
var engine: String
var amountFrom: Double
var amountTo: Double
var eta: Int? // minutes
var fixedRate: Bool
var spread: Double? // percent above/below the market rate
var kycRating: String? // A/B/C privacy grade from the aggregator
var logPolicy: String? // the provider's logging policy grade
var insurance: Int? // percent of the swap insured by the provider
var minDeposit: Double?
var maxDeposit: Double?
var recommended: Bool
}
struct SwapEstimate {
var id: String
var amountTo: Double
var routes: [SwapRoute]
}
/// A created swap order, persisted so it can be tracked after the app restarts.
struct SwapOrder: Codable, Identifiable {
var id: String
var engine: String
var provider: String
var depositAddress: String
var depositAmount: Double
var depositMemo: String?
var fromTicker: String
var fromAmount: Double
var toTicker: String
var toAmount: Double
var addressTo: String
var createdAt: Date
var eta: Int?
/// Latest-known lifecycle state (refreshed from the API).
var status: String?
/// Transaction hashes reported by the provider once the swap progresses.
var txIn: String?
var txOut: String?
/// Provider support link, reported by the status endpoint when available.
var supportUrl: String?
/// Set once the deposit has been broadcast from this device, so the
/// "Send deposit" button can never be pressed twice for the same order.
var depositSent: Bool?
}
/// Latest-known state of an order, as reported by the status endpoint.
struct SwapOrderUpdate {
var status: String
var txIn: String?
var txOut: String?
var supportUrl: String?
}
enum SwapError: LocalizedError {
case http(Int)
case api(String)
case noRoute
var errorDescription: String? {
switch self {
case .http(let code): return "Swap service unavailable (\(code))"
case .api(let message): return message
case .noRoute: return "No provider offers this swap right now"
}
}
}
/// kyc.rip (https://api.kyc.rip), a public no-KYC swap aggregator. No account,
/// no API key; the response is used directly.
final class SwapService {
static let shared = SwapService()
private let base = "https://api.kyc.rip"
private let session: URLSession
private init() {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 25
session = URLSession(configuration: configuration)
}
// MARK: - Estimates
func estimate(from: String, to: String, amount: Double) async throws -> SwapEstimate {
var components = URLComponents(string: "\(base)/v2/exchange/estimate")
components?.queryItems = [
URLQueryItem(name: "from", value: from),
URLQueryItem(name: "from_net", value: "Mainnet"),
URLQueryItem(name: "to", value: to),
URLQueryItem(name: "to_net", value: "Mainnet"),
URLQueryItem(name: "amount", value: String(amount)),
]
guard let url = components?.url else { throw SwapError.noRoute }
let (data, response) = try await session.data(from: url)
try check(response)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw SwapError.noRoute
}
if let error = json["error"] as? String { throw SwapError.api(error) }
let amountTo = (json["amount_to"] as? NSNumber)?.doubleValue ?? 0
let routes = (json["routes"] as? [[String: Any]] ?? []).map { entry in
SwapRoute(
provider: entry["provider"] as? String ?? "",
engine: entry["engine"] as? String ?? "",
amountFrom: (entry["amount_from"] as? NSNumber)?.doubleValue ?? 0,
amountTo: (entry["amount_to"] as? NSNumber)?.doubleValue ?? 0,
eta: (entry["eta"] as? NSNumber)?.intValue,
fixedRate: (entry["fixed"] as? Bool) ?? false,
spread: (entry["spread"] as? NSNumber)?.doubleValue,
kycRating: entry["kyc"] as? String,
logPolicy: entry["log_policy"] as? String,
insurance: (entry["insurance"] as? NSNumber)?.intValue,
minDeposit: (entry["min_deposit"] as? NSNumber)?.doubleValue,
maxDeposit: (entry["max_deposit"] as? NSNumber)?.doubleValue,
recommended: (entry["recommended"] as? Bool) ?? false)
}
return SwapEstimate(id: json["id"] as? String ?? "", amountTo: amountTo, routes: routes)
}
// MARK: - Orders
func createOrder(route: SwapRoute,
estimateID: String,
from: String,
to: String,
addressTo: String,
amountFrom: Double) async throws -> SwapOrder {
guard let url = URL(string: "\(base)/v2/exchange/create") else { throw SwapError.noRoute }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = [
"provider": route.provider,
"engine": route.engine,
"from_currency": from,
"from_network": "Mainnet",
"to_currency": to,
"to_network": "Mainnet",
"address_to": addressTo,
"amount_from": amountFrom,
"amount_to": route.amountTo,
"fixed_rate": route.fixedRate,
"source": "swap",
"isPayment": false,
]
// The create endpoint expects the quote it was estimated against.
if !estimateID.isEmpty { body["trade_id"] = estimateID }
if let kyc = route.kycRating { body["kyc_rating"] = kyc }
if let log = route.logPolicy { body["log_policy"] = log }
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
try check(response)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw SwapError.noRoute
}
if let error = json["error"] as? String { throw SwapError.api(error) }
if let code = json["code"] as? String { throw SwapError.api(code) }
guard let id = json["id"] as? String, !id.isEmpty else { throw SwapError.noRoute }
return SwapOrder(
id: id,
engine: json["engine"] as? String ?? route.engine,
provider: json["provider"] as? String ?? route.provider,
depositAddress: json["depositAddress"] as? String ?? "",
depositAmount: (json["depositAmount"] as? NSNumber)?.doubleValue ?? amountFrom,
depositMemo: json["depositMemo"] as? String,
fromTicker: (json["fromTicker"] as? String ?? from).uppercased(),
fromAmount: (json["fromAmount"] as? NSNumber)?.doubleValue ?? amountFrom,
toTicker: (json["toTicker"] as? String ?? to).uppercased(),
toAmount: (json["toAmount"] as? NSNumber)?.doubleValue ?? 0,
addressTo: json["address_user"] as? String ?? addressTo,
createdAt: Date(),
eta: (json["eta"] as? NSNumber)?.intValue,
status: json["status"] as? String,
supportUrl: nonEmpty(json["supportUrl"] as? String))
}
func orderStatus(_ order: SwapOrder) async throws -> SwapOrderUpdate {
var components = URLComponents(string: "\(base)/v2/exchange/status/\(order.id)")
components?.queryItems = [URLQueryItem(name: "engine", value: order.engine)]
guard let url = components?.url else { throw SwapError.noRoute }
let (data, response) = try await session.data(from: url)
try check(response)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw SwapError.noRoute
}
if let error = json["error"] as? String { throw SwapError.api(error) }
let txIn = nonEmpty(json["txIn"] as? String)
?? (json["txIn"] as? [String: Any]).flatMap { nonEmpty($0["hash"] as? String) }
let txOut = nonEmpty(json["txOut"] as? String)
?? (json["txOut"] as? [String: Any]).flatMap { nonEmpty($0["hash"] as? String) }
return SwapOrderUpdate(status: json["status"] as? String ?? "UNKNOWN",
txIn: txIn,
txOut: txOut,
supportUrl: nonEmpty(json["supportUrl"] as? String))
}
/// The API sends blank strings while a hash is still unknown; treat those as
/// absent so the UI does not render empty rows.
private func nonEmpty(_ value: String?) -> String? {
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return value
}
private func check(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else { return }
if http.statusCode == 429 { throw SwapError.api("Rate limited — try again in a minute") }
guard (200..<300).contains(http.statusCode) else { throw SwapError.http(http.statusCode) }
}
}
+6 -2
View File
@@ -87,9 +87,13 @@ final class XRPWalletService {
confirmed: (entry["validated"] as? Bool) ?? false,
fee: (tx["Fee"] as? String).map { XRPAmount.xrp(fromDrops: $0) },
tag: (tx["DestinationTag"] as? NSNumber)?.intValue,
counterparty: isIncoming ? (tx["Account"] as? String) : destination))
counterparty: isIncoming ? (tx["Account"] as? String) : destination,
height: (entry["ledger_index"] as? NSNumber)?.uint64Value,
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
+9 -2
View File
@@ -18,13 +18,18 @@ targets:
type: application
platform: iOS
sources:
- Koins
- path: Koins
excludes:
# Static archives are linked via LIBRARY_SEARCH_PATHS, not bundled.
- "Monero/libs"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.koins.app
INFOPLIST_FILE: Koins/Info.plist
CODE_SIGN_STYLE: Automatic
CODE_SIGNING_ALLOWED: NO
DEVELOPMENT_TEAM: RX6767S6BW
CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
SWIFT_OBJC_BRIDGING_HEADER: Koins/Bridge/Koins-Bridging-Header.h
OTHER_LDFLAGS[sdk=iphoneos*]:
- "$(inherited)"
@@ -61,6 +66,8 @@ targets:
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
UIUserInterfaceStyle: Dark
NSCameraUsageDescription: Scan a QR code to fill in a payment address.
NSFaceIDUsageDescription: Unlock Koins and approve payments.
NSAppTransportSecurity:
NSAllowsArbitraryLoads: true