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.
- checksums.yaml +4 -4
- data/README.md +63 -1
- data/lib/mpp/challenge.rb +15 -5
- data/lib/mpp/client/transport.rb +5 -4
- data/lib/mpp/errors.rb +22 -0
- data/lib/mpp/http/headers.rb +61 -0
- data/lib/mpp/http.rb +8 -0
- data/lib/mpp/methods/evm/assets.rb +103 -0
- data/lib/mpp/methods/evm/authorization.rb +140 -0
- data/lib/mpp/methods/evm/charge_intent.rb +129 -0
- data/lib/mpp/methods/evm/evm_method.rb +117 -0
- data/lib/mpp/methods/evm.rb +64 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +4 -1
- data/lib/mpp/methods/tempo/attribution.rb +3 -9
- data/lib/mpp/methods/tempo/client_method.rb +17 -6
- data/lib/mpp/methods/tempo/fee_payer_client.rb +120 -0
- data/lib/mpp/methods/tempo/intents.rb +27 -7
- data/lib/mpp/methods/tempo/proof.rb +39 -12
- data/lib/mpp/methods/tempo/relay.rb +257 -0
- data/lib/mpp/methods/tempo.rb +2 -0
- data/lib/mpp/parsing.rb +2 -0
- data/lib/mpp/receipt.rb +4 -3
- data/lib/mpp/server/accept_payment.rb +194 -0
- data/lib/mpp/server/compose.rb +387 -0
- data/lib/mpp/server/decorator.rb +28 -7
- data/lib/mpp/server/middleware.rb +162 -30
- data/lib/mpp/server/mpp_handler.rb +267 -33
- data/lib/mpp/server/result.rb +100 -0
- data/lib/mpp/server.rb +4 -0
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp/x402/facilitator.rb +127 -0
- data/lib/mpp/x402/header.rb +131 -0
- data/lib/mpp/x402/server.rb +247 -0
- data/lib/mpp/x402/types.rb +33 -0
- data/lib/mpp/x402.rb +12 -0
- data/lib/mpp.rb +6 -3
- metadata +32 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# typed:
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 =
|
|
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 |
|
|
90
|
-
raise ArgumentError, "extra must be a dict[str, str]" unless
|
|
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 |
|
|
96
|
-
raise ArgumentError, "mppx_scope must be a dict[str, str]" unless
|
|
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 ||=
|
|
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
|
-
|
|
239
|
+
Mpp::Server::MethodHelper.transform_request(method, request, nil)
|
|
240
|
+
end
|
|
113
241
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
@@ -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
|