#import "KoinsMoneroWallet.h" #import #include "monero_wallet_full.h" static std::string walletPath(NSString *name) { NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); NSString *dir = paths.firstObject ?: NSTemporaryDirectory(); NSFileManager *fm = [NSFileManager defaultManager]; if (![fm fileExistsAtPath:dir]) { [fm createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil]; } 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 *g_fallbackDaemons = nil; static std::atomic s_cachedConnected(false); static std::atomic s_cachedDaemonHeight(0); static std::atomic s_lastDaemonProbe(0); static void connectDaemon(monero_wallet_full *w) { if (!w) return; @autoreleasepool { if (!g_fallbackDaemons) { g_fallbackDaemons = @[ @"xmr-node.cakewallet.com:18081", @"node.monerodevs.org:18089", @"monero.stackwallet.com:18081", ]; } NSMutableArray *candidates = [NSMutableArray arrayWithObject:g_daemonURL]; for (NSString *url in g_fallbackDaemons) { if (![url isEqualToString:g_daemonURL]) [candidates addObject:url]; } for (NSString *url in candidates) { try { w->set_daemon_connection(std::string(url.UTF8String)); if (w->is_connected_to_daemon()) { g_daemonURL = url; return; } } catch (...) { // Try the next daemon. } } } } @implementation KoinsMoneroWallet + (void)invoke:(NSString *)jsonRequest completion:(void (^)(NSString *response))completion { static dispatch_queue_t invokeQueue; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ invokeQueue = dispatch_queue_create("com.koins.monero.invoke", DISPATCH_QUEUE_SERIAL); }); dispatch_async(invokeQueue, ^{ NSMutableDictionary *resp = [NSMutableDictionary new]; try { NSData *data = [jsonRequest dataUsingEncoding:NSUTF8StringEncoding]; NSDictionary *req = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; NSString *op = req[@"op"] ?: @""; resp[@"op"] = op; if ([op isEqualToString:@"setDaemon"]) { NSString *url = req[@"url"]; if (url.length > 0) g_daemonURL = url; resp[@"url"] = g_daemonURL; } else if ([op isEqualToString:@"createWallet"]) { NSString *name = req[@"name"]; std::string path = walletPath(name); std::string password = std::string([req[@"password"] UTF8String]); bool exists = false; try { exists = monero_wallet_full::wallet_exists(path); } catch (...) {} if (exists) { g_wallet = monero_wallet_full::open_wallet(path, password, monero_network_type::MAINNET, false); 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; config.m_password = password; config.m_network_type = monero_network_type::MAINNET; config.m_language = std::string([req[@"language"] ?: @"English" UTF8String]); config.m_save_current = false; monero_wallet_full *w = monero_wallet_full::create_wallet(config, nullptr); g_wallet = 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) { try { g_wallet->close(true); } catch (...) {} delete g_wallet; g_wallet = nullptr; } if ([op isEqualToString:@"openWallet"]) { g_wallet = monero_wallet_full::open_wallet( walletPath(req[@"name"]), std::string([req[@"password"] UTF8String]), monero_network_type::MAINNET, false); } else { monero_wallet_config config; config.m_path = walletPath(req[@"name"]); config.m_password = std::string([req[@"password"] UTF8String]); config.m_network_type = monero_network_type::MAINNET; config.m_seed = std::string([req[@"seed"] UTF8String]); config.m_restore_height = (uint64_t)[req[@"restoreHeight"] longLongValue]; config.m_language = "English"; config.m_save_current = false; 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) { resp[@"mnemonic"] = [NSString stringWithUTF8String:g_wallet->get_seed().c_str()]; } else { resp[@"error"] = @"No wallet open"; } } else if ([op isEqualToString:@"removeWallet"]) { NSString *name = req[@"name"]; if (g_wallet) { try { g_wallet->close(false); } catch (...) {} delete g_wallet; g_wallet = nullptr; } NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); NSString *dir = paths.firstObject; NSFileManager *fm = [NSFileManager defaultManager]; for (NSString *ext in @[ @"", @".keys", @".address.txt", @".mms" ]) { NSString *file = [dir stringByAppendingPathComponent:[name stringByAppendingString:ext]]; [fm removeItemAtPath:file error:nil]; } } else if ([op isEqualToString:@"closeWallet"]) { if (g_wallet) { try { g_wallet->close(true); } catch (...) {} 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; try { balance = g_wallet->get_balance(); unlocked = g_wallet->get_unlocked_balance(); } catch (const std::exception &e) { os_log_error(OS_LOG_DEFAULT, "[koins] getBalance: %{public}s", e.what()); } resp[@"balance"] = @(balance).stringValue; resp[@"unlocked"] = @(unlocked).stringValue; } else { resp[@"error"] = @"No wallet open"; } } else if ([op isEqualToString:@"getAddress"]) { if (g_wallet) { resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()]; } else { resp[@"error"] = @"No wallet open"; } } else if ([op isEqualToString:@"getTransactions"]) { NSMutableArray *entries = [NSMutableArray new]; if (!g_wallet) { resp[@"transactions"] = entries; } else { try { monero_transfer_query query; for (const auto &transfer : g_wallet->get_transfers(query)) { const auto &tx = transfer->m_tx; const auto isIn = transfer->is_incoming(); boost::optional 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, }]; } } catch (const std::exception &e) { os_log_error(OS_LOG_DEFAULT, "[koins] getTransactions: %{public}s", e.what()); } resp[@"transactions"] = entries; } } else if ([op isEqualToString:@"startSync"]) { if (g_wallet) { connectDaemon(g_wallet); try { if (g_wallet->is_connected_to_daemon()) { g_wallet->start_syncing(5000); resp[@"syncing"] = @YES; } else { resp[@"syncing"] = @NO; } } catch (const std::exception &e) { resp[@"syncing"] = @NO; } } else { resp[@"error"] = @"No wallet open"; } } else if ([op isEqualToString:@"stopSync"]) { try { if (g_wallet) g_wallet->stop_syncing(); } catch (...) {} } else if ([op isEqualToString:@"getSyncStatus"]) { uint64_t height = 0, daemonHeight = 0; bool connected = false; if (g_wallet) { try { height = g_wallet->get_height(); // is_connected_to_daemon performs a live round trip; public // daemons rate limit aggressive polling, so cache for 10s. long long now = (long long)([[NSDate date] timeIntervalSince1970] * 1000); if (now - s_lastDaemonProbe.load() > 10000) { s_lastDaemonProbe.store(now); connected = g_wallet->is_connected_to_daemon(); s_cachedConnected = connected; } else { connected = s_cachedConnected; } daemonHeight = s_cachedDaemonHeight; if (connected) { daemonHeight = g_wallet->get_daemon_height(); s_cachedDaemonHeight = daemonHeight; } } catch (const std::exception &e) { os_log_error(OS_LOG_DEFAULT, "[koins] getSyncStatus: %{public}s", e.what()); } } resp[@"height"] = @(height); resp[@"daemonHeight"] = @(daemonHeight); resp[@"syncing"] = @(connected && height < daemonHeight); resp[@"connected"] = @(connected); } else if ([op isEqualToString:@"transferTo"]) { if (!g_wallet) { resp[@"error"] = @"No wallet open"; } else { monero_tx_config config; auto destination = std::make_shared(); destination->m_address = std::string([req[@"address"] UTF8String]); destination->m_amount = (uint64_t)[req[@"amount"] longLongValue]; config.m_destinations = { destination }; config.m_priority = (monero_tx_priority)[req[@"priority"] integerValue]; 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; } } else { resp[@"error"] = [NSString stringWithFormat:@"Unknown op: %@", op]; } } catch (const std::exception &e) { os_log_error(OS_LOG_DEFAULT, "[koins] monero %{public}@ failed: %{public}s", resp[@"op"], e.what()); resp[@"error"] = [NSString stringWithUTF8String:e.what()]; } catch (...) { resp[@"error"] = @"Unknown native exception"; } NSData *out = [NSJSONSerialization dataWithJSONObject:resp options:0 error:nil]; NSString *outStr = [[NSString alloc] initWithData:out encoding:NSUTF8StringEncoding]; completion(outStr ?: @"{\"error\":\"encode failed\"}"); }); } @end