diff --git a/apps/ios/Koins/AppModel.swift b/apps/ios/Koins/AppModel.swift index fc9f4cb..408bc24 100644 --- a/apps/ios/Koins/AppModel.swift +++ b/apps/ios/Koins/AppModel.swift @@ -14,6 +14,7 @@ final class AppModel: ObservableObject { @Published var statusMessage: String? @Published var moneroReady = false @Published var moneroAddress: String? + @Published var moneroAccounts: [MoneroAccount] = [] /// Rough time remaining for the Monero sync, derived from observed block rate. @Published var syncEta: String? @@ -228,8 +229,10 @@ final class AppModel: ObservableObject { if force || refreshTick == 1 || refreshTick % 4 == 0 { async let balanceCall = MoneroService.shared.balance() async let txCall = MoneroService.shared.transactions() + async let accountsCall = MoneroService.shared.accounts() balance = try await balanceCall transactions = try await txCall + moneroAccounts = try await accountsCall } } catch { errorMessage = error.localizedDescription @@ -345,6 +348,24 @@ final class AppModel: ObservableObject { 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) + } + + /// 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 { diff --git a/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm b/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm index 8256fb1..7fba617 100644 --- a/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm +++ b/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm @@ -224,6 +224,59 @@ static void connectDaemon(monero_wallet_full *w) { } 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); diff --git a/apps/ios/Koins/Models.swift b/apps/ios/Koins/Models.swift index 84febe4..2b79983 100644 --- a/apps/ios/Koins/Models.swift +++ b/apps/ios/Koins/Models.swift @@ -66,4 +66,30 @@ enum TransactionPriority: Int { case .high: return "High" } } +} + +struct MoneroSubaddress: Identifiable { + 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 { + 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)" + } } \ No newline at end of file diff --git a/apps/ios/Koins/SelfTest.swift b/apps/ios/Koins/SelfTest.swift index 1d23dd2..0aa147a 100644 --- a/apps/ios/Koins/SelfTest.swift +++ b/apps/ios/Koins/SelfTest.swift @@ -91,6 +91,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 { diff --git a/apps/ios/Koins/Views/AccountsView.swift b/apps/ios/Koins/Views/AccountsView.swift new file mode 100644 index 0000000..6da9891 --- /dev/null +++ b/apps/ios/Koins/Views/AccountsView.swift @@ -0,0 +1,165 @@ +import SwiftUI + +struct AccountsView: View { + @EnvironmentObject private var model: AppModel + @Binding var route: Route + + @State private var error: String? + @State private var busy = false + @State private var expandedAddress: String? + @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(id: "\(account.index)/primary", + 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(id: subaddress.id, + 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(id: String, title: String, address: String, isUsed: Bool, balance: String? = nil) -> some View { + VStack(spacing: 10) { + 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) + Button { + expandedAddress = expandedAddress == id ? nil : id + } label: { + Image(systemName: expandedAddress == id ? "xmark" : "qrcode") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Theme.accent) + .frame(width: 32, height: 32) + .background(Theme.surfaceAlt) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + + if expandedAddress == id { + VStack(spacing: 10) { + QRCodeView(text: address) + .frame(maxWidth: 180) + HStack(spacing: 8) { + SecondaryButton(title: "Copy") { + UIPasteboard.general.string = address + } + } + } + .frame(maxWidth: .infinity) + } + } + } + + 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 + } + } + } +} \ No newline at end of file diff --git a/apps/ios/Koins/Views/RootView.swift b/apps/ios/Koins/Views/RootView.swift index 472d85e..4336271 100644 --- a/apps/ios/Koins/Views/RootView.swift +++ b/apps/ios/Koins/Views/RootView.swift @@ -7,6 +7,7 @@ enum Route: Equatable { case dashboard case send case receive + case accounts } struct SeedBox: Identifiable { @@ -18,7 +19,8 @@ struct SeedBox: Identifiable { struct RootView: View { @EnvironmentObject private var model: AppModel @Environment(\.scenePhase) private var scenePhase - @State private var route: Route = .home + @State private var route: Route = ProcessInfo.processInfo.arguments.contains("-koinsScreenAccounts") + ? .accounts : .home @State private var seedBox: SeedBox? var body: some View { @@ -39,6 +41,8 @@ struct RootView: View { SendView(route: $route) case .receive: ReceiveView(route: $route) + case .accounts: + AccountsView(route: $route) } } .padding(.horizontal, 20) @@ -159,8 +163,11 @@ struct HomeView: View { } if model.chain == .xmr { - SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") { - Task { await model.startMoneroSync() } + HStack(spacing: 10) { + SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") { + Task { await model.startMoneroSync() } + } + SecondaryButton(title: "Addresses") { route = .accounts } } } diff --git a/apps/ios/Koins/Wallet/MoneroService.swift b/apps/ios/Koins/Wallet/MoneroService.swift index ea16f1c..516ae36 100644 --- a/apps/ios/Koins/Wallet/MoneroService.swift +++ b/apps/ios/Koins/Wallet/MoneroService.swift @@ -196,6 +196,53 @@ final class MoneroService { } } + // 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 func startSync() async throws -> Bool {