Replace the JavaScript mobile app with a native Swift iOS wallet

This commit is contained in:
2026-09-19 13:33:11 +08:00
parent d80b58b704
commit 3e1a39d4d7
79 changed files with 25482 additions and 4023 deletions
@@ -0,0 +1,2 @@
#import "KoinsMoneroWallet.h"
#import "KoinsSecp256k1.h"
+16
View File
@@ -0,0 +1,16 @@
#import <Foundation/Foundation.h>
#import "KoinsSecp256k1.h"
NS_ASSUME_NONNULL_BEGIN
/// Objective-C++ bridge to the on-device monero-cpp wallet. A single JSON
/// in/out entry point keeps the C++ surface out of Swift; work is serialized
/// on a private queue because a monero_wallet_full is not thread safe.
@interface KoinsMoneroWallet : NSObject
+ (void)invoke:(NSString *)jsonRequest
completion:(void (^)(NSString *response))completion;
@end
NS_ASSUME_NONNULL_END
+276
View File
@@ -0,0 +1,276 @@
#import "KoinsMoneroWallet.h"
#import <os/log.h>
#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);
}
static monero_wallet_full *g_wallet = nullptr;
static NSString *g_daemonURL = @"xmr-node.cakewallet.com:18081";
static NSArray<NSString *> *g_fallbackDaemons = nil;
static std::atomic<bool> s_cachedConnected(false);
static std::atomic<unsigned long long> s_cachedDaemonHeight(0);
static std::atomic<long long> 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<NSString *> *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 (...) {}
} 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()];
}
} 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);
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;
}
} 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<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,
}];
}
} 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<monero_destination>();
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
+25
View File
@@ -0,0 +1,25 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// secp256k1 helpers used by the XRP wallet. Backed by the OpenSSL build that
/// already ships inside the on-device monero static library, so no extra
/// dependency is linked.
@interface KoinsSecp256k1 : NSObject
/// XRP account private scalar derived from a 16-byte family seed entropy.
+ (nullable NSData *)privateKeyFromEntropy:(NSData *)entropy;
/// 33-byte compressed public key for a 32-byte private scalar.
+ (nullable NSData *)publicKeyFromPrivateKey:(NSData *)privateKey;
/// DER-encoded, low-S ECDSA signature over a 32-byte digest.
+ (nullable NSData *)signDigest:(NSData *)digest
withPrivateKey:(NSData *)privateKey;
/// (left + right) mod n, used by BIP-32 hardened child derivation.
+ (nullable NSData *)addScalar:(NSData *)left toScalar:(NSData *)right;
@end
NS_ASSUME_NONNULL_END
+187
View File
@@ -0,0 +1,187 @@
#import "KoinsSecp256k1.h"
#import <openssl/bn.h>
#import <openssl/ec.h>
#import <openssl/ecdsa.h>
#import <openssl/obj_mac.h>
#import <openssl/sha.h>
// secp256k1 group order.
static const char *kCurveOrderHex =
"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141";
static BIGNUM *KoinsCurveOrder(void) {
BIGNUM *order = NULL;
BN_hex2bn(&order, kCurveOrderHex);
return order;
}
static NSData *KoinsSHA512Half(NSData *input) {
unsigned char digest[SHA512_DIGEST_LENGTH];
SHA512((const unsigned char *)input.bytes, (size_t)input.length, digest);
return [NSData dataWithBytes:digest length:32];
}
// Mirror of ripple-keypairs deriveScalar: hash the bytes (optionally with a
// 32-bit discriminator) until the result is a valid non-zero curve scalar.
static BIGNUM *KoinsDeriveScalar(NSData *bytes, BOOL hasDiscriminator,
uint32_t discriminator, const BIGNUM *order) {
for (uint32_t i = 0; i <= 4294967295u; i++) {
NSMutableData *input = [NSMutableData dataWithData:bytes];
if (hasDiscriminator) {
uint32_t d = htonl(discriminator);
[input appendBytes:&d length:4];
}
uint32_t idx = htonl(i);
[input appendBytes:&idx length:4];
NSData *half = KoinsSHA512Half(input);
BIGNUM *scalar = BN_bin2bn((const unsigned char *)half.bytes, (int)half.length, NULL);
if (scalar && !BN_is_zero(scalar) && BN_cmp(scalar, order) < 0) {
return scalar;
}
if (scalar) BN_free(scalar);
}
return NULL;
}
@implementation KoinsSecp256k1
+ (nullable NSData *)privateKeyFromEntropy:(NSData *)entropy {
if (entropy.length != 16) return nil;
BN_CTX *ctx = BN_CTX_new();
BIGNUM *order = KoinsCurveOrder();
EC_GROUP *group = EC_GROUP_new_by_curve_name(NID_secp256k1);
EC_POINT *publicGen = EC_POINT_new(group);
NSData *result = nil;
BIGNUM *privateGen = KoinsDeriveScalar(entropy, NO, 0, order);
if (privateGen && EC_POINT_mul(group, publicGen, privateGen, NULL, NULL, ctx)) {
unsigned char compressed[33];
size_t written = EC_POINT_point2oct(group, publicGen,
POINT_CONVERSION_COMPRESSED,
compressed, sizeof(compressed), ctx);
if (written == sizeof(compressed)) {
NSData *publicGenBytes = [NSData dataWithBytes:compressed length:written];
BIGNUM *offset = KoinsDeriveScalar(publicGenBytes, YES, 0, order);
if (offset) {
BIGNUM *derived = BN_new();
if (BN_mod_add(derived, offset, privateGen, order, ctx)) {
unsigned char out[32] = {0};
BN_bn2binpad(derived, out, sizeof(out));
result = [NSData dataWithBytes:out length:sizeof(out)];
}
BN_free(derived);
BN_free(offset);
}
}
}
if (privateGen) BN_free(privateGen);
if (publicGen) EC_POINT_free(publicGen);
if (group) EC_GROUP_free(group);
if (order) BN_free(order);
if (ctx) BN_CTX_free(ctx);
return result;
}
+ (nullable NSData *)publicKeyFromPrivateKey:(NSData *)privateKey {
if (privateKey.length != 32) return nil;
BN_CTX *ctx = BN_CTX_new();
EC_GROUP *group = EC_GROUP_new_by_curve_name(NID_secp256k1);
EC_POINT *point = EC_POINT_new(group);
BIGNUM *scalar = BN_bin2bn((const unsigned char *)privateKey.bytes, (int)privateKey.length, NULL);
NSData *result = nil;
if (scalar && EC_POINT_mul(group, point, scalar, NULL, NULL, ctx)) {
unsigned char compressed[33];
size_t written = EC_POINT_point2oct(group, point,
POINT_CONVERSION_COMPRESSED,
compressed, sizeof(compressed), ctx);
if (written == sizeof(compressed)) {
result = [NSData dataWithBytes:compressed length:written];
}
}
if (scalar) BN_free(scalar);
if (point) EC_POINT_free(point);
if (group) EC_GROUP_free(group);
if (ctx) BN_CTX_free(ctx);
return result;
}
+ (nullable NSData *)addScalar:(NSData *)left toScalar:(NSData *)right {
if (left.length != 32 || right.length != 32) return nil;
BN_CTX *ctx = BN_CTX_new();
BIGNUM *order = KoinsCurveOrder();
BIGNUM *a = BN_bin2bn((const unsigned char *)left.bytes, (int)left.length, NULL);
BIGNUM *b = BN_bin2bn((const unsigned char *)right.bytes, (int)right.length, NULL);
BIGNUM *sum = BN_new();
NSData *result = nil;
if (a && b && sum && BN_mod_add(sum, a, b, order, ctx)) {
unsigned char out[32] = {0};
BN_bn2binpad(sum, out, sizeof(out));
result = [NSData dataWithBytes:out length:sizeof(out)];
}
if (sum) BN_free(sum);
if (b) BN_free(b);
if (a) BN_free(a);
if (order) BN_free(order);
if (ctx) BN_CTX_free(ctx);
return result;
}
+ (nullable NSData *)signDigest:(NSData *)digest
withPrivateKey:(NSData *)privateKey {
if (digest.length != 32 || privateKey.length != 32) return nil;
BN_CTX *ctx = BN_CTX_new();
EC_KEY *key = EC_KEY_new_by_curve_name(NID_secp256k1);
BIGNUM *scalar = BN_bin2bn((const unsigned char *)privateKey.bytes, (int)privateKey.length, NULL);
NSData *result = nil;
if (key && scalar && EC_KEY_set_private_key(key, scalar)) {
const EC_GROUP *group = EC_KEY_get0_group(key);
EC_POINT *pub = EC_POINT_new(group);
if (pub && EC_POINT_mul(group, pub, scalar, NULL, NULL, ctx) &&
EC_KEY_set_public_key(key, pub)) {
ECDSA_SIG *sig = ECDSA_do_sign((const unsigned char *)digest.bytes, (int)digest.length, key);
if (sig) {
// Ripple only accepts canonical (low-S) signatures.
BIGNUM *order = KoinsCurveOrder();
BIGNUM *halfOrder = BN_dup(order);
BN_rshift1(halfOrder, halfOrder);
const BIGNUM *r = NULL;
const BIGNUM *s = NULL;
ECDSA_SIG_get0(sig, &r, &s);
if (BN_cmp(s, halfOrder) > 0) {
BIGNUM *lowS = BN_new();
BN_sub(lowS, order, s);
ECDSA_SIG_set0(sig, BN_dup(r), lowS);
}
unsigned char *der = NULL;
int derLen = i2d_ECDSA_SIG(sig, &der);
if (derLen > 0) {
result = [NSData dataWithBytes:der length:(NSUInteger)derLen];
OPENSSL_free(der);
}
BN_free(halfOrder);
BN_free(order);
ECDSA_SIG_free(sig);
}
}
if (pub) EC_POINT_free(pub);
}
if (scalar) BN_free(scalar);
if (key) EC_KEY_free(key);
if (ctx) BN_CTX_free(ctx);
return result;
}
@end