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 moneroReady = false
|
||||
@Published var moneroAddress: String?
|
||||
@Published var moneroAccounts: [MoneroAccount] = []
|
||||
@Published var prices = PriceService.Prices(currency: "USD")
|
||||
|
||||
/// Whether the app is behind the biometric lock screen right now.
|
||||
@Published var locked = false
|
||||
|
||||
/// Face ID/Touch ID lock. On by default when the device has biometrics;
|
||||
/// persisted across launches.
|
||||
var faceIDEnabled: Bool {
|
||||
get { UserDefaults.standard.object(forKey: "koins.faceID") as? Bool ?? true }
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: "koins.faceID")
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
/// Rough time remaining for the Monero sync, derived from observed block rate.
|
||||
@Published var syncEta: String?
|
||||
|
||||
@@ -21,6 +36,10 @@ final class AppModel: ObservableObject {
|
||||
private var refreshTick = 0
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private var lastSample: (height: UInt64, date: Date)?
|
||||
private var lastCheckpoint = Date.distantPast
|
||||
/// Invalidates in-flight refreshes: switching chains (or starting a newer
|
||||
/// refresh) must stop an older request from writing its result to the UI.
|
||||
private var refreshToken = UUID()
|
||||
|
||||
var wallet: WalletRecord? {
|
||||
chain == .xrp ? xrpWallet : xmrWallet
|
||||
@@ -36,6 +55,150 @@ final class AppModel: ObservableObject {
|
||||
|
||||
var symbol: String { chain.symbol }
|
||||
|
||||
// MARK: - Fiat display
|
||||
|
||||
/// The fiat currency the wallet shows; persisted, and changing it refetches
|
||||
/// the price cache.
|
||||
var fiatCurrency: String {
|
||||
get { UserDefaults.standard.string(forKey: "koins.fiatCurrency") ?? "USD" }
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: "koins.fiatCurrency")
|
||||
objectWillChange.send()
|
||||
Task { await refreshPrices() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The current chain's balance in the chosen fiat currency, when a price is
|
||||
/// known.
|
||||
var fiatBalance: String? {
|
||||
fiatValue(of: balance.total, chain: chain)
|
||||
}
|
||||
|
||||
/// Converts a chain amount into the chosen fiat currency, when a price is
|
||||
/// known.
|
||||
func fiatValue(of amount: String, chain: Chain) -> String? {
|
||||
let price = chain == .xrp ? prices.xrp : prices.xmr
|
||||
guard let price, let value = Decimal(string: amount) else { return nil }
|
||||
let fiat = value * Decimal(price)
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = fiatCurrency
|
||||
formatter.locale = Locale(identifier: "en_US")
|
||||
return formatter.string(from: fiat as NSDecimalNumber)
|
||||
}
|
||||
|
||||
private func refreshPrices() async {
|
||||
prices = await PriceService.shared.prices(currency: fiatCurrency)
|
||||
}
|
||||
|
||||
// MARK: - Biometric lock
|
||||
|
||||
// MARK: - Swaps (kyc.rip)
|
||||
|
||||
@Published var swapOrders: [SwapOrder] = []
|
||||
|
||||
func loadSwapOrders() {
|
||||
swapOrders = WalletStore.swapOrders()
|
||||
}
|
||||
|
||||
func estimateSwap(amount: String, from: Chain, to: Chain) async throws -> SwapEstimate {
|
||||
guard let amountNumber = Double(amount.trimmingCharacters(in: .whitespaces)),
|
||||
amountNumber > 0 else {
|
||||
throw SwapError.api("Enter a valid amount")
|
||||
}
|
||||
return try await SwapService.shared.estimate(from: from.rawValue, to: to.rawValue, amount: amountNumber)
|
||||
}
|
||||
|
||||
func createSwap(route: SwapRoute, estimateID: String, amountFrom: String,
|
||||
from: Chain, to: Chain) async throws -> SwapOrder {
|
||||
guard let amountNumber = Double(amountFrom.trimmingCharacters(in: .whitespaces)), amountNumber > 0 else {
|
||||
throw SwapError.api("Enter a valid amount")
|
||||
}
|
||||
guard let destination = (to == .xrp ? xrpWallet?.address : xmrWallet?.address), !destination.isEmpty else {
|
||||
throw SwapError.api("No \(to.title) wallet to receive into")
|
||||
}
|
||||
let order = try await SwapService.shared.createOrder(route: route,
|
||||
estimateID: estimateID,
|
||||
from: from.rawValue,
|
||||
to: to.rawValue,
|
||||
addressTo: destination,
|
||||
amountFrom: amountNumber)
|
||||
WalletStore.saveSwapOrder(order)
|
||||
loadSwapOrders()
|
||||
return order
|
||||
}
|
||||
|
||||
/// Sends the swap deposit using the same payment path as a normal send.
|
||||
func sendSwapDeposit(_ order: SwapOrder, accountIndex: Int = 0) async throws {
|
||||
try await send(to: order.depositAddress,
|
||||
amount: String(order.depositAmount),
|
||||
tag: order.depositMemo ?? "",
|
||||
priority: .defaultPriority,
|
||||
accountIndex: accountIndex)
|
||||
}
|
||||
|
||||
func refreshSwapStatus(_ order: SwapOrder) async -> SwapOrder {
|
||||
guard let updated = try? await SwapService.shared.orderStatus(order) else { return order }
|
||||
var order = order
|
||||
order.status = updated.status
|
||||
order.txIn = updated.txIn ?? order.txIn
|
||||
order.txOut = updated.txOut ?? order.txOut
|
||||
if order.txIn?.isEmpty == true { order.txIn = nil }
|
||||
if order.txOut?.isEmpty == true { order.txOut = nil }
|
||||
order.supportUrl = updated.supportUrl ?? order.supportUrl
|
||||
if let index = swapOrders.firstIndex(where: { $0.id == order.id }) {
|
||||
swapOrders[index] = order
|
||||
}
|
||||
WalletStore.saveSwapOrder(order)
|
||||
return order
|
||||
}
|
||||
|
||||
/// Persists a local change to an order (for example, the deposit was sent).
|
||||
func saveSwapOrder(_ order: SwapOrder) {
|
||||
WalletStore.saveSwapOrder(order)
|
||||
loadSwapOrders()
|
||||
}
|
||||
|
||||
func removeSwapOrder(id: String) {
|
||||
WalletStore.removeSwapOrder(id: id)
|
||||
loadSwapOrders()
|
||||
}
|
||||
|
||||
func authenticate() async {
|
||||
if await Biometrics.authenticate(reason: "Unlock Koins") {
|
||||
locked = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Requires biometrics for sensitive actions when the lock is enabled; a
|
||||
/// device without biometrics is not asked (the wallet is still usable).
|
||||
func requireAuth(reason: String) async throws {
|
||||
guard faceIDEnabled, Biometrics.isAvailable else { return }
|
||||
let ok = await Biometrics.authenticate(reason: reason)
|
||||
if !ok { throw XRPError.rpc("Authentication failed") }
|
||||
}
|
||||
|
||||
// MARK: - Cache
|
||||
|
||||
/// Restores the last-known state for the chain on screen instantly; the
|
||||
/// refresh that follows replaces it with live data.
|
||||
private func showCached(_ forChain: Chain) {
|
||||
guard let cached = WalletStore.cachedState(forChain) else { return }
|
||||
balance = cached.balance
|
||||
transactions = cached.transactions
|
||||
if let accounts = cached.accounts { moneroAccounts = accounts }
|
||||
}
|
||||
|
||||
private func persistState(for forChain: Chain) {
|
||||
guard (forChain == .xrp ? xrpWallet : xmrWallet) != nil else { return }
|
||||
WalletStore.saveCachedState(
|
||||
ChainCache(balance: balance,
|
||||
transactions: transactions,
|
||||
accounts: forChain == .xmr ? moneroAccounts : nil,
|
||||
savedAt: Date()),
|
||||
chain: forChain)
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap
|
||||
|
||||
func bootstrap() async {
|
||||
@@ -49,11 +212,21 @@ final class AppModel: ObservableObject {
|
||||
if let xmrWallet {
|
||||
await openMonero(afterRestore: false, wallet: xmrWallet)
|
||||
}
|
||||
if xrpWallet != nil || moneroReady {
|
||||
// Restore the chain the user was last on, as long as that wallet exists.
|
||||
let savedChain = UserDefaults.standard.string(forKey: "koins.chain").flatMap(Chain.init(rawValue:))
|
||||
if let savedChain, (savedChain == .xrp ? xrpWallet : xmrWallet) != nil {
|
||||
chain = savedChain
|
||||
} else if xrpWallet != nil || moneroReady {
|
||||
chain = xrpWallet != nil ? .xrp : .xmr
|
||||
}
|
||||
if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable {
|
||||
locked = true
|
||||
}
|
||||
loadSwapOrders()
|
||||
showCached(chain)
|
||||
await refresh(force: true)
|
||||
startAutoRefresh()
|
||||
Task { await refreshPrices() }
|
||||
}
|
||||
|
||||
private func adoptMoneroWallet(named name: String) async {
|
||||
@@ -91,11 +264,15 @@ final class AppModel: ObservableObject {
|
||||
|
||||
func select(_ newChain: Chain) async {
|
||||
chain = newChain
|
||||
UserDefaults.standard.set(newChain.rawValue, forKey: "koins.chain")
|
||||
refreshToken = UUID()
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
balance = Balance(total: "0", unlocked: "", funded: false)
|
||||
transactions = []
|
||||
moneroAccounts = []
|
||||
syncStatus = nil
|
||||
showCached(newChain)
|
||||
|
||||
if newChain == .xmr, let xmrWallet, !moneroOpened {
|
||||
await openMonero(afterRestore: false, wallet: xmrWallet)
|
||||
@@ -198,20 +375,30 @@ final class AppModel: ObservableObject {
|
||||
|
||||
func refresh(force: Bool = false) async {
|
||||
guard hasWallet else { return }
|
||||
let token = UUID()
|
||||
refreshToken = token
|
||||
let requestChain = chain
|
||||
errorMessage = nil
|
||||
await refreshPrices()
|
||||
if chain == .xrp, let wallet = xrpWallet {
|
||||
do {
|
||||
async let balanceCall = XRPWalletService.shared.balance(address: wallet.address)
|
||||
async let txCall = XRPWalletService.shared.transactions(address: wallet.address)
|
||||
balance = try await balanceCall
|
||||
transactions = try await txCall
|
||||
let fetchedBalance = try await balanceCall
|
||||
let fetchedTransactions = try await txCall
|
||||
guard !isStale(token, requestChain) else { return }
|
||||
balance = fetchedBalance
|
||||
transactions = fetchedTransactions
|
||||
persistState(for: requestChain)
|
||||
} catch {
|
||||
guard !isStale(token, requestChain) else { return }
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
// The Monero wallet keeps scanning while XRP is on screen, so keep its
|
||||
// progress current here too.
|
||||
if moneroReady, let status = try? await MoneroService.shared.syncStatus() {
|
||||
updateSyncStatus(status)
|
||||
await checkpointWalletIfSyncing()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -220,19 +407,37 @@ final class AppModel: ObservableObject {
|
||||
do {
|
||||
// Sync progress is cheap and polled every tick; the balance and transfer
|
||||
// list touch the wallet's data files, so they are refreshed less often.
|
||||
updateSyncStatus(try await MoneroService.shared.syncStatus())
|
||||
let status = try await MoneroService.shared.syncStatus()
|
||||
if !isStale(token, requestChain) {
|
||||
updateSyncStatus(status)
|
||||
}
|
||||
refreshTick += 1
|
||||
await checkpointWalletIfSyncing()
|
||||
if force || refreshTick == 1 || refreshTick % 4 == 0 {
|
||||
async let balanceCall = MoneroService.shared.balance()
|
||||
async let txCall = MoneroService.shared.transactions()
|
||||
balance = try await balanceCall
|
||||
transactions = try await txCall
|
||||
async let accountsCall = MoneroService.shared.accounts()
|
||||
let fetchedBalance = try await balanceCall
|
||||
let fetchedTransactions = try await txCall
|
||||
let fetchedAccounts = try await accountsCall
|
||||
guard !isStale(token, requestChain) else { return }
|
||||
balance = fetchedBalance
|
||||
transactions = fetchedTransactions
|
||||
moneroAccounts = fetchedAccounts
|
||||
persistState(for: requestChain)
|
||||
}
|
||||
} catch {
|
||||
guard !isStale(token, requestChain) else { return }
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a newer refresh has started or the user moved to another chain
|
||||
/// while this request was in flight.
|
||||
private func isStale(_ token: UUID, _ requestChain: Chain) -> Bool {
|
||||
token != refreshToken || requestChain != chain
|
||||
}
|
||||
|
||||
/// Tracks how fast blocks are being scanned so the UI can show an estimate.
|
||||
private func updateSyncStatus(_ status: SyncStatus) {
|
||||
syncStatus = status
|
||||
@@ -277,6 +482,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
func revealSeed() async throws -> String {
|
||||
try await requireAuth(reason: "Reveal your recovery phrase")
|
||||
if chain == .xrp {
|
||||
guard let seed = WalletStore.secret(.xrp) else {
|
||||
throw XRPError.rpc("No stored secret for this wallet")
|
||||
@@ -289,7 +495,9 @@ final class AppModel: ObservableObject {
|
||||
|
||||
// MARK: - Sends
|
||||
|
||||
func send(to: String, amount: String, tag: String, priority: TransactionPriority) async throws {
|
||||
func send(to: String, amount: String, tag: String, priority: TransactionPriority,
|
||||
accountIndex: Int = 0) async throws {
|
||||
try await requireAuth(reason: "Authorize this payment")
|
||||
let destinationTag = UInt32(tag.trimmingCharacters(in: .whitespaces))
|
||||
if chain == .xrp {
|
||||
guard let seed = WalletStore.secret(.xrp) else {
|
||||
@@ -304,7 +512,10 @@ final class AppModel: ObservableObject {
|
||||
await refresh(force: true)
|
||||
return
|
||||
}
|
||||
_ = try await MoneroService.shared.send(address: to, amountXMR: amount, priority: priority)
|
||||
_ = try await MoneroService.shared.send(address: to,
|
||||
amountXMR: amount,
|
||||
priority: priority,
|
||||
accountIndex: accountIndex)
|
||||
statusMessage = "Payment sent"
|
||||
await refresh(force: true)
|
||||
}
|
||||
@@ -334,4 +545,44 @@ final class AppModel: ObservableObject {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
}
|
||||
|
||||
/// Called when the app leaves the foreground: iOS can suspend or kill it at
|
||||
/// any point, so flush the wallet while there is still time.
|
||||
func persistWallet() async {
|
||||
guard moneroReady else { return }
|
||||
await MoneroService.shared.save()
|
||||
}
|
||||
|
||||
func addAccount(label: String = "") async throws {
|
||||
_ = try await MoneroService.shared.createAccount(label: label)
|
||||
await refresh(force: true)
|
||||
}
|
||||
|
||||
func addSubaddress(accountIndex: Int, label: String = "") async throws {
|
||||
_ = try await MoneroService.shared.createSubaddress(accountIndex: accountIndex, label: label)
|
||||
await refresh(force: true)
|
||||
}
|
||||
|
||||
/// The account a payment should leave from by default: the first one with
|
||||
/// spendable funds, otherwise the primary account.
|
||||
var defaultMoneroAccountIndex: Int {
|
||||
moneroAccounts.first { (Decimal(string: $0.unlocked) ?? 0) > 0 }?.index ?? 0
|
||||
}
|
||||
|
||||
/// Refreshes the account/subaddress tree independently of the active chain.
|
||||
func loadAccounts() async {
|
||||
guard moneroReady else { return }
|
||||
if let accounts = try? await MoneroService.shared.accounts() {
|
||||
moneroAccounts = accounts
|
||||
}
|
||||
}
|
||||
|
||||
/// monero-cpp only writes the wallet file from save(), and its sync loop
|
||||
/// never does, so without this a restart re-scans from the beginning.
|
||||
private func checkpointWalletIfSyncing() async {
|
||||
guard moneroReady, syncStatus?.syncing == true else { return }
|
||||
guard Date().timeIntervalSince(lastCheckpoint) >= 60 else { return }
|
||||
lastCheckpoint = Date()
|
||||
await MoneroService.shared.save()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,22 @@ static std::string walletPath(NSString *name) {
|
||||
return std::string([dir stringByAppendingPathComponent:name].UTF8String);
|
||||
}
|
||||
|
||||
// Keep wallet files out of iCloud/iTunes backups: the seed in the Keychain is
|
||||
// device-only and the file is rebuildable from it, so backing it up would only
|
||||
// consume the user's quota (it peaks near 120 MB while a wallet restored from
|
||||
// genesis scans). Must run after every write, since wallet2 rewrites these
|
||||
// files on each save.
|
||||
static void excludeWalletFilesFromBackup(const std::string &walletPathString) {
|
||||
NSString *base = [NSString stringWithUTF8String:walletPathString.c_str()];
|
||||
if (base.length == 0) return;
|
||||
for (NSString *suffix in @[ @"", @".keys", @".address.txt", @".mms" ]) {
|
||||
NSURL *url = [NSURL fileURLWithPath:[base stringByAppendingString:suffix]];
|
||||
[url setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil];
|
||||
}
|
||||
}
|
||||
|
||||
static monero_wallet_full *g_wallet = nullptr;
|
||||
static std::string g_walletPath;
|
||||
static NSString *g_daemonURL = @"xmr-node.cakewallet.com:18081";
|
||||
static NSArray<NSString *> *g_fallbackDaemons = nil;
|
||||
static std::atomic<bool> s_cachedConnected(false);
|
||||
@@ -82,6 +97,8 @@ static void connectDaemon(monero_wallet_full *w) {
|
||||
connectDaemon(g_wallet);
|
||||
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
|
||||
try { resp[@"mnemonic"] = [NSString stringWithUTF8String:g_wallet->get_seed().c_str()]; } catch (...) {}
|
||||
g_walletPath = path;
|
||||
excludeWalletFilesFromBackup(g_walletPath);
|
||||
} else {
|
||||
monero_wallet_config config;
|
||||
config.m_path = path;
|
||||
@@ -94,6 +111,8 @@ static void connectDaemon(monero_wallet_full *w) {
|
||||
connectDaemon(w);
|
||||
resp[@"address"] = [NSString stringWithUTF8String:w->get_address(0, 0).c_str()];
|
||||
resp[@"mnemonic"] = [NSString stringWithUTF8String:w->get_seed().c_str()];
|
||||
g_walletPath = path;
|
||||
excludeWalletFilesFromBackup(g_walletPath);
|
||||
}
|
||||
} else if ([op isEqualToString:@"openWallet"] || [op isEqualToString:@"restoreWallet"]) {
|
||||
if (g_wallet) {
|
||||
@@ -118,6 +137,8 @@ static void connectDaemon(monero_wallet_full *w) {
|
||||
g_wallet = monero_wallet_full::create_wallet(config, nullptr);
|
||||
}
|
||||
connectDaemon(g_wallet);
|
||||
g_walletPath = walletPath(req[@"name"]);
|
||||
excludeWalletFilesFromBackup(g_walletPath);
|
||||
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
|
||||
} else if ([op isEqualToString:@"getSeed"]) {
|
||||
if (g_wallet) {
|
||||
@@ -145,6 +166,18 @@ static void connectDaemon(monero_wallet_full *w) {
|
||||
delete g_wallet;
|
||||
g_wallet = nullptr;
|
||||
}
|
||||
g_walletPath.clear();
|
||||
} else if ([op isEqualToString:@"saveWallet"]) {
|
||||
// monero-cpp never persists while syncing, so scan progress survives a
|
||||
// restart only if this is called explicitly. save() pauses the sync
|
||||
// thread while it writes.
|
||||
if (g_wallet) {
|
||||
g_wallet->save();
|
||||
excludeWalletFilesFromBackup(g_walletPath);
|
||||
resp[@"saved"] = @YES;
|
||||
} else {
|
||||
resp[@"error"] = @"No wallet open";
|
||||
}
|
||||
} else if ([op isEqualToString:@"getBalance"]) {
|
||||
if (g_wallet) {
|
||||
uint64_t balance = 0, unlocked = 0;
|
||||
@@ -176,21 +209,100 @@ static void connectDaemon(monero_wallet_full *w) {
|
||||
const auto &tx = transfer->m_tx;
|
||||
const auto isIn = transfer->is_incoming();
|
||||
boost::optional<uint64_t> txHeight = tx ? tx->get_height() : boost::none;
|
||||
[entries addObject:@{
|
||||
@"hash" : [NSString stringWithUTF8String:((tx && tx->m_hash) ? *tx->m_hash : "").c_str()],
|
||||
@"amount" : @((transfer->m_amount ? *transfer->m_amount : 0)).stringValue,
|
||||
@"timestamp" : @((tx && tx->m_received_timestamp) ? *tx->m_received_timestamp : 0),
|
||||
@"direction" : (isIn && *isIn) ? @"in" : @"out",
|
||||
@"confirmed" : @((tx && tx->m_is_confirmed) ? *tx->m_is_confirmed : false),
|
||||
@"height" : @(txHeight ? *txHeight : 0),
|
||||
@"fee" : @((tx && tx->m_fee) ? *tx->m_fee : 0).stringValue,
|
||||
}];
|
||||
|
||||
NSMutableDictionary *entry = [NSMutableDictionary dictionary];
|
||||
entry[@"hash"] = [NSString stringWithUTF8String:((tx && tx->m_hash) ? *tx->m_hash : "").c_str()];
|
||||
entry[@"amount"] = @((transfer->m_amount ? *transfer->m_amount : 0)).stringValue;
|
||||
entry[@"timestamp"] = @((tx && tx->m_received_timestamp) ? *tx->m_received_timestamp : 0);
|
||||
entry[@"direction"] = (isIn && *isIn) ? @"in" : @"out";
|
||||
entry[@"confirmed"] = @((tx && tx->m_is_confirmed) ? *tx->m_is_confirmed : false);
|
||||
entry[@"height"] = @(txHeight ? *txHeight : 0);
|
||||
entry[@"fee"] = @((tx && tx->m_fee) ? *tx->m_fee : 0).stringValue;
|
||||
|
||||
// Extra detail for the transaction view.
|
||||
if (tx) {
|
||||
if (tx->m_payment_id) entry[@"paymentId"] = [NSString stringWithUTF8String:tx->m_payment_id->c_str()];
|
||||
if (tx->m_num_confirmations) entry[@"confirmations"] = @(*tx->m_num_confirmations);
|
||||
if (tx->m_weight) entry[@"weight"] = @(*tx->m_weight);
|
||||
if (tx->m_note) entry[@"note"] = [NSString stringWithUTF8String:tx->m_note->c_str()];
|
||||
if (tx->m_unlock_time) entry[@"unlockTime"] = @(*tx->m_unlock_time);
|
||||
}
|
||||
if (transfer->m_account_index) entry[@"accountIndex"] = @(*transfer->m_account_index);
|
||||
|
||||
if (isIn && *isIn) {
|
||||
auto incoming = std::dynamic_pointer_cast<monero_incoming_transfer>(transfer);
|
||||
if (incoming) {
|
||||
if (incoming->m_subaddress_index) entry[@"subaddressIndex"] = @(*incoming->m_subaddress_index);
|
||||
if (incoming->m_address) entry[@"counterparty"] = [NSString stringWithUTF8String:incoming->m_address->c_str()];
|
||||
}
|
||||
} else {
|
||||
auto outgoing = std::dynamic_pointer_cast<monero_outgoing_transfer>(transfer);
|
||||
if (outgoing) {
|
||||
if (!outgoing->m_addresses.empty()) entry[@"counterparty"] = [NSString stringWithUTF8String:outgoing->m_addresses.front().c_str()];
|
||||
if (!outgoing->m_subaddress_indices.empty()) entry[@"subaddressIndex"] = @(outgoing->m_subaddress_indices.front());
|
||||
}
|
||||
}
|
||||
|
||||
[entries addObject:entry];
|
||||
}
|
||||
} catch (const std::exception &e) {
|
||||
os_log_error(OS_LOG_DEFAULT, "[koins] getTransactions: %{public}s", e.what());
|
||||
}
|
||||
resp[@"transactions"] = entries;
|
||||
}
|
||||
} else if ([op isEqualToString:@"getAccounts"]) {
|
||||
NSMutableArray *accounts = [NSMutableArray new];
|
||||
if (!g_wallet) {
|
||||
resp[@"accounts"] = accounts;
|
||||
} else {
|
||||
for (const auto &account : g_wallet->get_accounts(true, "")) {
|
||||
NSMutableDictionary *accountDict = [NSMutableDictionary new];
|
||||
accountDict[@"index"] = @(account.m_index ? *account.m_index : 0);
|
||||
accountDict[@"address"] = [NSString stringWithUTF8String:(account.m_primary_address ? *account.m_primary_address : "").c_str()];
|
||||
accountDict[@"balance"] = @((account.m_balance ? *account.m_balance : 0)).stringValue;
|
||||
accountDict[@"unlocked"] = @((account.m_unlocked_balance ? *account.m_unlocked_balance : 0)).stringValue;
|
||||
NSMutableArray *subaddresses = [NSMutableArray new];
|
||||
for (const auto &sub : account.m_subaddresses) {
|
||||
[subaddresses addObject:@{
|
||||
@"accountIndex" : @(sub.m_account_index ? *sub.m_account_index : 0),
|
||||
@"index" : @(sub.m_index ? *sub.m_index : 0),
|
||||
@"address" : [NSString stringWithUTF8String:(sub.m_address ? *sub.m_address : "").c_str()],
|
||||
@"label" : [NSString stringWithUTF8String:(sub.m_label ? *sub.m_label : "").c_str()],
|
||||
@"balance" : @((sub.m_balance ? *sub.m_balance : 0)).stringValue,
|
||||
@"unlocked" : @((sub.m_unlocked_balance ? *sub.m_unlocked_balance : 0)).stringValue,
|
||||
@"isUsed" : @((sub.m_is_used ? *sub.m_is_used : false)),
|
||||
}];
|
||||
}
|
||||
accountDict[@"subaddresses"] = subaddresses;
|
||||
[accounts addObject:accountDict];
|
||||
}
|
||||
resp[@"accounts"] = accounts;
|
||||
}
|
||||
} else if ([op isEqualToString:@"createAccount"]) {
|
||||
if (!g_wallet) {
|
||||
resp[@"error"] = @"No wallet open";
|
||||
} else {
|
||||
monero_account account = g_wallet->create_account(std::string([req[@"label"] UTF8String]));
|
||||
g_wallet->save();
|
||||
excludeWalletFilesFromBackup(g_walletPath);
|
||||
resp[@"index"] = @(account.m_index ? *account.m_index : 0);
|
||||
resp[@"address"] = [NSString stringWithUTF8String:(account.m_primary_address ? *account.m_primary_address : "").c_str()];
|
||||
resp[@"balance"] = @((account.m_balance ? *account.m_balance : 0)).stringValue;
|
||||
resp[@"unlocked"] = @((account.m_unlocked_balance ? *account.m_unlocked_balance : 0)).stringValue;
|
||||
}
|
||||
} else if ([op isEqualToString:@"createSubaddress"]) {
|
||||
if (!g_wallet) {
|
||||
resp[@"error"] = @"No wallet open";
|
||||
} else {
|
||||
uint32_t accountIndex = (uint32_t)[req[@"accountIndex"] integerValue];
|
||||
monero_subaddress sub = g_wallet->create_subaddress(accountIndex, std::string([req[@"label"] UTF8String]));
|
||||
g_wallet->save();
|
||||
excludeWalletFilesFromBackup(g_walletPath);
|
||||
resp[@"accountIndex"] = @(sub.m_account_index ? *sub.m_account_index : 0);
|
||||
resp[@"index"] = @(sub.m_index ? *sub.m_index : 0);
|
||||
resp[@"address"] = [NSString stringWithUTF8String:(sub.m_address ? *sub.m_address : "").c_str()];
|
||||
resp[@"label"] = [NSString stringWithUTF8String:(sub.m_label ? *sub.m_label : "").c_str()];
|
||||
}
|
||||
} else if ([op isEqualToString:@"startSync"]) {
|
||||
if (g_wallet) {
|
||||
connectDaemon(g_wallet);
|
||||
@@ -248,11 +360,11 @@ static void connectDaemon(monero_wallet_full *w) {
|
||||
destination->m_amount = (uint64_t)[req[@"amount"] longLongValue];
|
||||
config.m_destinations = { destination };
|
||||
config.m_priority = (monero_tx_priority)[req[@"priority"] integerValue];
|
||||
config.m_account_index = (uint32_t)[req[@"accountIndex"] integerValue];
|
||||
// create_txs relays the transaction when m_relay is set, so it must
|
||||
// not be submitted a second time.
|
||||
config.m_relay = true;
|
||||
auto txs = g_wallet->create_txs(config);
|
||||
const auto &txSet = txs[0]->m_tx_set;
|
||||
std::string signedHex = (txSet && txSet->m_signed_tx_hex) ? *txSet->m_signed_tx_hex : "";
|
||||
g_wallet->submit_txs(signedHex);
|
||||
resp[@"txHash"] = [NSString stringWithUTF8String:(txs[0]->m_hash ? *txs[0]->m_hash : "").c_str()];
|
||||
resp[@"fee"] = @((txs[0]->m_fee ? *txs[0]->m_fee : 0)).stringValue;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Scan a QR code to fill in a payment address.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Unlock Koins and approve payments.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
|
||||
@@ -11,6 +11,26 @@ enum Chain: String, Codable, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Web block explorers used for "view on the web" links.
|
||||
enum BlockExplorer {
|
||||
static func transactionURL(chain: Chain, hash: String) -> URL? {
|
||||
guard !hash.isEmpty else { return nil }
|
||||
switch chain {
|
||||
case .xmr: return URL(string: "https://xmrchain.net/tx/\(hash)")
|
||||
case .xrp: return URL(string: "https://livenet.xrpl.org/transactions/\(hash)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Same lookup for a raw ticker ("XMR"/"XRP"), as used by the swap API.
|
||||
static func transactionURL(ticker: String, hash: String) -> URL? {
|
||||
switch ticker.uppercased() {
|
||||
case "XMR": return transactionURL(chain: .xmr, hash: hash)
|
||||
case "XRP": return transactionURL(chain: .xrp, hash: hash)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WalletRecord: Codable, Identifiable, Equatable {
|
||||
let id: String
|
||||
var name: String
|
||||
@@ -18,12 +38,12 @@ struct WalletRecord: Codable, Identifiable, Equatable {
|
||||
var createdAt: Date
|
||||
}
|
||||
|
||||
enum Direction {
|
||||
enum Direction: String, Codable {
|
||||
case incoming
|
||||
case outgoing
|
||||
}
|
||||
|
||||
struct TransactionRecord: Identifiable {
|
||||
struct TransactionRecord: Identifiable, Codable {
|
||||
let id: String
|
||||
let amount: String
|
||||
let timestamp: Date?
|
||||
@@ -32,14 +52,33 @@ struct TransactionRecord: Identifiable {
|
||||
var fee: String?
|
||||
var tag: Int?
|
||||
var counterparty: String?
|
||||
var confirmations: UInt64?
|
||||
var height: UInt64?
|
||||
var paymentId: String?
|
||||
var accountIndex: Int?
|
||||
var subaddressIndex: Int?
|
||||
var note: String?
|
||||
var weight: UInt64?
|
||||
var ledgerIndex: UInt64?
|
||||
var sequence: UInt64?
|
||||
|
||||
}
|
||||
|
||||
struct Balance {
|
||||
struct Balance: Codable {
|
||||
var total: String
|
||||
var unlocked: String
|
||||
var funded: Bool
|
||||
}
|
||||
|
||||
/// Persisted per chain so a switch or relaunch shows the last-known state
|
||||
/// instantly while the network catches up.
|
||||
struct ChainCache: Codable {
|
||||
var balance: Balance
|
||||
var transactions: [TransactionRecord]
|
||||
var accounts: [MoneroAccount]?
|
||||
var savedAt: Date
|
||||
}
|
||||
|
||||
struct SyncStatus {
|
||||
var height: UInt64
|
||||
var daemonHeight: UInt64
|
||||
@@ -66,4 +105,30 @@ enum TransactionPriority: Int {
|
||||
case .high: return "High"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MoneroSubaddress: Identifiable, Codable {
|
||||
let accountIndex: Int
|
||||
let index: Int
|
||||
let address: String
|
||||
let label: String
|
||||
let balance: String
|
||||
let unlocked: String
|
||||
let isUsed: Bool
|
||||
|
||||
var id: String { "\(accountIndex)/\(index)" }
|
||||
}
|
||||
|
||||
struct MoneroAccount: Identifiable, Codable {
|
||||
let index: Int
|
||||
let address: String
|
||||
let balance: String
|
||||
let unlocked: String
|
||||
var subaddresses: [MoneroSubaddress]
|
||||
|
||||
var id: Int { index }
|
||||
|
||||
var title: String {
|
||||
index == 0 ? "Primary account" : "Account \(index + 1)"
|
||||
}
|
||||
}
|
||||
+110
-15
@@ -9,20 +9,34 @@ enum SelfTest {
|
||||
|
||||
/// Writes to both stdout and the unified log so results are visible however
|
||||
/// the app was launched.
|
||||
private static var report: [String] = []
|
||||
|
||||
private static func emit(_ line: String) {
|
||||
print(line)
|
||||
os_log("%{public}@", log: log, type: .default, line)
|
||||
report.append(line)
|
||||
}
|
||||
|
||||
/// Writes the collected lines into the app container so the result can be
|
||||
/// read back off the device (console output is not always reachable).
|
||||
private static func flushReport() {
|
||||
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
||||
let url = documents.appendingPathComponent("koins-selftest.txt")
|
||||
try? report.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func runIfRequested() {
|
||||
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTest") {
|
||||
print("KOINS_SELFTEST begin")
|
||||
emit("KOINS_SELFTEST begin")
|
||||
run()
|
||||
print("KOINS_SELFTEST end")
|
||||
emit("KOINS_SELFTEST end")
|
||||
}
|
||||
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestNetwork") {
|
||||
runNetwork()
|
||||
}
|
||||
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestSwap") {
|
||||
runSwapCheckIfRequested()
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end check of the live network paths (XRP RPC + native Monero
|
||||
@@ -80,6 +94,16 @@ enum SelfTest {
|
||||
}
|
||||
let balance = try await MoneroService.shared.balance()
|
||||
emit("KOINS_NET xmr balance \(balance.total) unlocked \(balance.unlocked)")
|
||||
|
||||
let accounts = try await MoneroService.shared.accounts()
|
||||
emit("KOINS_NET xmr accounts \(accounts.count) subaddresses \(accounts.first?.subaddresses.count ?? 0)")
|
||||
let account = try await MoneroService.shared.createAccount(label: "Savings")
|
||||
emit("KOINS_NET xmr new account index \(account.index) address \(account.address.prefix(12))")
|
||||
let subaddress = try await MoneroService.shared.createSubaddress(accountIndex: 0, label: "From Alice")
|
||||
emit("KOINS_NET xmr new subaddress index \(subaddress.index) address \(subaddress.address.prefix(12))")
|
||||
let reloaded = try await MoneroService.shared.accounts()
|
||||
emit("KOINS_NET xmr accounts after \(reloaded.count) subaddresses \(reloaded.first?.subaddresses.count ?? 0)")
|
||||
|
||||
await MoneroService.shared.stopSync()
|
||||
await MoneroService.shared.removeWallet(name: "koins-net-selftest")
|
||||
} catch {
|
||||
@@ -88,6 +112,24 @@ enum SelfTest {
|
||||
|
||||
restoreWallets()
|
||||
emit("KOINS_NET end")
|
||||
flushReport()
|
||||
}
|
||||
}
|
||||
|
||||
/// Temporary: exercise the kyc.rip estimate path with real values.
|
||||
static func runSwapCheckIfRequested() {
|
||||
guard ProcessInfo.processInfo.arguments.contains("-koinsSelfTestSwap") else { return }
|
||||
Task.detached {
|
||||
do {
|
||||
let estimate = try await SwapService.shared.estimate(from: "xmr", to: "xrp", amount: 1)
|
||||
emit("KOINS_SWAP amountTo \(estimate.amountTo)")
|
||||
for route in estimate.routes.prefix(3) {
|
||||
emit("KOINS_SWAP route \(route.provider) (\(route.engine)) -> \(route.amountTo) eta \(route.eta ?? -1)")
|
||||
}
|
||||
} catch {
|
||||
emit("KOINS_SWAP FAIL \(error)")
|
||||
}
|
||||
flushReport()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,9 +139,12 @@ enum SelfTest {
|
||||
func expect(_ name: String, _ actual: String, _ wanted: String) {
|
||||
let ok = actual.lowercased() == wanted.lowercased()
|
||||
if !ok { failures += 1 }
|
||||
let line = "KOINS_SELFTEST \(name): \(ok ? "PASS" : "FAIL") \(actual)"
|
||||
print(line)
|
||||
os_log("%{public}@", log: log, type: .default, line)
|
||||
emit("KOINS_SELFTEST \(name): \(ok ? "PASS" : "FAIL") \(actual)")
|
||||
}
|
||||
|
||||
func expectTrue(_ name: String, _ value: Bool) {
|
||||
if !value { failures += 1 }
|
||||
emit("KOINS_SELFTEST \(name): \(value ? "PASS" : "FAIL")")
|
||||
}
|
||||
|
||||
expect("ripemd160", Hex.encode(RIPEMD160.hash(Array("abc".utf8))),
|
||||
@@ -114,7 +159,7 @@ enum SelfTest {
|
||||
expect("ed25519 address", keypair.address, edAddress)
|
||||
} else {
|
||||
failures += 1
|
||||
print("KOINS_SELFTEST ed25519: FAIL keypair threw")
|
||||
emit("KOINS_SELFTEST ed25519: FAIL keypair threw")
|
||||
}
|
||||
|
||||
// secp256k1 vector: exercises the OpenSSL-backed derivation on device.
|
||||
@@ -128,7 +173,7 @@ enum SelfTest {
|
||||
expect("secp256k1 address", keypair.address, secpAddress)
|
||||
} catch {
|
||||
failures += 1
|
||||
print("KOINS_SELFTEST secp256k1: FAIL \(error)")
|
||||
emit("KOINS_SELFTEST secp256k1: FAIL \(error)")
|
||||
}
|
||||
|
||||
// Serialization + hashing reference vector.
|
||||
@@ -189,12 +234,12 @@ enum SelfTest {
|
||||
expect("bip32 level \(level)", Hex.encode(child.privateKey), expected)
|
||||
} else {
|
||||
failures += 1
|
||||
print("KOINS_SELFTEST bip32 level \(level): FAIL derivation returned nil")
|
||||
emit("KOINS_SELFTEST bip32 level \(level): FAIL derivation returned nil")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
failures += 1
|
||||
print("KOINS_SELFTEST bip32 master: FAIL")
|
||||
emit("KOINS_SELFTEST bip32 master: FAIL")
|
||||
}
|
||||
|
||||
let mnemonicCases: [(String, String, String)] = [
|
||||
@@ -212,10 +257,61 @@ enum SelfTest {
|
||||
expect("bip44 address #\(position)", keypair.address, vector.2)
|
||||
} catch {
|
||||
failures += 1
|
||||
print("KOINS_SELFTEST bip44 #\(position): FAIL \(error)")
|
||||
emit("KOINS_SELFTEST bip44 #\(position): FAIL \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// QR round trip: generate an image, read it back, parse the payment URI.
|
||||
let xmrAddress = "46cRPeU77sBVGSVXPyCjvJUdC9AjqvETWb6giUGskdnWN8ZifindmrH7mcztKU52iKgvWXY4dyPujg9LYUY1Q7dvDiwHNCE"
|
||||
let xrpAddress = "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C"
|
||||
|
||||
func roundTrip(_ name: String, _ text: String, checks: [(String, String)]) {
|
||||
guard let image = QRCodeImage.make(text) else {
|
||||
failures += 1
|
||||
emit("KOINS_SELFTEST \(name): FAIL could not render QR image")
|
||||
return
|
||||
}
|
||||
guard let decoded = QRCodeReader.decodeText(in: image) else {
|
||||
failures += 1
|
||||
emit("KOINS_SELFTEST \(name): FAIL vision could not read the QR image (\(Int(image.size.width))px)")
|
||||
return
|
||||
}
|
||||
guard let payload = QRCodeReader.parse(decoded) else {
|
||||
failures += 1
|
||||
emit("KOINS_SELFTEST \(name): FAIL parse of '\(decoded)'")
|
||||
return
|
||||
}
|
||||
for (label, expected) in checks {
|
||||
let actual: String
|
||||
switch label {
|
||||
case "address": actual = payload.address
|
||||
case "amount": actual = payload.amount ?? ""
|
||||
case "tag": actual = payload.tag.map(String.init) ?? ""
|
||||
case "paymentId": actual = payload.paymentId ?? ""
|
||||
case "memo": actual = payload.memo ?? ""
|
||||
default: actual = ""
|
||||
}
|
||||
expect("\(name) \(label)", actual, expected)
|
||||
}
|
||||
}
|
||||
|
||||
roundTrip("qr monero uri",
|
||||
"monero:\(xmrAddress)?tx_amount=1.5&tx_payment_id=abc123&tx_description=Rent",
|
||||
checks: [("address", xmrAddress), ("amount", "1.5"), ("paymentId", "abc123"), ("memo", "Rent")])
|
||||
roundTrip("qr monero plain", xmrAddress, checks: [("address", xmrAddress)])
|
||||
roundTrip("qr xrp uri",
|
||||
"ripple:\(xrpAddress)?amount=12.5&dt=99",
|
||||
checks: [("address", xrpAddress), ("amount", "12.5"), ("tag", "99")])
|
||||
roundTrip("qr xrp plain", xrpAddress, checks: [("address", xrpAddress)])
|
||||
|
||||
expectTrue("qr monero detected", AddressFormat.isMonero(xmrAddress))
|
||||
expectTrue("qr xrp detected", AddressFormat.isXRP(xrpAddress))
|
||||
expectTrue("qr rejects junk", QRCodeReader.parse("hello world")?.address == "hello world")
|
||||
expectTrue("qr not monero", !AddressFormat.isMonero(xrpAddress))
|
||||
expectTrue("qr not xrp", !AddressFormat.isXRP(xmrAddress))
|
||||
|
||||
emit("biometrics available=\(Biometrics.isAvailable) label=\(Biometrics.label)")
|
||||
|
||||
// secp256k1 signing must produce a low-S DER signature.
|
||||
if let keypair = try? XRPKey.keypair(fromSeed: secpSeed),
|
||||
let message = Hex.decode(signingHex),
|
||||
@@ -227,15 +323,14 @@ enum SelfTest {
|
||||
let s = DER.signatureScalar(signature) {
|
||||
expect("secp256k1 signature is low-S", s.lessThanOrEqual(half) ? "yes" : "no", "yes")
|
||||
}
|
||||
print("KOINS_SELFTEST secp256k1 signature len: \(signature.count)")
|
||||
emit("KOINS_SELFTEST secp256k1 signature len: \(signature.count)")
|
||||
} else {
|
||||
failures += 1
|
||||
print("KOINS_SELFTEST secp256k1 sign: FAIL")
|
||||
emit("KOINS_SELFTEST secp256k1 sign: FAIL")
|
||||
}
|
||||
|
||||
let summary = "KOINS_SELFTEST summary: \(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")"
|
||||
print(summary)
|
||||
os_log("%{public}@", log: log, type: .default, summary)
|
||||
emit("KOINS_SELFTEST summary: \(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")")
|
||||
flushReport()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
defaults.removeObject(forKey: walletKey(chain))
|
||||
defaults.removeObject(forKey: cacheKey(chain))
|
||||
Keychain.remove(secretKey(chain))
|
||||
}
|
||||
|
||||
// MARK: - Cached state (balance, transactions, accounts)
|
||||
|
||||
private static func cacheKey(_ chain: Chain) -> String { "koins.cache.\(chain.rawValue)" }
|
||||
|
||||
static func cachedState(_ chain: Chain) -> ChainCache? {
|
||||
guard let data = defaults.data(forKey: cacheKey(chain)) else { return nil }
|
||||
return try? JSONDecoder().decode(ChainCache.self, from: data)
|
||||
}
|
||||
|
||||
static func saveCachedState(_ state: ChainCache, chain: Chain) {
|
||||
guard let data = try? JSONEncoder().encode(state) else { return }
|
||||
defaults.set(data, forKey: cacheKey(chain))
|
||||
}
|
||||
|
||||
// MARK: - Swap orders (kyc.rip)
|
||||
|
||||
private static let swapOrdersKey = "koins.swapOrders"
|
||||
|
||||
static func swapOrders() -> [SwapOrder] {
|
||||
guard let data = defaults.data(forKey: swapOrdersKey),
|
||||
let orders = try? JSONDecoder().decode([SwapOrder].self, from: data) else { return [] }
|
||||
return orders
|
||||
}
|
||||
|
||||
static func saveSwapOrder(_ order: SwapOrder) {
|
||||
var orders = swapOrders()
|
||||
orders.removeAll { $0.id == order.id }
|
||||
orders.insert(order, at: 0)
|
||||
guard let data = try? JSONEncoder().encode(orders) else { return }
|
||||
defaults.set(data, forKey: swapOrdersKey)
|
||||
}
|
||||
|
||||
static func removeSwapOrder(id: String) {
|
||||
var orders = swapOrders()
|
||||
orders.removeAll { $0.id == id }
|
||||
guard let data = try? JSONEncoder().encode(orders) else { return }
|
||||
defaults.set(data, forKey: swapOrdersKey)
|
||||
}
|
||||
|
||||
static func secret(_ chain: Chain) -> String? {
|
||||
Keychain.get(secretKey(chain))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
@@ -146,6 +166,54 @@ struct ChainPicker: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks which Monero account a payment leaves from. Each account has its own
|
||||
/// balance, so "Max" and the wallet's spend checks need the matching index.
|
||||
struct MoneroAccountPicker: View {
|
||||
var accounts: [MoneroAccount]
|
||||
@Binding var selection: Int
|
||||
var onSelect: (() -> Void)?
|
||||
|
||||
private var selected: MoneroAccount? {
|
||||
accounts.first(where: { $0.index == selection }) ?? accounts.first
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
FieldLabel(text: "Send from")
|
||||
Menu {
|
||||
ForEach(accounts) { account in
|
||||
Button {
|
||||
selection = account.index
|
||||
onSelect?()
|
||||
} label: {
|
||||
Text("\(account.title) · \(account.balance) XMR")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(selected?.title ?? "Primary account")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(Theme.text)
|
||||
Text("\(selected?.unlocked ?? "0") XMR available")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(Theme.surface)
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct QRCodeView: View {
|
||||
var text: String
|
||||
|
||||
@@ -166,15 +234,7 @@ struct QRCodeView: View {
|
||||
}
|
||||
|
||||
private static func makeImage(from text: String) -> UIImage? {
|
||||
guard !text.isEmpty else { return nil }
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(text.utf8)
|
||||
filter.correctionLevel = "M"
|
||||
guard let output = filter.outputImage else { return nil }
|
||||
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
|
||||
let context = CIContext()
|
||||
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
|
||||
return UIImage(cgImage: cgImage)
|
||||
QRCodeImage.make(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,12 +242,18 @@ struct TransactionRow: View {
|
||||
var record: TransactionRecord
|
||||
var symbol: String
|
||||
|
||||
private var formattedDate: String {
|
||||
guard let timestamp = record.timestamp else { return "Pending" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: timestamp)
|
||||
private var subtitle: String {
|
||||
if let timestamp = record.timestamp {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
return record.confirmed ? formatter.string(from: timestamp) : "Pending confirmation"
|
||||
}
|
||||
// Monero transfers carry no timestamp; the block height is the honest label.
|
||||
if let height = record.height, height > 0 {
|
||||
return record.confirmed ? "Block \(height)" : "In the transaction pool"
|
||||
}
|
||||
return record.confirmed ? "Confirmed" : "Pending"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -205,7 +271,7 @@ struct TransactionRow: View {
|
||||
Text(record.direction == .incoming ? "Received" : "Sent")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Theme.text)
|
||||
Text(record.confirmed ? formattedDate : "Pending confirmation")
|
||||
Text(subtitle)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
@@ -224,6 +290,9 @@ struct TransactionRow: View {
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
// Buttons only respond where the label has content; the middle of the row
|
||||
// is a Spacer, so without this the gap does not accept taps.
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,4 +326,53 @@ struct ScreenHeader: View {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Monero's chain sync as its own section, rather than buried in the wallet card.
|
||||
struct SyncCard: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Sync")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Theme.muted)
|
||||
Spacer()
|
||||
if let status = model.syncStatus { SyncBadge(status: status) }
|
||||
}
|
||||
|
||||
if let status = model.syncStatus {
|
||||
SyncProgress(status: status, eta: model.syncEta)
|
||||
} else {
|
||||
Text("Sync status is not available yet.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
|
||||
SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") {
|
||||
Task { await model.startMoneroSync() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A small circular icon button for utility actions (refresh, delete, …).
|
||||
struct IconButton: View {
|
||||
var systemName: String
|
||||
var destructive = false
|
||||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(destructive ? Theme.danger : Theme.muted)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(Theme.surface)
|
||||
.overlay(Circle().stroke(Theme.border))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
@EnvironmentObject private var drawers: DrawerCoordinator
|
||||
@Binding var route: Route
|
||||
|
||||
@State private var showSeed = false
|
||||
@@ -22,6 +24,11 @@ struct DashboardView: View {
|
||||
Text("\(model.balance.total) \(model.symbol)")
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.text)
|
||||
if let fiat = model.fiatBalance {
|
||||
Text("\(fiat)")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
if model.chain == .xmr, model.balance.unlocked != model.balance.total {
|
||||
Text("\(model.balance.unlocked) \(model.symbol) spendable now")
|
||||
.font(.system(size: 11))
|
||||
@@ -63,9 +70,14 @@ struct DashboardView: View {
|
||||
Text("Activity")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Theme.muted)
|
||||
ForEach(model.transactions) { record in
|
||||
TransactionRow(record: record, symbol: model.symbol)
|
||||
if record.id != model.transactions.last?.id {
|
||||
ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in
|
||||
Button {
|
||||
drawers.transaction = record
|
||||
} label: {
|
||||
TransactionRow(record: record, symbol: model.symbol)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if index < model.transactions.count - 1 {
|
||||
Divider().overlay(Theme.border)
|
||||
}
|
||||
}
|
||||
@@ -112,7 +124,12 @@ struct SendView: View {
|
||||
@State private var amount = ""
|
||||
@State private var tag = ""
|
||||
@State private var priority: TransactionPriority = .defaultPriority
|
||||
@State private var accountIndex = 0
|
||||
@State private var accountPicked = false
|
||||
@State private var localError: String?
|
||||
@State private var status: String?
|
||||
@State private var showScanner = false
|
||||
@State private var photoItem: PhotosPickerItem?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
@@ -120,6 +137,26 @@ struct SendView: View {
|
||||
subtitle: "Balance \(model.balance.total) \(model.symbol)",
|
||||
onBack: { route = .home })
|
||||
|
||||
HStack(spacing: 10) {
|
||||
SecondaryButton(title: "Scan QR") { showScanner = true }
|
||||
PhotosPicker(selection: $photoItem, matching: .images, photoLibrary: .shared()) {
|
||||
Text("Photo")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Theme.surface)
|
||||
.foregroundStyle(Theme.text)
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
if model.chain == .xmr, model.moneroAccounts.count > 1 {
|
||||
MoneroAccountPicker(accounts: model.moneroAccounts,
|
||||
selection: $accountIndex,
|
||||
onSelect: { accountPicked = true })
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address")
|
||||
KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination)
|
||||
@@ -152,6 +189,9 @@ struct SendView: View {
|
||||
if let message = localError ?? model.errorMessage {
|
||||
MessageBanner(message: message, isError: true)
|
||||
}
|
||||
if let message = status {
|
||||
MessageBanner(message: message, isError: false)
|
||||
}
|
||||
if let message = model.statusMessage {
|
||||
MessageBanner(message: message, isError: false)
|
||||
}
|
||||
@@ -161,7 +201,11 @@ struct SendView: View {
|
||||
localError = nil
|
||||
model.clearMessages()
|
||||
do {
|
||||
try await model.send(to: destination, amount: amount, tag: tag, priority: priority)
|
||||
try await model.send(to: destination,
|
||||
amount: amount,
|
||||
tag: tag,
|
||||
priority: priority,
|
||||
accountIndex: accountIndex)
|
||||
amount = ""
|
||||
tag = ""
|
||||
route = .dashboard
|
||||
@@ -173,6 +217,71 @@ struct SendView: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.sheet(isPresented: $showScanner) {
|
||||
QRScannerView(onScan: { value in
|
||||
showScanner = false
|
||||
applyScanned(value)
|
||||
}, onCancel: {
|
||||
showScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.onChange(of: photoItem) { item in
|
||||
guard let item else { return }
|
||||
Task {
|
||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||
if let value = QRCodeReader.decodeText(in: data) {
|
||||
applyScanned(value)
|
||||
} else {
|
||||
localError = "No QR code found in that image"
|
||||
}
|
||||
} else {
|
||||
localError = "That image could not be read"
|
||||
}
|
||||
photoItem = nil
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if !accountPicked { accountIndex = model.defaultMoneroAccountIndex }
|
||||
}
|
||||
.onChange(of: model.moneroAccounts.count) { _ in
|
||||
if !accountPicked { accountIndex = model.defaultMoneroAccountIndex }
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills the form from a scanned code, accepting both plain addresses and
|
||||
/// wallet payment URIs (`monero:…?tx_amount=`, `ripple:…?amount=&dt=`).
|
||||
private func applyScanned(_ value: String) {
|
||||
localError = nil
|
||||
status = nil
|
||||
guard let payload = QRCodeReader.parse(value) else {
|
||||
localError = "That QR code is not a payment request"
|
||||
return
|
||||
}
|
||||
|
||||
let isForThisChain = model.chain == .xrp
|
||||
? AddressFormat.isXRP(payload.address)
|
||||
: AddressFormat.isMonero(payload.address)
|
||||
guard isForThisChain else {
|
||||
localError = model.chain == .xrp
|
||||
? "That looks like a Monero address. Switch to Monero to use it."
|
||||
: "That looks like an XRP address. Switch to XRP to use it."
|
||||
return
|
||||
}
|
||||
|
||||
destination = payload.address
|
||||
if let scannedAmount = payload.amount, !scannedAmount.isEmpty {
|
||||
amount = scannedAmount
|
||||
}
|
||||
if model.chain == .xrp, let scannedTag = payload.tag {
|
||||
tag = String(scannedTag)
|
||||
}
|
||||
|
||||
var filled: [String] = ["address"]
|
||||
if payload.amount?.isEmpty == false { filled.append("amount") }
|
||||
if payload.tag != nil { filled.append("tag") }
|
||||
if payload.paymentId?.isEmpty == false { filled.append("payment ID") }
|
||||
status = "Filled \(filled.joined(separator: ", ")) from the QR code"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 send
|
||||
case receive
|
||||
case accounts
|
||||
case swap
|
||||
}
|
||||
|
||||
struct SeedBox: Identifiable {
|
||||
@@ -17,8 +19,19 @@ struct SeedBox: Identifiable {
|
||||
|
||||
struct RootView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
@State private var route: Route = .home
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var route: Route = RootView.initialRoute
|
||||
|
||||
private static var initialRoute: Route {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
if arguments.contains("-koinsScreenAccounts") { return .accounts }
|
||||
if arguments.contains("-koinsScreenSend") { return .send }
|
||||
if arguments.contains("-koinsScreenReceive") { return .receive }
|
||||
if arguments.contains("-koinsScreenSwap") { return .swap }
|
||||
return .home
|
||||
}
|
||||
@State private var seedBox: SeedBox?
|
||||
@StateObject private var drawers = DrawerCoordinator()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -38,6 +51,10 @@ struct RootView: View {
|
||||
SendView(route: $route)
|
||||
case .receive:
|
||||
ReceiveView(route: $route)
|
||||
case .accounts:
|
||||
AccountsView(route: $route)
|
||||
case .swap:
|
||||
SwapView(route: $route)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
@@ -51,16 +68,54 @@ struct RootView: View {
|
||||
.sheet(item: $seedBox) { box in
|
||||
SeedRevealView(seed: box.seed, symbol: box.symbol) { seedBox = nil }
|
||||
}
|
||||
.bottomDrawer(isPresented: Binding(
|
||||
get: { drawers.transaction != nil },
|
||||
set: { if !$0 { drawers.transaction = nil } }
|
||||
)) {
|
||||
if let record = drawers.transaction {
|
||||
TransactionDetailView(record: record,
|
||||
symbol: model.symbol,
|
||||
chain: model.chain) { drawers.transaction = nil }
|
||||
}
|
||||
}
|
||||
.bottomDrawer(isPresented: Binding(
|
||||
get: { drawers.qr != nil },
|
||||
set: { if !$0 { drawers.qr = nil } }
|
||||
)) {
|
||||
if let item = drawers.qr {
|
||||
AddressQRSheet(title: item.title, address: item.address) { drawers.qr = nil }
|
||||
}
|
||||
}
|
||||
.bottomDrawer(isPresented: $drawers.settings) {
|
||||
SettingsView()
|
||||
}
|
||||
.environmentObject(drawers)
|
||||
// Above the drawers so a lock always hides whatever was on screen.
|
||||
.overlay {
|
||||
if model.locked, model.faceIDEnabled {
|
||||
LockScreen {
|
||||
Task { await model.authenticate() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: model.chain) { _ in
|
||||
if route == .dashboard { route = .home }
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
if phase == .background, model.faceIDEnabled, Biometrics.isAvailable {
|
||||
model.locked = true
|
||||
}
|
||||
if phase != .active {
|
||||
Task { await model.persistWallet() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HomeView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
@EnvironmentObject private var drawers: DrawerCoordinator
|
||||
@Binding var route: Route
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
@@ -81,48 +136,47 @@ struct HomeView: View {
|
||||
MessageBanner(message: message, isError: false)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Delete this wallet?",
|
||||
isPresented: $showDeleteConfirm,
|
||||
titleVisibility: .visible) {
|
||||
Button("Delete wallet", role: .destructive) {
|
||||
Task {
|
||||
await model.deleteWallet()
|
||||
route = .home
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("The keys stored on this device are removed. Make sure your recovery seed is backed up.")
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Koins")
|
||||
.font(.system(size: 30, weight: .heavy))
|
||||
.foregroundStyle(Theme.text)
|
||||
Text("Monero and XRP wallet")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.muted)
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Koins")
|
||||
.font(.system(size: 30, weight: .heavy))
|
||||
.foregroundStyle(Theme.text)
|
||||
Text("Monero and XRP wallet")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
drawers.settings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(Theme.muted)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Theme.surface)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var walletCard: some View {
|
||||
VStack(spacing: 14) {
|
||||
ChainPicker(selection: Binding(get: { model.chain },
|
||||
set: { newValue in Task { await model.select(newValue) } }))
|
||||
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text(model.wallet?.name ?? "Wallet")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
if model.chain == .xmr, let status = model.syncStatus {
|
||||
SyncBadge(status: status)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.wallet?.name ?? "Wallet")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Theme.text)
|
||||
Text(model.chain.title)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -132,6 +186,11 @@ struct HomeView: View {
|
||||
Text("\(model.balance.total) \(model.symbol)")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.text)
|
||||
if let fiat = model.fiatBalance {
|
||||
Text("\(fiat)")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
if model.chain == .xmr, model.balance.unlocked != model.balance.total {
|
||||
Text("\(model.balance.unlocked) \(model.symbol) unlocked")
|
||||
.font(.system(size: 11))
|
||||
@@ -139,48 +198,36 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
AddressRow(address: model.displayAddress)
|
||||
|
||||
if model.chain == .xmr, let status = model.syncStatus {
|
||||
SyncProgress(status: status, eta: model.syncEta)
|
||||
if model.chain == .xrp {
|
||||
AddressRow(address: model.displayAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if model.chain == .xmr, !model.moneroAccounts.isEmpty {
|
||||
// Cake Wallet style: a deck of account cards as its own section, drag
|
||||
// the front card away to bring the next one up.
|
||||
AccountStack(accounts: model.moneroAccounts,
|
||||
fiat: { model.fiatValue(of: $0, chain: .xmr) },
|
||||
onQR: { drawers.qr = AddressQR(title: $0.title, address: $0.address) })
|
||||
}
|
||||
|
||||
if model.chain == .xmr {
|
||||
SyncCard()
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
PrimaryButton(title: "Send") { route = .send }
|
||||
SecondaryButton(title: "Receive") { route = .receive }
|
||||
SecondaryButton(title: "Swap") { route = .swap }
|
||||
}
|
||||
|
||||
if model.chain == .xmr {
|
||||
SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") {
|
||||
Task { await model.startMoneroSync() }
|
||||
HStack(spacing: 12) {
|
||||
IconButton(systemName: "arrow.clockwise") { Task { await model.refresh(force: true) } }
|
||||
if model.chain == .xmr {
|
||||
IconButton(systemName: "person.2.fill") { route = .accounts }
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
SecondaryButton(title: "Refresh") { Task { await model.refresh(force: true) } }
|
||||
SecondaryButton(title: "Delete") { showDeleteConfirm = true }
|
||||
}
|
||||
|
||||
if !model.transactions.isEmpty {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Activity")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Theme.muted)
|
||||
ForEach(model.transactions) { record in
|
||||
TransactionRow(record: record, symbol: model.symbol)
|
||||
if record.id != model.transactions.last?.id {
|
||||
Divider().overlay(Theme.border)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if model.chain == .xrp, model.balance.funded == false {
|
||||
Text("This account is not funded yet. Send XRP to the address above to activate it.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.muted)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if model.chain == .xrp, model.moneroReady, let status = model.syncStatus, status.syncing {
|
||||
@@ -202,13 +249,40 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if !model.transactions.isEmpty {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Activity")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Theme.muted)
|
||||
// A Monero transaction can produce several transfer entries with the
|
||||
// same hash, so index-based identities keep the rows stable.
|
||||
ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in
|
||||
Button {
|
||||
drawers.transaction = record
|
||||
} label: {
|
||||
TransactionRow(record: record, symbol: model.symbol)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if index < model.transactions.count - 1 {
|
||||
Divider().overlay(Theme.border)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if model.chain == .xrp, model.balance.funded == false {
|
||||
Text("This account is not funded yet. Send XRP to the address above to activate it.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private var moneroUnavailableCard: some View {
|
||||
VStack(spacing: 14) {
|
||||
ChainPicker(selection: Binding(get: { model.chain },
|
||||
set: { newValue in Task { await model.select(newValue) } }))
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Monero wallet")
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
/// Writes the wallet file, which is how scan progress survives a restart.
|
||||
func save() async {
|
||||
_ = try? await invoke(["op": "saveWallet"])
|
||||
}
|
||||
|
||||
func seed() async throws -> String {
|
||||
let response = try await invoke(["op": "getSeed"])
|
||||
return response["mnemonic"] as? String ?? ""
|
||||
}
|
||||
|
||||
/// Block time for a height. wallet2 keeps no timestamp for transfers, so the
|
||||
/// only way to date a Monero transaction is to ask the daemon for the block
|
||||
/// it landed in.
|
||||
func blockTimestamp(height: UInt64) async throws -> Date? {
|
||||
guard let url = URL(string: "http://\(Self.daemonHost):18081/json_rpc") else { return nil }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.timeoutInterval = 15
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: [
|
||||
"jsonrpc": "2.0",
|
||||
"id": "koins",
|
||||
"method": "get_block_header_by_height",
|
||||
"params": ["height": height],
|
||||
])
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let result = json["result"] as? [String: Any],
|
||||
let header = result["block_header"] as? [String: Any],
|
||||
let timestamp = (header["timestamp"] as? NSNumber)?.doubleValue, timestamp > 0 else {
|
||||
return nil
|
||||
}
|
||||
return Date(timeIntervalSince1970: timestamp)
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
func balance() async throws -> Balance {
|
||||
@@ -187,8 +219,77 @@ final class MoneroService {
|
||||
confirmed: (entry["confirmed"] as? Bool) ?? false,
|
||||
fee: MoneroAmount.xmr(fromAtomic: entry["fee"] as? String ?? "0"),
|
||||
tag: nil,
|
||||
counterparty: nil)
|
||||
counterparty: entry["counterparty"] as? String,
|
||||
confirmations: (entry["confirmations"] as? NSNumber)?.uint64Value,
|
||||
height: (entry["height"] as? NSNumber)?.uint64Value,
|
||||
paymentId: (entry["paymentId"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||
accountIndex: (entry["accountIndex"] as? NSNumber)?.intValue,
|
||||
subaddressIndex: (entry["subaddressIndex"] as? NSNumber)?.intValue,
|
||||
note: (entry["note"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||
weight: (entry["weight"] as? NSNumber)?.uint64Value)
|
||||
}
|
||||
.sorted { newestFirst($0, $1) }
|
||||
}
|
||||
|
||||
/// Newest first. wallet2 gives transfers no timestamp, but the block height is
|
||||
/// chronological and always present, so that is the ordering key; anything
|
||||
/// still in the pool (height 0) belongs at the top.
|
||||
private func newestFirst(_ left: TransactionRecord, _ right: TransactionRecord) -> Bool {
|
||||
func key(_ record: TransactionRecord) -> UInt64 {
|
||||
guard let height = record.height, height > 0 else { return .max }
|
||||
return height
|
||||
}
|
||||
let leftKey = key(left)
|
||||
let rightKey = key(right)
|
||||
if leftKey != rightKey { return leftKey > rightKey }
|
||||
return (left.timestamp ?? .distantFuture) > (right.timestamp ?? .distantFuture)
|
||||
}
|
||||
|
||||
// MARK: - Accounts
|
||||
|
||||
func accounts() async throws -> [MoneroAccount] {
|
||||
let response = try await invoke(["op": "getAccounts"])
|
||||
let entries = response["accounts"] as? [[String: Any]] ?? []
|
||||
return entries.map { entry in
|
||||
let subEntries = entry["subaddresses"] as? [[String: Any]] ?? []
|
||||
return MoneroAccount(
|
||||
index: (entry["index"] as? NSNumber)?.intValue ?? 0,
|
||||
address: entry["address"] as? String ?? "",
|
||||
balance: MoneroAmount.xmr(fromAtomic: entry["balance"] as? String ?? "0"),
|
||||
unlocked: MoneroAmount.xmr(fromAtomic: entry["unlocked"] as? String ?? "0"),
|
||||
subaddresses: subEntries.map { sub in
|
||||
MoneroSubaddress(
|
||||
accountIndex: (sub["accountIndex"] as? NSNumber)?.intValue ?? 0,
|
||||
index: (sub["index"] as? NSNumber)?.intValue ?? 0,
|
||||
address: sub["address"] as? String ?? "",
|
||||
label: sub["label"] as? String ?? "",
|
||||
balance: MoneroAmount.xmr(fromAtomic: sub["balance"] as? String ?? "0"),
|
||||
unlocked: MoneroAmount.xmr(fromAtomic: sub["unlocked"] as? String ?? "0"),
|
||||
isUsed: (sub["isUsed"] as? Bool) ?? false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createAccount(label: String) async throws -> MoneroAccount {
|
||||
let response = try await invoke(["op": "createAccount", "label": label])
|
||||
return MoneroAccount(index: (response["index"] as? NSNumber)?.intValue ?? 0,
|
||||
address: response["address"] as? String ?? "",
|
||||
balance: MoneroAmount.xmr(fromAtomic: response["balance"] as? String ?? "0"),
|
||||
unlocked: MoneroAmount.xmr(fromAtomic: response["unlocked"] as? String ?? "0"),
|
||||
subaddresses: [])
|
||||
}
|
||||
|
||||
func createSubaddress(accountIndex: Int, label: String) async throws -> MoneroSubaddress {
|
||||
let response = try await invoke(["op": "createSubaddress",
|
||||
"accountIndex": accountIndex,
|
||||
"label": label])
|
||||
return MoneroSubaddress(accountIndex: (response["accountIndex"] as? NSNumber)?.intValue ?? accountIndex,
|
||||
index: (response["index"] as? NSNumber)?.intValue ?? 0,
|
||||
address: response["address"] as? String ?? "",
|
||||
label: response["label"] as? String ?? label,
|
||||
balance: "0",
|
||||
unlocked: "0",
|
||||
isUsed: false)
|
||||
}
|
||||
|
||||
// MARK: - Sync
|
||||
@@ -212,12 +313,14 @@ final class MoneroService {
|
||||
|
||||
// MARK: - Sends
|
||||
|
||||
func send(address: String, amountXMR: String, priority: TransactionPriority) async throws -> (hash: String, fee: String) {
|
||||
func send(address: String, amountXMR: String, priority: TransactionPriority,
|
||||
accountIndex: Int = 0) async throws -> (hash: String, fee: String) {
|
||||
let atomic = try MoneroAmount.atomic(fromXMR: amountXMR)
|
||||
let response = try await invoke(["op": "transferTo",
|
||||
"address": address.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
"amount": String(atomic),
|
||||
"priority": priority.rawValue])
|
||||
"priority": priority.rawValue,
|
||||
"accountIndex": accountIndex])
|
||||
return (response["txHash"] as? String ?? "",
|
||||
MoneroAmount.xmr(fromAtomic: response["fee"] as? String ?? "0"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
fee: (tx["Fee"] as? String).map { XRPAmount.xrp(fromDrops: $0) },
|
||||
tag: (tx["DestinationTag"] as? NSNumber)?.intValue,
|
||||
counterparty: isIncoming ? (tx["Account"] as? String) : destination))
|
||||
counterparty: isIncoming ? (tx["Account"] as? String) : destination,
|
||||
height: (entry["ledger_index"] as? NSNumber)?.uint64Value,
|
||||
ledgerIndex: (entry["ledger_index"] as? NSNumber)?.uint64Value,
|
||||
sequence: (tx["Sequence"] as? NSNumber)?.uint64Value))
|
||||
}
|
||||
return records
|
||||
// Newest first, matching the Monero list.
|
||||
return records.sorted { ($0.timestamp ?? .distantFuture) > ($1.timestamp ?? .distantFuture) }
|
||||
}
|
||||
|
||||
// MARK: - Sends
|
||||
|
||||
@@ -18,13 +18,18 @@ targets:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- Koins
|
||||
- path: Koins
|
||||
excludes:
|
||||
# Static archives are linked via LIBRARY_SEARCH_PATHS, not bundled.
|
||||
- "Monero/libs"
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.koins.app
|
||||
INFOPLIST_FILE: Koins/Info.plist
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
CODE_SIGNING_ALLOWED: NO
|
||||
DEVELOPMENT_TEAM: RX6767S6BW
|
||||
CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]: NO
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
SWIFT_OBJC_BRIDGING_HEADER: Koins/Bridge/Koins-Bridging-Header.h
|
||||
OTHER_LDFLAGS[sdk=iphoneos*]:
|
||||
- "$(inherited)"
|
||||
@@ -61,6 +66,8 @@ targets:
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
UIUserInterfaceStyle: Dark
|
||||
NSCameraUsageDescription: Scan a QR code to fill in a payment address.
|
||||
NSFaceIDUsageDescription: Unlock Koins and approve payments.
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoads: true
|
||||
|
||||
|
||||
Reference in New Issue
Block a user