Add Monero subaccounts and subaddresses with a QR per address
This commit is contained in:
@@ -14,6 +14,7 @@ final class AppModel: ObservableObject {
|
|||||||
@Published var statusMessage: String?
|
@Published var statusMessage: String?
|
||||||
@Published var moneroReady = false
|
@Published var moneroReady = false
|
||||||
@Published var moneroAddress: String?
|
@Published var moneroAddress: String?
|
||||||
|
@Published var moneroAccounts: [MoneroAccount] = []
|
||||||
/// Rough time remaining for the Monero sync, derived from observed block rate.
|
/// Rough time remaining for the Monero sync, derived from observed block rate.
|
||||||
@Published var syncEta: String?
|
@Published var syncEta: String?
|
||||||
|
|
||||||
@@ -228,8 +229,10 @@ final class AppModel: ObservableObject {
|
|||||||
if force || refreshTick == 1 || refreshTick % 4 == 0 {
|
if force || refreshTick == 1 || refreshTick % 4 == 0 {
|
||||||
async let balanceCall = MoneroService.shared.balance()
|
async let balanceCall = MoneroService.shared.balance()
|
||||||
async let txCall = MoneroService.shared.transactions()
|
async let txCall = MoneroService.shared.transactions()
|
||||||
|
async let accountsCall = MoneroService.shared.accounts()
|
||||||
balance = try await balanceCall
|
balance = try await balanceCall
|
||||||
transactions = try await txCall
|
transactions = try await txCall
|
||||||
|
moneroAccounts = try await accountsCall
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
@@ -345,6 +348,24 @@ final class AppModel: ObservableObject {
|
|||||||
await MoneroService.shared.save()
|
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
|
/// 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.
|
/// never does, so without this a restart re-scans from the beginning.
|
||||||
private func checkpointWalletIfSyncing() async {
|
private func checkpointWalletIfSyncing() async {
|
||||||
|
|||||||
@@ -224,6 +224,59 @@ static void connectDaemon(monero_wallet_full *w) {
|
|||||||
}
|
}
|
||||||
resp[@"transactions"] = entries;
|
resp[@"transactions"] = entries;
|
||||||
}
|
}
|
||||||
|
} else if ([op isEqualToString:@"getAccounts"]) {
|
||||||
|
NSMutableArray *accounts = [NSMutableArray new];
|
||||||
|
if (!g_wallet) {
|
||||||
|
resp[@"accounts"] = accounts;
|
||||||
|
} else {
|
||||||
|
for (const auto &account : g_wallet->get_accounts(true, "")) {
|
||||||
|
NSMutableDictionary *accountDict = [NSMutableDictionary new];
|
||||||
|
accountDict[@"index"] = @(account.m_index ? *account.m_index : 0);
|
||||||
|
accountDict[@"address"] = [NSString stringWithUTF8String:(account.m_primary_address ? *account.m_primary_address : "").c_str()];
|
||||||
|
accountDict[@"balance"] = @((account.m_balance ? *account.m_balance : 0)).stringValue;
|
||||||
|
accountDict[@"unlocked"] = @((account.m_unlocked_balance ? *account.m_unlocked_balance : 0)).stringValue;
|
||||||
|
NSMutableArray *subaddresses = [NSMutableArray new];
|
||||||
|
for (const auto &sub : account.m_subaddresses) {
|
||||||
|
[subaddresses addObject:@{
|
||||||
|
@"accountIndex" : @(sub.m_account_index ? *sub.m_account_index : 0),
|
||||||
|
@"index" : @(sub.m_index ? *sub.m_index : 0),
|
||||||
|
@"address" : [NSString stringWithUTF8String:(sub.m_address ? *sub.m_address : "").c_str()],
|
||||||
|
@"label" : [NSString stringWithUTF8String:(sub.m_label ? *sub.m_label : "").c_str()],
|
||||||
|
@"balance" : @((sub.m_balance ? *sub.m_balance : 0)).stringValue,
|
||||||
|
@"unlocked" : @((sub.m_unlocked_balance ? *sub.m_unlocked_balance : 0)).stringValue,
|
||||||
|
@"isUsed" : @((sub.m_is_used ? *sub.m_is_used : false)),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
accountDict[@"subaddresses"] = subaddresses;
|
||||||
|
[accounts addObject:accountDict];
|
||||||
|
}
|
||||||
|
resp[@"accounts"] = accounts;
|
||||||
|
}
|
||||||
|
} else if ([op isEqualToString:@"createAccount"]) {
|
||||||
|
if (!g_wallet) {
|
||||||
|
resp[@"error"] = @"No wallet open";
|
||||||
|
} else {
|
||||||
|
monero_account account = g_wallet->create_account(std::string([req[@"label"] UTF8String]));
|
||||||
|
g_wallet->save();
|
||||||
|
excludeWalletFilesFromBackup(g_walletPath);
|
||||||
|
resp[@"index"] = @(account.m_index ? *account.m_index : 0);
|
||||||
|
resp[@"address"] = [NSString stringWithUTF8String:(account.m_primary_address ? *account.m_primary_address : "").c_str()];
|
||||||
|
resp[@"balance"] = @((account.m_balance ? *account.m_balance : 0)).stringValue;
|
||||||
|
resp[@"unlocked"] = @((account.m_unlocked_balance ? *account.m_unlocked_balance : 0)).stringValue;
|
||||||
|
}
|
||||||
|
} else if ([op isEqualToString:@"createSubaddress"]) {
|
||||||
|
if (!g_wallet) {
|
||||||
|
resp[@"error"] = @"No wallet open";
|
||||||
|
} else {
|
||||||
|
uint32_t accountIndex = (uint32_t)[req[@"accountIndex"] integerValue];
|
||||||
|
monero_subaddress sub = g_wallet->create_subaddress(accountIndex, std::string([req[@"label"] UTF8String]));
|
||||||
|
g_wallet->save();
|
||||||
|
excludeWalletFilesFromBackup(g_walletPath);
|
||||||
|
resp[@"accountIndex"] = @(sub.m_account_index ? *sub.m_account_index : 0);
|
||||||
|
resp[@"index"] = @(sub.m_index ? *sub.m_index : 0);
|
||||||
|
resp[@"address"] = [NSString stringWithUTF8String:(sub.m_address ? *sub.m_address : "").c_str()];
|
||||||
|
resp[@"label"] = [NSString stringWithUTF8String:(sub.m_label ? *sub.m_label : "").c_str()];
|
||||||
|
}
|
||||||
} else if ([op isEqualToString:@"startSync"]) {
|
} else if ([op isEqualToString:@"startSync"]) {
|
||||||
if (g_wallet) {
|
if (g_wallet) {
|
||||||
connectDaemon(g_wallet);
|
connectDaemon(g_wallet);
|
||||||
|
|||||||
@@ -66,4 +66,30 @@ enum TransactionPriority: Int {
|
|||||||
case .high: return "High"
|
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)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +91,16 @@ enum SelfTest {
|
|||||||
}
|
}
|
||||||
let balance = try await MoneroService.shared.balance()
|
let balance = try await MoneroService.shared.balance()
|
||||||
emit("KOINS_NET xmr balance \(balance.total) unlocked \(balance.unlocked)")
|
emit("KOINS_NET xmr balance \(balance.total) unlocked \(balance.unlocked)")
|
||||||
|
|
||||||
|
let accounts = try await MoneroService.shared.accounts()
|
||||||
|
emit("KOINS_NET xmr accounts \(accounts.count) subaddresses \(accounts.first?.subaddresses.count ?? 0)")
|
||||||
|
let account = try await MoneroService.shared.createAccount(label: "Savings")
|
||||||
|
emit("KOINS_NET xmr new account index \(account.index) address \(account.address.prefix(12))")
|
||||||
|
let subaddress = try await MoneroService.shared.createSubaddress(accountIndex: 0, label: "From Alice")
|
||||||
|
emit("KOINS_NET xmr new subaddress index \(subaddress.index) address \(subaddress.address.prefix(12))")
|
||||||
|
let reloaded = try await MoneroService.shared.accounts()
|
||||||
|
emit("KOINS_NET xmr accounts after \(reloaded.count) subaddresses \(reloaded.first?.subaddresses.count ?? 0)")
|
||||||
|
|
||||||
await MoneroService.shared.stopSync()
|
await MoneroService.shared.stopSync()
|
||||||
await MoneroService.shared.removeWallet(name: "koins-net-selftest")
|
await MoneroService.shared.removeWallet(name: "koins-net-selftest")
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ enum Route: Equatable {
|
|||||||
case dashboard
|
case dashboard
|
||||||
case send
|
case send
|
||||||
case receive
|
case receive
|
||||||
|
case accounts
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SeedBox: Identifiable {
|
struct SeedBox: Identifiable {
|
||||||
@@ -18,7 +19,8 @@ struct SeedBox: Identifiable {
|
|||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@EnvironmentObject private var model: AppModel
|
@EnvironmentObject private var model: AppModel
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@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?
|
@State private var seedBox: SeedBox?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -39,6 +41,8 @@ struct RootView: View {
|
|||||||
SendView(route: $route)
|
SendView(route: $route)
|
||||||
case .receive:
|
case .receive:
|
||||||
ReceiveView(route: $route)
|
ReceiveView(route: $route)
|
||||||
|
case .accounts:
|
||||||
|
AccountsView(route: $route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -159,8 +163,11 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if model.chain == .xmr {
|
if model.chain == .xmr {
|
||||||
SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") {
|
HStack(spacing: 10) {
|
||||||
Task { await model.startMoneroSync() }
|
SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") {
|
||||||
|
Task { await model.startMoneroSync() }
|
||||||
|
}
|
||||||
|
SecondaryButton(title: "Addresses") { route = .accounts }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// MARK: - Sync
|
||||||
|
|
||||||
func startSync() async throws -> Bool {
|
func startSync() async throws -> Bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user