From 99f97ec56c8020b0f7e0fc3593fd2a05ad7e95eb Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:56:43 -0300 Subject: [PATCH 1/4] fix(pay): handle nullable options in get_payment_options response The API returns `options: null` for payments in terminal states (succeeded, failed, expired, cancelled). Mark the field as nullable in the OpenAPI spec and coerce null to an empty vec so callers can check `info.status` instead of hitting an "Invalid Response Payload" deserialization error. Co-Authored-By: Claude Opus 4.6 --- crates/yttrium/src/pay/mod.rs | 8 ++++---- crates/yttrium/src/pay/openapi.json | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/yttrium/src/pay/mod.rs b/crates/yttrium/src/pay/mod.rs index 27558aff..6e0c461a 100644 --- a/crates/yttrium/src/pay/mod.rs +++ b/crates/yttrium/src/pay/mod.rs @@ -983,14 +983,14 @@ impl WalletConnectPay { })?; let api_response = response.into_inner(); + let options = api_response.options.unwrap_or_default(); pay_debug!( "get_payment_options: success, {} options", - api_response.options.len() + options.len() ); // Cache the options with their raw actions - let cached: Vec = api_response - .options + let cached: Vec = options .iter() .map(|o| CachedPaymentOption { option_id: o.id.clone(), @@ -1007,7 +1007,7 @@ impl WalletConnectPay { Ok(PaymentOptionsResponse { payment_id, info: api_response.info.map(Into::into), - options: api_response.options.into_iter().map(Into::into).collect(), + options: options.into_iter().map(Into::into).collect(), collect_data: api_response.collect_data.map(Into::into), }) } diff --git a/crates/yttrium/src/pay/openapi.json b/crates/yttrium/src/pay/openapi.json index a9bec22d..44ce4775 100644 --- a/crates/yttrium/src/pay/openapi.json +++ b/crates/yttrium/src/pay/openapi.json @@ -1760,7 +1760,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/PaymentOption" - } + }, + "nullable": true } }, "example": { From e5371c33ea96be5d9dd328f67d9c0a64c175d5a9 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:01:27 -0300 Subject: [PATCH 2/4] test(pay): add test for null options in terminal payment state Verifies that get_payment_options handles options: null correctly when the API returns a 200 for a succeeded payment with includePaymentInfo. Co-Authored-By: Claude Opus 4.6 --- crates/yttrium/src/pay/mod.rs | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/yttrium/src/pay/mod.rs b/crates/yttrium/src/pay/mod.rs index 6e0c461a..879705c1 100644 --- a/crates/yttrium/src/pay/mod.rs +++ b/crates/yttrium/src/pay/mod.rs @@ -1926,6 +1926,61 @@ mod tests { )); } + #[tokio::test] + async fn test_get_payment_options_null_options_terminal_state() { + let mock_server = MockServer::start().await; + let mock_response = serde_json::json!({ + "info": { + "status": "succeeded", + "amount": { + "unit": "iso4217/USD", + "value": "1", + "display": { + "assetSymbol": "USD", + "assetName": "US Dollar", + "decimals": 2 + } + }, + "expiresAt": 1775490768_i64, + "merchant": { + "name": "Test Merchant", + "iconUrl": null + } + }, + "collectData": null, + "options": null + }); + + Mock::given(method("POST")) + .and(path( + "/v1/gateway/payment/pay_succeeded/options", + )) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(&mock_response), + ) + .mount(&mock_server) + .await; + + let client = + WalletConnectPay::new(test_config(mock_server.uri())) + .unwrap(); + let result = client + .get_payment_options( + "pay_succeeded".to_string(), + vec!["eip155:8453:0x123".to_string()], + true, + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert!(response.options.is_empty()); + assert!(response.info.is_some()); + let info = response.info.unwrap(); + assert_eq!(info.status, PaymentStatus::Succeeded); + } + #[tokio::test] async fn test_extract_payment_id() { // pay.walletconnect.com with path segment From cd7768e4db7dd41759f9a46c5a6321a01040e15e Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:05:17 -0300 Subject: [PATCH 3/4] style: fix nightly rustfmt formatting Co-Authored-By: Claude Opus 4.6 --- crates/yttrium/src/pay/mod.rs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/crates/yttrium/src/pay/mod.rs b/crates/yttrium/src/pay/mod.rs index 879705c1..da78448f 100644 --- a/crates/yttrium/src/pay/mod.rs +++ b/crates/yttrium/src/pay/mod.rs @@ -984,10 +984,7 @@ impl WalletConnectPay { let api_response = response.into_inner(); let options = api_response.options.unwrap_or_default(); - pay_debug!( - "get_payment_options: success, {} options", - options.len() - ); + pay_debug!("get_payment_options: success, {} options", options.len()); // Cache the options with their raw actions let cached: Vec = options @@ -1952,19 +1949,15 @@ mod tests { }); Mock::given(method("POST")) - .and(path( - "/v1/gateway/payment/pay_succeeded/options", - )) + .and(path("/v1/gateway/payment/pay_succeeded/options")) .respond_with( - ResponseTemplate::new(200) - .set_body_json(&mock_response), + ResponseTemplate::new(200).set_body_json(&mock_response), ) .mount(&mock_server) .await; let client = - WalletConnectPay::new(test_config(mock_server.uri())) - .unwrap(); + WalletConnectPay::new(test_config(mock_server.uri())).unwrap(); let result = client .get_payment_options( "pay_succeeded".to_string(), From d336b02d85a96e0bf0d67d139f721732877aaf35 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:40:23 -0300 Subject: [PATCH 4/4] refactor(pay): expose options as Option in PaymentOptionsResponse Preserve the distinction between null (terminal payment state) and empty array (no viable options for the provided accounts) instead of coercing null to an empty vec. Callers can now branch on info.status when options is None. BREAKING: PaymentOptionsResponse.options is now Option>. Co-Authored-By: Claude Opus 4.6 --- crates/yttrium/src/pay/e2e_tests.rs | 28 ++++++++----------- crates/yttrium/src/pay/mod.rs | 42 +++++++++++++++++------------ 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/crates/yttrium/src/pay/e2e_tests.rs b/crates/yttrium/src/pay/e2e_tests.rs index cafce3f4..479c3bed 100644 --- a/crates/yttrium/src/pay/e2e_tests.rs +++ b/crates/yttrium/src/pay/e2e_tests.rs @@ -249,11 +249,8 @@ async fn e2e_payment_options_only() { .expect("Failed to get payment options"); assert!(!response.payment_id.is_empty()); - println!( - "Payment {} has {} options", - response.payment_id, - response.options.len() - ); + let options = response.options.expect("Expected options to be present"); + println!("Payment {} has {} options", response.payment_id, options.len()); if let Some(info) = &response.info { println!( @@ -263,10 +260,7 @@ async fn e2e_payment_options_only() { println!("Merchant: {}", info.merchant.name); } - assert!( - !response.options.is_empty(), - "Expected at least one payment option" - ); + assert!(!options.is_empty(), "Expected at least one payment option"); } #[tokio::test] @@ -297,11 +291,12 @@ async fn e2e_payment_happy_path() { .await .expect("Failed to get payment options"); - println!("Got {} payment options", options_response.options.len()); - assert!( - !options_response.options.is_empty(), - "Expected at least one payment option" - ); + let options = options_response + .options + .as_ref() + .expect("Expected options to be present"); + println!("Got {} payment options", options.len()); + assert!(!options.is_empty(), "Expected at least one payment option"); if let Some(info) = &options_response.info { println!( @@ -312,13 +307,12 @@ async fn e2e_payment_happy_path() { } // Step 3: Select first available option (prefer Base chain) - let selected_option = options_response - .options + let selected_option = options .iter() .find(|opt| { opt.actions.iter().any(|a| a.wallet_rpc.chain_id == CHAIN_BASE) }) - .or_else(|| options_response.options.first()) + .or_else(|| options.first()) .expect("No payment option available"); println!( diff --git a/crates/yttrium/src/pay/mod.rs b/crates/yttrium/src/pay/mod.rs index da78448f..f9ffa6f0 100644 --- a/crates/yttrium/src/pay/mod.rs +++ b/crates/yttrium/src/pay/mod.rs @@ -778,7 +778,7 @@ impl From for PaymentInfo { pub struct PaymentOptionsResponse { pub payment_id: String, pub info: Option, - pub options: Vec, + pub options: Option>, pub collect_data: Option, } @@ -983,11 +983,16 @@ impl WalletConnectPay { })?; let api_response = response.into_inner(); - let options = api_response.options.unwrap_or_default(); - pay_debug!("get_payment_options: success, {} options", options.len()); + pay_debug!( + "get_payment_options: success, {} options", + api_response.options.as_ref().map_or(0, Vec::len) + ); // Cache the options with their raw actions - let cached: Vec = options + let cached: Vec = api_response + .options + .as_deref() + .unwrap_or_default() .iter() .map(|o| CachedPaymentOption { option_id: o.id.clone(), @@ -1004,7 +1009,9 @@ impl WalletConnectPay { Ok(PaymentOptionsResponse { payment_id, info: api_response.info.map(Into::into), - options: options.into_iter().map(Into::into).collect(), + options: api_response + .options + .map(|opts| opts.into_iter().map(Into::into).collect()), collect_data: api_response.collect_data.map(Into::into), }) } @@ -1839,18 +1846,15 @@ mod tests { assert!(result.is_ok()); let response = result.unwrap(); - assert_eq!(response.options.len(), 1); - assert_eq!(response.options[0].id, "opt_1"); - assert_eq!( - response.options[0].amount.unit, - "caip19/eip155:8453/erc20:0xUSDC" - ); + let options = response.options.expect("options"); + assert_eq!(options.len(), 1); + assert_eq!(options[0].id, "opt_1"); + assert_eq!(options[0].amount.unit, "caip19/eip155:8453/erc20:0xUSDC"); assert_eq!( - response.options[0].amount.display.network_name, + options[0].amount.display.network_name, Some("Base".to_string()) ); - let opt_cd = - response.options[0].collect_data.as_ref().expect("collect_data"); + let opt_cd = options[0].collect_data.as_ref().expect("collect_data"); assert_eq!(opt_cd.fields.len(), 1); assert_eq!(opt_cd.fields[0].id, "fullName"); assert_eq!(opt_cd.fields[0].field_type, CollectDataFieldType::Text); @@ -1968,7 +1972,7 @@ mod tests { assert!(result.is_ok()); let response = result.unwrap(); - assert!(response.options.is_empty()); + assert!(response.options.is_none()); assert!(response.info.is_some()); let info = response.info.unwrap(); assert_eq!(info.status, PaymentStatus::Succeeded); @@ -2198,7 +2202,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.options.len(), 1); + assert_eq!(response.options.as_ref().map(Vec::len), Some(1)); let actions = client .get_required_payment_actions( @@ -2665,7 +2669,11 @@ mod tests { Some("https://data-collection.example.com/ic/pay_123".to_string()) ); assert!(data.schema.is_some()); - assert!(response.options[0].collect_data.is_none()); + assert!( + response.options.as_ref().expect("options")[0] + .collect_data + .is_none() + ); } #[tokio::test]