Skip to content

Commit bc7e441

Browse files
zeugmasterthesimplekid
authored andcommitted
feat(payment-processor): propagate mint quote_id to backend
Adds the mint's QuoteId to the three OutgoingPaymentOptions struct variants so custom payment-method backends can deterministically correlate their `get_payment_quote` and `make_payment` calls for the same melt. Today, the gRPC payment-processor proto carries no link between those two calls for the same melt: a backend's `get_payment_quote` returns a PaymentQuoteResponse whose `request_lookup_id` the mint stores on the quote, but the eventual `make_payment` receives an OutgoingPaymentOptions with neither the stored lookup_id nor the mint's quote_id (`from_melt_quote_with_fee` in cdk-common drops `extra_json` and has no quote_id field). bolt11/bolt12 backends are insulated by the payment_hash / offer being intrinsically derivable from the invoice in both calls. Custom backends have no such anchor, and have to fabricate a derivation — typically hashing `request` — which forces the wallet to make `request` unique per melt. That's awkward for any custom rail whose "request" doesn't naturally carry uniqueness (branch tickets, account IDs, free-text references). With `quote_id` plumbed through, the backend has the same stable key the mint uses everywhere else, so correlation is trivial. Changes: - cdk-common::payment: add `quote_id: QuoteId` to Bolt11/Bolt12/Custom OutgoingPaymentOptions structs. Propagate it in `from_melt_quote_with_fee` from `melt_quote.id`. - cdk::mint::melt: pre-generate the quote_id before each `get_payment_quote` call (bolt11, bolt12, custom paths) and pass it both to the backend and to `MeltQuote::new`, replacing the previous implicit auto-generation. - payment_processor.proto: add required `string quote_id` to Bolt11/Bolt12/CustomOutgoingPaymentOptions and PaymentQuoteRequest. Field-numbers appended (5/6/7/6) to preserve wire compatibility with the existing fields. - cdk-payment-processor server/client: serialize/deserialize quote_id directly (no Option wrapping). Empty/invalid values surface as `Status::invalid_argument("Invalid quote_id: ...")` from QuoteId::from_str. - cdk-common: bump PAYMENT_PROCESSOR_PROTOCOL_VERSION 2.0.0 -> 3.0.0. The VersionInterceptor's strict equality check means any old mint/backend pair will see a clear "Protocol version mismatch" at connection time instead of misbehaving silently, which already covers the case where one side doesn't know about quote_id. - cdk-integration-tests: extend the existing custom-payment-processor fixture to capture the received quote_id, and add a regression test asserting it matches the quote_id surfaced to the wallet. Existing bolt11/bolt12 backends (cln, lnd, lnbits, ldk-node, fake-wallet) need no code changes — they continue to correlate via payment_hash / offer and simply ignore the new field on the OutgoingPaymentOptions struct they already receive. The struct gains one field; consumers using non-exhaustive field access (`opts.bolt11`, `opts.melt_options`, etc.) are unaffected. Only consumers that exhaustively destructure the struct would need to add the new field to their pattern.
1 parent 1abcb28 commit bc7e441

7 files changed

Lines changed: 146 additions & 8 deletions

File tree

crates/cdk-common/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ pub mod auth;
1616
pub const MINT_RPC_PROTOCOL_VERSION: &str = "1.0.0";
1717

1818
/// Protocol version for gRPC Payment Processor communication
19-
pub const PAYMENT_PROCESSOR_PROTOCOL_VERSION: &str = "2.0.0";
19+
pub const PAYMENT_PROCESSOR_PROTOCOL_VERSION: &str = "3.0.0";
2020

2121
#[cfg(feature = "grpc")]
2222
pub mod grpc;

crates/cdk-common/src/payment.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,11 @@ pub struct Bolt11OutgoingPaymentOptions {
251251
pub timeout_secs: Option<u64>,
252252
/// Melt options
253253
pub melt_options: Option<MeltOptions>,
254+
/// The mint's quote id for this melt. Set in both `get_payment_quote`
255+
/// and `make_payment` so backends can correlate the two calls. For
256+
/// BOLT11 backends the payment_hash already provides correlation, so
257+
/// this is informational; it is still required for protocol uniformity.
258+
pub quote_id: QuoteId,
254259
}
255260

256261
/// Options for BOLT12 outgoing payments
@@ -264,6 +269,8 @@ pub struct Bolt12OutgoingPaymentOptions {
264269
pub timeout_secs: Option<u64>,
265270
/// Melt options
266271
pub melt_options: Option<MeltOptions>,
272+
/// The mint's quote id for this melt. See [`Bolt11OutgoingPaymentOptions::quote_id`].
273+
pub quote_id: QuoteId,
267274
}
268275

269276
/// Options for custom outgoing payments
@@ -284,6 +291,12 @@ pub struct CustomOutgoingPaymentOptions {
284291
/// These fields are passed through to the payment processor for
285292
/// method-specific validation.
286293
pub extra_json: Option<String>,
294+
/// The mint's quote id for this melt. Custom backends should use this
295+
/// as the stable correlation key between `get_payment_quote` and
296+
/// `make_payment` (and any later `check_outgoing_payment` polls) — it
297+
/// is the only field guaranteed to be unique per melt without relying
298+
/// on wallet-supplied uniqueness in `request`.
299+
pub quote_id: QuoteId,
287300
}
288301

289302
/// Options for outgoing payments
@@ -303,13 +316,15 @@ impl OutgoingPaymentOptions {
303316
melt_quote: MeltQuote,
304317
) -> Result<OutgoingPaymentOptions, Error> {
305318
let fee_reserve = melt_quote.fee_reserve();
319+
let quote_id = melt_quote.id.clone();
306320
match &melt_quote.request {
307321
MeltPaymentRequest::Bolt11 { bolt11 } => Ok(OutgoingPaymentOptions::Bolt11(Box::new(
308322
Bolt11OutgoingPaymentOptions {
309323
max_fee_amount: Some(fee_reserve),
310324
timeout_secs: None,
311325
bolt11: bolt11.clone(),
312326
melt_options: melt_quote.options,
327+
quote_id,
313328
},
314329
))),
315330
MeltPaymentRequest::Bolt12 { offer } => {
@@ -325,6 +340,7 @@ impl OutgoingPaymentOptions {
325340
timeout_secs: None,
326341
offer: *offer.clone(),
327342
melt_options,
343+
quote_id,
328344
},
329345
)))
330346
}
@@ -336,6 +352,7 @@ impl OutgoingPaymentOptions {
336352
timeout_secs: None,
337353
melt_options: melt_quote.options,
338354
extra_json: None,
355+
quote_id,
339356
}),
340357
)),
341358
}

crates/cdk-integration-tests/tests/integration_tests_pure.rs

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2245,8 +2245,18 @@ async fn test_restore_after_keyset_rotation() {
22452245
);
22462246
}
22472247

2248-
#[derive(Debug)]
2249-
struct CustomPaymentProcessor;
2248+
#[derive(Debug, Default)]
2249+
struct CustomPaymentProcessor {
2250+
/// Captures the quote_id seen by `get_payment_quote` so tests can assert
2251+
/// the mint propagates it correctly.
2252+
last_quote_id: std::sync::Mutex<Option<cdk_common::QuoteId>>,
2253+
}
2254+
2255+
impl CustomPaymentProcessor {
2256+
fn last_quote_id(&self) -> Option<cdk_common::QuoteId> {
2257+
self.last_quote_id.lock().expect("poisoned").clone()
2258+
}
2259+
}
22502260

22512261
#[async_trait::async_trait]
22522262
impl MintPayment for CustomPaymentProcessor {
@@ -2282,6 +2292,11 @@ impl MintPayment for CustomPaymentProcessor {
22822292
Some("{\"request_metadata\":true}".to_string())
22832293
);
22842294

2295+
// Capture the mint-supplied quote_id; the new-in-3.0 protocol
2296+
// field that lets backends correlate get_payment_quote with the
2297+
// eventual make_payment.
2298+
*self.last_quote_id.lock().expect("poisoned") = Some(custom.quote_id.clone());
2299+
22852300
Ok(PaymentQuoteResponse {
22862301
request_lookup_id: Some(PaymentIdentifier::CustomId(
22872302
"custom-lookup-id".to_string(),
@@ -2368,7 +2383,7 @@ async fn test_custom_melt_quote_status_preserves_extra_json() {
23682383
CurrencyUnit::Sat,
23692384
PaymentMethod::Custom("test-custom".to_string()),
23702385
cdk::mint::MintMeltLimits::new(1, 10_000),
2371-
Arc::new(CustomPaymentProcessor),
2386+
Arc::new(CustomPaymentProcessor::default()),
23722387
)
23732388
.await
23742389
.expect("custom payment processor");
@@ -2416,3 +2431,60 @@ async fn test_custom_melt_quote_status_preserves_extra_json() {
24162431
}))
24172432
);
24182433
}
2434+
2435+
#[tokio::test]
2436+
async fn test_custom_melt_quote_id_propagates_to_payment_processor() {
2437+
setup_tracing();
2438+
2439+
let localstore = Arc::new(cdk_sqlite::mint::memory::empty().await.expect("memory db"));
2440+
let processor = Arc::new(CustomPaymentProcessor::default());
2441+
2442+
let mut mint_builder = cdk::mint::MintBuilder::new(localstore.clone());
2443+
let mnemonic = Mnemonic::generate(12).expect("mnemonic");
2444+
mint_builder
2445+
.add_payment_processor(
2446+
CurrencyUnit::Sat,
2447+
PaymentMethod::Custom("test-custom".to_string()),
2448+
cdk::mint::MintMeltLimits::new(1, 10_000),
2449+
processor.clone(),
2450+
)
2451+
.await
2452+
.expect("custom payment processor");
2453+
2454+
mint_builder = mint_builder
2455+
.with_name("quote-id propagation test".to_string())
2456+
.with_description("quote-id propagation test".to_string())
2457+
.with_urls(vec!["https://example-mint".to_string()])
2458+
.with_limits(2000, 2000);
2459+
2460+
let mint = mint_builder
2461+
.build_with_seed(localstore.clone(), &mnemonic.to_seed_normalized(""))
2462+
.await
2463+
.expect("mint build");
2464+
2465+
mint.set_quote_ttl(QuoteTTL::new(10_000, 10_000))
2466+
.await
2467+
.expect("quote ttl");
2468+
2469+
let response = mint
2470+
.get_melt_quote(cdk_common::MeltQuoteRequest::Custom(
2471+
cdk::nuts::MeltQuoteCustomRequest {
2472+
method: "test-custom".to_string(),
2473+
request: "custom-request".to_string(),
2474+
unit: CurrencyUnit::Sat,
2475+
extra: serde_json::json!({ "request_metadata": true }),
2476+
},
2477+
))
2478+
.await
2479+
.expect("custom melt quote");
2480+
2481+
let response_quote_id = response.quote().clone();
2482+
let seen_by_processor = processor
2483+
.last_quote_id()
2484+
.expect("processor must have received a quote_id in get_payment_quote");
2485+
2486+
assert_eq!(
2487+
seen_by_processor, response_quote_id,
2488+
"the quote_id passed to get_payment_quote must equal the quote_id surfaced to the wallet",
2489+
);
2490+
}

crates/cdk-payment-processor/src/proto/client.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,13 +253,20 @@ impl MintPayment for PaymentProcessorClient {
253253
_ => None,
254254
};
255255

256+
let quote_id = match &options {
257+
cdk_common::payment::OutgoingPaymentOptions::Custom(opts) => opts.quote_id.to_string(),
258+
cdk_common::payment::OutgoingPaymentOptions::Bolt11(opts) => opts.quote_id.to_string(),
259+
cdk_common::payment::OutgoingPaymentOptions::Bolt12(opts) => opts.quote_id.to_string(),
260+
};
261+
256262
let response = inner
257263
.get_payment_quote(Request::new(PaymentQuoteRequest {
258264
request: proto_request,
259265
unit: unit.to_string(),
260266
options: proto_options.map(Into::into),
261267
request_type: request_type.into(),
262268
extra_json,
269+
quote_id,
263270
}))
264271
.await
265272
.map_err(|err| {
@@ -292,6 +299,7 @@ impl MintPayment for PaymentProcessorClient {
292299
timeout_secs: opts.timeout_secs,
293300
melt_options: opts.melt_options.map(Into::into),
294301
extra_json: opts.extra_json.clone(),
302+
quote_id: opts.quote_id.to_string(),
295303
},
296304
)),
297305
}
@@ -304,6 +312,7 @@ impl MintPayment for PaymentProcessorClient {
304312
max_fee_amount: opts.max_fee_amount.into_proto(),
305313
timeout_secs: opts.timeout_secs,
306314
melt_options: opts.melt_options.map(Into::into),
315+
quote_id: opts.quote_id.to_string(),
307316
},
308317
)),
309318
}
@@ -316,6 +325,7 @@ impl MintPayment for PaymentProcessorClient {
316325
max_fee_amount: opts.max_fee_amount.into_proto(),
317326
timeout_secs: opts.timeout_secs,
318327
melt_options: opts.melt_options.map(Into::into),
328+
quote_id: opts.quote_id.to_string(),
319329
},
320330
)),
321331
}

crates/cdk-payment-processor/src/proto/payment_processor.proto

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ message PaymentQuoteRequest {
134134
// Extra payment-method-specific fields as JSON string
135135
// For custom payment methods, these fields are passed through for validation
136136
optional string extra_json = 5;
137+
// The mint's quote_id for the melt this payment-quote is for. Required;
138+
// backends should use this as the stable correlation key between
139+
// get_payment_quote and the eventual make_payment.
140+
string quote_id = 6;
137141
}
138142

139143
enum QuoteState {
@@ -162,13 +166,17 @@ message Bolt11OutgoingPaymentOptions {
162166
optional AmountMessage max_fee_amount = 2;
163167
optional uint64 timeout_secs = 3;
164168
optional MeltOptions melt_options = 4;
169+
// The mint's quote_id for this melt. Required.
170+
string quote_id = 5;
165171
}
166172

167173
message Bolt12OutgoingPaymentOptions {
168174
string offer = 1;
169175
optional AmountMessage max_fee_amount = 2;
170176
optional uint64 timeout_secs = 3;
171177
optional MeltOptions melt_options = 5;
178+
// The mint's quote_id for this melt. Required.
179+
string quote_id = 6;
172180
}
173181
message CustomOutgoingPaymentOptions {
174182
string offer = 1;
@@ -178,6 +186,9 @@ message CustomOutgoingPaymentOptions {
178186
// Extra payment-method-specific fields as JSON string
179187
// These fields are flattened into the JSON representation on the client side
180188
optional string extra_json = 6;
189+
// The mint's quote_id for this melt. Required. Custom backends should use
190+
// this as the stable correlation key with the earlier get_payment_quote call.
191+
string quote_id = 7;
181192
}
182193

183194
enum OutgoingPaymentOptionsType {

crates/cdk-payment-processor/src/proto/server.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use std::time::Duration;
77

88
use cdk_common::grpc::create_version_check_interceptor;
99
use cdk_common::payment::{IncomingPaymentOptions, MintPayment};
10-
use cdk_common::CurrencyUnit;
10+
use cdk_common::{CurrencyUnit, QuoteId};
1111
use futures::{Stream, StreamExt};
1212
use lightning::offers::offer::Offer;
1313
use tokio::sync::{mpsc, Notify};
@@ -282,6 +282,8 @@ impl CdkPaymentProcessor for PaymentProcessorServer {
282282
let unit = CurrencyUnit::from_str(&request.unit)
283283
.map_err(|_| Status::invalid_argument("Invalid currency unit"))?;
284284

285+
let quote_id = parse_quote_id(&request.quote_id)?;
286+
285287
let options = match request.request_type() {
286288
OutgoingPaymentRequestType::Bolt11Invoice => {
287289
let bolt11: cdk_common::Bolt11Invoice =
@@ -293,6 +295,7 @@ impl CdkPaymentProcessor for PaymentProcessorServer {
293295
max_fee_amount: None,
294296
timeout_secs: None,
295297
melt_options: request.options.map(Into::into),
298+
quote_id,
296299
},
297300
))
298301
}
@@ -307,6 +310,7 @@ impl CdkPaymentProcessor for PaymentProcessorServer {
307310
max_fee_amount: None,
308311
timeout_secs: None,
309312
melt_options: request.options.map(Into::into),
313+
quote_id,
310314
},
311315
))
312316
}
@@ -320,6 +324,7 @@ impl CdkPaymentProcessor for PaymentProcessorServer {
320324
timeout_secs: None,
321325
melt_options: request.options.map(Into::into),
322326
extra_json: request.extra_json.clone(),
327+
quote_id,
323328
},
324329
))
325330
}
@@ -365,13 +370,15 @@ impl CdkPaymentProcessor for PaymentProcessorServer {
365370
.max_fee_amount
366371
.try_from_proto()
367372
.map_err(|_| Status::invalid_argument("Invalid max_fee_amount"))?;
373+
let quote_id = parse_quote_id(&opts.quote_id)?;
368374

369375
cdk_common::payment::OutgoingPaymentOptions::Bolt11(Box::new(
370376
cdk_common::payment::Bolt11OutgoingPaymentOptions {
371377
bolt11,
372378
max_fee_amount,
373379
timeout_secs: opts.timeout_secs,
374380
melt_options: opts.melt_options.map(Into::into),
381+
quote_id,
375382
},
376383
))
377384
}
@@ -382,13 +389,15 @@ impl CdkPaymentProcessor for PaymentProcessorServer {
382389
.max_fee_amount
383390
.try_from_proto()
384391
.map_err(|_| Status::invalid_argument("Invalid max_fee_amount"))?;
392+
let quote_id = parse_quote_id(&opts.quote_id)?;
385393

386394
cdk_common::payment::OutgoingPaymentOptions::Bolt12(Box::new(
387395
cdk_common::payment::Bolt12OutgoingPaymentOptions {
388396
offer,
389397
max_fee_amount,
390398
timeout_secs: opts.timeout_secs,
391399
melt_options: opts.melt_options.map(Into::into),
400+
quote_id,
392401
},
393402
))
394403
}
@@ -397,6 +406,7 @@ impl CdkPaymentProcessor for PaymentProcessorServer {
397406
.max_fee_amount
398407
.try_from_proto()
399408
.map_err(|_| Status::invalid_argument("Invalid max_fee_amount"))?;
409+
let quote_id = parse_quote_id(&opts.quote_id)?;
400410

401411
cdk_common::payment::OutgoingPaymentOptions::Custom(Box::new(
402412
cdk_common::payment::CustomOutgoingPaymentOptions {
@@ -406,6 +416,7 @@ impl CdkPaymentProcessor for PaymentProcessorServer {
406416
timeout_secs: opts.timeout_secs,
407417
melt_options: opts.melt_options.map(Into::into),
408418
extra_json: opts.extra_json,
419+
quote_id,
409420
},
410421
))
411422
}
@@ -528,3 +539,8 @@ impl CdkPaymentProcessor for PaymentProcessorServer {
528539
))
529540
}
530541
}
542+
543+
fn parse_quote_id(s: &str) -> Result<QuoteId, Status> {
544+
s.parse()
545+
.map_err(|err| Status::invalid_argument(format!("Invalid quote_id: {err}")))
546+
}

0 commit comments

Comments
 (0)