161 lines
7.1 KiB
Swift
161 lines
7.1 KiB
Swift
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,
|
|
height: (entry["ledger_index"] as? NSNumber)?.uint64Value,
|
|
ledgerIndex: (entry["ledger_index"] as? NSNumber)?.uint64Value,
|
|
sequence: (tx["Sequence"] as? NSNumber)?.uint64Value))
|
|
}
|
|
// Newest first, matching the Monero list.
|
|
return records.sorted { ($0.timestamp ?? .distantFuture) > ($1.timestamp ?? .distantFuture) }
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
} |