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
+224
View File
@@ -0,0 +1,224 @@
import Foundation
/// Monero amounts are atomic units (1 XMR = 1e12 piconero).
enum MoneroAmount {
static let atomicPerXMR: UInt64 = 1_000_000_000_000
static func xmr(fromAtomic value: String) -> String {
guard let atomic = UInt64(value) else { return "0" }
return xmr(fromAtomic: atomic)
}
static func xmr(fromAtomic value: UInt64) -> String {
let whole = value / atomicPerXMR
let fraction = value % atomicPerXMR
if fraction == 0 { return String(whole) }
var text = String(format: "%012llu", fraction)
while text.hasSuffix("0") { text.removeLast() }
return "\(whole).\(text)"
}
static func atomic(fromXMR value: String) throws -> UInt64 {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !text.hasPrefix("-") else { throw XRPError.rpc("Enter a valid amount") }
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count <= 2 else { throw XRPError.rpc("Enter a valid amount") }
guard let whole = UInt64(parts[0].isEmpty ? "0" : String(parts[0])) else {
throw XRPError.rpc("Enter a valid amount")
}
var fraction: UInt64 = 0
if parts.count == 2 {
let fractionText = String(parts[1])
guard fractionText.count <= 12, fractionText.allSatisfy({ $0.isNumber }) else {
throw XRPError.rpc("XMR supports at most 12 decimal places")
}
let padded = fractionText.padding(toLength: 12, withPad: "0", startingAt: 0)
guard let parsed = UInt64(padded) else { throw XRPError.rpc("Enter a valid amount") }
fraction = parsed
}
let (wholeAtomic, overflow) = whole.multipliedReportingOverflow(by: atomicPerXMR)
guard !overflow else { throw XRPError.rpc("Amount is too large") }
let (total, carry) = wholeAtomic.addingReportingOverflow(fraction)
guard !carry else { throw XRPError.rpc("Amount is too large") }
return total
}
}
/// Swift wrapper around the Objective-C++ monero-cpp bridge.
final class MoneroService {
static let shared = MoneroService()
/// Wallet files that exist on disk without a stored record, so a lost
/// preference file does not strand a wallet.
static func orphanedWalletNames() -> [String] {
let manager = FileManager.default
guard let documents = manager.urls(for: .documentDirectory, in: .userDomainMask).first,
let items = try? manager.contentsOfDirectory(at: documents,
includingPropertiesForKeys: nil) else {
return []
}
let keys = Set(items.filter { $0.pathExtension == "keys" }
.map { $0.deletingPathExtension().lastPathComponent })
return items
.filter { $0.pathExtension.isEmpty && !$0.lastPathComponent.hasPrefix(".") }
.map { $0.lastPathComponent }
.filter { keys.contains($0) }
.sorted()
}
/// The wallet file is encrypted with this password; the recovery seed lives
/// in the Keychain.
static let walletPassword = "koins"
/// Default daemon, kept in sync with the bridge. Used for the cheap
/// daemon-only queries that do not need an open wallet.
static let daemonHost = "xmr-node.cakewallet.com"
/// Current chain tip, so an import can start scanning from now instead of
/// from the genesis block.
func daemonHeight() async throws -> UInt64 {
guard let url = URL(string: "http://\(Self.daemonHost):18081/json_rpc") else {
throw XRPError.network("Invalid Monero daemon address")
}
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_info",
])
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw XRPError.network("Monero daemon is unreachable")
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let result = json["result"] as? [String: Any],
let height = (result["height"] as? NSNumber)?.uint64Value else {
throw XRPError.network("Could not read the Monero chain tip")
}
return height
}
private func invoke(_ request: [String: Any]) async throws -> [String: Any] {
let json = try JSONSerialization.data(withJSONObject: request)
let requestString = String(data: json, encoding: .utf8) ?? "{}"
let responseString: String = await withCheckedContinuation { continuation in
KoinsMoneroWallet.invoke(requestString) { response in
continuation.resume(returning: response)
}
}
guard let data = responseString.data(using: .utf8),
let response = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw XRPError.rpc("Unexpected response from the Monero wallet")
}
if let error = response["error"] as? String, !error.isEmpty {
throw XRPError.rpc(error)
}
return response
}
// MARK: - Wallet lifecycle
func createWallet(name: String) async throws -> (address: String, mnemonic: String) {
let response = try await invoke(["op": "createWallet",
"name": name,
"password": Self.walletPassword,
"language": "English"])
return (response["address"] as? String ?? "", response["mnemonic"] as? String ?? "")
}
func openWallet(name: String) async throws -> String {
let response = try await invoke(["op": "openWallet",
"name": name,
"password": Self.walletPassword])
return response["address"] as? String ?? ""
}
func restoreWallet(name: String,
seed: String,
restoreHeight: UInt64) async throws -> String {
let response = try await invoke(["op": "restoreWallet",
"name": name,
"password": Self.walletPassword,
"seed": seed,
"restoreHeight": restoreHeight])
return response["address"] as? String ?? ""
}
func removeWallet(name: String) async {
_ = try? await invoke(["op": "removeWallet", "name": name])
}
func closeWallet() async {
_ = try? await invoke(["op": "closeWallet"])
}
func seed() async throws -> String {
let response = try await invoke(["op": "getSeed"])
return response["mnemonic"] as? String ?? ""
}
// MARK: - Reads
func balance() async throws -> Balance {
let response = try await invoke(["op": "getBalance"])
let total = response["balance"] as? String ?? "0"
let unlocked = response["unlocked"] as? String ?? total
return Balance(total: MoneroAmount.xmr(fromAtomic: total),
unlocked: MoneroAmount.xmr(fromAtomic: unlocked),
funded: (UInt64(total) ?? 0) > 0)
}
func transactions() async throws -> [TransactionRecord] {
let response = try await invoke(["op": "getTransactions"])
let entries = response["transactions"] as? [[String: Any]] ?? []
return entries.map { entry in
let timestamp = (entry["timestamp"] as? NSNumber)?.doubleValue ?? 0
return TransactionRecord(
id: entry["hash"] as? String ?? UUID().uuidString,
amount: MoneroAmount.xmr(fromAtomic: entry["amount"] as? String ?? "0"),
timestamp: timestamp > 0 ? Date(timeIntervalSince1970: timestamp) : nil,
direction: (entry["direction"] as? String) == "in" ? .incoming : .outgoing,
confirmed: (entry["confirmed"] as? Bool) ?? false,
fee: MoneroAmount.xmr(fromAtomic: entry["fee"] as? String ?? "0"),
tag: nil,
counterparty: nil)
}
}
// MARK: - Sync
func startSync() async throws -> Bool {
let response = try await invoke(["op": "startSync"])
return (response["syncing"] as? Bool) ?? false
}
func stopSync() async {
_ = try? await invoke(["op": "stopSync"])
}
func syncStatus() async throws -> SyncStatus {
let response = try await invoke(["op": "getSyncStatus"])
return SyncStatus(height: (response["height"] as? NSNumber)?.uint64Value ?? 0,
daemonHeight: (response["daemonHeight"] as? NSNumber)?.uint64Value ?? 0,
syncing: (response["syncing"] as? Bool) ?? false,
connected: (response["connected"] as? Bool) ?? false)
}
// MARK: - Sends
func send(address: String, amountXMR: String, priority: TransactionPriority) 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])
return (response["txHash"] as? String ?? "",
MoneroAmount.xmr(fromAtomic: response["fee"] as? String ?? "0"))
}
}
+53
View File
@@ -0,0 +1,53 @@
import Foundation
/// Exact decimal <-> drops conversion (1 XRP = 1_000_000 drops) without
/// floating point rounding errors.
enum XRPAmount {
static let dropsPerXRP: UInt64 = 1_000_000
static let maxDrops: UInt64 = 100_000_000_000 * dropsPerXRP // 1e11 XRP
static func drops(fromXRP value: String) throws -> UInt64 {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !text.hasPrefix("-"), !text.hasPrefix("+") else {
throw XRPError.rpc("Enter a valid amount")
}
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count <= 2 else { throw XRPError.rpc("Enter a valid amount") }
let wholeText = parts[0].isEmpty ? "0" : String(parts[0])
guard let whole = UInt64(wholeText) else { throw XRPError.rpc("Enter a valid amount") }
var fraction: UInt64 = 0
if parts.count == 2 {
let fractionText = String(parts[1])
guard fractionText.count <= 6, fractionText.allSatisfy({ $0.isNumber }) else {
throw XRPError.rpc("XRP supports at most 6 decimal places")
}
let padded = fractionText.padding(toLength: 6, withPad: "0", startingAt: 0)
guard let parsed = UInt64(padded) else { throw XRPError.rpc("Enter a valid amount") }
fraction = parsed
}
let (wholeDrops, overflow) = whole.multipliedReportingOverflow(by: dropsPerXRP)
guard !overflow else { throw XRPError.rpc("Amount is too large") }
let (total, carry) = wholeDrops.addingReportingOverflow(fraction)
guard !carry, total <= maxDrops else { throw XRPError.rpc("Amount is too large") }
return total
}
static func xrp(fromDrops drops: String) -> String {
guard let value = UInt64(drops.trimmingCharacters(in: .whitespacesAndNewlines)) else {
return "0"
}
return xrp(fromDrops: value)
}
static func xrp(fromDrops value: UInt64) -> String {
let whole = value / dropsPerXRP
let fraction = value % dropsPerXRP
if fraction == 0 { return String(whole) }
var fractionText = String(format: "%06llu", fraction)
while fractionText.hasSuffix("0") { fractionText.removeLast() }
return "\(whole).\(fractionText)"
}
}
+136
View File
@@ -0,0 +1,136 @@
import Foundation
/// Canonical XRP Ledger binary serialization for the Payment transactions this
/// wallet sends, plus the signing / transaction-id hashing around it.
struct XRPPayment {
var account: String
var destination: String
var amountDrops: UInt64
var feeDrops: UInt64
var sequence: UInt32
var lastLedgerSequence: UInt32?
var destinationTag: UInt32?
var signingPublicKey: [UInt8]
var signature: [UInt8]?
private static let typeUInt16: UInt8 = 1
private static let typeUInt32: UInt8 = 2
private static let typeAmount: UInt8 = 6
private static let typeBlob: UInt8 = 7
private static let typeAccountID: UInt8 = 8
func serialized(includingSignature: Bool) throws -> [UInt8] {
var output: [UInt8] = []
// Fields must appear in (type, field) order.
output += header(type: Self.typeUInt16, field: 2) // TransactionType
output += uint16(0) // ttPAYMENT
output += header(type: Self.typeUInt32, field: 2) // Flags
output += uint32(0)
output += header(type: Self.typeUInt32, field: 4) // Sequence
output += uint32(sequence)
if let tag = destinationTag {
output += header(type: Self.typeUInt32, field: 14) // DestinationTag
output += uint32(tag)
}
if let lastLedger = lastLedgerSequence {
output += header(type: Self.typeUInt32, field: 27) // LastLedgerSequence
output += uint32(lastLedger)
}
output += header(type: Self.typeAmount, field: 1) // Amount
output += try amount(amountDrops)
output += header(type: Self.typeAmount, field: 8) // Fee
output += try amount(feeDrops)
output += header(type: Self.typeBlob, field: 3) // SigningPubKey
output += variableLength(signingPublicKey)
if includingSignature, let signature {
output += header(type: Self.typeBlob, field: 4) // TxnSignature
output += variableLength(signature)
}
output += header(type: Self.typeAccountID, field: 1) // Account
output += variableLength(try XRPKey.accountId(fromAddress: account))
output += header(type: Self.typeAccountID, field: 3) // Destination
output += variableLength(try XRPKey.accountId(fromAddress: destination))
return output
}
/// Bytes the private key signs (HashPrefix.transactionSig + tx).
func signingPayload() throws -> [UInt8] {
[0x53, 0x54, 0x58, 0x00] + (try serialized(includingSignature: false))
}
/// The signed transaction blob submitted to the network (no hash prefix).
func txBlob() throws -> [UInt8] {
guard signature != nil else { throw XRPError.rpc("Transaction is not signed") }
return try serialized(includingSignature: true)
}
func transactionHash() throws -> [UInt8] {
guard signature != nil else { throw XRPError.rpc("Transaction is not signed") }
let blob = try serialized(includingSignature: true)
return XRPHash.sha512Half([0x54, 0x58, 0x4e, 0x00] + blob)
}
// MARK: - Encoding helpers
private func header(type: UInt8, field: UInt8) -> [UInt8] {
if field < 16 { return [(type << 4) | field] }
return [type << 4, field]
}
private func uint16(_ value: UInt16) -> [UInt8] {
[UInt8(value >> 8), UInt8(value & 0xff)]
}
private func uint32(_ value: UInt32) -> [UInt8] {
[
UInt8((value >> 24) & 0xff),
UInt8((value >> 16) & 0xff),
UInt8((value >> 8) & 0xff),
UInt8(value & 0xff),
]
}
/// Native (XRP) amount: 8 bytes big-endian drops with the XRP flag bit set.
private func amount(_ drops: UInt64) throws -> [UInt8] {
guard drops <= XRPAmount.maxDrops else { throw XRPError.rpc("Amount is too large") }
var bytes = uint64(drops)
bytes[0] |= 0x40
return bytes
}
private func uint64(_ value: UInt64) -> [UInt8] {
var bytes: [UInt8] = []
for shift in stride(from: 56, through: 0, by: -8) {
bytes.append(UInt8((value >> UInt64(shift)) & 0xff))
}
return bytes
}
private func variableLength(_ bytes: [UInt8]) -> [UInt8] {
var output: [UInt8] = []
let count = bytes.count
if count < 192 {
output.append(UInt8(count))
} else if count <= 12_480 {
output.append(UInt8((count >> 8) + 192))
output.append(UInt8(count & 0xff))
} else {
output.append(UInt8((count >> 16) + 240))
output.append(UInt8((count >> 8) & 0xff))
output.append(UInt8(count & 0xff))
}
return output + bytes
}
}
+72
View File
@@ -0,0 +1,72 @@
import Foundation
/// Minimal XRP Ledger JSON-RPC client. A couple of public endpoints are tried
/// in order so a single outage does not break the wallet.
final class XRPClient {
static let shared = XRPClient()
private let endpoints = [
"https://s1.ripple.com:51234/",
"https://s2.ripple.com:51234/",
"https://xrplcluster.com/",
]
private var preferredEndpoint = 0
private let session: URLSession
private init() {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 20
configuration.waitsForConnectivity = false
session = URLSession(configuration: configuration)
}
func rpc(_ method: String, _ params: [String: Any] = [:]) async throws -> [String: Any] {
var lastError: Error = XRPError.network("XRP network is unavailable")
for attempt in 0..<endpoints.count {
let index = (preferredEndpoint + attempt) % endpoints.count
guard let url = URL(string: endpoints[index]) else { continue }
do {
let result = try await send(method, params, to: url)
preferredEndpoint = index
return result
} catch {
lastError = error
}
}
throw lastError
}
private func send(_ method: String, _ params: [String: Any], to url: URL) async throws -> [String: Any] {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = ["method": method, "params": [params], "id": 1]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw XRPError.network("Ledger request failed (\(method))")
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw XRPError.network("Malformed ledger response")
}
let result = json["result"] as? [String: Any] ?? [:]
if let error = result["error"] as? String {
let message = result["error_message"] as? String ?? error
throw XRPError.rpc(message)
}
if let error = json["error"] as? String {
throw XRPError.rpc(json["error_message"] as? String ?? error)
}
return result
}
static func isAccountNotFound(_ error: Error) -> Bool {
if case XRPError.rpc(let message) = error {
return message == "actNotFound" || message.contains("Account not found")
}
return false
}
}
+148
View File
@@ -0,0 +1,148 @@
import CryptoKit
import Foundation
enum XRPAlgorithm: String {
case ed25519
case secp256k1
}
/// Set by the app target so this file stays compilable outside iOS.
enum Secp256k1Backend {
static var derivePrivateKey: ((Data) -> Data?)?
static var publicKey: ((Data) -> Data?)?
static var sign: ((Data, Data) -> Data?)?
static var addScalar: ((Data, Data) -> Data?)?
}
struct XRPKeypair {
let algorithm: XRPAlgorithm
/// ed25519: 32-byte seed. secp256k1: 32-byte scalar.
let privateKey: [UInt8]
/// ed25519: 0xED + 32-byte key. secp256k1: 33-byte compressed point.
let publicKey: [UInt8]
var address: String { XRPKey.address(fromPublicKey: publicKey) }
var signingPublicKeyHex: String { Hex.encode(publicKey) }
}
enum XRPKey {
static let ed25519SeedVersion: [UInt8] = [0x01, 0xe1, 0x4b]
static let familySeedVersion: [UInt8] = [0x21]
static let accountIdVersion: [UInt8] = [0x00]
static let ed25519Prefix: UInt8 = 0xED
// MARK: - Seeds
static func generateSeed(algorithm: XRPAlgorithm = .ed25519) -> String {
var entropy = [UInt8](repeating: 0, count: 16)
for index in entropy.indices { entropy[index] = UInt8.random(in: 0...255) }
let version = algorithm == .ed25519 ? ed25519SeedVersion : familySeedVersion
return XRPBase58.checkEncode(version: version, payload: entropy)
}
static func decodeSeed(_ seed: String) throws -> (entropy: [UInt8], algorithm: XRPAlgorithm) {
let decoded = try XRPBase58.decode(seed.trimmingCharacters(in: .whitespacesAndNewlines))
guard decoded.count > 4 else { throw XRPError.invalidSeed }
let body = Array(decoded[0..<(decoded.count - 4)])
let checksum = Array(decoded[(decoded.count - 4)...])
guard XRPHash.checksum(body) == checksum else { throw XRPError.invalidSeed }
if body.count == ed25519SeedVersion.count + 16, Array(body.prefix(3)) == ed25519SeedVersion {
return (Array(body.dropFirst(3)), .ed25519)
}
if body.count == familySeedVersion.count + 16, body[0] == familySeedVersion[0] {
return (Array(body.dropFirst(1)), .secp256k1)
}
throw XRPError.unsupportedSeed
}
// MARK: - Keys
/// Recovery phrase for a new wallet: 12 BIP-39 words.
static func generateMnemonic() -> String {
BIP39.generateMnemonic()
}
/// Accepts either a 12 word BIP-39 recovery phrase or a legacy XRP family
/// seed (`sEd…` / `s…`).
static func keypair(fromSecret secret: String) throws -> XRPKeypair {
let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
if BIP39.isMnemonic(trimmed) {
return try keypair(fromMnemonic: trimmed)
}
return try keypair(fromSeed: trimmed)
}
/// Derives the account at `m/44'/144'/0'/0/0`, the path hardware and mobile
/// wallets use for XRP.
static func keypair(fromMnemonic mnemonic: String) throws -> XRPKeypair {
guard BIP39.entropy(from: mnemonic) != nil else {
throw XRPError.invalidSeed
}
let seed = BIP39.seed(from: mnemonic)
guard let master = BIP32.masterKey(fromSeed: seed),
let derived = BIP32.derive(BIP32.xrpPath, from: master) else {
throw XRPError.unsupportedSeed
}
return XRPKeypair(algorithm: .secp256k1,
privateKey: derived.privateKey,
publicKey: derived.publicKey)
}
static func keypair(fromSeed seed: String) throws -> XRPKeypair {
let (entropy, algorithm) = try decodeSeed(seed)
switch algorithm {
case .ed25519:
let raw = XRPHash.sha512Half(entropy)
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(raw))
let publicKey = [ed25519Prefix] + Array(privateKey.publicKey.rawRepresentation)
return XRPKeypair(algorithm: .ed25519, privateKey: raw, publicKey: publicKey)
case .secp256k1:
guard let derive = Secp256k1Backend.derivePrivateKey,
let toPublic = Secp256k1Backend.publicKey,
let privateKey = derive(Data(entropy)),
let publicKey = toPublic(privateKey) else {
throw XRPError.unsupportedSeed
}
return XRPKeypair(algorithm: .secp256k1,
privateKey: Array(privateKey),
publicKey: Array(publicKey))
}
}
static func address(fromPublicKey publicKey: [UInt8]) -> String {
let accountId = XRPHash.ripemd160(XRPHash.sha256(publicKey))
return XRPBase58.checkEncode(version: accountIdVersion, payload: accountId)
}
static func isValidAddress(_ address: String) -> Bool {
(try? XRPBase58.checkDecode(address, versionLength: 1)) != nil
}
static func accountId(fromAddress address: String) throws -> [UInt8] {
let payload = try XRPBase58.checkDecode(address, versionLength: 1)
guard payload.count == 20 else { throw XRPError.invalidAddress }
return payload
}
// MARK: - Signing
/// ed25519 returns a 64-byte signature; secp256k1 a DER-encoded one, matching
/// what the XRP Ledger expects for each signing scheme.
static func sign(message: [UInt8], keypair: XRPKeypair) throws -> [UInt8] {
switch keypair.algorithm {
case .ed25519:
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(keypair.privateKey))
let signature = try privateKey.signature(for: Data(message))
return Array(signature)
case .secp256k1:
let digest = XRPHash.sha512Half(message)
guard let signFn = Secp256k1Backend.sign,
let signature = signFn(Data(digest), Data(keypair.privateKey)) else {
throw XRPError.unsupportedSeed
}
return Array(signature)
}
}
}
@@ -0,0 +1,157 @@
import Foundation
/// High level XRP operations: wallet creation/import, balances, history, sends.
final class XRPWalletService {
static let shared = XRPWalletService()
private let rippleEpoch: TimeInterval = 946_684_800
// MARK: - Wallet lifecycle
func createWallet(name: String) throws -> (WalletRecord, String) {
let mnemonic = XRPKey.generateMnemonic()
let keypair = try XRPKey.keypair(fromMnemonic: mnemonic)
let record = WalletRecord(id: "xrp_\(Int(Date().timeIntervalSince1970))",
name: name,
address: keypair.address,
createdAt: Date())
WalletStore.saveWallet(record, chain: .xrp)
WalletStore.saveSecret(mnemonic, chain: .xrp)
return (record, mnemonic)
}
func importWallet(name: String, secret: String) throws -> WalletRecord {
let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
let keypair = try XRPKey.keypair(fromSecret: trimmed)
let record = WalletRecord(id: "xrp_\(Int(Date().timeIntervalSince1970))",
name: name,
address: keypair.address,
createdAt: Date())
WalletStore.saveWallet(record, chain: .xrp)
WalletStore.saveSecret(trimmed, chain: .xrp)
return record
}
func address(forSecret secret: String) throws -> String {
try XRPKey.keypair(fromSecret: secret.trimmingCharacters(in: .whitespacesAndNewlines)).address
}
// MARK: - Reads
func balance(address: String) async throws -> Balance {
do {
let result = try await XRPClient.shared.rpc("account_info",
["account": address,
"ledger_index": "current"])
let accountData = result["account_data"] as? [String: Any] ?? [:]
let drops = accountData["Balance"] as? String ?? "0"
return Balance(total: XRPAmount.xrp(fromDrops: drops), unlocked: "", funded: true)
} catch {
if XRPClient.isAccountNotFound(error) {
return Balance(total: "0", unlocked: "", funded: false)
}
throw error
}
}
func transactions(address: String) async throws -> [TransactionRecord] {
let result = try await XRPClient.shared.rpc("account_tx",
["account": address,
"ledger_index_min": -1,
"ledger_index_max": -1,
"limit": 40])
let entries = result["transactions"] as? [[String: Any]] ?? []
var records: [TransactionRecord] = []
for entry in entries {
guard let tx = entry["tx_json"] as? [String: Any] ?? entry["tx"] as? [String: Any] else { continue }
guard (tx["TransactionType"] as? String) == "Payment" else { continue }
let meta = entry["meta"] as? [String: Any] ?? entry["metaData"] as? [String: Any] ?? [:]
if let txResult = meta["TransactionResult"] as? String, txResult != "tesSUCCESS" { continue }
// IOU / MPT deliveries carry an object; only native XRP is shown.
let delivered = (meta["delivered_amount"] as? String)
?? (meta["DeliveredAmount"] as? String)
?? (tx["Amount"] as? String)
guard let deliveredDrops = delivered else { continue }
let destination = tx["Destination"] as? String ?? ""
let isIncoming = destination == address
let date = (tx["date"] as? NSNumber).map { Date(timeIntervalSince1970: $0.doubleValue + rippleEpoch) }
records.append(TransactionRecord(
id: entry["hash"] as? String ?? tx["hash"] as? String ?? UUID().uuidString,
amount: XRPAmount.xrp(fromDrops: deliveredDrops),
timestamp: date,
direction: isIncoming ? .incoming : .outgoing,
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))
}
return records
}
// MARK: - Sends
func send(secret: String,
to: String,
amountXRP: String,
destinationTag: UInt32?) async throws -> (hash: String, fee: String) {
let destination = to.trimmingCharacters(in: .whitespacesAndNewlines)
guard XRPKey.isValidAddress(destination) else {
throw XRPError.rpc("Enter a valid XRP address")
}
let keypair = try XRPKey.keypair(fromSeed: secret)
let amountDrops = try XRPAmount.drops(fromXRP: amountXRP)
async let infoCall = XRPClient.shared.rpc("account_info",
["account": keypair.address,
"ledger_index": "current"])
async let feeCall = XRPClient.shared.rpc("fee", [:])
async let ledgerCall = XRPClient.shared.rpc("ledger_current", [:])
let info = try await infoCall
let accountData = info["account_data"] as? [String: Any] ?? [:]
guard let sequence = (accountData["Sequence"] as? NSNumber)?.uint32Value else {
throw XRPError.rpc("Could not read the account sequence")
}
let balanceDrops = UInt64((accountData["Balance"] as? String) ?? "0") ?? 0
let feeResult = try await feeCall
let feeDrops = (feeResult["drops"] as? [String: Any])
.flatMap { $0["open_ledger_fee"] as? String }
.flatMap { UInt64($0) } ?? 12
let ledger = try await ledgerCall
let ledgerIndex = (ledger["ledger_current_index"] as? NSNumber)?.uint32Value
guard amountDrops + feeDrops <= balanceDrops else {
throw XRPError.rpc("Insufficient balance to cover the amount and network fee")
}
var payment = XRPPayment(account: keypair.address,
destination: destination,
amountDrops: amountDrops,
feeDrops: feeDrops,
sequence: sequence,
lastLedgerSequence: ledgerIndex.map { $0 + 20 },
destinationTag: destinationTag,
signingPublicKey: keypair.publicKey,
signature: nil)
let signature = try XRPKey.sign(message: payment.signingPayload(), keypair: keypair)
payment.signature = signature
let blob = Hex.encode(try payment.txBlob())
let result = try await XRPClient.shared.rpc("submit", ["tx_blob": blob])
let engineResult = result["engine_result"] as? String ?? ""
guard engineResult.hasPrefix("tes") else {
let message = result["engine_result_message"] as? String ?? "Transaction failed"
throw XRPError.rpc("\(engineResult): \(message)")
}
return (Hex.encode(try payment.transactionHash()), XRPAmount.xrp(fromDrops: feeDrops))
}
}