Compare commits
27
Commits
6d50566f37
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ff5385a9b | ||
|
|
2f70931cba | ||
|
|
e67ed9e063 | ||
|
|
c9679e35c4 | ||
|
|
7e739e1b77 | ||
|
|
4fa1584dfc | ||
|
|
31429e6923 | ||
|
|
782143e87b | ||
|
|
994ba5ed6b | ||
|
|
e3846fe881 | ||
|
|
edfefb6210 | ||
|
|
58d862161e | ||
|
|
0b1b253d30 | ||
|
|
78e1cefad4 | ||
|
|
b989b0f791 | ||
|
|
2728bf161e | ||
|
|
51dc007107 | ||
|
|
27dc122632 | ||
|
|
e9720391b7 | ||
|
|
c0903f4f02 | ||
|
|
399502cf72 | ||
|
|
e53c6aa7a9 | ||
|
|
021672b47c | ||
|
|
caca4321cc | ||
|
|
1a23c730c6 | ||
|
|
807388ea6b | ||
|
|
f4037a188a |
@@ -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 |
@@ -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 |
@@ -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")
|
||||||
@@ -14,6 +14,21 @@ final class AppModel: ObservableObject {
|
|||||||
@Published var statusMessage: String?
|
@Published var statusMessage: String?
|
||||||
@Published var moneroReady = false
|
@Published var moneroReady = false
|
||||||
@Published var moneroAddress: String?
|
@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.
|
/// Rough time remaining for the Monero sync, derived from observed block rate.
|
||||||
@Published var syncEta: String?
|
@Published var syncEta: String?
|
||||||
|
|
||||||
@@ -21,6 +36,10 @@ final class AppModel: ObservableObject {
|
|||||||
private var refreshTick = 0
|
private var refreshTick = 0
|
||||||
private var refreshTask: Task<Void, Never>?
|
private var refreshTask: Task<Void, Never>?
|
||||||
private var lastSample: (height: UInt64, date: Date)?
|
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? {
|
var wallet: WalletRecord? {
|
||||||
chain == .xrp ? xrpWallet : xmrWallet
|
chain == .xrp ? xrpWallet : xmrWallet
|
||||||
@@ -36,6 +55,150 @@ final class AppModel: ObservableObject {
|
|||||||
|
|
||||||
var symbol: String { chain.symbol }
|
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
|
// MARK: - Bootstrap
|
||||||
|
|
||||||
func bootstrap() async {
|
func bootstrap() async {
|
||||||
@@ -49,11 +212,21 @@ final class AppModel: ObservableObject {
|
|||||||
if let xmrWallet {
|
if let xmrWallet {
|
||||||
await openMonero(afterRestore: false, wallet: 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
|
chain = xrpWallet != nil ? .xrp : .xmr
|
||||||
}
|
}
|
||||||
|
if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable {
|
||||||
|
locked = true
|
||||||
|
}
|
||||||
|
loadSwapOrders()
|
||||||
|
showCached(chain)
|
||||||
await refresh(force: true)
|
await refresh(force: true)
|
||||||
startAutoRefresh()
|
startAutoRefresh()
|
||||||
|
Task { await refreshPrices() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func adoptMoneroWallet(named name: String) async {
|
private func adoptMoneroWallet(named name: String) async {
|
||||||
@@ -91,11 +264,15 @@ final class AppModel: ObservableObject {
|
|||||||
|
|
||||||
func select(_ newChain: Chain) async {
|
func select(_ newChain: Chain) async {
|
||||||
chain = newChain
|
chain = newChain
|
||||||
|
UserDefaults.standard.set(newChain.rawValue, forKey: "koins.chain")
|
||||||
|
refreshToken = UUID()
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
statusMessage = nil
|
statusMessage = nil
|
||||||
balance = Balance(total: "0", unlocked: "", funded: false)
|
balance = Balance(total: "0", unlocked: "", funded: false)
|
||||||
transactions = []
|
transactions = []
|
||||||
|
moneroAccounts = []
|
||||||
syncStatus = nil
|
syncStatus = nil
|
||||||
|
showCached(newChain)
|
||||||
|
|
||||||
if newChain == .xmr, let xmrWallet, !moneroOpened {
|
if newChain == .xmr, let xmrWallet, !moneroOpened {
|
||||||
await openMonero(afterRestore: false, wallet: xmrWallet)
|
await openMonero(afterRestore: false, wallet: xmrWallet)
|
||||||
@@ -198,20 +375,30 @@ final class AppModel: ObservableObject {
|
|||||||
|
|
||||||
func refresh(force: Bool = false) async {
|
func refresh(force: Bool = false) async {
|
||||||
guard hasWallet else { return }
|
guard hasWallet else { return }
|
||||||
|
let token = UUID()
|
||||||
|
refreshToken = token
|
||||||
|
let requestChain = chain
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
await refreshPrices()
|
||||||
if chain == .xrp, let wallet = xrpWallet {
|
if chain == .xrp, let wallet = xrpWallet {
|
||||||
do {
|
do {
|
||||||
async let balanceCall = XRPWalletService.shared.balance(address: wallet.address)
|
async let balanceCall = XRPWalletService.shared.balance(address: wallet.address)
|
||||||
async let txCall = XRPWalletService.shared.transactions(address: wallet.address)
|
async let txCall = XRPWalletService.shared.transactions(address: wallet.address)
|
||||||
balance = try await balanceCall
|
let fetchedBalance = try await balanceCall
|
||||||
transactions = try await txCall
|
let fetchedTransactions = try await txCall
|
||||||
|
guard !isStale(token, requestChain) else { return }
|
||||||
|
balance = fetchedBalance
|
||||||
|
transactions = fetchedTransactions
|
||||||
|
persistState(for: requestChain)
|
||||||
} catch {
|
} catch {
|
||||||
|
guard !isStale(token, requestChain) else { return }
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
// The Monero wallet keeps scanning while XRP is on screen, so keep its
|
// The Monero wallet keeps scanning while XRP is on screen, so keep its
|
||||||
// progress current here too.
|
// progress current here too.
|
||||||
if moneroReady, let status = try? await MoneroService.shared.syncStatus() {
|
if moneroReady, let status = try? await MoneroService.shared.syncStatus() {
|
||||||
updateSyncStatus(status)
|
updateSyncStatus(status)
|
||||||
|
await checkpointWalletIfSyncing()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -220,19 +407,37 @@ final class AppModel: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
// Sync progress is cheap and polled every tick; the balance and transfer
|
// 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.
|
// 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
|
refreshTick += 1
|
||||||
|
await checkpointWalletIfSyncing()
|
||||||
if force || refreshTick == 1 || refreshTick % 4 == 0 {
|
if force || refreshTick == 1 || refreshTick % 4 == 0 {
|
||||||
async let balanceCall = MoneroService.shared.balance()
|
async let balanceCall = MoneroService.shared.balance()
|
||||||
async let txCall = MoneroService.shared.transactions()
|
async let txCall = MoneroService.shared.transactions()
|
||||||
balance = try await balanceCall
|
async let accountsCall = MoneroService.shared.accounts()
|
||||||
transactions = try await txCall
|
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 {
|
} catch {
|
||||||
|
guard !isStale(token, requestChain) else { return }
|
||||||
errorMessage = error.localizedDescription
|
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.
|
/// Tracks how fast blocks are being scanned so the UI can show an estimate.
|
||||||
private func updateSyncStatus(_ status: SyncStatus) {
|
private func updateSyncStatus(_ status: SyncStatus) {
|
||||||
syncStatus = status
|
syncStatus = status
|
||||||
@@ -277,6 +482,7 @@ final class AppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func revealSeed() async throws -> String {
|
func revealSeed() async throws -> String {
|
||||||
|
try await requireAuth(reason: "Reveal your recovery phrase")
|
||||||
if chain == .xrp {
|
if chain == .xrp {
|
||||||
guard let seed = WalletStore.secret(.xrp) else {
|
guard let seed = WalletStore.secret(.xrp) else {
|
||||||
throw XRPError.rpc("No stored secret for this wallet")
|
throw XRPError.rpc("No stored secret for this wallet")
|
||||||
@@ -289,7 +495,9 @@ final class AppModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Sends
|
// 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))
|
let destinationTag = UInt32(tag.trimmingCharacters(in: .whitespaces))
|
||||||
if chain == .xrp {
|
if chain == .xrp {
|
||||||
guard let seed = WalletStore.secret(.xrp) else {
|
guard let seed = WalletStore.secret(.xrp) else {
|
||||||
@@ -304,7 +512,10 @@ final class AppModel: ObservableObject {
|
|||||||
await refresh(force: true)
|
await refresh(force: true)
|
||||||
return
|
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"
|
statusMessage = "Payment sent"
|
||||||
await refresh(force: true)
|
await refresh(force: true)
|
||||||
}
|
}
|
||||||
@@ -334,4 +545,44 @@ final class AppModel: ObservableObject {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
statusMessage = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,22 @@ static std::string walletPath(NSString *name) {
|
|||||||
return std::string([dir stringByAppendingPathComponent:name].UTF8String);
|
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 monero_wallet_full *g_wallet = nullptr;
|
||||||
|
static std::string g_walletPath;
|
||||||
static NSString *g_daemonURL = @"xmr-node.cakewallet.com:18081";
|
static NSString *g_daemonURL = @"xmr-node.cakewallet.com:18081";
|
||||||
static NSArray<NSString *> *g_fallbackDaemons = nil;
|
static NSArray<NSString *> *g_fallbackDaemons = nil;
|
||||||
static std::atomic<bool> s_cachedConnected(false);
|
static std::atomic<bool> s_cachedConnected(false);
|
||||||
@@ -82,6 +97,8 @@ static void connectDaemon(monero_wallet_full *w) {
|
|||||||
connectDaemon(g_wallet);
|
connectDaemon(g_wallet);
|
||||||
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
|
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
|
||||||
try { resp[@"mnemonic"] = [NSString stringWithUTF8String:g_wallet->get_seed().c_str()]; } catch (...) {}
|
try { resp[@"mnemonic"] = [NSString stringWithUTF8String:g_wallet->get_seed().c_str()]; } catch (...) {}
|
||||||
|
g_walletPath = path;
|
||||||
|
excludeWalletFilesFromBackup(g_walletPath);
|
||||||
} else {
|
} else {
|
||||||
monero_wallet_config config;
|
monero_wallet_config config;
|
||||||
config.m_path = path;
|
config.m_path = path;
|
||||||
@@ -94,6 +111,8 @@ static void connectDaemon(monero_wallet_full *w) {
|
|||||||
connectDaemon(w);
|
connectDaemon(w);
|
||||||
resp[@"address"] = [NSString stringWithUTF8String:w->get_address(0, 0).c_str()];
|
resp[@"address"] = [NSString stringWithUTF8String:w->get_address(0, 0).c_str()];
|
||||||
resp[@"mnemonic"] = [NSString stringWithUTF8String:w->get_seed().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"]) {
|
} else if ([op isEqualToString:@"openWallet"] || [op isEqualToString:@"restoreWallet"]) {
|
||||||
if (g_wallet) {
|
if (g_wallet) {
|
||||||
@@ -118,6 +137,8 @@ static void connectDaemon(monero_wallet_full *w) {
|
|||||||
g_wallet = monero_wallet_full::create_wallet(config, nullptr);
|
g_wallet = monero_wallet_full::create_wallet(config, nullptr);
|
||||||
}
|
}
|
||||||
connectDaemon(g_wallet);
|
connectDaemon(g_wallet);
|
||||||
|
g_walletPath = walletPath(req[@"name"]);
|
||||||
|
excludeWalletFilesFromBackup(g_walletPath);
|
||||||
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
|
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
|
||||||
} else if ([op isEqualToString:@"getSeed"]) {
|
} else if ([op isEqualToString:@"getSeed"]) {
|
||||||
if (g_wallet) {
|
if (g_wallet) {
|
||||||
@@ -145,6 +166,18 @@ static void connectDaemon(monero_wallet_full *w) {
|
|||||||
delete g_wallet;
|
delete g_wallet;
|
||||||
g_wallet = nullptr;
|
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"]) {
|
} else if ([op isEqualToString:@"getBalance"]) {
|
||||||
if (g_wallet) {
|
if (g_wallet) {
|
||||||
uint64_t balance = 0, unlocked = 0;
|
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 &tx = transfer->m_tx;
|
||||||
const auto isIn = transfer->is_incoming();
|
const auto isIn = transfer->is_incoming();
|
||||||
boost::optional<uint64_t> txHeight = tx ? tx->get_height() : boost::none;
|
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()],
|
NSMutableDictionary *entry = [NSMutableDictionary dictionary];
|
||||||
@"amount" : @((transfer->m_amount ? *transfer->m_amount : 0)).stringValue,
|
entry[@"hash"] = [NSString stringWithUTF8String:((tx && tx->m_hash) ? *tx->m_hash : "").c_str()];
|
||||||
@"timestamp" : @((tx && tx->m_received_timestamp) ? *tx->m_received_timestamp : 0),
|
entry[@"amount"] = @((transfer->m_amount ? *transfer->m_amount : 0)).stringValue;
|
||||||
@"direction" : (isIn && *isIn) ? @"in" : @"out",
|
entry[@"timestamp"] = @((tx && tx->m_received_timestamp) ? *tx->m_received_timestamp : 0);
|
||||||
@"confirmed" : @((tx && tx->m_is_confirmed) ? *tx->m_is_confirmed : false),
|
entry[@"direction"] = (isIn && *isIn) ? @"in" : @"out";
|
||||||
@"height" : @(txHeight ? *txHeight : 0),
|
entry[@"confirmed"] = @((tx && tx->m_is_confirmed) ? *tx->m_is_confirmed : false);
|
||||||
@"fee" : @((tx && tx->m_fee) ? *tx->m_fee : 0).stringValue,
|
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) {
|
} catch (const std::exception &e) {
|
||||||
os_log_error(OS_LOG_DEFAULT, "[koins] getTransactions: %{public}s", e.what());
|
os_log_error(OS_LOG_DEFAULT, "[koins] getTransactions: %{public}s", e.what());
|
||||||
}
|
}
|
||||||
resp[@"transactions"] = entries;
|
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"]) {
|
} else if ([op isEqualToString:@"startSync"]) {
|
||||||
if (g_wallet) {
|
if (g_wallet) {
|
||||||
connectDaemon(g_wallet);
|
connectDaemon(g_wallet);
|
||||||
@@ -248,11 +360,11 @@ static void connectDaemon(monero_wallet_full *w) {
|
|||||||
destination->m_amount = (uint64_t)[req[@"amount"] longLongValue];
|
destination->m_amount = (uint64_t)[req[@"amount"] longLongValue];
|
||||||
config.m_destinations = { destination };
|
config.m_destinations = { destination };
|
||||||
config.m_priority = (monero_tx_priority)[req[@"priority"] integerValue];
|
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;
|
config.m_relay = true;
|
||||||
auto txs = g_wallet->create_txs(config);
|
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[@"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;
|
resp[@"fee"] = @((txs[0]->m_fee ? *txs[0]->m_fee : 0)).stringValue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</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>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
|||||||
@@ -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 {
|
struct WalletRecord: Codable, Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
var name: String
|
var name: String
|
||||||
@@ -18,12 +38,12 @@ struct WalletRecord: Codable, Identifiable, Equatable {
|
|||||||
var createdAt: Date
|
var createdAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Direction {
|
enum Direction: String, Codable {
|
||||||
case incoming
|
case incoming
|
||||||
case outgoing
|
case outgoing
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TransactionRecord: Identifiable {
|
struct TransactionRecord: Identifiable, Codable {
|
||||||
let id: String
|
let id: String
|
||||||
let amount: String
|
let amount: String
|
||||||
let timestamp: Date?
|
let timestamp: Date?
|
||||||
@@ -32,14 +52,33 @@ struct TransactionRecord: Identifiable {
|
|||||||
var fee: String?
|
var fee: String?
|
||||||
var tag: Int?
|
var tag: Int?
|
||||||
var counterparty: String?
|
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 total: String
|
||||||
var unlocked: String
|
var unlocked: String
|
||||||
var funded: Bool
|
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 {
|
struct SyncStatus {
|
||||||
var height: UInt64
|
var height: UInt64
|
||||||
var daemonHeight: UInt64
|
var daemonHeight: UInt64
|
||||||
@@ -67,3 +106,29 @@ enum TransactionPriority: Int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
@@ -9,20 +9,34 @@ enum SelfTest {
|
|||||||
|
|
||||||
/// Writes to both stdout and the unified log so results are visible however
|
/// Writes to both stdout and the unified log so results are visible however
|
||||||
/// the app was launched.
|
/// the app was launched.
|
||||||
|
private static var report: [String] = []
|
||||||
|
|
||||||
private static func emit(_ line: String) {
|
private static func emit(_ line: String) {
|
||||||
print(line)
|
print(line)
|
||||||
os_log("%{public}@", log: log, type: .default, 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() {
|
static func runIfRequested() {
|
||||||
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTest") {
|
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTest") {
|
||||||
print("KOINS_SELFTEST begin")
|
emit("KOINS_SELFTEST begin")
|
||||||
run()
|
run()
|
||||||
print("KOINS_SELFTEST end")
|
emit("KOINS_SELFTEST end")
|
||||||
}
|
}
|
||||||
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestNetwork") {
|
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestNetwork") {
|
||||||
runNetwork()
|
runNetwork()
|
||||||
}
|
}
|
||||||
|
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestSwap") {
|
||||||
|
runSwapCheckIfRequested()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// End-to-end check of the live network paths (XRP RPC + native Monero
|
/// 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()
|
let balance = try await MoneroService.shared.balance()
|
||||||
emit("KOINS_NET xmr balance \(balance.total) unlocked \(balance.unlocked)")
|
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.stopSync()
|
||||||
await MoneroService.shared.removeWallet(name: "koins-net-selftest")
|
await MoneroService.shared.removeWallet(name: "koins-net-selftest")
|
||||||
} catch {
|
} catch {
|
||||||
@@ -88,6 +112,24 @@ enum SelfTest {
|
|||||||
|
|
||||||
restoreWallets()
|
restoreWallets()
|
||||||
emit("KOINS_NET end")
|
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) {
|
func expect(_ name: String, _ actual: String, _ wanted: String) {
|
||||||
let ok = actual.lowercased() == wanted.lowercased()
|
let ok = actual.lowercased() == wanted.lowercased()
|
||||||
if !ok { failures += 1 }
|
if !ok { failures += 1 }
|
||||||
let line = "KOINS_SELFTEST \(name): \(ok ? "PASS" : "FAIL") \(actual)"
|
emit("KOINS_SELFTEST \(name): \(ok ? "PASS" : "FAIL") \(actual)")
|
||||||
print(line)
|
}
|
||||||
os_log("%{public}@", log: log, type: .default, line)
|
|
||||||
|
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))),
|
expect("ripemd160", Hex.encode(RIPEMD160.hash(Array("abc".utf8))),
|
||||||
@@ -114,7 +159,7 @@ enum SelfTest {
|
|||||||
expect("ed25519 address", keypair.address, edAddress)
|
expect("ed25519 address", keypair.address, edAddress)
|
||||||
} else {
|
} else {
|
||||||
failures += 1
|
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.
|
// secp256k1 vector: exercises the OpenSSL-backed derivation on device.
|
||||||
@@ -128,7 +173,7 @@ enum SelfTest {
|
|||||||
expect("secp256k1 address", keypair.address, secpAddress)
|
expect("secp256k1 address", keypair.address, secpAddress)
|
||||||
} catch {
|
} catch {
|
||||||
failures += 1
|
failures += 1
|
||||||
print("KOINS_SELFTEST secp256k1: FAIL \(error)")
|
emit("KOINS_SELFTEST secp256k1: FAIL \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serialization + hashing reference vector.
|
// Serialization + hashing reference vector.
|
||||||
@@ -189,12 +234,12 @@ enum SelfTest {
|
|||||||
expect("bip32 level \(level)", Hex.encode(child.privateKey), expected)
|
expect("bip32 level \(level)", Hex.encode(child.privateKey), expected)
|
||||||
} else {
|
} else {
|
||||||
failures += 1
|
failures += 1
|
||||||
print("KOINS_SELFTEST bip32 level \(level): FAIL derivation returned nil")
|
emit("KOINS_SELFTEST bip32 level \(level): FAIL derivation returned nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
failures += 1
|
failures += 1
|
||||||
print("KOINS_SELFTEST bip32 master: FAIL")
|
emit("KOINS_SELFTEST bip32 master: FAIL")
|
||||||
}
|
}
|
||||||
|
|
||||||
let mnemonicCases: [(String, String, String)] = [
|
let mnemonicCases: [(String, String, String)] = [
|
||||||
@@ -212,10 +257,61 @@ enum SelfTest {
|
|||||||
expect("bip44 address #\(position)", keypair.address, vector.2)
|
expect("bip44 address #\(position)", keypair.address, vector.2)
|
||||||
} catch {
|
} catch {
|
||||||
failures += 1
|
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.
|
// secp256k1 signing must produce a low-S DER signature.
|
||||||
if let keypair = try? XRPKey.keypair(fromSeed: secpSeed),
|
if let keypair = try? XRPKey.keypair(fromSeed: secpSeed),
|
||||||
let message = Hex.decode(signingHex),
|
let message = Hex.decode(signingHex),
|
||||||
@@ -227,15 +323,14 @@ enum SelfTest {
|
|||||||
let s = DER.signatureScalar(signature) {
|
let s = DER.signatureScalar(signature) {
|
||||||
expect("secp256k1 signature is low-S", s.lessThanOrEqual(half) ? "yes" : "no", "yes")
|
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 {
|
} else {
|
||||||
failures += 1
|
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)")"
|
emit("KOINS_SELFTEST summary: \(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")")
|
||||||
print(summary)
|
flushReport()
|
||||||
os_log("%{public}@", log: log, type: .default, summary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,9 +63,49 @@ enum WalletStore {
|
|||||||
|
|
||||||
static func removeWallet(_ chain: Chain) {
|
static func removeWallet(_ chain: Chain) {
|
||||||
defaults.removeObject(forKey: walletKey(chain))
|
defaults.removeObject(forKey: walletKey(chain))
|
||||||
|
defaults.removeObject(forKey: cacheKey(chain))
|
||||||
Keychain.remove(secretKey(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? {
|
static func secret(_ chain: Chain) -> String? {
|
||||||
Keychain.get(secretKey(chain))
|
Keychain.get(secretKey(chain))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
struct Card<Content: View>: View {
|
||||||
@ViewBuilder var content: Content
|
@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 {
|
struct QRCodeView: View {
|
||||||
var text: String
|
var text: String
|
||||||
|
|
||||||
@@ -166,15 +234,7 @@ struct QRCodeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func makeImage(from text: String) -> UIImage? {
|
private static func makeImage(from text: String) -> UIImage? {
|
||||||
guard !text.isEmpty else { return nil }
|
QRCodeImage.make(text)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,12 +242,18 @@ struct TransactionRow: View {
|
|||||||
var record: TransactionRecord
|
var record: TransactionRecord
|
||||||
var symbol: String
|
var symbol: String
|
||||||
|
|
||||||
private var formattedDate: String {
|
private var subtitle: String {
|
||||||
guard let timestamp = record.timestamp else { return "Pending" }
|
if let timestamp = record.timestamp {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateStyle = .medium
|
formatter.dateStyle = .medium
|
||||||
formatter.timeStyle = .short
|
formatter.timeStyle = .short
|
||||||
return formatter.string(from: timestamp)
|
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 {
|
var body: some View {
|
||||||
@@ -205,7 +271,7 @@ struct TransactionRow: View {
|
|||||||
Text(record.direction == .incoming ? "Received" : "Sent")
|
Text(record.direction == .incoming ? "Received" : "Sent")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.foregroundStyle(Theme.text)
|
.foregroundStyle(Theme.text)
|
||||||
Text(record.confirmed ? formattedDate : "Pending confirmation")
|
Text(subtitle)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(Theme.muted)
|
.foregroundStyle(Theme.muted)
|
||||||
}
|
}
|
||||||
@@ -224,6 +290,9 @@ struct TransactionRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,3 +327,52 @@ struct ScreenHeader: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@EnvironmentObject private var model: AppModel
|
@EnvironmentObject private var model: AppModel
|
||||||
|
@EnvironmentObject private var drawers: DrawerCoordinator
|
||||||
@Binding var route: Route
|
@Binding var route: Route
|
||||||
|
|
||||||
@State private var showSeed = false
|
@State private var showSeed = false
|
||||||
@@ -22,6 +24,11 @@ struct DashboardView: View {
|
|||||||
Text("\(model.balance.total) \(model.symbol)")
|
Text("\(model.balance.total) \(model.symbol)")
|
||||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(Theme.text)
|
.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 {
|
if model.chain == .xmr, model.balance.unlocked != model.balance.total {
|
||||||
Text("\(model.balance.unlocked) \(model.symbol) spendable now")
|
Text("\(model.balance.unlocked) \(model.symbol) spendable now")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
@@ -63,9 +70,14 @@ struct DashboardView: View {
|
|||||||
Text("Activity")
|
Text("Activity")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
.foregroundStyle(Theme.muted)
|
.foregroundStyle(Theme.muted)
|
||||||
ForEach(model.transactions) { record in
|
ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in
|
||||||
|
Button {
|
||||||
|
drawers.transaction = record
|
||||||
|
} label: {
|
||||||
TransactionRow(record: record, symbol: model.symbol)
|
TransactionRow(record: record, symbol: model.symbol)
|
||||||
if record.id != model.transactions.last?.id {
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
if index < model.transactions.count - 1 {
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,7 +124,12 @@ struct SendView: View {
|
|||||||
@State private var amount = ""
|
@State private var amount = ""
|
||||||
@State private var tag = ""
|
@State private var tag = ""
|
||||||
@State private var priority: TransactionPriority = .defaultPriority
|
@State private var priority: TransactionPriority = .defaultPriority
|
||||||
|
@State private var accountIndex = 0
|
||||||
|
@State private var accountPicked = false
|
||||||
@State private var localError: String?
|
@State private var localError: String?
|
||||||
|
@State private var status: String?
|
||||||
|
@State private var showScanner = false
|
||||||
|
@State private var photoItem: PhotosPickerItem?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
@@ -120,6 +137,26 @@ struct SendView: View {
|
|||||||
subtitle: "Balance \(model.balance.total) \(model.symbol)",
|
subtitle: "Balance \(model.balance.total) \(model.symbol)",
|
||||||
onBack: { route = .home })
|
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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address")
|
FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address")
|
||||||
KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination)
|
KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination)
|
||||||
@@ -152,6 +189,9 @@ struct SendView: View {
|
|||||||
if let message = localError ?? model.errorMessage {
|
if let message = localError ?? model.errorMessage {
|
||||||
MessageBanner(message: message, isError: true)
|
MessageBanner(message: message, isError: true)
|
||||||
}
|
}
|
||||||
|
if let message = status {
|
||||||
|
MessageBanner(message: message, isError: false)
|
||||||
|
}
|
||||||
if let message = model.statusMessage {
|
if let message = model.statusMessage {
|
||||||
MessageBanner(message: message, isError: false)
|
MessageBanner(message: message, isError: false)
|
||||||
}
|
}
|
||||||
@@ -161,7 +201,11 @@ struct SendView: View {
|
|||||||
localError = nil
|
localError = nil
|
||||||
model.clearMessages()
|
model.clearMessages()
|
||||||
do {
|
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 = ""
|
amount = ""
|
||||||
tag = ""
|
tag = ""
|
||||||
route = .dashboard
|
route = .dashboard
|
||||||
@@ -173,6 +217,71 @@ struct SendView: View {
|
|||||||
|
|
||||||
Spacer(minLength: 0)
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ enum Route: Equatable {
|
|||||||
case dashboard
|
case dashboard
|
||||||
case send
|
case send
|
||||||
case receive
|
case receive
|
||||||
|
case accounts
|
||||||
|
case swap
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SeedBox: Identifiable {
|
struct SeedBox: Identifiable {
|
||||||
@@ -17,8 +19,19 @@ struct SeedBox: Identifiable {
|
|||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@EnvironmentObject private var model: AppModel
|
@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?
|
@State private var seedBox: SeedBox?
|
||||||
|
@StateObject private var drawers = DrawerCoordinator()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -38,6 +51,10 @@ struct RootView: View {
|
|||||||
SendView(route: $route)
|
SendView(route: $route)
|
||||||
case .receive:
|
case .receive:
|
||||||
ReceiveView(route: $route)
|
ReceiveView(route: $route)
|
||||||
|
case .accounts:
|
||||||
|
AccountsView(route: $route)
|
||||||
|
case .swap:
|
||||||
|
SwapView(route: $route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -51,16 +68,54 @@ struct RootView: View {
|
|||||||
.sheet(item: $seedBox) { box in
|
.sheet(item: $seedBox) { box in
|
||||||
SeedRevealView(seed: box.seed, symbol: box.symbol) { seedBox = nil }
|
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
|
.onChange(of: model.chain) { _ in
|
||||||
if route == .dashboard { route = .home }
|
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 {
|
struct HomeView: View {
|
||||||
@EnvironmentObject private var model: AppModel
|
@EnvironmentObject private var model: AppModel
|
||||||
|
@EnvironmentObject private var drawers: DrawerCoordinator
|
||||||
@Binding var route: Route
|
@Binding var route: Route
|
||||||
@State private var showDeleteConfirm = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 18) {
|
VStack(spacing: 18) {
|
||||||
@@ -81,22 +136,10 @@ struct HomeView: View {
|
|||||||
MessageBanner(message: message, isError: false)
|
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 {
|
private var header: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Koins")
|
Text("Koins")
|
||||||
.font(.system(size: 30, weight: .heavy))
|
.font(.system(size: 30, weight: .heavy))
|
||||||
@@ -105,24 +148,35 @@ struct HomeView: View {
|
|||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundStyle(Theme.muted)
|
.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)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var walletCard: some View {
|
private var walletCard: some View {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
ChainPicker(selection: Binding(get: { model.chain },
|
|
||||||
set: { newValue in Task { await model.select(newValue) } }))
|
|
||||||
|
|
||||||
Card {
|
Card {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(model.wallet?.name ?? "Wallet")
|
Text(model.wallet?.name ?? "Wallet")
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.system(size: 16, weight: .semibold))
|
||||||
.foregroundStyle(Theme.text)
|
.foregroundStyle(Theme.text)
|
||||||
Spacer()
|
Text(model.chain.title)
|
||||||
if model.chain == .xmr, let status = model.syncStatus {
|
.font(.system(size: 11, weight: .medium))
|
||||||
SyncBadge(status: status)
|
.foregroundStyle(Theme.muted)
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
@@ -132,6 +186,11 @@ struct HomeView: View {
|
|||||||
Text("\(model.balance.total) \(model.symbol)")
|
Text("\(model.balance.total) \(model.symbol)")
|
||||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(Theme.text)
|
.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 {
|
if model.chain == .xmr, model.balance.unlocked != model.balance.total {
|
||||||
Text("\(model.balance.unlocked) \(model.symbol) unlocked")
|
Text("\(model.balance.unlocked) \(model.symbol) unlocked")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
@@ -139,48 +198,36 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if model.chain == .xrp {
|
||||||
AddressRow(address: model.displayAddress)
|
AddressRow(address: model.displayAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if model.chain == .xmr, let status = model.syncStatus {
|
if model.chain == .xmr, !model.moneroAccounts.isEmpty {
|
||||||
SyncProgress(status: status, eta: model.syncEta)
|
// 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) {
|
HStack(spacing: 10) {
|
||||||
PrimaryButton(title: "Send") { route = .send }
|
PrimaryButton(title: "Send") { route = .send }
|
||||||
SecondaryButton(title: "Receive") { route = .receive }
|
SecondaryButton(title: "Receive") { route = .receive }
|
||||||
|
SecondaryButton(title: "Swap") { route = .swap }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
IconButton(systemName: "arrow.clockwise") { Task { await model.refresh(force: true) } }
|
||||||
if model.chain == .xmr {
|
if model.chain == .xmr {
|
||||||
SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") {
|
IconButton(systemName: "person.2.fill") { route = .accounts }
|
||||||
Task { await model.startMoneroSync() }
|
|
||||||
}
|
}
|
||||||
}
|
Spacer()
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if model.chain == .xrp, model.moneroReady, let status = model.syncStatus, status.syncing {
|
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 {
|
private var moneroUnavailableCard: some View {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
ChainPicker(selection: Binding(get: { model.chain },
|
|
||||||
set: { newValue in Task { await model.select(newValue) } }))
|
|
||||||
Card {
|
Card {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Monero wallet")
|
Text("Monero wallet")
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,11 +158,43 @@ final class MoneroService {
|
|||||||
_ = try? await invoke(["op": "closeWallet"])
|
_ = 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 {
|
func seed() async throws -> String {
|
||||||
let response = try await invoke(["op": "getSeed"])
|
let response = try await invoke(["op": "getSeed"])
|
||||||
return response["mnemonic"] as? String ?? ""
|
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
|
// MARK: - Reads
|
||||||
|
|
||||||
func balance() async throws -> Balance {
|
func balance() async throws -> Balance {
|
||||||
@@ -187,8 +219,77 @@ final class MoneroService {
|
|||||||
confirmed: (entry["confirmed"] as? Bool) ?? false,
|
confirmed: (entry["confirmed"] as? Bool) ?? false,
|
||||||
fee: MoneroAmount.xmr(fromAtomic: entry["fee"] as? String ?? "0"),
|
fee: MoneroAmount.xmr(fromAtomic: entry["fee"] as? String ?? "0"),
|
||||||
tag: nil,
|
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
|
// MARK: - Sync
|
||||||
@@ -212,12 +313,14 @@ final class MoneroService {
|
|||||||
|
|
||||||
// MARK: - Sends
|
// 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 atomic = try MoneroAmount.atomic(fromXMR: amountXMR)
|
||||||
let response = try await invoke(["op": "transferTo",
|
let response = try await invoke(["op": "transferTo",
|
||||||
"address": address.trimmingCharacters(in: .whitespacesAndNewlines),
|
"address": address.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
"amount": String(atomic),
|
"amount": String(atomic),
|
||||||
"priority": priority.rawValue])
|
"priority": priority.rawValue,
|
||||||
|
"accountIndex": accountIndex])
|
||||||
return (response["txHash"] as? String ?? "",
|
return (response["txHash"] as? String ?? "",
|
||||||
MoneroAmount.xmr(fromAtomic: response["fee"] as? String ?? "0"))
|
MoneroAmount.xmr(fromAtomic: response["fee"] as? String ?? "0"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,9 +87,13 @@ final class XRPWalletService {
|
|||||||
confirmed: (entry["validated"] as? Bool) ?? false,
|
confirmed: (entry["validated"] as? Bool) ?? false,
|
||||||
fee: (tx["Fee"] as? String).map { XRPAmount.xrp(fromDrops: $0) },
|
fee: (tx["Fee"] as? String).map { XRPAmount.xrp(fromDrops: $0) },
|
||||||
tag: (tx["DestinationTag"] as? NSNumber)?.intValue,
|
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
|
// MARK: - Sends
|
||||||
|
|||||||
@@ -18,13 +18,18 @@ targets:
|
|||||||
type: application
|
type: application
|
||||||
platform: iOS
|
platform: iOS
|
||||||
sources:
|
sources:
|
||||||
- Koins
|
- path: Koins
|
||||||
|
excludes:
|
||||||
|
# Static archives are linked via LIBRARY_SEARCH_PATHS, not bundled.
|
||||||
|
- "Monero/libs"
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.koins.app
|
PRODUCT_BUNDLE_IDENTIFIER: com.koins.app
|
||||||
INFOPLIST_FILE: Koins/Info.plist
|
INFOPLIST_FILE: Koins/Info.plist
|
||||||
CODE_SIGN_STYLE: Automatic
|
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
|
SWIFT_OBJC_BRIDGING_HEADER: Koins/Bridge/Koins-Bridging-Header.h
|
||||||
OTHER_LDFLAGS[sdk=iphoneos*]:
|
OTHER_LDFLAGS[sdk=iphoneos*]:
|
||||||
- "$(inherited)"
|
- "$(inherited)"
|
||||||
@@ -61,6 +66,8 @@ targets:
|
|||||||
UISupportedInterfaceOrientations:
|
UISupportedInterfaceOrientations:
|
||||||
- UIInterfaceOrientationPortrait
|
- UIInterfaceOrientationPortrait
|
||||||
UIUserInterfaceStyle: Dark
|
UIUserInterfaceStyle: Dark
|
||||||
|
NSCameraUsageDescription: Scan a QR code to fill in a payment address.
|
||||||
|
NSFaceIDUsageDescription: Unlock Koins and approve payments.
|
||||||
NSAppTransportSecurity:
|
NSAppTransportSecurity:
|
||||||
NSAllowsArbitraryLoads: true
|
NSAllowsArbitraryLoads: true
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user