mpp-rb 0.1.4 → 0.1.6

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +128 -1
  3. data/lib/mpp/challenge.rb +31 -8
  4. data/lib/mpp/challenge_echo.rb +14 -3
  5. data/lib/mpp/challenge_id.rb +30 -6
  6. data/lib/mpp/client/transport.rb +1 -1
  7. data/lib/mpp/errors.rb +22 -0
  8. data/lib/mpp/http/headers.rb +61 -0
  9. data/lib/mpp/http.rb +8 -0
  10. data/lib/mpp/methods/evm/assets.rb +103 -0
  11. data/lib/mpp/methods/evm/authorization.rb +140 -0
  12. data/lib/mpp/methods/evm/charge_intent.rb +129 -0
  13. data/lib/mpp/methods/evm/evm_method.rb +133 -0
  14. data/lib/mpp/methods/evm.rb +67 -0
  15. data/lib/mpp/methods/stripe/charge_intent.rb +10 -8
  16. data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +55 -0
  17. data/lib/mpp/methods/stripe/defaults.rb +1 -0
  18. data/lib/mpp/methods/stripe/machine_payments.rb +130 -0
  19. data/lib/mpp/methods/stripe/stripe_method.rb +22 -3
  20. data/lib/mpp/methods/stripe.rb +23 -0
  21. data/lib/mpp/methods/tempo/attribution.rb +3 -9
  22. data/lib/mpp/methods/tempo/client_method.rb +35 -6
  23. data/lib/mpp/methods/tempo/fee_payer_client.rb +120 -0
  24. data/lib/mpp/methods/tempo/intents.rb +80 -48
  25. data/lib/mpp/methods/tempo/proof.rb +39 -12
  26. data/lib/mpp/methods/tempo/relay.rb +257 -0
  27. data/lib/mpp/methods/tempo.rb +2 -0
  28. data/lib/mpp/parsing.rb +22 -2
  29. data/lib/mpp/receipt.rb +4 -3
  30. data/lib/mpp/server/accept_payment.rb +194 -0
  31. data/lib/mpp/server/compose.rb +399 -0
  32. data/lib/mpp/server/decorator.rb +30 -7
  33. data/lib/mpp/server/method.rb +15 -0
  34. data/lib/mpp/server/middleware.rb +160 -18
  35. data/lib/mpp/server/mpp_handler.rb +322 -34
  36. data/lib/mpp/server/result.rb +100 -0
  37. data/lib/mpp/server/verify.rb +24 -14
  38. data/lib/mpp/server.rb +4 -0
  39. data/lib/mpp/version.rb +1 -1
  40. data/lib/mpp/x402/facilitator.rb +127 -0
  41. data/lib/mpp/x402/header.rb +131 -0
  42. data/lib/mpp/x402/server.rb +247 -0
  43. data/lib/mpp/x402/types.rb +33 -0
  44. data/lib/mpp/x402.rb +12 -0
  45. data/lib/mpp.rb +16 -3
  46. metadata +48 -1
@@ -14,6 +14,8 @@ module Mpp
14
14
  #
15
15
  # Payment is verified BEFORE the downstream app runs — if verification
16
16
  # fails, the app never executes and a 402 challenge is returned.
17
+ # Payment-Receipt is attached only when the app then returns 2xx, so a
18
+ # failed fulfillment is not reported as a successful paid response.
17
19
  #
18
20
  # Example:
19
21
  # use Mpp::Server::Middleware,
@@ -22,10 +24,10 @@ module Mpp
22
24
  class Middleware
23
25
  extend T::Sig
24
26
 
25
- sig { params(app: T.untyped, handler: Mpp::Server::MppHandler, pricing: T.untyped).void }
27
+ sig { params(app: T.untyped, handler: T.untyped, pricing: T.untyped).void }
26
28
  def initialize(app, handler:, pricing:)
27
29
  @app = T.let(app, T.untyped)
28
- @handler = T.let(handler, Mpp::Server::MppHandler)
30
+ @handler = T.let(handler, T.untyped)
29
31
  @pricing = T.let(pricing, T.untyped)
30
32
  end
31
33
 
@@ -35,40 +37,58 @@ module Mpp
35
37
  return @app.call(env) unless charge_opts
36
38
 
37
39
  authorization = env["HTTP_AUTHORIZATION"]
40
+ payment_authorization = env["HTTP_PAYMENT_AUTHORIZATION"]
41
+ payment_signature = env["HTTP_PAYMENT_SIGNATURE"]
42
+ accept_payment = env["HTTP_ACCEPT_PAYMENT"]
43
+ http_method = env["REQUEST_METHOD"]
44
+ url = request_url(env)
38
45
  body_capture = capture_request_body(env)
39
46
 
40
47
  request_body = body_capture&.materialize
41
48
  env["rack.input"] = StringIO.new(request_body || "") if body_capture
42
49
 
43
- amount = charge_opts[:amount]
44
- opts = charge_opts.except(:amount, :body)
45
- opts[:mppx_scope] ||= mppx_scope(env)
50
+ result = verify_payment(
51
+ env,
52
+ charge_opts,
53
+ authorization: authorization,
54
+ payment_authorization: payment_authorization,
55
+ payment_signature: payment_signature,
56
+ accept_payment: accept_payment,
57
+ http_method: http_method,
58
+ url: url,
59
+ request_body: request_body
60
+ )
46
61
 
47
- result = @handler.charge(authorization, amount, **opts, body: request_body)
48
-
49
- if result.is_a?(Mpp::Challenge)
50
- resp = Mpp::Server::Decorator.make_challenge_response(result, @handler.realm)
51
- return [resp["status"], resp["headers"], [resp["body"]]]
52
- end
62
+ challenge_response = challenge_rack_response(result, url: url, http_method: http_method)
63
+ return challenge_response if challenge_response
53
64
 
54
- _credential, receipt = result
65
+ credential, receipt, extra_headers = paid_result(result)
55
66
  status, headers, body = @app.call(env)
56
- headers["Payment-Receipt"] = receipt.to_payment_receipt
57
- self.class.mark_authorization_bound_response(headers)
67
+ if success_status?(status)
68
+ headers["Payment-Receipt"] = receipt.to_payment_receipt
69
+ extra_headers.each { |key, value| headers[key] = value unless value.nil? }
70
+ decorate_single_method_receipt(headers, credential, receipt, payment_signature)
71
+ end
72
+ vary = [credential_vary_field]
73
+ vary << "PAYMENT-SIGNATURE" if x402_bound?(headers, payment_signature)
74
+ self.class.mark_authorization_bound_response(headers, vary: vary)
58
75
 
59
76
  [status, headers, body]
60
77
  end
61
78
 
62
- sig { params(headers: T::Hash[T.untyped, T.untyped]).void }
63
- def self.mark_authorization_bound_response(headers)
79
+ sig { params(headers: T::Hash[T.untyped, T.untyped], vary: T::Array[String]).void }
80
+ def self.mark_authorization_bound_response(headers, vary: ["Authorization"])
64
81
  headers["Cache-Control"] = "no-store"
65
82
 
66
83
  vary_values = headers["Vary"].to_s.split(",").map do |value|
67
84
  value.strip.downcase
68
85
  end
69
- return if vary_values.include?("*") || vary_values.include?("authorization")
86
+ return if vary_values.include?("*")
87
+
88
+ additions = vary.reject { |field| vary_values.include?(field.downcase) }
89
+ return if additions.empty?
70
90
 
71
- headers["Vary"] = [headers["Vary"], "Authorization"]
91
+ headers["Vary"] = [headers["Vary"], *additions]
72
92
  .compact
73
93
  .reject(&:empty?)
74
94
  .join(", ")
@@ -76,6 +96,128 @@ module Mpp
76
96
 
77
97
  private
78
98
 
99
+ sig do
100
+ params(
101
+ env: T.untyped,
102
+ charge_opts: T.untyped,
103
+ authorization: T.untyped,
104
+ payment_authorization: T.untyped,
105
+ payment_signature: T.untyped,
106
+ accept_payment: T.untyped,
107
+ http_method: T.untyped,
108
+ url: T.untyped,
109
+ request_body: T.untyped
110
+ ).returns(T.untyped)
111
+ end
112
+ def verify_payment(env, charge_opts, authorization:, payment_authorization:, payment_signature:, accept_payment:, http_method:, url:, request_body:)
113
+ if @handler.is_a?(Mpp::Server::ComposedHandler)
114
+ return @handler.call(
115
+ authorization: authorization,
116
+ payment_authorization: payment_authorization,
117
+ payment_signature: payment_signature,
118
+ body: request_body,
119
+ url: url,
120
+ accept_payment: accept_payment,
121
+ http_method: http_method,
122
+ scope: mppx_scope(env)
123
+ )
124
+ end
125
+
126
+ amount = charge_opts[:amount]
127
+ opts = charge_opts.except(:amount, :body)
128
+ opts[:mppx_scope] ||= mppx_scope(env)
129
+ @handler.charge(
130
+ authorization,
131
+ amount,
132
+ **opts,
133
+ body: request_body,
134
+ payment_authorization: payment_authorization,
135
+ payment_signature: payment_signature,
136
+ url: url,
137
+ accept_payment: accept_payment,
138
+ http_method: http_method
139
+ )
140
+ end
141
+
142
+ sig { params(result: T.untyped, url: T.nilable(String), http_method: T.nilable(String)).returns(T.nilable(T::Array[T.untyped])) }
143
+ def challenge_rack_response(result, url:, http_method:)
144
+ if result.is_a?(Mpp::Server::ComposedResult)
145
+ return nil unless result.payment_required?
146
+
147
+ resp = result.to_response
148
+ return [resp["status"], resp["headers"], [resp["body"]]]
149
+ end
150
+
151
+ return nil unless result.is_a?(Mpp::Challenge)
152
+
153
+ resp = if @handler.respond_to?(:challenge_response)
154
+ @handler.challenge_response(result, url: url, http_method: http_method)
155
+ else
156
+ Mpp::Server::Decorator.make_challenge_response(result, @handler.realm)
157
+ end
158
+ [resp["status"], resp["headers"], [resp["body"]]]
159
+ end
160
+
161
+ sig { params(status: T.untyped).returns(T::Boolean) }
162
+ def success_status?(status)
163
+ code = Integer(status)
164
+ (code >= 200) && (code < 300)
165
+ rescue ArgumentError, TypeError
166
+ false
167
+ end
168
+
169
+ sig { params(result: T.untyped).returns(T::Array[T.untyped]) }
170
+ def paid_result(result)
171
+ if result.is_a?(Mpp::Server::ComposedResult)
172
+ credential, receipt = result.payment
173
+ return [credential, receipt, result.extra_headers]
174
+ end
175
+
176
+ credential, receipt = result
177
+ [credential, receipt, {}]
178
+ end
179
+
180
+ sig { params(headers: T::Hash[T.untyped, T.untyped], credential: T.untyped, receipt: T.untyped, payment_signature: T.untyped).void }
181
+ def decorate_single_method_receipt(headers, credential, receipt, payment_signature)
182
+ return unless payment_signature
183
+ return unless @handler.respond_to?(:method)
184
+ return unless @handler.method.respond_to?(:decorate_receipt)
185
+
186
+ @handler.method.decorate_receipt(headers, receipt, credential, payment_signature: payment_signature)
187
+ end
188
+
189
+ sig { params(headers: T::Hash[T.untyped, T.untyped], payment_signature: T.untyped).returns(T::Boolean) }
190
+ def x402_bound?(headers, payment_signature)
191
+ !payment_signature.to_s.empty? ||
192
+ headers.key?("PAYMENT-RESPONSE") ||
193
+ headers.key?("PAYMENT-REQUIRED")
194
+ end
195
+
196
+ sig { returns(String) }
197
+ def credential_vary_field
198
+ if @handler.respond_to?(:requires_auth) && @handler.requires_auth
199
+ Mpp::PAYMENT_AUTHORIZATION_HEADER
200
+ else
201
+ Mpp::AUTHORIZATION_HEADER
202
+ end
203
+ end
204
+
205
+ sig { params(env: T.untyped).returns(String) }
206
+ def request_url(env)
207
+ scheme = env["HTTP_X_FORWARDED_PROTO"] || env["rack.url_scheme"] || "http"
208
+ host = env["HTTP_HOST"]
209
+ unless host
210
+ server = env["SERVER_NAME"] || "localhost"
211
+ port = env["SERVER_PORT"]
212
+ host = (port && !["80", "443"].include?(port.to_s)) ? "#{server}:#{port}" : server
213
+ end
214
+ path = "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}"
215
+ query = env["QUERY_STRING"]
216
+ url = "#{scheme}://#{host}#{path}"
217
+ url += "?#{query}" if query && !query.empty?
218
+ url
219
+ end
220
+
79
221
  sig { params(env: T.untyped).returns(T.nilable(RackInputCapture)) }
80
222
  def capture_request_body(env)
81
223
  input = env["rack.input"]
@@ -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, :payment_authorization].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,23 +27,55 @@ 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 { returns(T::Boolean) }
31
+ attr_reader :requires_auth
32
+
33
+ sig { returns(T.nilable(String)) }
34
+ attr_reader :credential_header
35
+
36
+ sig do
37
+ params(
38
+ realm: String,
39
+ secret_key: String,
40
+ method: T.untyped,
41
+ methods: T.nilable(T::Array[T.untyped]),
42
+ defaults: T.nilable(T::Hash[String, T.untyped]),
43
+ events: T.nilable(Mpp::Events::Dispatcher),
44
+ requires_auth: T::Boolean
45
+ ).void
46
+ end
47
+ def initialize(realm:, secret_key:, method: nil, methods: nil, defaults: nil, events: nil, requires_auth: false)
48
+ @methods = T.let(normalize_methods(method, methods), T::Array[T.untyped])
49
+ @method = T.let(@methods.first, T.untyped)
28
50
  @realm = T.let(realm, String)
29
51
  @secret_key = T.let(secret_key, String)
30
52
  @defaults = T.let(defaults || {}, T::Hash[String, T.untyped])
31
53
  @events = T.let(events || Mpp::Events.server_dispatcher, Mpp::Events::Dispatcher)
54
+ @method_hook_events = T.let(Mpp::Events.server_dispatcher, Mpp::Events::Dispatcher)
55
+ @requires_auth = T.let(requires_auth, T::Boolean)
56
+ @credential_header = T.let(requires_auth ? Mpp::PAYMENT_AUTHORIZATION_HEADER : nil, T.nilable(String))
57
+ register_method_payment_success
32
58
  end
33
59
 
34
60
  # 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)
61
+ sig do
62
+ params(
63
+ method: T.untyped,
64
+ methods: T.nilable(T::Array[T.untyped]),
65
+ realm: T.untyped,
66
+ secret_key: T.untyped,
67
+ events: T.nilable(Mpp::Events::Dispatcher),
68
+ requires_auth: T::Boolean
69
+ ).returns(T.attached_class)
70
+ end
71
+ def self.create(method: nil, methods: nil, realm: nil, secret_key: nil, events: nil, requires_auth: false)
37
72
  new(
38
73
  method: method,
74
+ methods: methods,
39
75
  realm: realm || Defaults.detect_realm,
40
76
  secret_key: secret_key || Defaults.detect_secret_key,
41
- events: events
77
+ events: events,
78
+ requires_auth: requires_auth
42
79
  )
43
80
  end
44
81
 
@@ -62,20 +99,138 @@ module Mpp
62
99
  on(Mpp::Events::PAYMENT_SUCCESS, handler, &block)
63
100
  end
64
101
 
102
+ # Select the HTTP header value that carries the Payment credential.
103
+ # When requires_auth is true, Payment credentials ride in
104
+ # Payment-Authorization so Authorization can hold application auth.
105
+ sig { params(authorization: T.nilable(String), payment_authorization: T.nilable(String)).returns(T.nilable(String)) }
106
+ def payment_credential_value(authorization, payment_authorization = nil)
107
+ @requires_auth ? payment_authorization : authorization
108
+ end
109
+
110
+ # Combine method offers into a single callable that presents every method.
111
+ sig { params(entries: T.untyped).returns(ComposedHandler) }
112
+ def compose(*entries)
113
+ ComposedHandler.from_entries(self, entries)
114
+ end
115
+
116
+ # Look up a registered method by object, name, or "name/intent" key.
117
+ sig { params(method_ref: T.untyped).returns(T.untyped) }
118
+ def resolve_method(method_ref)
119
+ case method_ref
120
+ when String
121
+ resolve_method_key(method_ref)
122
+ else
123
+ found = @methods.find { |candidate| candidate.equal?(method_ref) } ||
124
+ @methods.find { |candidate| candidate.name == method_ref.name }
125
+ return found if found
126
+
127
+ raise ArgumentError, "No handler for #{method_ref.inspect}. Is this method in your methods array?"
128
+ end
129
+ end
130
+
65
131
  # 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)
132
+ #
133
+ # With a single registered charge method this returns Challenge or
134
+ # [Credential, Receipt]. With multiple charge methods it implicitly
135
+ # composes them and returns a ComposedResult.
136
+ sig { params(authorization: T.nilable(String), amount: String, kwargs: T.untyped).returns(T.untyped) }
137
+ def charge(authorization, amount, **kwargs)
138
+ charge_methods = @methods.select { |candidate| candidate.intents.key?("charge") }
139
+ raise ArgumentError, "No registered method supports charge intent" if charge_methods.empty?
140
+
141
+ request_opts = kwargs.slice(*REQUEST_OPTION_KEYS)
142
+ offer_opts = kwargs.except(*REQUEST_OPTION_KEYS)
143
+ payment = payment_credential_value(authorization, request_opts[:payment_authorization])
144
+
145
+ if charge_methods.length == 1
146
+ return charge_one(
147
+ charge_methods.first,
148
+ payment,
149
+ amount,
150
+ **request_opts,
151
+ **offer_opts
152
+ )
153
+ end
154
+
155
+ entries = charge_methods.map { |candidate| [candidate, offer_opts.merge(amount: amount)] }
156
+ ComposedHandler.from_entries(self, entries).call(
157
+ authorization: authorization,
158
+ payment_authorization: request_opts[:payment_authorization],
159
+ payment_signature: request_opts[:payment_signature],
160
+ body: request_opts[:body],
161
+ url: request_opts[:url],
162
+ accept_payment: request_opts[:accept_payment],
163
+ http_method: request_opts[:http_method]
164
+ )
165
+ end
166
+
167
+ # Verify or challenge a single method. Used by compose and charge.
168
+ # The optional request guard can omit an offer after its canonical request
169
+ # is built, ensuring the guard and challenge use the exact same request.
170
+ sig { params(method: T.untyped, authorization: T.nilable(String), amount: String, kwargs: T.untyped, request_guard: T.nilable(T.proc.params(request: T::Hash[String, T.untyped]).returns(T::Boolean))).returns(T.untyped) }
171
+ def charge_one(method, authorization, amount, **kwargs, &request_guard)
172
+ intent = method.intents["charge"]
173
+ raise ArgumentError, "Method #{method.name} does not support charge intent" unless intent
174
+
175
+ body = kwargs[:body]
176
+ url = kwargs[:url]
177
+ payment_signature = kwargs[:payment_signature]
178
+ offer_opts = kwargs.except(*REQUEST_OPTION_KEYS)
179
+ request = build_charge_request(method, amount, **offer_opts)
180
+ return nil if request_guard && !request_guard.call(request)
181
+
182
+ if payment_signature && method.respond_to?(:bind_x402_credential)
183
+ return verify_x402(
184
+ method,
185
+ intent,
186
+ request,
187
+ payment_signature: payment_signature,
188
+ url: url,
189
+ body: body,
190
+ description: offer_opts[:description],
191
+ expires: offer_opts[:expires],
192
+ http_method: kwargs[:http_method]
193
+ )
194
+ end
195
+
196
+ Verify.verify_or_challenge(
197
+ authorization: authorization,
198
+ intent: intent,
199
+ request: request,
200
+ realm: @realm,
201
+ secret_key: @secret_key,
202
+ method: method.name,
203
+ description: offer_opts[:description],
204
+ expires: offer_opts[:expires],
205
+ events: @events,
206
+ method_hook_events: @method_hook_events,
207
+ body: body,
208
+ header: @credential_header
209
+ )
210
+ end
211
+
212
+ # Build the canonical charge request for a method without verifying.
213
+ sig { params(method: T.untyped, amount: String, kwargs: T.untyped).returns(T::Hash[String, T.untyped]) }
214
+ def build_charge_request(method, amount, **kwargs)
215
+ currency = kwargs[:currency]
216
+ recipient = kwargs[:recipient]
217
+ external_id = kwargs[:external_id]
218
+ memo = kwargs[:memo]
219
+ fee_payer = if kwargs.key?(:fee_payer)
220
+ kwargs[:fee_payer]
221
+ else
222
+ method.respond_to?(:fee_payer) && method.fee_payer
223
+ end
224
+ chain_id = kwargs[:chain_id]
225
+ extra = kwargs[:extra]
226
+ mppx_scope = kwargs[:mppx_scope]
227
+
228
+ resolved_currency = currency || (method.respond_to?(:currency) ? method.currency : nil)
229
+ resolved_recipient = recipient || (method.respond_to?(:recipient) ? method.recipient : nil)
75
230
  raise ArgumentError, "currency must be set on the method or passed to charge()" unless resolved_currency
76
231
  raise ArgumentError, "recipient must be set on the method or passed to charge()" unless resolved_recipient
77
232
 
78
- decimals = @method.respond_to?(:decimals) ? @method.decimals : DEFAULT_DECIMALS
233
+ decimals = method.respond_to?(:decimals) ? method.decimals : DEFAULT_DECIMALS
79
234
  base_amount = Mpp::Units.parse_units(amount, decimals).to_s
80
235
 
81
236
  request = {
@@ -86,20 +241,20 @@ module Mpp
86
241
  request["externalId"] = external_id unless external_id.nil?
87
242
 
88
243
  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)
244
+ extra.each do |key, value|
245
+ raise ArgumentError, "extra must be a dict[str, str]" unless key.is_a?(String) && value.is_a?(String)
91
246
  end
92
247
  request["extra"] = extra
93
248
  end
94
249
  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)
250
+ mppx_scope.each do |key, value|
251
+ raise ArgumentError, "mppx_scope must be a dict[str, str]" unless key.is_a?(String) && value.is_a?(String)
97
252
  end
98
253
  request["_mppx_scope"] = mppx_scope
99
254
  end
100
255
 
101
256
  resolved_chain_id = chain_id
102
- resolved_chain_id ||= @method.chain_id if @method.respond_to?(:chain_id)
257
+ resolved_chain_id ||= method.chain_id if method.respond_to?(:chain_id)
103
258
 
104
259
  if memo || fee_payer || !resolved_chain_id.nil?
105
260
  method_details = {}
@@ -109,20 +264,153 @@ module Mpp
109
264
  request["methodDetails"] = method_details
110
265
  end
111
266
 
112
- request = Mpp::Server::MethodHelper.transform_request(@method, request, nil)
267
+ Mpp::Server::MethodHelper.transform_request(method, request, nil)
268
+ end
113
269
 
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
270
+ # Render a 402 response, applying method-specific extra headers (x402).
271
+ sig { params(challenge: T.untyped, url: T.nilable(String), http_method: T.nilable(String)).returns(T::Hash[T.untyped, T.untyped]) }
272
+ def challenge_response(challenge, url: nil, http_method: nil)
273
+ challenges = challenge.is_a?(Array) ? challenge : [challenge]
274
+ extra = {}
275
+ if @methods.length == 1 && @method.respond_to?(:decorate_challenge)
276
+ @method.decorate_challenge(extra, challenges.first, url: url, http_method: http_method)
277
+ end
278
+ Decorator.make_challenge_response(challenges, @realm, extra_headers: extra)
279
+ end
280
+
281
+ private
282
+
283
+ sig { params(method: T.untyped, methods: T.untyped).returns(T::Array[T.untyped]) }
284
+ def normalize_methods(method, methods)
285
+ if !method.nil? && !methods.nil?
286
+ raise ArgumentError, "pass method: or methods:, not both"
287
+ end
288
+
289
+ list = methods || (method.nil? ? nil : [method])
290
+ raise ArgumentError, "method: or methods: is required" if list.nil? || list.empty?
291
+
292
+ names = list.map(&:name)
293
+ duplicates = names.tally.select { |_name, count| count > 1 }.keys
294
+ raise ArgumentError, "duplicate payment method names: #{duplicates.join(", ")}" unless duplicates.empty?
295
+
296
+ list
297
+ end
298
+
299
+ sig { params(key: String).returns(T.untyped) }
300
+ def resolve_method_key(key)
301
+ name, intent = key.split("/", 2)
302
+ found = if intent
303
+ @methods.find { |candidate| candidate.name == name && candidate.intents.key?(intent) }
304
+ else
305
+ @methods.find { |candidate| candidate.name == name }
306
+ end
307
+ return found if found
308
+
309
+ raise ArgumentError, "No handler for #{key.inspect}. Is this method in your methods array?"
310
+ end
311
+
312
+ sig do
313
+ params(
314
+ method: T.untyped,
315
+ intent: T.untyped,
316
+ request: T::Hash[String, T.untyped],
317
+ payment_signature: String,
318
+ url: T.nilable(String),
319
+ body: T.untyped,
320
+ description: T.nilable(String),
321
+ expires: T.nilable(String),
322
+ http_method: T.nilable(String)
323
+ ).returns(T.untyped)
324
+ end
325
+ def verify_x402(method, intent, request, payment_signature:, url:, body:, description:, expires:, http_method: nil)
326
+ challenge = Verify.create_challenge(
327
+ method.name,
328
+ intent.name,
329
+ Mpp::Units.transform_units(request),
330
+ @realm,
331
+ @secret_key,
332
+ description,
333
+ nil,
334
+ expires,
335
+ body,
336
+ @credential_header
125
337
  )
338
+ method_context = {name: method.name, intent: intent.name}
339
+
340
+ begin
341
+ credential = method.bind_x402_credential(
342
+ payment_signature,
343
+ challenge: challenge,
344
+ request: challenge.request,
345
+ url: url,
346
+ body: body,
347
+ http_method: http_method
348
+ )
349
+ receipt = intent.verify(credential, challenge.request)
350
+ rescue => error
351
+ if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
352
+ Verify.emit_payment_failed(
353
+ dispatcher: @events,
354
+ challenge: challenge,
355
+ credential: nil,
356
+ error: error,
357
+ method: method_context,
358
+ request: challenge.request,
359
+ retry_challenge: challenge
360
+ )
361
+ end
362
+ if error.is_a?(Mpp::ParseError) || error.is_a?(Mpp::VerificationError) || error.is_a?(Mpp::PaymentError)
363
+ if @events.has_handlers?(Mpp::Events::CHALLENGE_CREATED)
364
+ Verify.emit_challenge_created(
365
+ dispatcher: @events,
366
+ challenge: challenge,
367
+ credential: nil,
368
+ error: error,
369
+ method: method_context,
370
+ request: challenge.request
371
+ )
372
+ end
373
+ return challenge
374
+ end
375
+ raise
376
+ end
377
+
378
+ if @events.has_handlers?(Mpp::Events::PAYMENT_SUCCESS) || @method_hook_events.has_handlers?(Mpp::Events::PAYMENT_SUCCESS)
379
+ Verify.emit_payment_success(
380
+ dispatcher: @events,
381
+ method_hook_dispatcher: @method_hook_events,
382
+ challenge: challenge,
383
+ credential: credential,
384
+ method: method_context,
385
+ receipt: receipt,
386
+ request: challenge.request
387
+ )
388
+ end
389
+ [credential, receipt]
390
+ end
391
+
392
+ private
393
+
394
+ sig { void }
395
+ def register_method_payment_success
396
+ @methods.each do |payment_method|
397
+ next unless payment_method.respond_to?(:on_payment_success)
398
+
399
+ hook = payment_method.on_payment_success
400
+ next if hook.nil?
401
+ raise ArgumentError, "on_payment_success must be callable" unless hook.respond_to?(:call)
402
+
403
+ method_name = payment_method.name
404
+ intent_names = payment_method.intents.each_value.map { |intent| intent.name }
405
+ @method_hook_events.on(Mpp::Events::PAYMENT_SUCCESS) do |payload|
406
+ event_method = payload[:method]
407
+ next unless event_method.is_a?(Hash)
408
+ next unless event_method[:name] == method_name
409
+ next unless intent_names.include?(event_method[:intent])
410
+
411
+ hook.call(payload)
412
+ end
413
+ end
126
414
  end
127
415
  end
128
416
  end