ruby-x402 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 781025ab8d5eea01d68d2123dbfc9976fe140fbddd291604b1068d62507af5db
4
+ data.tar.gz: 305bfd02dcff64cd373f0d5871d5c6f6fa1ba55a65f2eafc166668a310743af8
5
+ SHA512:
6
+ metadata.gz: 13c3bda6931660daaf0a2fce995048128c3e4e74bb4c970d484b8eaee2331f7b096e11c5b527e656d287b26d81939237faa1e0cbf421d17e66b14d5cb6e23730
7
+ data.tar.gz: 07d5904a21f8b2b419f0ee63caaebe8c2964a5e6a6797dc7af406926ed52d9e6f67eed57cc72b0dbd689c1ab7dec11e9ff19adf0a271c24173042ec2c316d461
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ ## 0.1.0 (2025-11-10)
2
+
3
+ - Rails integration:
4
+ - Railtie auto-inserts `X402::Rack::RequirePayment` and filters sensitive headers
5
+ - Installer generator with initializer template
6
+ - Optional controller concern `X402::Rails::ControllerConcern#require_x402_payment`
7
+ - Optional proxy facilitator Rails engine (`/verify`, `/settle`, `/discovery/resources`)
8
+ - Config API: `X402.configure` with validated settings and defaults
9
+ - Middleware: `:settle_async` mode using ActiveJob; `:verify_only` support
10
+ - FacilitatorClient: timeouts, retries, error mapping, and robust JSON handling
11
+ - Types: validation and error classes (`X402::Errors::*`)
12
+ - Docs: Rails quickstart and `docs/rails.md`
data/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # ruby-x402
2
+
3
+ Server-side Ruby implementation of the x402 payments protocol.
4
+
5
+ Includes:
6
+
7
+ - Rack middleware to require payments for routes
8
+ - Facilitator client to verify and settle payments
9
+ - Support for the `exact` scheme on EVM and Solana (SVM)
10
+
11
+ This gem does not provide client-side signing. Use a facilitator (e.g., `https://x402.org/facilitator`) for verify/settle.
12
+
13
+ ## Installation
14
+
15
+ Add the gem to your Gemfile:
16
+
17
+ ```ruby
18
+ gem "ruby-x402"
19
+ ```
20
+
21
+ ## Quick start (Rack)
22
+
23
+ ```ruby
24
+ use X402::Rack::RequirePayment, config: {
25
+ networks: ["base-sepolia", "solana-devnet"],
26
+ routes: {
27
+ "/paid" => {
28
+ evm: { amount: "1000000" },
29
+ svm: { amount: "1000000" }
30
+ }
31
+ },
32
+ evm: {
33
+ asset: "0x036CbD53842c...",
34
+ pay_to: "0xYourEvmAddress",
35
+ extra: { name: "USDC", version: "2" }
36
+ },
37
+ svm: {
38
+ asset: "EPjFWdd5Aufq...",
39
+ pay_to: "YourSolanaAddress",
40
+ extra: { fee_payer: "FacilitatorFeePayerAddress" }
41
+ },
42
+ facilitator: { url: "https://x402.org/facilitator" }
43
+ }
44
+ ```
45
+
46
+ ## Quick start (Rails)
47
+
48
+ 1. Add the gem and bundle.
49
+ 2. Run the installer:
50
+
51
+ ```bash
52
+ bin/rails g x402:install
53
+ ```
54
+
55
+ This creates `config/initializers/x402.rb`. Adjust networks, pricing, and facilitator settings.
56
+
57
+ 3. (Optional) Mount the proxy facilitator engine to forward `/verify`, `/settle`, and discovery to an upstream:
58
+
59
+ ```ruby
60
+ # config/routes.rb
61
+ mount X402::Facilitator::Engine => "/x402"
62
+ ```
63
+
64
+ 4. Modes:
65
+ - `:settle_sync` (default): verify then settle synchronously; returns `X-PAYMENT-RESPONSE` header
66
+ - `:verify_only`: verify only, no settlement
67
+ - `:settle_async`: verify then enqueue settlement via ActiveJob (if available)
68
+
69
+ ## Facilitator client
70
+
71
+ ```ruby
72
+ client = X402::FacilitatorClient.new(
73
+ url: "https://x402.org/facilitator",
74
+ create_headers: -> {
75
+ { verify: { "Authorization" => "Bearer <token>" } }
76
+ }
77
+ )
78
+
79
+ payment = X402::Types::PaymentPayload.new(
80
+ x402_version: 1, scheme: "exact", network: "base-sepolia",
81
+ payload: { "authorization" => {}, "signature" => "0x" }
82
+ )
83
+
84
+ reqs = X402::Types::PaymentRequirements.new(
85
+ scheme: "exact", network: "base-sepolia", max_amount_required: "1000000",
86
+ resource: "/paid", description: "API access", mime_type: "application/json",
87
+ pay_to: "0x...", max_timeout_seconds: 60, asset: "0x...", extra: { "name" => "USDC", "version" => "2" }
88
+ )
89
+
90
+ verify = client.verify(payment: payment, payment_requirements: reqs)
91
+ if verify.is_valid
92
+ settle = client.settle(payment: payment, payment_requirements: reqs)
93
+ end
94
+ ```
95
+
96
+ ## Docs
97
+
98
+ - See `docs/rails.md` for Rails integration details and troubleshooting.
99
+
100
+ ## License
101
+
102
+ Apache-2.0
data/docs/rails.md ADDED
@@ -0,0 +1,60 @@
1
+ # Rails integration
2
+
3
+ ## Install
4
+
5
+ ```bash
6
+ bundle add ruby-x402
7
+ bin/rails g x402:install
8
+ ```
9
+
10
+ This adds `config/initializers/x402.rb` with sensible defaults.
11
+
12
+ ## Configuration
13
+
14
+ Use `X402.configure`:
15
+
16
+ ```ruby
17
+ X402.configure do |c|
18
+ c.networks = %w[base-sepolia solana-devnet]
19
+ c.routes = { "/paid" => { evm: { amount: "1000000" }, svm: { amount: "1000000" } } }
20
+ c.evm.asset = ENV["X402_EVM_ASSET"]
21
+ c.evm.pay_to = ENV["X402_EVM_PAY_TO"]
22
+ c.svm.asset = ENV["X402_SVM_ASSET"]
23
+ c.svm.pay_to = ENV["X402_SVM_PAY_TO"]
24
+ c.facilitator.url = ENV.fetch("X402_FACILITATOR_URL", "https://x402.org/facilitator")
25
+ c.facilitator.create_headers = -> { { verify: { "Authorization" => "Bearer #{ENV["X402_FACILITATOR_TOKEN"]}" } } }
26
+ c.mode = :settle_sync # or :verify_only, :settle_async
27
+ end
28
+ ```
29
+
30
+ The Railtie auto-inserts the `X402::Rack::RequirePayment` middleware using the configured settings.
31
+
32
+ ## Proxy facilitator engine (optional)
33
+
34
+ Mount the engine to forward requests to a configured upstream facilitator:
35
+
36
+ ```ruby
37
+ # config/routes.rb
38
+ mount X402::Facilitator::Engine => "/x402"
39
+ ```
40
+
41
+ Endpoints:
42
+ - POST `/x402/verify`
43
+ - POST `/x402/settle`
44
+ - GET `/x402/discovery/resources`
45
+
46
+ ## Modes
47
+ - `:settle_sync` (default): verify then settle synchronously; sets `X-PAYMENT-RESPONSE`
48
+ - `:verify_only`: verify only
49
+ - `:settle_async`: verify, then enqueue settlement with `ActiveJob` if present
50
+
51
+ ## Security and logging
52
+ - `X-PAYMENT` and `X-PAYMENT-RESPONSE` are filtered from Rails logs by default.
53
+ - CORS: Ensure `Access-Control-Expose-Headers` includes `X-PAYMENT-RESPONSE` (middleware sets it).
54
+
55
+ ## Troubleshooting
56
+ - `invalid_x402_version`, `invalid_network`, `invalid_scheme`: Check payload formation and network selection.
57
+ - `unsupported network` during boot: Ensure `c.networks` is one of `X402::Types::Networks::ALL`.
58
+ - Settlement not appearing with `:settle_async`: Ensure ActiveJob queue is running, or use `:settle_sync`.
59
+
60
+
@@ -0,0 +1,21 @@
1
+ begin
2
+ require "rails/generators"
3
+ rescue LoadError
4
+ # Rails not available
5
+ end
6
+
7
+ module X402
8
+ module Generators
9
+ class InstallGenerator < (defined?(Rails::Generators::Base) ? Rails::Generators::Base : Object)
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ def create_initializer
13
+ return unless defined?(Rails::Generators::Base)
14
+ template "x402.rb", "config/initializers/x402.rb"
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+
21
+
@@ -0,0 +1,31 @@
1
+ X402.configure do |c|
2
+ c.networks = %w[base-sepolia solana-devnet]
3
+ c.routes = {
4
+ "/paid" => {
5
+ evm: { amount: "1000000" },
6
+ svm: { amount: "1000000" }
7
+ }
8
+ }
9
+
10
+ # EVM network settings
11
+ c.evm.asset = ENV.fetch("X402_EVM_ASSET", nil)
12
+ c.evm.pay_to = ENV.fetch("X402_EVM_PAY_TO", nil)
13
+ c.evm.extra = { name: "USDC", version: "2" }
14
+
15
+ # SVM network settings
16
+ c.svm.asset = ENV.fetch("X402_SVM_ASSET", nil)
17
+ c.svm.pay_to = ENV.fetch("X402_SVM_PAY_TO", nil)
18
+ c.svm.extra = { fee_payer: ENV.fetch("X402_SVM_FEE_PAYER", nil) }.compact
19
+
20
+ # Facilitator settings
21
+ c.facilitator.url = ENV.fetch("X402_FACILITATOR_URL", "https://x402.org/protected")
22
+ c.facilitator.create_headers = lambda {
23
+ token = ENV["X402_FACILITATOR_TOKEN"]
24
+ token ? { verify: { "Authorization" => "Bearer #{token}" }, settle: { "Authorization" => "Bearer #{token}" } } : {}
25
+ }
26
+
27
+ c.mode = :settle_sync # :verify_only or :settle_async
28
+ c.max_timeout_seconds = 60
29
+ end
30
+
31
+
@@ -0,0 +1,120 @@
1
+ require_relative "types/networks"
2
+ require_relative "errors"
3
+
4
+ module X402
5
+ class Config
6
+ DEFAULT_MODE = :settle_sync
7
+ DEFAULT_TIMEOUT = 60
8
+
9
+ class NetworkSettings
10
+ attr_accessor :asset, :pay_to, :extra
11
+
12
+ def initialize
13
+ @asset = nil
14
+ @pay_to = nil
15
+ @extra = {}
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ asset: asset,
21
+ pay_to: pay_to,
22
+ extra: extra || {}
23
+ }
24
+ end
25
+ end
26
+
27
+ class FacilitatorSettings
28
+ attr_accessor :url, :create_headers
29
+
30
+ def initialize
31
+ @url = X402::FacilitatorClient::DEFAULT_URL
32
+ @create_headers = nil
33
+ end
34
+
35
+ def to_h
36
+ {
37
+ url: url,
38
+ create_headers: create_headers
39
+ }
40
+ end
41
+ end
42
+
43
+ attr_accessor :networks, :routes, :mode, :max_timeout_seconds
44
+ attr_reader :evm, :svm, :facilitator
45
+
46
+ def initialize
47
+ @networks = []
48
+ @routes = {}
49
+ @evm = NetworkSettings.new
50
+ @svm = NetworkSettings.new
51
+ @facilitator = FacilitatorSettings.new
52
+ @mode = DEFAULT_MODE
53
+ @max_timeout_seconds = DEFAULT_TIMEOUT
54
+ end
55
+
56
+ def routes=(value)
57
+ @routes = deep_symbolize(value || {})
58
+ end
59
+
60
+ def validate!
61
+ unless mode.is_a?(Symbol) && %i[settle_sync verify_only settle_async].include?(mode)
62
+ raise X402::Errors::ConfigurationError, "Invalid mode: #{mode.inspect}"
63
+ end
64
+ unless max_timeout_seconds.is_a?(Integer) && max_timeout_seconds.positive?
65
+ raise X402::Errors::ConfigurationError, "max_timeout_seconds must be a positive Integer"
66
+ end
67
+
68
+ networks.each do |n|
69
+ unless X402::Types::Networks::ALL.include?(n)
70
+ raise X402::Errors::ValidationError, "Unsupported network: #{n}"
71
+ end
72
+ end
73
+
74
+ if networks.any? { |n| n.include?("base") }
75
+ %i[asset pay_to].each do |k|
76
+ v = evm.public_send(k)
77
+ raise X402::Errors::ConfigurationError, "EVM #{k} must be configured" if v.nil? || v.to_s.empty?
78
+ end
79
+ end
80
+ if networks.any? { |n| n.start_with?("solana") }
81
+ %i[asset pay_to].each do |k|
82
+ v = svm.public_send(k)
83
+ raise X402::Errors::ConfigurationError, "SVM #{k} must be configured" if v.nil? || v.to_s.empty?
84
+ end
85
+ end
86
+ self
87
+ end
88
+
89
+ def to_h
90
+ {
91
+ networks: networks,
92
+ routes: routes,
93
+ evm: evm.to_h,
94
+ svm: svm.to_h,
95
+ facilitator: facilitator.to_h,
96
+ mode: mode,
97
+ max_timeout_seconds: max_timeout_seconds
98
+ }
99
+ end
100
+
101
+ private
102
+
103
+ def deep_symbolize(obj)
104
+ case obj
105
+ when Hash
106
+ obj.each_with_object({}) do |(k, v), acc|
107
+ key = (k.to_sym rescue k) || k
108
+ acc[key] = deep_symbolize(v)
109
+ end
110
+ when Array
111
+ obj.map { |v| deep_symbolize(v) }
112
+ else
113
+ obj
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+
120
+
@@ -0,0 +1,18 @@
1
+ require "base64"
2
+ require "json"
3
+
4
+ module X402
5
+ module Encoding
6
+ module_function
7
+
8
+ def encode_header(hash)
9
+ json = JSON.generate(hash)
10
+ Base64.strict_encode64(json)
11
+ end
12
+
13
+ def decode_header(base64_string)
14
+ json = Base64.decode64(base64_string.to_s)
15
+ JSON.parse(json)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ module X402
2
+ module Errors
3
+ class BaseError < StandardError; end
4
+
5
+ class ConfigurationError < BaseError; end
6
+ class ValidationError < BaseError; end
7
+
8
+ class NetworkError < BaseError; end
9
+ class TimeoutError < NetworkError; end
10
+ class HttpError < NetworkError
11
+ attr_reader :status, :body
12
+ def initialize(message = "HTTP error", status: nil, body: nil)
13
+ @status = status
14
+ @body = body
15
+ super(message)
16
+ end
17
+ end
18
+ class ParseError < BaseError; end
19
+
20
+ class UnsupportedError < BaseError; end
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+ begin
2
+ require "rails/engine"
3
+ rescue LoadError
4
+ # Rails not available
5
+ end
6
+
7
+ module X402
8
+ module Facilitator
9
+ class Engine < ::Rails::Engine
10
+ isolate_namespace X402::Facilitator
11
+
12
+ initializer "x402.facilitator.routes" do
13
+ X402::Facilitator::Engine.routes.draw do
14
+ post "/verify", to: "requests#verify"
15
+ post "/settle", to: "requests#settle"
16
+ get "/discovery/resources", to: "requests#list"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+
24
+
@@ -0,0 +1,68 @@
1
+ begin
2
+ require "action_controller/railtie"
3
+ rescue LoadError
4
+ # Rails not available
5
+ end
6
+
7
+ require_relative "../facilitator_client"
8
+ require_relative "../types/payment_payload"
9
+ require_relative "../types/payment_requirements"
10
+ require_relative "../errors"
11
+
12
+ module X402
13
+ module Facilitator
14
+ class RequestsController < (defined?(::ActionController::API) ? ::ActionController::API : ::ActionController::Base)
15
+ def verify
16
+ client = build_client
17
+ payload, reqs = parse_payload_and_requirements
18
+ render json: client.verify(payment: payload, payment_requirements: reqs).to_h
19
+ rescue X402::Errors::BaseError => e
20
+ render json: { isValid: false, invalidReason: e.class.name.split("::").last.underscore }, status: :bad_request
21
+ rescue StandardError => e
22
+ render json: { isValid: false, invalidReason: "unexpected_verify_error" }, status: :internal_server_error
23
+ end
24
+
25
+ def settle
26
+ client = build_client
27
+ payload, reqs = parse_payload_and_requirements
28
+ render json: client.settle(payment: payload, payment_requirements: reqs).to_h
29
+ rescue X402::Errors::BaseError => e
30
+ render json: { success: false, errorReason: e.class.name.split("::").last.underscore }, status: :bad_request
31
+ rescue StandardError => e
32
+ render json: { success: false, errorReason: "unexpected_settle_error" }, status: :internal_server_error
33
+ end
34
+
35
+ def list
36
+ client = build_client
37
+ items = client.list(params: request.query_parameters)
38
+ render json: items
39
+ rescue X402::Errors::BaseError => e
40
+ render json: { error: e.message }, status: :bad_request
41
+ rescue StandardError => e
42
+ render json: { error: "unexpected_list_error" }, status: :internal_server_error
43
+ end
44
+
45
+ private
46
+
47
+ def build_client
48
+ ::X402::FacilitatorClient.new(
49
+ url: ::X402.config.facilitator.url,
50
+ create_headers: ::X402.config.facilitator.create_headers
51
+ )
52
+ end
53
+
54
+ def parse_payload_and_requirements
55
+ body = request.request_parameters.presence || JSON.parse(request.raw_post)
56
+ payload = body["paymentPayload"] || body["payment_payload"]
57
+ requirements = body["paymentRequirements"] || body["payment_requirements"]
58
+ [
59
+ ::X402::Types::PaymentPayload.from_hash(payload),
60
+ ::X402::Types::PaymentRequirements.from_hash(requirements)
61
+ ]
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+
68
+
@@ -0,0 +1,155 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require_relative "types/payment_payload"
5
+ require_relative "types/payment_requirements"
6
+ require_relative "types/responses"
7
+ require_relative "errors"
8
+
9
+ module X402
10
+ class FacilitatorClient
11
+ DEFAULT_URL = ENV.fetch("X402_FACILITATOR_URL", "https://x402.org/protected").freeze
12
+
13
+ def initialize(url: DEFAULT_URL, create_headers: nil, open_timeout: 5, read_timeout: 10, write_timeout: 5, retry_count: 1)
14
+ raise ArgumentError, "Invalid URL #{url}" unless url.to_s.start_with?("http://", "https://")
15
+ @base_url = url.end_with?("/") ? url[0..-2] : url
16
+ @create_headers = create_headers # -> { verify: {..}, settle: {..}, list: {..} }
17
+ @open_timeout = Integer(open_timeout)
18
+ @read_timeout = Integer(read_timeout)
19
+ @write_timeout = Integer(write_timeout) rescue 5
20
+ @retry_count = Integer(retry_count)
21
+ end
22
+
23
+ def verify(payment:, payment_requirements:)
24
+ body = {
25
+ "x402Version" => normalize_payload(payment).x402_version,
26
+ "paymentPayload" => normalize_payload(payment).to_h,
27
+ "paymentRequirements" => normalize_requirements(payment_requirements).to_h
28
+ }
29
+ headers = base_headers.merge(endpoint_headers(:verify))
30
+ data = post_json("#{@base_url}/verify", body, headers)
31
+ Types::VerifyResponse.from_hash(data)
32
+ end
33
+
34
+ def settle(payment:, payment_requirements:)
35
+ body = {
36
+ "x402Version" => normalize_payload(payment).x402_version,
37
+ "paymentPayload" => normalize_payload(payment).to_h,
38
+ "paymentRequirements" => normalize_requirements(payment_requirements).to_h
39
+ }
40
+ headers = base_headers.merge(endpoint_headers(:settle))
41
+ data = post_json("#{@base_url}/settle", body, headers)
42
+ Types::SettleResponse.from_hash(data)
43
+ end
44
+
45
+ def list(params: {})
46
+ headers = base_headers.merge(endpoint_headers(:list))
47
+ uri = URI("#{@base_url}/discovery/resources")
48
+ uri.query = URI.encode_www_form(params.transform_keys { |k| camelize(k.to_s) })
49
+ res = with_retries { http_get(uri, headers) }
50
+ ensure_success!(res)
51
+ parse_json(res.body)
52
+ end
53
+
54
+ private
55
+
56
+ def base_headers
57
+ { "Content-Type" => "application/json" }
58
+ end
59
+
60
+ def endpoint_headers(kind)
61
+ return {} unless @create_headers
62
+ custom = @create_headers.call
63
+ (custom[kind] || {}) || {}
64
+ end
65
+
66
+ def http_post(uri, body, headers)
67
+ uri = URI(uri)
68
+ req = Net::HTTP::Post.new(uri)
69
+ headers.each { |k, v| req[k] = v }
70
+ req.body = body
71
+ perform_http(uri, req)
72
+ end
73
+
74
+ def http_get(uri, headers)
75
+ req = Net::HTTP::Get.new(uri)
76
+ headers.each { |k, v| req[k] = v }
77
+ perform_http(uri, req)
78
+ end
79
+
80
+ def post_json(uri, obj, headers)
81
+ res = http_post(uri, JSON.generate(obj), headers)
82
+ ensure_success!(res)
83
+ parse_json(res.body)
84
+ rescue JSON::ParserError => e
85
+ raise X402::Errors::ParseError, "Invalid JSON response: #{e.message}"
86
+ rescue Timeout::Error => e
87
+ raise X402::Errors::TimeoutError, e.message
88
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
89
+ raise X402::Errors::NetworkError, e.message
90
+ end
91
+
92
+ def normalize_payload(obj)
93
+ case obj
94
+ when Types::PaymentPayload then obj
95
+ when String then Types::PaymentPayload.from_json(obj)
96
+ when Hash then Types::PaymentPayload.from_hash(obj)
97
+ else
98
+ raise ArgumentError, "Unsupported payment payload type: #{obj.class}"
99
+ end
100
+ end
101
+
102
+ def normalize_requirements(obj)
103
+ case obj
104
+ when Types::PaymentRequirements then obj
105
+ when String then Types::PaymentRequirements.from_json(obj)
106
+ when Hash then Types::PaymentRequirements.from_hash(obj)
107
+ else
108
+ raise ArgumentError, "Unsupported payment requirements type: #{obj.class}"
109
+ end
110
+ end
111
+
112
+ def camelize(key)
113
+ parts = key.split("_")
114
+ parts[0] + parts[1..].map(&:capitalize).join
115
+ end
116
+
117
+ def perform_http(uri, req)
118
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
119
+ http.open_timeout = @open_timeout if http.respond_to?(:open_timeout=)
120
+ http.read_timeout = @read_timeout if http.respond_to?(:read_timeout=)
121
+ http.write_timeout = @write_timeout if http.respond_to?(:write_timeout=)
122
+ http.request(req)
123
+ end
124
+ rescue Timeout::Error => e
125
+ raise X402::Errors::TimeoutError, e.message
126
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
127
+ raise X402::Errors::NetworkError, e.message
128
+ end
129
+
130
+ def with_retries
131
+ attempts = 0
132
+ begin
133
+ yield
134
+ rescue X402::Errors::TimeoutError, X402::Errors::NetworkError => e
135
+ attempts += 1
136
+ raise e if attempts > @retry_count
137
+ sleep(0.2 * attempts)
138
+ retry
139
+ end
140
+ end
141
+
142
+ def ensure_success!(res)
143
+ return if res.is_a?(Net::HTTPSuccess)
144
+ raise X402::Errors::HttpError.new("HTTP #{res.code}", status: res.code.to_i, body: res.body)
145
+ end
146
+
147
+ def parse_json(body)
148
+ JSON.parse(body)
149
+ rescue JSON::ParserError => e
150
+ raise X402::Errors::ParseError, "Invalid JSON response: #{e.message}"
151
+ end
152
+ end
153
+ end
154
+
155
+