mpp-rb 0.1.3 → 0.1.5

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.
@@ -1,4 +1,4 @@
1
- # typed: strict
1
+ # typed: true
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "method"
@@ -10,9 +10,14 @@ module Mpp
10
10
  class MppHandler
11
11
  extend T::Sig
12
12
 
13
+ REQUEST_OPTION_KEYS = [:body, :url, :payment_signature, :accept_payment, :http_method].freeze
14
+
13
15
  sig { returns(T.untyped) }
14
16
  attr_reader :method
15
17
 
18
+ sig { returns(T::Array[T.untyped]) }
19
+ attr_reader :methods
20
+
16
21
  sig { returns(String) }
17
22
  attr_reader :realm
18
23
 
@@ -22,9 +27,19 @@ module Mpp
22
27
  sig { returns(T::Hash[String, T.untyped]) }
23
28
  attr_reader :defaults
24
29
 
25
- sig { params(method: T.untyped, realm: String, secret_key: String, defaults: T.nilable(T::Hash[String, T.untyped]), events: T.nilable(Mpp::Events::Dispatcher)).void }
26
- def initialize(method:, realm:, secret_key:, defaults: nil, events: nil)
27
- @method = T.let(method, T.untyped)
30
+ sig do
31
+ params(
32
+ realm: String,
33
+ secret_key: String,
34
+ method: T.untyped,
35
+ methods: T.nilable(T::Array[T.untyped]),
36
+ defaults: T.nilable(T::Hash[String, T.untyped]),
37
+ events: T.nilable(Mpp::Events::Dispatcher)
38
+ ).void
39
+ end
40
+ def initialize(realm:, secret_key:, method: nil, methods: nil, defaults: nil, events: nil)
41
+ @methods = T.let(normalize_methods(method, methods), T::Array[T.untyped])
42
+ @method = T.let(@methods.first, T.untyped)
28
43
  @realm = T.let(realm, String)
29
44
  @secret_key = T.let(secret_key, String)
30
45
  @defaults = T.let(defaults || {}, T::Hash[String, T.untyped])
@@ -32,10 +47,19 @@ module Mpp
32
47
  end
33
48
 
34
49
  # Create with auto-detected realm and secret_key.
35
- sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped, events: T.nilable(Mpp::Events::Dispatcher)).returns(T.attached_class) }
36
- def self.create(method:, realm: nil, secret_key: nil, events: nil)
50
+ sig do
51
+ params(
52
+ method: T.untyped,
53
+ methods: T.nilable(T::Array[T.untyped]),
54
+ realm: T.untyped,
55
+ secret_key: T.untyped,
56
+ events: T.nilable(Mpp::Events::Dispatcher)
57
+ ).returns(T.attached_class)
58
+ end
59
+ def self.create(method: nil, methods: nil, realm: nil, secret_key: nil, events: nil)
37
60
  new(
38
61
  method: method,
62
+ methods: methods,
39
63
  realm: realm || Defaults.detect_realm,
40
64
  secret_key: secret_key || Defaults.detect_secret_key,
41
65
  events: events
@@ -62,20 +86,123 @@ module Mpp
62
86
  on(Mpp::Events::PAYMENT_SUCCESS, handler, &block)
63
87
  end
64
88
 
89
+ # Combine method offers into a single callable that presents every method.
90
+ sig { params(entries: T.untyped).returns(ComposedHandler) }
91
+ def compose(*entries)
92
+ ComposedHandler.from_entries(self, entries)
93
+ end
94
+
95
+ # Look up a registered method by object, name, or "name/intent" key.
96
+ sig { params(method_ref: T.untyped).returns(T.untyped) }
97
+ def resolve_method(method_ref)
98
+ case method_ref
99
+ when String
100
+ resolve_method_key(method_ref)
101
+ else
102
+ found = @methods.find { |candidate| candidate.equal?(method_ref) } ||
103
+ @methods.find { |candidate| candidate.name == method_ref.name }
104
+ return found if found
105
+
106
+ raise ArgumentError, "No handler for #{method_ref.inspect}. Is this method in your methods array?"
107
+ end
108
+ end
109
+
65
110
  # Handle a charge intent.
66
- sig { params(authorization: T.nilable(String), amount: String, currency: T.nilable(String), recipient: T.nilable(String), expires: T.nilable(String), description: T.nilable(String), external_id: T.nilable(String), memo: T.nilable(String), fee_payer: T::Boolean, chain_id: T.nilable(Integer), extra: T.nilable(T::Hash[String, String]), mppx_scope: T.nilable(T::Hash[String, String]), body: T.untyped).returns(T.untyped) }
67
- def charge(authorization, amount, currency: nil, recipient: nil, expires: nil,
68
- description: nil, external_id: nil, memo: nil, fee_payer: false, chain_id: nil,
69
- extra: nil, mppx_scope: nil, body: nil)
70
- intent = @method.intents["charge"]
71
- raise ArgumentError, "Method #{@method.name} does not support charge intent" unless intent
72
-
73
- resolved_currency = currency || (@method.respond_to?(:currency) ? @method.currency : nil)
74
- resolved_recipient = recipient || (@method.respond_to?(:recipient) ? @method.recipient : nil)
111
+ #
112
+ # With a single registered charge method this returns Challenge or
113
+ # [Credential, Receipt]. With multiple charge methods it implicitly
114
+ # composes them and returns a ComposedResult.
115
+ sig { params(authorization: T.nilable(String), amount: String, kwargs: T.untyped).returns(T.untyped) }
116
+ def charge(authorization, amount, **kwargs)
117
+ charge_methods = @methods.select { |candidate| candidate.intents.key?("charge") }
118
+ raise ArgumentError, "No registered method supports charge intent" if charge_methods.empty?
119
+
120
+ request_opts = kwargs.slice(*REQUEST_OPTION_KEYS)
121
+ offer_opts = kwargs.except(*REQUEST_OPTION_KEYS)
122
+
123
+ if charge_methods.length == 1
124
+ return charge_one(
125
+ charge_methods.first,
126
+ authorization,
127
+ amount,
128
+ **request_opts,
129
+ **offer_opts
130
+ )
131
+ end
132
+
133
+ entries = charge_methods.map { |candidate| [candidate, offer_opts.merge(amount: amount)] }
134
+ ComposedHandler.from_entries(self, entries).call(
135
+ authorization: authorization,
136
+ payment_signature: request_opts[:payment_signature],
137
+ body: request_opts[:body],
138
+ url: request_opts[:url],
139
+ accept_payment: request_opts[:accept_payment],
140
+ http_method: request_opts[:http_method]
141
+ )
142
+ end
143
+
144
+ # Verify or challenge a single method. Used by compose and charge.
145
+ sig { params(method: T.untyped, authorization: T.nilable(String), amount: String, kwargs: T.untyped).returns(T.untyped) }
146
+ def charge_one(method, authorization, amount, **kwargs)
147
+ intent = method.intents["charge"]
148
+ raise ArgumentError, "Method #{method.name} does not support charge intent" unless intent
149
+
150
+ body = kwargs[:body]
151
+ url = kwargs[:url]
152
+ payment_signature = kwargs[:payment_signature]
153
+ offer_opts = kwargs.except(*REQUEST_OPTION_KEYS)
154
+ request = build_charge_request(method, amount, **offer_opts)
155
+
156
+ if payment_signature && method.respond_to?(:bind_x402_credential)
157
+ return verify_x402(
158
+ method,
159
+ intent,
160
+ request,
161
+ payment_signature: payment_signature,
162
+ url: url,
163
+ body: body,
164
+ description: offer_opts[:description],
165
+ expires: offer_opts[:expires],
166
+ http_method: kwargs[:http_method]
167
+ )
168
+ end
169
+
170
+ Verify.verify_or_challenge(
171
+ authorization: authorization,
172
+ intent: intent,
173
+ request: request,
174
+ realm: @realm,
175
+ secret_key: @secret_key,
176
+ method: method.name,
177
+ description: offer_opts[:description],
178
+ expires: offer_opts[:expires],
179
+ events: @events,
180
+ body: body
181
+ )
182
+ end
183
+
184
+ # Build the canonical charge request for a method without verifying.
185
+ sig { params(method: T.untyped, amount: String, kwargs: T.untyped).returns(T::Hash[String, T.untyped]) }
186
+ def build_charge_request(method, amount, **kwargs)
187
+ currency = kwargs[:currency]
188
+ recipient = kwargs[:recipient]
189
+ external_id = kwargs[:external_id]
190
+ memo = kwargs[:memo]
191
+ fee_payer = if kwargs.key?(:fee_payer)
192
+ kwargs[:fee_payer]
193
+ else
194
+ method.respond_to?(:fee_payer) && method.fee_payer
195
+ end
196
+ chain_id = kwargs[:chain_id]
197
+ extra = kwargs[:extra]
198
+ mppx_scope = kwargs[:mppx_scope]
199
+
200
+ resolved_currency = currency || (method.respond_to?(:currency) ? method.currency : nil)
201
+ resolved_recipient = recipient || (method.respond_to?(:recipient) ? method.recipient : nil)
75
202
  raise ArgumentError, "currency must be set on the method or passed to charge()" unless resolved_currency
76
203
  raise ArgumentError, "recipient must be set on the method or passed to charge()" unless resolved_recipient
77
204
 
78
- decimals = @method.respond_to?(:decimals) ? @method.decimals : DEFAULT_DECIMALS
205
+ decimals = method.respond_to?(:decimals) ? method.decimals : DEFAULT_DECIMALS
79
206
  base_amount = Mpp::Units.parse_units(amount, decimals).to_s
80
207
 
81
208
  request = {
@@ -86,20 +213,20 @@ module Mpp
86
213
  request["externalId"] = external_id unless external_id.nil?
87
214
 
88
215
  if extra
89
- extra.each do |k, v|
90
- raise ArgumentError, "extra must be a dict[str, str]" unless k.is_a?(String) && v.is_a?(String)
216
+ extra.each do |key, value|
217
+ raise ArgumentError, "extra must be a dict[str, str]" unless key.is_a?(String) && value.is_a?(String)
91
218
  end
92
219
  request["extra"] = extra
93
220
  end
94
221
  if mppx_scope
95
- mppx_scope.each do |k, v|
96
- raise ArgumentError, "mppx_scope must be a dict[str, str]" unless k.is_a?(String) && v.is_a?(String)
222
+ mppx_scope.each do |key, value|
223
+ raise ArgumentError, "mppx_scope must be a dict[str, str]" unless key.is_a?(String) && value.is_a?(String)
97
224
  end
98
225
  request["_mppx_scope"] = mppx_scope
99
226
  end
100
227
 
101
228
  resolved_chain_id = chain_id
102
- resolved_chain_id ||= @method.chain_id if @method.respond_to?(:chain_id)
229
+ resolved_chain_id ||= method.chain_id if method.respond_to?(:chain_id)
103
230
 
104
231
  if memo || fee_payer || !resolved_chain_id.nil?
105
232
  method_details = {}
@@ -109,20 +236,127 @@ module Mpp
109
236
  request["methodDetails"] = method_details
110
237
  end
111
238
 
112
- request = Mpp::Server::MethodHelper.transform_request(@method, request, nil)
239
+ Mpp::Server::MethodHelper.transform_request(method, request, nil)
240
+ end
113
241
 
114
- Verify.verify_or_challenge(
115
- authorization: authorization,
116
- intent: intent,
117
- request: request,
118
- realm: @realm,
119
- secret_key: @secret_key,
120
- method: @method.name,
121
- description: description,
122
- expires: expires,
123
- events: @events,
124
- body: body
242
+ # Render a 402 response, applying method-specific extra headers (x402).
243
+ sig { params(challenge: T.untyped, url: T.nilable(String), http_method: T.nilable(String)).returns(T::Hash[T.untyped, T.untyped]) }
244
+ def challenge_response(challenge, url: nil, http_method: nil)
245
+ challenges = challenge.is_a?(Array) ? challenge : [challenge]
246
+ extra = {}
247
+ if @methods.length == 1 && @method.respond_to?(:decorate_challenge)
248
+ @method.decorate_challenge(extra, challenges.first, url: url, http_method: http_method)
249
+ end
250
+ Decorator.make_challenge_response(challenges, @realm, extra_headers: extra)
251
+ end
252
+
253
+ private
254
+
255
+ sig { params(method: T.untyped, methods: T.untyped).returns(T::Array[T.untyped]) }
256
+ def normalize_methods(method, methods)
257
+ if !method.nil? && !methods.nil?
258
+ raise ArgumentError, "pass method: or methods:, not both"
259
+ end
260
+
261
+ list = methods || (method.nil? ? nil : [method])
262
+ raise ArgumentError, "method: or methods: is required" if list.nil? || list.empty?
263
+
264
+ names = list.map(&:name)
265
+ duplicates = names.tally.select { |_name, count| count > 1 }.keys
266
+ raise ArgumentError, "duplicate payment method names: #{duplicates.join(", ")}" unless duplicates.empty?
267
+
268
+ list
269
+ end
270
+
271
+ sig { params(key: String).returns(T.untyped) }
272
+ def resolve_method_key(key)
273
+ name, intent = key.split("/", 2)
274
+ found = if intent
275
+ @methods.find { |candidate| candidate.name == name && candidate.intents.key?(intent) }
276
+ else
277
+ @methods.find { |candidate| candidate.name == name }
278
+ end
279
+ return found if found
280
+
281
+ raise ArgumentError, "No handler for #{key.inspect}. Is this method in your methods array?"
282
+ end
283
+
284
+ sig do
285
+ params(
286
+ method: T.untyped,
287
+ intent: T.untyped,
288
+ request: T::Hash[String, T.untyped],
289
+ payment_signature: String,
290
+ url: T.nilable(String),
291
+ body: T.untyped,
292
+ description: T.nilable(String),
293
+ expires: T.nilable(String),
294
+ http_method: T.nilable(String)
295
+ ).returns(T.untyped)
296
+ end
297
+ def verify_x402(method, intent, request, payment_signature:, url:, body:, description:, expires:, http_method: nil)
298
+ challenge = Verify.create_challenge(
299
+ method.name,
300
+ intent.name,
301
+ Mpp::Units.transform_units(request),
302
+ @realm,
303
+ @secret_key,
304
+ description,
305
+ nil,
306
+ expires,
307
+ body
125
308
  )
309
+ method_context = {name: method.name, intent: intent.name}
310
+
311
+ begin
312
+ credential = method.bind_x402_credential(
313
+ payment_signature,
314
+ challenge: challenge,
315
+ request: challenge.request,
316
+ url: url,
317
+ body: body,
318
+ http_method: http_method
319
+ )
320
+ receipt = intent.verify(credential, challenge.request)
321
+ rescue => error
322
+ if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
323
+ Verify.emit_payment_failed(
324
+ dispatcher: @events,
325
+ challenge: challenge,
326
+ credential: nil,
327
+ error: error,
328
+ method: method_context,
329
+ request: challenge.request,
330
+ retry_challenge: challenge
331
+ )
332
+ end
333
+ if error.is_a?(Mpp::ParseError) || error.is_a?(Mpp::VerificationError) || error.is_a?(Mpp::PaymentError)
334
+ if @events.has_handlers?(Mpp::Events::CHALLENGE_CREATED)
335
+ Verify.emit_challenge_created(
336
+ dispatcher: @events,
337
+ challenge: challenge,
338
+ credential: nil,
339
+ error: error,
340
+ method: method_context,
341
+ request: challenge.request
342
+ )
343
+ end
344
+ return challenge
345
+ end
346
+ raise
347
+ end
348
+
349
+ if @events.has_handlers?(Mpp::Events::PAYMENT_SUCCESS)
350
+ Verify.emit_payment_success(
351
+ dispatcher: @events,
352
+ challenge: challenge,
353
+ credential: credential,
354
+ method: method_context,
355
+ receipt: receipt,
356
+ request: challenge.request
357
+ )
358
+ end
359
+ [credential, receipt]
126
360
  end
127
361
  end
128
362
  end
@@ -0,0 +1,100 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Server
6
+ # Result of a composed (or multi-method) payment verification.
7
+ #
8
+ # Either a 402 with one or more Challenges, or a verified payment.
9
+ class ComposedResult
10
+ extend T::Sig
11
+
12
+ sig { returns(Integer) }
13
+ attr_reader :status
14
+
15
+ sig { returns(T::Array[Mpp::Challenge]) }
16
+ attr_reader :challenges
17
+
18
+ sig { returns(T.untyped) }
19
+ attr_reader :credential
20
+
21
+ sig { returns(T.nilable(Mpp::Receipt)) }
22
+ attr_reader :receipt
23
+
24
+ sig { returns(T::Hash[String, T.untyped]) }
25
+ attr_reader :extra_headers
26
+
27
+ sig { returns(String) }
28
+ attr_reader :realm
29
+
30
+ sig do
31
+ params(
32
+ status: Integer,
33
+ realm: String,
34
+ challenges: T::Array[Mpp::Challenge],
35
+ credential: T.untyped,
36
+ receipt: T.nilable(Mpp::Receipt),
37
+ extra_headers: T::Hash[String, T.untyped]
38
+ ).void
39
+ end
40
+ def initialize(status:, realm:, challenges: [], credential: nil, receipt: nil, extra_headers: {})
41
+ @status = T.let(status, Integer)
42
+ @realm = T.let(realm, String)
43
+ @challenges = T.let(challenges, T::Array[Mpp::Challenge])
44
+ @credential = T.let(credential, T.untyped)
45
+ @receipt = T.let(receipt, T.nilable(Mpp::Receipt))
46
+ @extra_headers = T.let(extra_headers, T::Hash[String, T.untyped])
47
+ end
48
+
49
+ sig do
50
+ params(
51
+ challenges: T::Array[Mpp::Challenge],
52
+ realm: String,
53
+ extra_headers: T::Hash[String, T.untyped]
54
+ ).returns(ComposedResult)
55
+ end
56
+ def self.payment_required(challenges, realm:, extra_headers: {})
57
+ new(status: 402, realm: realm, challenges: challenges, extra_headers: extra_headers)
58
+ end
59
+
60
+ sig do
61
+ params(
62
+ credential: T.untyped,
63
+ receipt: Mpp::Receipt,
64
+ realm: String,
65
+ extra_headers: T::Hash[String, T.untyped]
66
+ ).returns(ComposedResult)
67
+ end
68
+ def self.paid(credential, receipt, realm:, extra_headers: {})
69
+ new(status: 200, realm: realm, credential: credential, receipt: receipt, extra_headers: extra_headers)
70
+ end
71
+
72
+ sig { returns(T::Boolean) }
73
+ def payment_required?
74
+ @status == 402
75
+ end
76
+
77
+ # Returns [credential, receipt] for a successful payment.
78
+ sig { returns(T::Array[T.untyped]) }
79
+ def payment
80
+ Kernel.raise ArgumentError, "payment is not available for a 402 response" if payment_required?
81
+
82
+ [@credential, @receipt]
83
+ end
84
+
85
+ sig { returns(T.nilable(String)) }
86
+ def payment_response
87
+ value = @extra_headers["PAYMENT-RESPONSE"]
88
+ value.is_a?(String) ? value : nil
89
+ end
90
+
91
+ # Render a 402 challenge response hash, including extra x402 headers.
92
+ sig { returns(T::Hash[T.untyped, T.untyped]) }
93
+ def to_response
94
+ Kernel.raise ArgumentError, "to_response is only valid for 402 results" unless payment_required?
95
+
96
+ Decorator.make_challenge_response(@challenges, @realm, extra_headers: @extra_headers)
97
+ end
98
+ end
99
+ end
100
+ end
data/lib/mpp/server.rb CHANGED
@@ -11,5 +11,9 @@ module Mpp
11
11
  autoload :MppHandler, "mpp/server/mpp_handler"
12
12
  autoload :Decorator, "mpp/server/decorator"
13
13
  autoload :Middleware, "mpp/server/middleware"
14
+ autoload :AcceptPayment, "mpp/server/accept_payment"
15
+ autoload :ComposedResult, "mpp/server/result"
16
+ autoload :ComposedHandler, "mpp/server/compose"
17
+ autoload :ComposeOffer, "mpp/server/compose"
14
18
  end
15
19
  end
data/lib/mpp/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Mpp
5
- VERSION = "0.1.3"
5
+ VERSION = "0.1.5"
6
6
  end
@@ -0,0 +1,127 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "types"
5
+ require_relative "../http/headers"
6
+ require "json"
7
+ require "net/http"
8
+ require "uri"
9
+
10
+ module Mpp
11
+ module X402
12
+ # HTTP client for an x402 facilitator `/verify` and `/settle` API.
13
+ #
14
+ # `resolve` accepts:
15
+ # * a URL string (public / unauthenticated facilitator)
16
+ # * a config hash (`url:` plus optional `headers:` as a Hash or per-request proc)
17
+ # * any object that responds to `#verify` and `#settle` (e.g. a CDP HTTPFacilitatorClient)
18
+ class Facilitator
19
+ extend T::Sig
20
+
21
+ sig { returns(String) }
22
+ attr_reader :base_url
23
+
24
+ sig {
25
+ params(
26
+ url: String,
27
+ headers: T.untyped
28
+ ).void
29
+ }
30
+ def initialize(url, headers: nil)
31
+ @base_url = T.let(Mpp::Http::Headers.normalize_base_url(url), String)
32
+ Kernel.raise ArgumentError, "x402 exact requires `facilitator`." if @base_url.empty?
33
+
34
+ @headers = T.let(headers, T.untyped)
35
+ end
36
+
37
+ sig { params(facilitator: T.untyped).returns(T.untyped) }
38
+ def self.resolve(facilitator)
39
+ return facilitator if facilitator.is_a?(Facilitator)
40
+ return new(facilitator) if facilitator.is_a?(String) && !facilitator.empty?
41
+ return from_config(facilitator) if facilitator.is_a?(Hash)
42
+ return facilitator if duck_type?(facilitator)
43
+
44
+ Kernel.raise ArgumentError, "x402 exact requires `facilitator`."
45
+ end
46
+
47
+ sig { params(config: T::Hash[T.untyped, T.untyped]).returns(Facilitator) }
48
+ def self.from_config(config)
49
+ cfg = Mpp::Http::Headers.symbolize(config)
50
+ url = cfg[:url] || cfg[:base_url] || cfg[:baseUrl]
51
+
52
+ new(url.to_s, headers: cfg[:headers])
53
+ end
54
+
55
+ sig { params(facilitator: T.untyped).returns(T::Boolean) }
56
+ def self.duck_type?(facilitator)
57
+ !facilitator.nil? && facilitator.respond_to?(:verify) && facilitator.respond_to?(:settle)
58
+ end
59
+
60
+ sig do
61
+ params(
62
+ payment_payload: T::Hash[T.untyped, T.untyped],
63
+ payment_requirements: T::Hash[T.untyped, T.untyped]
64
+ ).returns(T::Hash[T.untyped, T.untyped])
65
+ end
66
+ def verify(payment_payload, payment_requirements)
67
+ post("/verify", payment_payload, payment_requirements)
68
+ end
69
+
70
+ sig do
71
+ params(
72
+ payment_payload: T::Hash[T.untyped, T.untyped],
73
+ payment_requirements: T::Hash[T.untyped, T.untyped]
74
+ ).returns(T::Hash[T.untyped, T.untyped])
75
+ end
76
+ def settle(payment_payload, payment_requirements)
77
+ post("/settle", payment_payload, payment_requirements)
78
+ end
79
+
80
+ private
81
+
82
+ sig { params(path: String).returns(T::Hash[String, String]) }
83
+ def request_headers(path)
84
+ Mpp::Http::Headers.resolve(@headers, path)
85
+ end
86
+
87
+ sig do
88
+ params(
89
+ path: String,
90
+ payment_payload: T::Hash[T.untyped, T.untyped],
91
+ payment_requirements: T::Hash[T.untyped, T.untyped]
92
+ ).returns(T::Hash[T.untyped, T.untyped])
93
+ end
94
+ def post(path, payment_payload, payment_requirements)
95
+ uri = URI.parse("#{@base_url}#{path}")
96
+ http = Net::HTTP.new(uri.host, uri.port)
97
+ http.use_ssl = uri.scheme == "https"
98
+ request = Net::HTTP::Post.new(uri)
99
+ request["Content-Type"] = "application/json"
100
+ request_headers(path).each { |key, value| request[key] = value }
101
+ request.body = JSON.generate({
102
+ "paymentPayload" => payment_payload,
103
+ "paymentRequirements" => payment_requirements,
104
+ "x402Version" => VERSION
105
+ })
106
+ response = http.request(request)
107
+ unless response.is_a?(Net::HTTPSuccess)
108
+ Kernel.raise Mpp::VerificationFailedError.new(reason: "facilitator #{path} returned HTTP #{response.code}")
109
+ end
110
+
111
+ body = response.body.to_s
112
+ if body.empty?
113
+ Kernel.raise Mpp::VerificationFailedError.new(reason: "facilitator #{path} returned an empty body")
114
+ end
115
+
116
+ parsed = JSON.parse(body)
117
+ unless parsed.is_a?(Hash)
118
+ Kernel.raise Mpp::VerificationFailedError.new(reason: "facilitator #{path} returned JSON that is not an object")
119
+ end
120
+
121
+ parsed
122
+ rescue JSON::ParserError
123
+ Kernel.raise Mpp::VerificationFailedError.new(reason: "facilitator #{path} returned invalid JSON")
124
+ end
125
+ end
126
+ end
127
+ end