1512 lines
56 KiB
Swift
1512 lines
56 KiB
Swift
import Foundation
|
|
import CommonCrypto
|
|
import CashuDevKit
|
|
import ZeusCashuRestore
|
|
|
|
/// CashuDevKit Native Module for React Native
|
|
/// Provides bridge to CDK FFI bindings
|
|
@objc(CashuDevKitModule)
|
|
class CashuDevKitModule: RCTEventEmitter {
|
|
|
|
// MARK: - Properties
|
|
|
|
private var wallet: MultiMintWallet?
|
|
private var db: WalletSqliteDatabase?
|
|
private var preparedSends: [String: PreparedSend] = [:]
|
|
private var isInitialized: Bool = false
|
|
|
|
// Serial queue for thread-safe wallet access
|
|
private let walletQueue = DispatchQueue(label: "app.zeusln.cashudevkit.wallet")
|
|
|
|
// MARK: - Module Setup
|
|
|
|
@objc
|
|
override static func moduleName() -> String! {
|
|
return "CashuDevKitModule"
|
|
}
|
|
|
|
@objc
|
|
override static func requiresMainQueueSetup() -> Bool {
|
|
return false
|
|
}
|
|
|
|
// MARK: - Helper Methods
|
|
|
|
/// Parse P2PK spending conditions from JSON dictionary
|
|
private func parseP2PKConditions(from json: [String: Any]) -> SpendingConditions? {
|
|
guard let kind = json["kind"] as? String,
|
|
kind == "P2PK",
|
|
let condData = json["data"] as? [String: Any],
|
|
let pubkey = condData["pubkey"] as? String,
|
|
!pubkey.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
let locktime: UInt64 = {
|
|
if let lt = condData["locktime"] as? NSNumber {
|
|
return lt.uint64Value
|
|
}
|
|
return 0
|
|
}()
|
|
|
|
let refundKeys: [String] = {
|
|
if let keys = condData["refund_keys"] as? [String] {
|
|
return keys.filter { !$0.isEmpty }
|
|
}
|
|
return []
|
|
}()
|
|
|
|
let conditions = Conditions(
|
|
locktime: locktime,
|
|
pubkeys: [],
|
|
refundKeys: refundKeys,
|
|
numSigs: 0,
|
|
sigFlag: 0,
|
|
numSigsRefund: 0
|
|
)
|
|
|
|
return .p2pk(pubkey: pubkey, conditions: conditions)
|
|
}
|
|
|
|
private func readPositiveUInt64(from json: [String: Any], key: String) -> UInt64 {
|
|
guard let raw = json[key] else {
|
|
return 0
|
|
}
|
|
|
|
if raw is Bool {
|
|
return 0
|
|
}
|
|
|
|
if let number = raw as? NSNumber {
|
|
let value = number.int64Value
|
|
return value > 0 ? UInt64(value) : 0
|
|
}
|
|
|
|
if let string = raw as? String,
|
|
let value = UInt64(string),
|
|
value > 0 {
|
|
return value
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
private func parseMeltOptions(from optionsJson: String?) -> MeltOptions? {
|
|
guard
|
|
let json = optionsJson,
|
|
let data = json.data(using: .utf8),
|
|
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
if let mpp = parsed["mpp"] as? [String: Any] {
|
|
let amount = readPositiveUInt64(from: mpp, key: "amount")
|
|
if amount > 0 {
|
|
return .mpp(amount: Amount(value: amount))
|
|
}
|
|
}
|
|
|
|
if let amountless = parsed["amountless"] as? [String: Any] {
|
|
let amountMsat = readPositiveUInt64(from: amountless, key: "amount_msat")
|
|
if amountMsat > 0 {
|
|
return .amountless(
|
|
amountMsat: Amount(value: amountMsat)
|
|
)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Returns the initialized wallet or rejects with NO_WALLET error
|
|
private func getInitializedWallet(reject: @escaping RCTPromiseRejectBlock) -> MultiMintWallet? {
|
|
guard isInitialized, let wallet = wallet else {
|
|
reject("NO_WALLET", "Wallet not initialized", nil)
|
|
return nil
|
|
}
|
|
return wallet
|
|
}
|
|
|
|
private var currentDbPath: String?
|
|
|
|
private func getDatabasePath(for mnemonic: String) -> String {
|
|
let paths = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
|
|
let appSupport = paths[0]
|
|
|
|
// Ensure directory exists
|
|
try? FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true)
|
|
|
|
// Hash the mnemonic to create a unique, deterministic filename per wallet
|
|
let data = Data(mnemonic.utf8)
|
|
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
|
_ = data.withUnsafeBytes { CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) }
|
|
let hashHex = hash.prefix(8).map { String(format: "%02x", $0) }.joined()
|
|
|
|
let dbPath = appSupport.appendingPathComponent("cashu_wallet_\(hashHex).db")
|
|
return dbPath.path
|
|
}
|
|
|
|
private func encodeToJson(_ object: Any) -> String? {
|
|
guard let data = try? JSONSerialization.data(withJSONObject: object) else {
|
|
return nil
|
|
}
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
private func parseCurrencyUnit(_ unit: String) -> CurrencyUnit {
|
|
switch unit.lowercased() {
|
|
case "sat":
|
|
return .sat
|
|
case "msat":
|
|
return .msat
|
|
case "usd":
|
|
return .usd
|
|
case "eur":
|
|
return .eur
|
|
default:
|
|
return .sat
|
|
}
|
|
}
|
|
|
|
private func quoteStateToString(_ state: QuoteState) -> String {
|
|
switch state {
|
|
case .unpaid:
|
|
return "Unpaid"
|
|
case .paid:
|
|
return "Paid"
|
|
case .pending:
|
|
return "Pending"
|
|
case .issued:
|
|
return "Issued"
|
|
@unknown default:
|
|
return "Unknown"
|
|
}
|
|
}
|
|
|
|
private func currencyUnitToString(_ unit: CurrencyUnit) -> String {
|
|
switch unit {
|
|
case .sat:
|
|
return "sat"
|
|
case .msat:
|
|
return "msat"
|
|
case .usd:
|
|
return "usd"
|
|
case .eur:
|
|
return "eur"
|
|
case .auth:
|
|
return "auth"
|
|
case .custom(let unit):
|
|
return unit
|
|
@unknown default:
|
|
return "sat"
|
|
}
|
|
}
|
|
|
|
private func mapFfiError(_ error: FfiError) -> (code: String, message: String) {
|
|
switch error {
|
|
case .Generic(let message):
|
|
return ("GENERIC_ERROR", message)
|
|
case .AmountOverflow(let message):
|
|
return ("AMOUNT_OVERFLOW", message)
|
|
case .PaymentFailed(let message):
|
|
return ("PAYMENT_FAILED", message)
|
|
case .PaymentPending(let message):
|
|
return ("PAYMENT_PENDING", message)
|
|
case .InsufficientFunds(let message):
|
|
return ("INSUFFICIENT_FUNDS", message)
|
|
case .Database(let message):
|
|
return ("DATABASE_ERROR", message)
|
|
case .Network(let message):
|
|
return ("NETWORK_ERROR", message)
|
|
case .InvalidToken(let message):
|
|
return ("INVALID_TOKEN", message)
|
|
case .Wallet(let message):
|
|
return ("WALLET_ERROR", message)
|
|
case .KeysetUnknown(let message):
|
|
return ("KEYSET_UNKNOWN", message)
|
|
case .UnitNotSupported(let message):
|
|
return ("UNIT_NOT_SUPPORTED", message)
|
|
case .InvalidMnemonic(let message):
|
|
return ("INVALID_MNEMONIC", message)
|
|
case .InvalidUrl(let message):
|
|
return ("INVALID_URL", message)
|
|
case .DivisionByZero(let message):
|
|
return ("DIVISION_BY_ZERO", message)
|
|
case .Amount(let message):
|
|
return ("AMOUNT_ERROR", message)
|
|
case .RuntimeTaskJoin(let message):
|
|
return ("RUNTIME_ERROR", message)
|
|
case .InvalidHex(let message):
|
|
return ("INVALID_HEX", message)
|
|
case .InvalidCryptographicKey(let message):
|
|
return ("INVALID_KEY", message)
|
|
case .Serialization(let message):
|
|
return ("SERIALIZATION_ERROR", message)
|
|
@unknown default:
|
|
return ("UNKNOWN_ERROR", "Unknown error occurred")
|
|
}
|
|
}
|
|
|
|
private func encodeMintQuote(_ quote: MintQuote) -> [String: Any] {
|
|
return [
|
|
"id": quote.id,
|
|
"amount": quote.amount?.value ?? 0,
|
|
"unit": currencyUnitToString(quote.unit),
|
|
"request": quote.request,
|
|
"state": quoteStateToString(quote.state),
|
|
"expiry": quote.expiry,
|
|
"mint_url": quote.mintUrl.url
|
|
]
|
|
}
|
|
|
|
private func encodeMeltQuote(_ quote: MeltQuote) -> [String: Any] {
|
|
var result: [String: Any] = [
|
|
"id": quote.id,
|
|
"amount": quote.amount.value,
|
|
"unit": currencyUnitToString(quote.unit),
|
|
"request": quote.request,
|
|
"fee_reserve": quote.feeReserve.value,
|
|
"state": quoteStateToString(quote.state),
|
|
"expiry": quote.expiry
|
|
]
|
|
if let preimage = quote.paymentPreimage {
|
|
result["payment_preimage"] = preimage
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func encodeMelted(_ melted: Melted) -> [String: Any] {
|
|
var result: [String: Any] = [
|
|
"state": quoteStateToString(melted.state),
|
|
"amount": melted.amount.value,
|
|
"fee_paid": melted.feePaid.value
|
|
]
|
|
if let preimage = melted.preimage {
|
|
result["preimage"] = preimage
|
|
}
|
|
if let change = melted.change {
|
|
result["change"] = change.map { encodeProof($0) }
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func encodeProof(_ proof: Proof) -> [String: Any] {
|
|
let result: [String: Any] = [
|
|
"amount": proof.amount.value,
|
|
"secret": proof.secret,
|
|
"c": proof.c,
|
|
"keyset_id": proof.keysetId
|
|
]
|
|
// Witness and DLEQ are optional
|
|
return result
|
|
}
|
|
|
|
private func encodeToken(_ token: Token) async throws -> [String: Any] {
|
|
let value = try token.value()
|
|
let mintUrl = try token.mintUrl()
|
|
var result: [String: Any] = [
|
|
"encoded": token.encode(),
|
|
"value": value.value,
|
|
"mint_url": mintUrl.url,
|
|
"memo": token.memo() ?? "",
|
|
"unit": token.unit().map { currencyUnitToString($0) } ?? "sat"
|
|
]
|
|
do {
|
|
if let wallet = self.wallet {
|
|
let keysets = try await wallet.getMintKeysets(mintUrl: mintUrl)
|
|
let proofs = try token.proofs(mintKeysets: keysets)
|
|
result["proofs"] = proofs.map { encodeProof($0) }
|
|
}
|
|
} catch {
|
|
result["proofs"] = []
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func encodeMintInfo(_ info: MintInfo) -> [String: Any] {
|
|
var result: [String: Any] = [:]
|
|
|
|
if let name = info.name {
|
|
result["name"] = name
|
|
}
|
|
if let pubkey = info.pubkey {
|
|
result["pubkey"] = pubkey
|
|
}
|
|
if let version = info.version {
|
|
result["version"] = version
|
|
}
|
|
if let description = info.description {
|
|
result["description"] = description
|
|
}
|
|
if let descriptionLong = info.descriptionLong {
|
|
result["description_long"] = descriptionLong
|
|
}
|
|
if let motd = info.motd {
|
|
result["motd"] = motd
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private func encodeKeyset(_ keyset: KeySetInfo) -> [String: Any] {
|
|
return [
|
|
"id": keyset.id,
|
|
"unit": currencyUnitToString(keyset.unit),
|
|
"active": keyset.active,
|
|
"input_fee_ppk": keyset.inputFeePpk
|
|
]
|
|
}
|
|
|
|
// MARK: - Database Path
|
|
|
|
@objc(getDatabasePath:rejecter:)
|
|
func getDatabasePath(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
resolve(currentDbPath ?? "")
|
|
}
|
|
|
|
// MARK: - Wallet Management
|
|
|
|
@objc(initializeWallet:unit:resolver:rejecter:)
|
|
func initializeWallet(_ mnemonic: String, unit: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
Task {
|
|
do {
|
|
let dbPath = getDatabasePath(for: mnemonic)
|
|
self.currentDbPath = dbPath
|
|
let sqliteDb = try WalletSqliteDatabase(filePath: dbPath)
|
|
self.db = sqliteDb
|
|
|
|
let currencyUnit = parseCurrencyUnit(unit)
|
|
|
|
// WalletSqliteDatabase conforms to WalletDatabase protocol, pass directly
|
|
let newWallet = try MultiMintWallet(
|
|
unit: currencyUnit,
|
|
mnemonic: mnemonic,
|
|
db: sqliteDb
|
|
)
|
|
|
|
walletQueue.sync {
|
|
self.wallet = newWallet
|
|
self.isInitialized = true
|
|
}
|
|
|
|
resolve(nil)
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("INIT_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(addMint:targetProofCount:resolver:rejecter:)
|
|
func addMint(_ mintUrl: String, targetProofCount: NSNumber,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
// Use nil if targetProofCount is 0 or negative (sentinel for "use default")
|
|
let count: UInt32? = targetProofCount.intValue > 0 ? targetProofCount.uint32Value : nil
|
|
try await wallet.addMint(mintUrl: url, targetProofCount: count)
|
|
resolve(nil)
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("ADD_MINT_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(removeMint:resolver:rejecter:)
|
|
func removeMint(_ mintUrl: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
await wallet.removeMint(mintUrl: url)
|
|
if let db = self.db {
|
|
try await db.removeMint(mintUrl: url)
|
|
} else {
|
|
reject(
|
|
"DATABASE_NOT_INITIALIZED",
|
|
"Database not initialized",
|
|
nil
|
|
)
|
|
return
|
|
}
|
|
resolve(nil)
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("REMOVE_MINT_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(getMintUrls:rejecter:)
|
|
func getMintUrls(resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
let urls = await wallet.getMintUrls()
|
|
resolve(urls)
|
|
}
|
|
}
|
|
|
|
// MARK: - Balance Operations
|
|
|
|
@objc(getTotalBalance:rejecter:)
|
|
func getTotalBalance(resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let balances = try await wallet.getBalances()
|
|
var total: UInt64 = 0
|
|
for (_, amount) in balances {
|
|
total += amount.value
|
|
}
|
|
resolve(NSNumber(value: total))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("BALANCE_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(getBalances:rejecter:)
|
|
func getBalances(resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let balances = try await wallet.getBalances()
|
|
var result: [String: UInt64] = [:]
|
|
for (mintUrl, amount) in balances {
|
|
result[mintUrl] = amount.value
|
|
}
|
|
resolve(encodeToJson(result))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("BALANCE_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Mint Info
|
|
|
|
@objc(fetchMintInfo:resolver:rejecter:)
|
|
func fetchMintInfo(_ mintUrl: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
// fetchMintInfo uses direct HTTP - works without wallet initialization
|
|
Task {
|
|
do {
|
|
// Normalize URL and construct info endpoint
|
|
let normalizedUrl = mintUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
let infoUrl = normalizedUrl + "/v1/info"
|
|
|
|
guard let requestUrl = URL(string: infoUrl) else {
|
|
reject("INVALID_URL", "Invalid mint URL: \(mintUrl)", nil)
|
|
return
|
|
}
|
|
|
|
let (data, response) = try await URLSession.shared.data(from: requestUrl)
|
|
|
|
// Check HTTP response status
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
guard (200...299).contains(httpResponse.statusCode) else {
|
|
reject("HTTP_ERROR", "Mint returned HTTP \(httpResponse.statusCode)", nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Parse JSON response
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
reject("PARSE_ERROR", "Failed to parse mint info response", nil)
|
|
return
|
|
}
|
|
|
|
// Encode and resolve
|
|
if let encoded = encodeToJson(json) {
|
|
resolve(encoded)
|
|
} else {
|
|
reject("ENCODE_ERROR", "Failed to encode mint info", nil)
|
|
}
|
|
} catch {
|
|
reject("NETWORK_ERROR", "Failed to fetch mint info: \(error.localizedDescription)", error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(getMintKeysets:resolver:rejecter:)
|
|
func getMintKeysets(_ mintUrl: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let keysets = try await wallet.getMintKeysets(mintUrl: url)
|
|
let encoded = keysets.map { encodeKeyset($0) }
|
|
resolve(encodeToJson(encoded))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("KEYSETS_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Mint Quotes (Receiving)
|
|
|
|
@objc(createMintQuote:amount:description:resolver:rejecter:)
|
|
func createMintQuote(_ mintUrl: String, amount: NSNumber, description: String?,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let amt = Amount(value: amount.uint64Value)
|
|
let quote = try await wallet.mintQuote(mintUrl: url, amount: amt, description: description)
|
|
resolve(encodeToJson(encodeMintQuote(quote)))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("MINT_QUOTE_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(checkMintQuote:quoteId:resolver:rejecter:)
|
|
func checkMintQuote(_ mintUrl: String, quoteId: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let quote = try await wallet.checkMintQuote(mintUrl: url, quoteId: quoteId)
|
|
resolve(encodeToJson(encodeMintQuote(quote)))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("CHECK_MINT_QUOTE_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check mint quote status directly from the mint's HTTP API.
|
|
/// This bypasses the local database check and works for external quotes
|
|
/// (e.g., quotes created by ZeusPay server).
|
|
@objc(checkExternalMintQuote:quoteId:resolver:rejecter:)
|
|
func checkExternalMintQuote(_ mintUrl: String, quoteId: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
Task {
|
|
do {
|
|
// Normalize mint URL and construct the quote endpoint
|
|
let normalizedUrl = mintUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
let quoteUrlStr = "\(normalizedUrl)/v1/mint/quote/bolt11/\(quoteId)"
|
|
|
|
guard let quoteUrl = URL(string: quoteUrlStr) else {
|
|
reject("INVALID_URL", "Invalid mint URL: \(mintUrl)", nil)
|
|
return
|
|
}
|
|
|
|
let (data, response) = try await URLSession.shared.data(from: quoteUrl)
|
|
|
|
// Check HTTP response status
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
guard (200...299).contains(httpResponse.statusCode) else {
|
|
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
reject("HTTP_ERROR", "Mint returned HTTP \(httpResponse.statusCode): \(errorBody)", nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Parse JSON response
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
reject("PARSE_ERROR", "Failed to parse mint quote response", nil)
|
|
return
|
|
}
|
|
|
|
// Parse the response according to NUT-04 spec
|
|
var result: [String: Any] = [
|
|
"id": json["quote"] as? String ?? quoteId,
|
|
"amount": json["amount"] as? Int ?? 0,
|
|
"request": json["request"] as? String ?? "",
|
|
"state": json["state"] as? String ?? "Unknown",
|
|
"expiry": json["expiry"] as? Int ?? 0,
|
|
"mint_url": mintUrl
|
|
]
|
|
|
|
// Include pubkey if present (for P2PK locked quotes)
|
|
if let pubkey = json["pubkey"] as? String {
|
|
result["pubkey"] = pubkey
|
|
}
|
|
|
|
if let encoded = encodeToJson(result) {
|
|
resolve(encoded)
|
|
} else {
|
|
reject("ENCODE_ERROR", "Failed to encode quote response", nil)
|
|
}
|
|
} catch {
|
|
reject("EXTERNAL_QUOTE_ERROR", "Failed to check external quote: \(error.localizedDescription)", error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Add an external mint quote to CDK's database.
|
|
/// This allows minting from quotes created externally (e.g., by ZeusPay server).
|
|
@objc(addExternalMintQuote:quoteId:amount:request:state:expiry:secretKey:resolver:rejecter:)
|
|
func addExternalMintQuote(_ mintUrl: String, quoteId: String, amount: NSNumber,
|
|
request: String, state: String, expiry: NSNumber,
|
|
secretKey: String?,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard isInitialized, let db = db else {
|
|
reject("NO_WALLET", "Wallet not initialized", nil)
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let amt = Amount(value: UInt64(truncating: amount))
|
|
|
|
// Map state string to QuoteState enum
|
|
let quoteState: QuoteState
|
|
switch state.uppercased() {
|
|
case "UNPAID":
|
|
quoteState = .unpaid
|
|
case "PAID":
|
|
quoteState = .paid
|
|
case "PENDING":
|
|
quoteState = .pending
|
|
case "ISSUED":
|
|
quoteState = .issued
|
|
default:
|
|
quoteState = .paid // Default to PAID for external quotes
|
|
}
|
|
|
|
// Create the MintQuote object
|
|
// For external quotes that are PAID, we set amountPaid = amount
|
|
// Include secretKey for P2PK-locked quotes
|
|
let zeroAmount = Amount(value: 0)
|
|
let quote = MintQuote(
|
|
id: quoteId,
|
|
amount: amt,
|
|
unit: .sat,
|
|
request: request,
|
|
state: quoteState,
|
|
expiry: UInt64(truncating: expiry),
|
|
mintUrl: url,
|
|
amountIssued: quoteState == .issued ? amt : zeroAmount,
|
|
amountPaid: (quoteState == .paid || quoteState == .issued) ? amt : zeroAmount,
|
|
paymentMethod: .bolt11,
|
|
secretKey: secretKey
|
|
)
|
|
|
|
// Add to database
|
|
try await db.addMintQuote(quote: quote)
|
|
|
|
resolve(true)
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("ADD_QUOTE_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mint tokens directly from an external quote.
|
|
/// This creates the quote in CDK's database first, then mints.
|
|
@objc(mintExternal:quoteId:amount:resolver:rejecter:)
|
|
func mintExternal(_ mintUrl: String, quoteId: String, amount: NSNumber,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
|
|
// Mint - quote should be in database now
|
|
let proofs = try await wallet.mint(mintUrl: url, quoteId: quoteId, spendingConditions: nil)
|
|
let encoded = proofs.map { encodeProof($0) }
|
|
resolve(encodeToJson(encoded))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("MINT_EXTERNAL_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(mint:quoteId:conditionsJson:resolver:rejecter:)
|
|
func mint(_ mintUrl: String, quoteId: String, conditionsJson: String?,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
|
|
// Parse spending conditions if provided
|
|
var conditions: SpendingConditions? = nil
|
|
if let json = conditionsJson,
|
|
let data = json.data(using: .utf8),
|
|
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
conditions = parseP2PKConditions(from: parsed)
|
|
}
|
|
|
|
let proofs = try await wallet.mint(mintUrl: url, quoteId: quoteId, spendingConditions: conditions)
|
|
let encoded = proofs.map { encodeProof($0) }
|
|
resolve(encodeToJson(encoded))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("MINT_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Melt Quotes (Paying)
|
|
|
|
@objc(createMeltQuote:request:optionsJson:resolver:rejecter:)
|
|
func createMeltQuote(_ mintUrl: String, request: String, optionsJson: String?,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let options = parseMeltOptions(from: optionsJson)
|
|
let quote = try await wallet.meltQuote(
|
|
mintUrl: url,
|
|
request: request,
|
|
options: options
|
|
)
|
|
resolve(encodeToJson(encodeMeltQuote(quote)))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("MELT_QUOTE_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(checkMeltQuote:quoteId:resolver:rejecter:)
|
|
func checkMeltQuote(_ mintUrl: String, quoteId: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let quote = try await wallet.checkMeltQuote(mintUrl: url, quoteId: quoteId)
|
|
resolve(encodeToJson(encodeMeltQuote(quote)))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("CHECK_MELT_QUOTE_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(melt:quoteId:resolver:rejecter:)
|
|
func melt(_ mintUrl: String, quoteId: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let melted = try await wallet.meltWithMint(mintUrl: url, quoteId: quoteId)
|
|
resolve(encodeToJson(encodeMelted(melted)))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("MELT_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(meltMpp:optionsJson:maxFee:resolver:rejecter:)
|
|
func meltMpp(_ bolt11: String, optionsJson: String?, maxFee: NSNumber,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let options = parseMeltOptions(from: optionsJson)
|
|
let fee = maxFee.uint64Value > 0 ? Amount(value: maxFee.uint64Value) : nil
|
|
let melted = try await wallet.melt(bolt11: bolt11, options: options, maxFee: fee)
|
|
resolve(encodeToJson(encodeMelted(melted)))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("MELT_MPP_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(meltPartial:bolt11:mppAmountMsat:resolver:rejecter:)
|
|
func meltPartial(_ mintUrl: String, bolt11: String, mppAmountMsat: NSNumber,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let mppAmount = Amount(value: mppAmountMsat.uint64Value)
|
|
|
|
// Step 1: Create melt quote via CDK with MPP options
|
|
let options = MeltOptions.mpp(amount: mppAmount)
|
|
let quote = try await wallet.meltQuote(
|
|
mintUrl: url,
|
|
request: bolt11,
|
|
options: options
|
|
)
|
|
|
|
// Step 2: Select proofs via CDK and execute melt
|
|
// Try meltProofs with all proofs from this mint —
|
|
// the mint knows the MPP partial amount from the quote
|
|
let allProofs = try await wallet.listProofs()
|
|
let normalizedUrl = mintUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
var mintProofs: [Proof] = []
|
|
for (key, proofs) in allProofs {
|
|
let normalizedKey = key.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
if normalizedKey == normalizedUrl {
|
|
mintProofs = proofs
|
|
break
|
|
}
|
|
}
|
|
|
|
guard !mintProofs.isEmpty else {
|
|
reject("NO_PROOFS", "No proofs found for mint \(mintUrl)", nil)
|
|
return
|
|
}
|
|
|
|
// Step 3: Try CDK's meltProofs first (keeps proof DB in sync)
|
|
let melted = try await wallet.meltProofs(
|
|
mintUrl: url,
|
|
quoteId: quote.id,
|
|
proofs: mintProofs
|
|
)
|
|
|
|
resolve(encodeToJson(encodeMelted(melted)))
|
|
} catch {
|
|
reject("MELT_PARTIAL_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Token Operations
|
|
|
|
@objc(prepareSend:amount:optionsJson:resolver:rejecter:)
|
|
func prepareSend(_ mintUrl: String, amount: NSNumber, optionsJson: String?,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let amt = Amount(value: amount.uint64Value)
|
|
|
|
// Parse options if provided
|
|
var includeFee = false
|
|
var spendingConditions: SpendingConditions? = nil
|
|
var sendKind: SendKind = .onlineExact
|
|
if let json = optionsJson,
|
|
let data = json.data(using: .utf8),
|
|
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
if let fee = parsed["include_fee"] as? Bool {
|
|
includeFee = fee
|
|
}
|
|
if let cond = parsed["conditions"] as? [String: Any] {
|
|
spendingConditions = parseP2PKConditions(from: cond)
|
|
}
|
|
if let kindStr = parsed["send_kind"] as? String {
|
|
let tolerance = (parsed["tolerance"] as? NSNumber).map { Amount(value: $0.uint64Value) } ?? Amount(value: 0)
|
|
switch kindStr {
|
|
case "OfflineExact":
|
|
sendKind = .offlineExact
|
|
case "OnlineTolerance":
|
|
sendKind = .onlineTolerance(tolerance: tolerance)
|
|
case "OfflineTolerance":
|
|
sendKind = .offlineTolerance(tolerance: tolerance)
|
|
default:
|
|
sendKind = .onlineExact
|
|
}
|
|
}
|
|
}
|
|
|
|
let innerSendOptions = SendOptions(
|
|
memo: nil,
|
|
conditions: spendingConditions,
|
|
amountSplitTarget: .none,
|
|
sendKind: sendKind,
|
|
includeFee: includeFee,
|
|
maxProofs: nil,
|
|
metadata: [:]
|
|
)
|
|
let sendOptions = MultiMintSendOptions(
|
|
allowTransfer: false,
|
|
maxTransferAmount: nil,
|
|
allowedMints: [],
|
|
excludedMints: [],
|
|
sendOptions: innerSendOptions
|
|
)
|
|
|
|
let prepared = try await wallet.prepareSend(mintUrl: url, amount: amt, options: sendOptions)
|
|
let preparedId = prepared.id()
|
|
let preparedAmount = prepared.amount().value
|
|
let preparedFee = prepared.fee().value
|
|
|
|
walletQueue.sync {
|
|
self.preparedSends[preparedId] = prepared
|
|
}
|
|
|
|
let result: [String: Any] = [
|
|
"id": preparedId,
|
|
"amount": preparedAmount,
|
|
"fee": preparedFee
|
|
]
|
|
let jsonData = try JSONSerialization.data(withJSONObject: result)
|
|
resolve(String(data: jsonData, encoding: .utf8)!)
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("PREPARE_SEND_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(confirmSend:memo:resolver:rejecter:)
|
|
func confirmSend(_ preparedSendId: String, memo: String?,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let prepared = preparedSends[preparedSendId] else {
|
|
reject("NO_PREPARED_SEND", "Prepared send not found", nil)
|
|
return
|
|
}
|
|
|
|
Task {
|
|
defer {
|
|
// Always clean up prepared send, whether success or failure
|
|
_ = walletQueue.sync {
|
|
self.preparedSends.removeValue(forKey: preparedSendId)
|
|
}
|
|
}
|
|
|
|
do {
|
|
let token = try await prepared.confirm(memo: memo)
|
|
let encoded = try await encodeToken(token)
|
|
resolve(encodeToJson(encoded))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("CONFIRM_SEND_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(cancelSend:resolver:rejecter:)
|
|
func cancelSend(_ preparedSendId: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let prepared = preparedSends[preparedSendId] else {
|
|
resolve(nil)
|
|
return
|
|
}
|
|
|
|
Task {
|
|
defer {
|
|
// Always clean up prepared send, whether success or failure
|
|
_ = walletQueue.sync {
|
|
self.preparedSends.removeValue(forKey: preparedSendId)
|
|
}
|
|
}
|
|
|
|
do {
|
|
try await prepared.cancel()
|
|
resolve(nil)
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("CANCEL_SEND_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(receive:optionsJson:resolver:rejecter:)
|
|
func receive(_ encodedToken: String, optionsJson: String?,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let token = try Token.fromString(encodedToken: encodedToken)
|
|
|
|
// Parse options if provided
|
|
var p2pkSigningKeys: [SecretKey] = []
|
|
var preimages: [String] = []
|
|
|
|
if let json = optionsJson,
|
|
let data = json.data(using: .utf8),
|
|
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
if let keys = parsed["p2pk_signing_keys"] as? [String] {
|
|
p2pkSigningKeys = keys.compactMap {
|
|
SecretKey(hex: $0)
|
|
}
|
|
}
|
|
if let imgs = parsed["preimages"] as? [String] {
|
|
preimages = imgs
|
|
}
|
|
}
|
|
|
|
let innerReceiveOptions = ReceiveOptions(
|
|
amountSplitTarget: .none,
|
|
p2pkSigningKeys: p2pkSigningKeys,
|
|
preimages: preimages,
|
|
metadata: [:]
|
|
)
|
|
let receiveOptions = MultiMintReceiveOptions(
|
|
allowUntrusted: true,
|
|
transferToMint: nil,
|
|
receiveOptions: innerReceiveOptions
|
|
)
|
|
|
|
let amount = try await wallet.receive(token: token, options: receiveOptions)
|
|
resolve(NSNumber(value: amount.value))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("RECEIVE_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Token Utility
|
|
|
|
@objc(decodeToken:resolver:rejecter:)
|
|
func decodeToken(_ encodedToken: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
Task {
|
|
do {
|
|
let token = try Token.fromString(encodedToken: encodedToken)
|
|
let encoded = try await encodeToken(token)
|
|
resolve(encodeToJson(encoded))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("DECODE_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(isValidToken:resolver:rejecter:)
|
|
func isValidToken(_ encodedToken: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
do {
|
|
let _ = try Token.fromString(encodedToken: encodedToken)
|
|
resolve(true)
|
|
} catch {
|
|
resolve(false)
|
|
}
|
|
}
|
|
|
|
// MARK: - Restore
|
|
|
|
@objc(restore:resolver:rejecter:)
|
|
func restore(_ mintUrl: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let amount = try await wallet.restore(mintUrl: url)
|
|
resolve(NSNumber(value: amount.value))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("RESTORE_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(restoreFromSeed:seedHex:resolver:rejecter:)
|
|
func restoreFromSeed(_ mintUrl: String, seedHex: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
// Step 1: Call standalone restore crate to get v1 proofs as a cashu token
|
|
let tokenString = try ZeusCashuRestore.restoreFromSeed(mintUrl: mintUrl, seedHex: seedHex)
|
|
|
|
// If no proofs found, return 0
|
|
if tokenString.isEmpty {
|
|
resolve(NSNumber(value: 0))
|
|
return
|
|
}
|
|
|
|
// Step 2: Feed the token into CDK's receive to import proofs into the wallet
|
|
let token = try Token.fromString(encodedToken: tokenString)
|
|
|
|
let innerReceiveOptions = ReceiveOptions(
|
|
amountSplitTarget: .none,
|
|
p2pkSigningKeys: [],
|
|
preimages: [],
|
|
metadata: [:]
|
|
)
|
|
let receiveOptions = MultiMintReceiveOptions(
|
|
allowUntrusted: true,
|
|
transferToMint: nil,
|
|
receiveOptions: innerReceiveOptions
|
|
)
|
|
|
|
let amount = try await wallet.receive(token: token, options: receiveOptions)
|
|
resolve(NSNumber(value: amount.value))
|
|
} catch let error as ZeusCashuRestore.RestoreError {
|
|
reject("RESTORE_FROM_SEED_ERROR", "\(error)", nil)
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("RESTORE_FROM_SEED_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Proof Management
|
|
|
|
@objc(checkProofsState:proofsJson:resolver:rejecter:)
|
|
func checkProofsState(_ mintUrl: String, proofsJson: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
|
|
// Parse proofs from JSON
|
|
guard let data = proofsJson.data(using: .utf8),
|
|
let proofsArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
|
|
reject("INVALID_PROOFS", "Could not parse proofs JSON", nil)
|
|
return
|
|
}
|
|
|
|
var proofs: [Proof] = []
|
|
for proofDict in proofsArray {
|
|
guard let amount = proofDict["amount"] as? UInt64,
|
|
let secret = proofDict["secret"] as? String,
|
|
let c = proofDict["c"] as? String,
|
|
let keysetId = proofDict["keyset_id"] as? String else {
|
|
continue
|
|
}
|
|
|
|
let proof = Proof(
|
|
amount: Amount(value: amount),
|
|
secret: secret,
|
|
c: c,
|
|
keysetId: keysetId,
|
|
witness: nil,
|
|
dleq: nil
|
|
)
|
|
proofs.append(proof)
|
|
}
|
|
|
|
let states = try await wallet.checkProofsState(mintUrl: url, proofs: proofs)
|
|
let encoded = states.map { state -> [String: Any] in
|
|
var stateStr: String
|
|
switch state {
|
|
case .unspent:
|
|
stateStr = "Unspent"
|
|
case .pending:
|
|
stateStr = "Pending"
|
|
case .spent:
|
|
stateStr = "Spent"
|
|
default:
|
|
stateStr = "Unknown"
|
|
}
|
|
return ["state": stateStr]
|
|
}
|
|
resolve(encodeToJson(encoded))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("CHECK_PROOFS_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - BOLT12 Support
|
|
|
|
@objc(createMintBolt12Quote:amount:description:resolver:rejecter:)
|
|
func createMintBolt12Quote(_ mintUrl: String, amount: NSNumber, description: String?,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let amt = Amount(value: amount.uint64Value)
|
|
|
|
// Note: BOLT12 mint quote might need different API - check CDK version
|
|
let quote = try await wallet.mintQuote(mintUrl: url, amount: amt, description: description)
|
|
resolve(encodeToJson(encodeMintQuote(quote)))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("BOLT12_MINT_QUOTE_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(createMeltBolt12Quote:request:optionsJson:resolver:rejecter:)
|
|
func createMeltBolt12Quote(_ mintUrl: String, request: String, optionsJson: String?,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let options = parseMeltOptions(from: optionsJson)
|
|
let quote = try await wallet.meltQuote(
|
|
mintUrl: url,
|
|
request: request,
|
|
options: options
|
|
)
|
|
resolve(encodeToJson(encodeMeltQuote(quote)))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("BOLT12_MELT_QUOTE_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(createMeltHumanReadableQuote:address:amountMsat:resolver:rejecter:)
|
|
func createMeltHumanReadableQuote(_ mintUrl: String, address: String, amountMsat: NSNumber,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let wallet = getInitializedWallet(reject: reject) else { return }
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let quote = try await wallet.meltHumanReadableQuote(
|
|
mintUrl: url,
|
|
address: address,
|
|
amountMsat: amountMsat.uint64Value
|
|
)
|
|
resolve(encodeToJson(encodeMeltQuote(quote)))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("HUMAN_READABLE_QUOTE_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Transactions
|
|
|
|
@objc(listTransactions:resolver:rejecter:)
|
|
func listTransactions(_ direction: String?,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard isInitialized, let _ = wallet, let db = db else {
|
|
reject("NO_WALLET", "Wallet not initialized", nil)
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
var txDirection: TransactionDirection? = nil
|
|
if let dir = direction {
|
|
txDirection = dir == "incoming" ? .incoming : .outgoing
|
|
}
|
|
|
|
let transactions = try await db.listTransactions(
|
|
mintUrl: nil,
|
|
direction: txDirection,
|
|
unit: nil
|
|
)
|
|
|
|
let encoded = transactions.map { tx -> [String: Any] in
|
|
var result: [String: Any] = [
|
|
"id": tx.id.hex,
|
|
"direction": tx.direction == .incoming ? "incoming" : "outgoing",
|
|
"amount": tx.amount.value,
|
|
"mint_url": tx.mintUrl.url,
|
|
"timestamp": tx.timestamp,
|
|
"fee": tx.fee.value
|
|
]
|
|
if let memo = tx.memo {
|
|
result["memo"] = memo
|
|
}
|
|
return result
|
|
}
|
|
|
|
resolve(encodeToJson(encoded))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("LIST_TRANSACTIONS_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Direct Proof Access (Offline Send)
|
|
|
|
@objc(getUnspentProofs:resolver:rejecter:)
|
|
func getUnspentProofs(_ mintUrl: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let db = self.db else {
|
|
reject("NO_WALLET", "Wallet not initialized", nil)
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
let url = MintUrl(url: mintUrl)
|
|
let proofInfos = try await db.getProofs(
|
|
mintUrl: url,
|
|
unit: .sat,
|
|
state: [.unspent],
|
|
spendingConditions: nil
|
|
)
|
|
|
|
let result = proofInfos.map { info -> [String: Any] in
|
|
return [
|
|
"amount": info.proof.amount.value,
|
|
"secret": info.proof.secret,
|
|
"c": info.proof.c,
|
|
"keyset_id": info.proof.keysetId,
|
|
"y": info.y.hex
|
|
]
|
|
}
|
|
|
|
resolve(encodeToJson(result))
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("GET_PROOFS_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(removeProofs:resolver:rejecter:)
|
|
func removeProofs(_ proofsYJson: String,
|
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
guard let db = self.db else {
|
|
reject("NO_WALLET", "Wallet not initialized", nil)
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
guard let data = proofsYJson.data(using: .utf8),
|
|
let yStrings = try? JSONSerialization.jsonObject(with: data) as? [String] else {
|
|
reject("INVALID_INPUT", "Could not parse Y values JSON", nil)
|
|
return
|
|
}
|
|
|
|
if yStrings.isEmpty {
|
|
resolve(nil)
|
|
return
|
|
}
|
|
|
|
let ys = yStrings.map { hex -> CashuDevKit.PublicKey in
|
|
CashuDevKit.PublicKey(hex: hex)
|
|
}
|
|
|
|
try await db.updateProofs(added: [], removedYs: ys)
|
|
resolve(nil)
|
|
} catch let error as FfiError {
|
|
let (code, message) = mapFfiError(error)
|
|
reject(code, message, error)
|
|
} catch {
|
|
reject("REMOVE_PROOFS_ERROR", error.localizedDescription, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Cleanup
|
|
|
|
override func invalidate() {
|
|
walletQueue.sync {
|
|
wallet = nil
|
|
db = nil
|
|
preparedSends.removeAll()
|
|
isInitialized = false
|
|
}
|
|
super.invalidate()
|
|
}
|
|
}
|