From b2638e1ec9320cfa77135186016a9079bb5f19b0 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 24 Sep 2026 09:35:48 -0600 Subject: [PATCH 1/2] feat(ruby): PayKit.configure_from_env mirrors Python's configure_from Add `PayKit.configure_from_env(prefix = "PAY_KIT_", env: ENV)` to the Ruby SDK, reading the same knobs Python's `configure_from` reads and funnelling them through `PayKit.configure`. `docs/paykit-interface.md:89` makes `configure_from_env` a required entry point in every SDK; Ruby has only the block form, the gap lists. --- docs/paykit-interface.md | 1 - ruby/lib/pay_kit/config.rb | 35 ++++++++++++++++++++ ruby/test/pay_kit/config_test.rb | 56 ++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/docs/paykit-interface.md b/docs/paykit-interface.md index a7d433c31..ed304fb2e 100644 --- a/docs/paykit-interface.md +++ b/docs/paykit-interface.md @@ -269,7 +269,6 @@ conformant. - Add `accepts?(protocol)` on `Gate`; keep `x402_accepted?` / `mpp_accepted?` as deprecated aliases for one release. - Add `payer` and ensure `scheme` on `Payment`. -- Add `PayKit.configure_from_env`. - Stablecoin set: add `USDG`, `CASH`. - Wire `external_id` through every flow (currently inconsistent). diff --git a/ruby/lib/pay_kit/config.rb b/ruby/lib/pay_kit/config.rb index 54531af96..589f7482a 100644 --- a/ruby/lib/pay_kit/config.rb +++ b/ruby/lib/pay_kit/config.rb @@ -264,6 +264,21 @@ def configure @config end + def configure_from_env(prefix = "PAY_KIT_", env: ENV) + vars = env.to_h.select { |k, _| k.start_with?(prefix) }.transform_keys { |k| k.delete_prefix(prefix) } + configure do |c| + c.network = vars["NETWORK"] if vars.key?("NETWORK") + c.rpc_url = vars["RPC_URL"] if vars.key?("RPC_URL") + c.accept = split_env_list(vars["ACCEPT"]) if vars.key?("ACCEPT") + c.stablecoins = split_env_list(vars["STABLECOINS"]) if vars.key?("STABLECOINS") + c.preflight = parse_env_boolean(vars["PREFLIGHT"], "#{prefix}PREFLIGHT") if vars.key?("PREFLIGHT") + c.mpp.realm = vars["MPP_REALM"] if vars.key?("MPP_REALM") + c.mpp.challenge_binding_secret = vars["MPP_CHALLENGE_BINDING_SECRET"] if vars.key?("MPP_CHALLENGE_BINDING_SECRET") + c.mpp.expires_in = parse_env_integer(vars["MPP_EXPIRES_IN"], "#{prefix}MPP_EXPIRES_IN") if vars.key?("MPP_EXPIRES_IN") + c.x402.facilitator_url = vars["X402_FACILITATOR_URL"] if vars.key?("X402_FACILITATOR_URL") + end + end + def config @config ||= Config.new end @@ -279,5 +294,25 @@ def reset! @config = nil @pricing = nil end + + private + + def split_env_list(value) + value.split(",").map(&:strip).reject(&:empty?) + end + + def parse_env_boolean(value, key) + case value.strip.downcase + when "true", "1" then true + when "false", "0" then false + else raise ConfigurationError, "#{key} must be true, false, 1 or 0, got #{value.inspect}" + end + end + + def parse_env_integer(value, key) + Integer(value.strip, 10) + rescue ArgumentError + raise ConfigurationError, "#{key} must be an integer, got #{value.inspect}" + end end end diff --git a/ruby/test/pay_kit/config_test.rb b/ruby/test/pay_kit/config_test.rb index 21ea530eb..4e9a4e766 100644 --- a/ruby/test/pay_kit/config_test.rb +++ b/ruby/test/pay_kit/config_test.rb @@ -291,6 +291,62 @@ def test_x402_unknown_scheme_raises end end + # --- configure_from_env ---------------------------------------------- + + def test_configure_from_env_reads_scalars + env = { + "PAY_KIT_NETWORK" => "solana_devnet", + "PAY_KIT_RPC_URL" => "https://helius.example.com", + "PAY_KIT_PREFLIGHT" => "FALSE", + "PAY_KIT_MPP_REALM" => "Shop", + "PAY_KIT_MPP_CHALLENGE_BINDING_SECRET" => "rotate-me", + "PAY_KIT_MPP_EXPIRES_IN" => "90", + "PAY_KIT_X402_FACILITATOR_URL" => "https://facilitator.example.com" + } + config = PayKit.configure_from_env(env: env) + assert config.frozen? + assert_equal :solana_devnet, config.network + assert_equal "https://helius.example.com", config.rpc_url + refute config.preflight + assert_equal "Shop", config.mpp.realm + assert_equal "rotate-me", config.mpp.challenge_binding_secret + assert_equal 90, config.mpp.expires_in + assert_equal "https://facilitator.example.com", config.x402.facilitator_url + end + + def test_configure_from_env_splits_lists + config = PayKit.configure_from_env(env: {"PAY_KIT_ACCEPT" => "x402, mpp", "PAY_KIT_STABLECOINS" => "USDC,PYUSD"}) + assert_equal %i[x402 mpp], config.accept + assert_equal %i[USDC PYUSD], config.stablecoins + end + + def test_configure_from_env_keeps_defaults_when_unset + config = PayKit.configure_from_env(env: {}) + assert_equal :solana_localnet, config.network + assert_equal %i[x402 mpp], config.accept + assert_equal %i[USDC], config.stablecoins + assert_equal 300, config.mpp.expires_in + end + + def test_configure_from_env_custom_prefix + config = PayKit.configure_from_env("APP_", env: {"APP_NETWORK" => "solana_devnet", "PAY_KIT_NETWORK" => "bitcoin"}) + assert_equal :solana_devnet, config.network + end + + def test_configure_from_env_rejects_bad_values + { + "PAY_KIT_NETWORK" => "bitcoin", + "PAY_KIT_PREFLIGHT" => "maybe", + "PAY_KIT_MPP_EXPIRES_IN" => "soon", + "PAY_KIT_STABLECOINS" => " , " + }.each do |key, value| + PayKit.reset! + assert_raises(PayKit::ConfigurationError, key) do + PayKit.configure_from_env(env: {key => value}) + end + end + end + private # Replace `PayKit::Preflight.run` with a no-op spy for the duration of From d848b0261f20e7330f56be2695d17ddab9add50c Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 24 Sep 2026 10:33:35 -0600 Subject: [PATCH 2/2] feat(ruby): address review --- ruby/lib/pay_kit/config.rb | 46 +++++++++--------- ruby/test/pay_kit/config_test.rb | 83 +++++++++++++++++++++----------- 2 files changed, 78 insertions(+), 51 deletions(-) diff --git a/ruby/lib/pay_kit/config.rb b/ruby/lib/pay_kit/config.rb index 589f7482a..063679767 100644 --- a/ruby/lib/pay_kit/config.rb +++ b/ruby/lib/pay_kit/config.rb @@ -265,17 +265,22 @@ def configure end def configure_from_env(prefix = "PAY_KIT_", env: ENV) - vars = env.to_h.select { |k, _| k.start_with?(prefix) }.transform_keys { |k| k.delete_prefix(prefix) } configure do |c| - c.network = vars["NETWORK"] if vars.key?("NETWORK") - c.rpc_url = vars["RPC_URL"] if vars.key?("RPC_URL") - c.accept = split_env_list(vars["ACCEPT"]) if vars.key?("ACCEPT") - c.stablecoins = split_env_list(vars["STABLECOINS"]) if vars.key?("STABLECOINS") - c.preflight = parse_env_boolean(vars["PREFLIGHT"], "#{prefix}PREFLIGHT") if vars.key?("PREFLIGHT") - c.mpp.realm = vars["MPP_REALM"] if vars.key?("MPP_REALM") - c.mpp.challenge_binding_secret = vars["MPP_CHALLENGE_BINDING_SECRET"] if vars.key?("MPP_CHALLENGE_BINDING_SECRET") - c.mpp.expires_in = parse_env_integer(vars["MPP_EXPIRES_IN"], "#{prefix}MPP_EXPIRES_IN") if vars.key?("MPP_EXPIRES_IN") - c.x402.facilitator_url = vars["X402_FACILITATOR_URL"] if vars.key?("X402_FACILITATOR_URL") + env.each do |name, value| + next unless name.start_with?(prefix) + + case name.delete_prefix(prefix) + when "NETWORK" then c.network = value + when "RPC_URL" then c.rpc_url = value unless value.empty? + when "ACCEPT" then c.accept = value.split(",").map(&:strip) - [""] + when "STABLECOINS" then c.stablecoins = value.split(",").map(&:strip) - [""] + when "PREFLIGHT" then c.preflight = env_bool(name, value) + when "MPP_REALM" then c.mpp.realm = value + when "MPP_CHALLENGE_BINDING_SECRET" then c.mpp.challenge_binding_secret = value + when "MPP_EXPIRES_IN" then c.mpp.expires_in = env_positive_int(name, value) + when "X402_FACILITATOR_URL" then c.x402.facilitator_url = value + end + end end end @@ -297,22 +302,19 @@ def reset! private - def split_env_list(value) - value.split(",").map(&:strip).reject(&:empty?) - end - - def parse_env_boolean(value, key) + def env_bool(name, value) case value.strip.downcase - when "true", "1" then true - when "false", "0" then false - else raise ConfigurationError, "#{key} must be true, false, 1 or 0, got #{value.inspect}" + when "1", "true", "yes", "on" then true + when "0", "false", "no", "off" then false + else raise ConfigurationError, "#{name} must be one of 1/true/yes/on or 0/false/no/off, got #{value.inspect}" end end - def parse_env_integer(value, key) - Integer(value.strip, 10) - rescue ArgumentError - raise ConfigurationError, "#{key} must be an integer, got #{value.inspect}" + def env_positive_int(name, value) + int = Integer(value, 10, exception: false) + return int if int&.positive? + + raise ConfigurationError, "#{name} must be a positive integer, got #{value.inspect}" end end end diff --git a/ruby/test/pay_kit/config_test.rb b/ruby/test/pay_kit/config_test.rb index 4e9a4e766..9835e7cbc 100644 --- a/ruby/test/pay_kit/config_test.rb +++ b/ruby/test/pay_kit/config_test.rb @@ -293,60 +293,85 @@ def test_x402_unknown_scheme_raises # --- configure_from_env ---------------------------------------------- - def test_configure_from_env_reads_scalars - env = { + def test_configure_from_env_reads_scalars_onto_frozen_config + config = PayKit.configure_from_env(env: { "PAY_KIT_NETWORK" => "solana_devnet", - "PAY_KIT_RPC_URL" => "https://helius.example.com", - "PAY_KIT_PREFLIGHT" => "FALSE", + "PAY_KIT_RPC_URL" => "https://rpc.example.com", "PAY_KIT_MPP_REALM" => "Shop", "PAY_KIT_MPP_CHALLENGE_BINDING_SECRET" => "rotate-me", "PAY_KIT_MPP_EXPIRES_IN" => "90", "PAY_KIT_X402_FACILITATOR_URL" => "https://facilitator.example.com" - } - config = PayKit.configure_from_env(env: env) + }) + assert_same PayKit.config, config assert config.frozen? assert_equal :solana_devnet, config.network - assert_equal "https://helius.example.com", config.rpc_url - refute config.preflight + assert_equal "https://rpc.example.com", config.rpc_url assert_equal "Shop", config.mpp.realm assert_equal "rotate-me", config.mpp.challenge_binding_secret assert_equal 90, config.mpp.expires_in assert_equal "https://facilitator.example.com", config.x402.facilitator_url end - def test_configure_from_env_splits_lists - config = PayKit.configure_from_env(env: {"PAY_KIT_ACCEPT" => "x402, mpp", "PAY_KIT_STABLECOINS" => "USDC,PYUSD"}) - assert_equal %i[x402 mpp], config.accept - assert_equal %i[USDC PYUSD], config.stablecoins + def test_configure_from_env_splits_comma_lists + PayKit.configure_from_env(env: {"PAY_KIT_ACCEPT" => "x402, mpp", "PAY_KIT_STABLECOINS" => "USDC,PYUSD"}) + assert_equal %i[x402 mpp], PayKit.config.accept + assert_equal %i[USDC PYUSD], PayKit.config.stablecoins end - def test_configure_from_env_keeps_defaults_when_unset - config = PayKit.configure_from_env(env: {}) - assert_equal :solana_localnet, config.network - assert_equal %i[x402 mpp], config.accept - assert_equal %i[USDC], config.stablecoins - assert_equal 300, config.mpp.expires_in + def test_configure_from_env_with_empty_env_keeps_defaults + PayKit.configure_from_env(env: {}) + assert_equal :solana_localnet, PayKit.config.network + assert_equal %i[x402 mpp], PayKit.config.accept + assert_equal %i[USDC], PayKit.config.stablecoins + assert_equal 300, PayKit.config.mpp.expires_in end - def test_configure_from_env_custom_prefix - config = PayKit.configure_from_env("APP_", env: {"APP_NETWORK" => "solana_devnet", "PAY_KIT_NETWORK" => "bitcoin"}) - assert_equal :solana_devnet, config.network + def test_configure_from_env_custom_prefix_ignores_default_prefix + PayKit.configure_from_env("APP_", env: {"APP_NETWORK" => "solana_devnet", "PAY_KIT_NETWORK" => "solana_mainnet"}) + assert_equal :solana_devnet, PayKit.config.network end def test_configure_from_env_rejects_bad_values - { - "PAY_KIT_NETWORK" => "bitcoin", - "PAY_KIT_PREFLIGHT" => "maybe", - "PAY_KIT_MPP_EXPIRES_IN" => "soon", - "PAY_KIT_STABLECOINS" => " , " - }.each do |key, value| + [ + {"PAY_KIT_NETWORK" => "bitcoin"}, + {"PAY_KIT_PREFLIGHT" => "maybe"}, + {"PAY_KIT_MPP_EXPIRES_IN" => "soon"}, + {"PAY_KIT_STABLECOINS" => " , "} + ].each do |env| + PayKit.reset! + assert_raises(PayKit::ConfigurationError, env.inspect) { PayKit.configure_from_env(env: env) } + end + end + + def test_configure_from_env_preflight_accepts_python_boolean_words + {"1" => true, "True" => true, "YES" => true, " on " => true, + "0" => false, "false" => false, "No" => false, " OFF\n" => false}.each do |value, expected| + PayKit.reset! + PayKit.configure_from_env(env: {"PAY_KIT_PREFLIGHT" => value}) + assert_equal expected, PayKit.config.preflight, value + end + end + + def test_configure_from_env_empty_rpc_url_leaves_rpc_url_unset + PayKit.configure_from_env(env: {"PAY_KIT_RPC_URL" => ""}) + assert_nil PayKit.config.rpc_url + assert PayKit.config.using_public_rpc_default? + end + + def test_configure_from_env_rejects_non_positive_expires_in + %w[0 -5].each do |value| PayKit.reset! - assert_raises(PayKit::ConfigurationError, key) do - PayKit.configure_from_env(env: {key => value}) + assert_raises(PayKit::ConfigurationError, value) do + PayKit.configure_from_env(env: {"PAY_KIT_MPP_EXPIRES_IN" => value}) end end end + def test_configure_from_env_ignores_lowercase_names + PayKit.configure_from_env(env: {"pay_kit_network" => "solana_devnet"}) + assert_equal :solana_localnet, PayKit.config.network + end + private # Replace `PayKit::Preflight.run` with a no-op spy for the duration of