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.
- checksums.yaml +4 -4
- data/README.md +128 -1
- data/lib/mpp/challenge.rb +31 -8
- data/lib/mpp/challenge_echo.rb +14 -3
- data/lib/mpp/challenge_id.rb +30 -6
- data/lib/mpp/client/transport.rb +1 -1
- 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 +133 -0
- data/lib/mpp/methods/evm.rb +67 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +10 -8
- data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +55 -0
- data/lib/mpp/methods/stripe/defaults.rb +1 -0
- data/lib/mpp/methods/stripe/machine_payments.rb +130 -0
- data/lib/mpp/methods/stripe/stripe_method.rb +22 -3
- data/lib/mpp/methods/stripe.rb +23 -0
- data/lib/mpp/methods/tempo/attribution.rb +3 -9
- data/lib/mpp/methods/tempo/client_method.rb +35 -6
- data/lib/mpp/methods/tempo/fee_payer_client.rb +120 -0
- data/lib/mpp/methods/tempo/intents.rb +80 -48
- 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 +22 -2
- data/lib/mpp/receipt.rb +4 -3
- data/lib/mpp/server/accept_payment.rb +194 -0
- data/lib/mpp/server/compose.rb +399 -0
- data/lib/mpp/server/decorator.rb +30 -7
- data/lib/mpp/server/method.rb +15 -0
- data/lib/mpp/server/middleware.rb +160 -18
- data/lib/mpp/server/mpp_handler.rb +322 -34
- data/lib/mpp/server/result.rb +100 -0
- data/lib/mpp/server/verify.rb +24 -14
- 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 +16 -3
- 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:
|
|
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,
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
credential, receipt, extra_headers = paid_result(result)
|
|
55
66
|
status, headers, body = @app.call(env)
|
|
56
|
-
|
|
57
|
-
|
|
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?("*")
|
|
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"],
|
|
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:
|
|
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 {
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 =
|
|
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 |
|
|
90
|
-
raise ArgumentError, "extra must be a dict[str, str]" unless
|
|
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 |
|
|
96
|
-
raise ArgumentError, "mppx_scope must be a dict[str, str]" unless
|
|
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 ||=
|
|
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
|
-
|
|
267
|
+
Mpp::Server::MethodHelper.transform_request(method, request, nil)
|
|
268
|
+
end
|
|
113
269
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|