fix: multi-mint MPP payments with direct mint API for melt execution

This commit is contained in:
Evan Kaloudis
2026-06-01 06:06:31 +05:30
committed by Khushal
parent 858d8b9330
commit 754f7d4d11
6 changed files with 529 additions and 59 deletions
+12
View File
@@ -105,6 +105,18 @@ RCT_EXTERN_METHOD(melt:(NSString *)mintUrl
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(meltMpp:(NSString *)bolt11
optionsJson:(NSString * _Nullable)optionsJson
maxFee:(nonnull NSNumber *)maxFee
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(meltPartial:(NSString *)mintUrl
bolt11:(NSString *)bolt11
mppAmountMsat:(nonnull NSNumber *)mppAmountMsat
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
// Token Operations
RCT_EXTERN_METHOD(prepareSend:(NSString *)mintUrl
amount:(nonnull NSNumber *)amount
+172
View File
@@ -868,6 +868,178 @@ class CashuDevKitModule: RCTEventEmitter {
}
}
@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 normalizedUrl = mintUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
// Step 1: Create melt quote directly with the mint to get actual fee
let quoteUrl = normalizedUrl + "/v1/melt/quote/bolt11"
guard let quoteRequestUrl = URL(string: quoteUrl) else {
reject("INVALID_URL", "Invalid mint URL", nil)
return
}
let quoteBody: [String: Any] = [
"request": bolt11,
"unit": "sat",
"options": ["mpp": ["amount": mppAmountMsat.uint64Value]]
]
var quoteRequest = URLRequest(url: quoteRequestUrl)
quoteRequest.httpMethod = "POST"
quoteRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
quoteRequest.httpBody = try JSONSerialization.data(withJSONObject: quoteBody)
let (quoteData, quoteResponse) = try await URLSession.shared.data(for: quoteRequest)
if let httpResp = quoteResponse as? HTTPURLResponse,
!(200...299).contains(httpResp.statusCode) {
let errorBody = String(data: quoteData, encoding: .utf8) ?? "Unknown"
reject("QUOTE_HTTP_ERROR", "Quote failed HTTP \(httpResp.statusCode): \(errorBody)", nil)
return
}
guard let quoteJson = try? JSONSerialization.jsonObject(with: quoteData) as? [String: Any],
let actualQuoteId = quoteJson["quote"] as? String else {
reject("PARSE_ERROR", "Failed to parse quote response", nil)
return
}
// JSONSerialization returns numbers as __NSCFNumber;
// cast to Int first then convert to UInt64
let actualAmount: UInt64 = {
if let n = quoteJson["amount"] as? Int { return UInt64(max(0, n)) }
if let n = quoteJson["amount"] as? Double { return UInt64(max(0, n)) }
return 0
}()
let actualFeeReserve: UInt64 = {
if let n = quoteJson["fee_reserve"] as? Int { return UInt64(max(0, n)) }
if let n = quoteJson["fee_reserve"] as? Double { return UInt64(max(0, n)) }
return 0
}()
let totalNeeded = actualAmount + actualFeeReserve
// Step 2: Get all proofs for this mint
let allProofs = try await wallet.listProofs()
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
}
let totalProofs = mintProofs.reduce(UInt64(0)) { $0 + $1.amount.value }
guard totalProofs >= totalNeeded else {
reject("INSUFFICIENT_BALANCE",
"Mint balance \(totalProofs) < needed \(totalNeeded) (\(actualAmount) + \(actualFeeReserve) fee)",
nil)
return
}
// Step 3: Send all proofs to the mint's melt endpoint
var inputs: [[String: Any]] = []
for proof in mintProofs {
var p: [String: Any] = [
"amount": proof.amount.value,
"secret": proof.secret,
"C": proof.c,
"id": proof.keysetId
]
if let witness = proof.witness {
p["witness"] = witness
}
inputs.append(p)
}
let meltUrl = normalizedUrl + "/v1/melt/bolt11"
guard let meltRequestUrl = URL(string: meltUrl) else {
reject("INVALID_URL", "Invalid melt URL", nil)
return
}
let meltBody: [String: Any] = [
"quote": actualQuoteId,
"inputs": inputs
]
var meltRequest = URLRequest(url: meltRequestUrl)
meltRequest.httpMethod = "POST"
meltRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
meltRequest.httpBody = try JSONSerialization.data(withJSONObject: meltBody)
let (meltData, meltResponse) = try await URLSession.shared.data(for: meltRequest)
if let httpResp = meltResponse as? HTTPURLResponse,
!(200...299).contains(httpResp.statusCode) {
let errorBody = String(data: meltData, encoding: .utf8) ?? "Unknown"
reject("MELT_HTTP_ERROR", "Mint returned HTTP \(httpResp.statusCode): \(errorBody)", nil)
return
}
guard let json = try? JSONSerialization.jsonObject(with: meltData) as? [String: Any] else {
reject("PARSE_ERROR", "Failed to parse melt response", nil)
return
}
// Sync wallet state after direct melt restore
// re-derives proofs from seed and checks with the mint
// which ones are still valid, updating the CDK's database
try? await wallet.restore(mintUrl: url)
var result: [String: Any] = [
"state": (json["state"] as? String) ?? "PAID",
"amount": json["amount"] ?? actualAmount,
"fee_paid": json["fee_paid"] ?? json["fee_reserve"] ?? actualFeeReserve
]
if let preimage = json["payment_preimage"] as? String {
result["preimage"] = preimage
}
if let change = json["change"] as? [[String: Any]] {
result["change"] = change
}
resolve(encodeToJson(result))
} catch {
reject("MELT_PARTIAL_ERROR", error.localizedDescription, error)
}
}
}
// MARK: - Token Operations
@objc(prepareSend:amount:optionsJson:resolver:rejecter:)