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
@@ -0,0 +1,247 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "header"
5
+ require "digest"
6
+ require "openssl"
7
+
8
+ module Mpp
9
+ module X402
10
+ # Server-side x402 exact binding, challenge decoration, and header merge.
11
+ module Server
12
+ extend T::Sig
13
+
14
+ module_function
15
+
16
+ sig { params(values: T::Array[String]).returns(String) }
17
+ def merge_payment_required(values)
18
+ decoded = values.map { |value| Header.decode_payment_required(value) }
19
+ first = decoded.first
20
+ Kernel.raise ArgumentError, "Expected at least one x402 payment-required header." unless first
21
+
22
+ rest = decoded.drop(1)
23
+ incompatible = rest.any? do |value|
24
+ value["resource"] != first["resource"] || value["extensions"] != first["extensions"]
25
+ end
26
+ if incompatible
27
+ error = [first["error"], "Cannot merge x402 payment requirements with different resources."]
28
+ .compact
29
+ .reject { |item| item.to_s.empty? }
30
+ .join("; ")
31
+ return Header.encode_payment_required(first.merge("error" => error))
32
+ end
33
+
34
+ errors = [first["error"], *rest.map { |value| value["error"] }]
35
+ .compact
36
+ .reject { |item| item.to_s.empty? }
37
+ merged = first.merge("accepts" => decoded.flat_map { |value| value["accepts"] })
38
+ merged["error"] = errors.join("; ") unless errors.empty?
39
+ Header.encode_payment_required(merged)
40
+ end
41
+
42
+ sig do
43
+ params(
44
+ request: T::Hash[T.untyped, T.untyped],
45
+ authorization: T::Hash[T.untyped, T.untyped],
46
+ max_timeout_seconds: Integer
47
+ ).returns(T::Hash[String, T.untyped])
48
+ end
49
+ def to_payment_requirements(request, authorization:, max_timeout_seconds:)
50
+ chain_id = request.dig("methodDetails", "chainId")
51
+ Kernel.raise ArgumentError, "EVM charge request requires methodDetails.chainId" if chain_id.nil?
52
+
53
+ {
54
+ "amount" => request["amount"].to_s,
55
+ "asset" => request["currency"].to_s,
56
+ "extra" => {
57
+ "assetTransferMethod" => ASSET_TRANSFER_EIP3009,
58
+ "name" => authorization["name"] || authorization[:name],
59
+ "version" => authorization["version"] || authorization[:version]
60
+ },
61
+ "maxTimeoutSeconds" => max_timeout_seconds,
62
+ "network" => "#{EVM_NETWORK_PREFIX}#{chain_id}",
63
+ "payTo" => request["recipient"].to_s,
64
+ "scheme" => SCHEME_EXACT
65
+ }
66
+ end
67
+
68
+ sig do
69
+ params(
70
+ requirements: T::Hash[T.untyped, T.untyped],
71
+ resource_url: String,
72
+ extensions: T.nilable(T::Hash[T.untyped, T.untyped]),
73
+ error: T.nilable(String)
74
+ ).returns(T::Hash[String, T.untyped])
75
+ end
76
+ def payment_required_body(requirements:, resource_url:, extensions: nil, error: nil)
77
+ body = {
78
+ "accepts" => [requirements],
79
+ "resource" => {"url" => resource_url},
80
+ "x402Version" => VERSION
81
+ }
82
+ body["extensions"] = extensions if extensions
83
+ body["error"] = error if error
84
+ body
85
+ end
86
+
87
+ sig do
88
+ params(
89
+ challenge: Mpp::Challenge,
90
+ http_method: T.nilable(String)
91
+ ).returns(T.nilable(T::Hash[String, T.untyped]))
92
+ end
93
+ def route_extensions(challenge, http_method)
94
+ return nil if http_method.nil? || http_method.empty?
95
+
96
+ binding = {"method" => http_method}
97
+ binding["digest"] = challenge.digest if challenge.digest
98
+ binding["opaque"] = Mpp::Parsing.b64_encode(challenge.opaque) if challenge.opaque
99
+ {
100
+ MPPX_EXTENSION_KEY => {
101
+ "info" => binding,
102
+ "schema" => MPPX_ROUTE_BINDING_SCHEMA
103
+ }
104
+ }
105
+ end
106
+
107
+ sig do
108
+ params(
109
+ accepted: T::Hash[T.untyped, T.untyped],
110
+ resource: T::Hash[T.untyped, T.untyped],
111
+ extensions: T::Hash[T.untyped, T.untyped]
112
+ ).returns(String)
113
+ end
114
+ def route_nonce(accepted:, resource:, extensions:)
115
+ input = [
116
+ serialize_request(accepted),
117
+ serialize_request(resource),
118
+ serialize_request(extensions)
119
+ ].join("|")
120
+ digest = Digest::SHA256.digest(input)
121
+ "0x#{digest.unpack1("H*")}"
122
+ end
123
+
124
+ sig { params(value: T.untyped).returns(String) }
125
+ def serialize_request(value)
126
+ json = Mpp::Json.compact_encode(value)
127
+ Mpp.b64url_encode(json)
128
+ end
129
+
130
+ sig do
131
+ params(
132
+ payment_payload: T::Hash[T.untyped, T.untyped],
133
+ requirements: T::Hash[T.untyped, T.untyped],
134
+ resource_url: T.nilable(String),
135
+ challenge: Mpp::Challenge,
136
+ body: T.untyped,
137
+ route_binding: Symbol,
138
+ http_method: T.nilable(String)
139
+ ).returns(T::Hash[T.untyped, T.untyped])
140
+ end
141
+ def bind_credential(payment_payload:, requirements:, resource_url:, challenge:, body:, route_binding:, http_method: nil)
142
+ unless canonical_equal?(payment_payload["accepted"], requirements)
143
+ Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 payment payload does not match route requirements")
144
+ end
145
+
146
+ assert_body_digest!(challenge, body)
147
+
148
+ expected_resource = {"url" => resource_url}
149
+ client_nonce = payment_payload.dig("extensions", MPPX_EXTENSION_KEY, "info", "nonce")
150
+ route_bound = !client_nonce.nil?
151
+ route_requires_binding = !challenge.digest.nil? || !challenge.opaque.nil?
152
+
153
+ if route_requires_binding && !route_bound && route_binding == :required
154
+ Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 payment payload does not bind required route metadata")
155
+ end
156
+
157
+ payload_resource = payment_payload["resource"]
158
+ if route_bound
159
+ unless payload_resource.is_a?(Hash) && payload_resource["url"] == expected_resource["url"]
160
+ Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 payment payload resource does not match route resource")
161
+ end
162
+ expected_extensions = route_extensions(challenge, http_method) || {}
163
+ unless contains_extensions?(payment_payload["extensions"], expected_extensions)
164
+ Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 payment payload extensions do not match route binding")
165
+ end
166
+ elsif resource_url
167
+ if payload_resource.is_a?(Hash) && payload_resource["url"] != resource_url
168
+ Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 payment payload resource does not match route resource")
169
+ end
170
+ if route_requires_binding && (payload_resource.nil? || payload_resource["url"] != resource_url)
171
+ Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 payment payload resource does not match route resource")
172
+ end
173
+ end
174
+
175
+ authorization = payload_to_authorization(payment_payload)
176
+ if route_bound
177
+ expected_nonce = route_nonce(
178
+ accepted: requirements,
179
+ extensions: payment_payload["extensions"],
180
+ resource: expected_resource
181
+ )
182
+ unless authorization["nonce"].to_s.downcase == expected_nonce.downcase
183
+ Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 authorization nonce does not match route binding")
184
+ end
185
+ end
186
+
187
+ authorization
188
+ end
189
+
190
+ sig { params(payment_payload: T::Hash[T.untyped, T.untyped]).returns(T::Hash[String, T.untyped]) }
191
+ def payload_to_authorization(payment_payload)
192
+ payload = payment_payload["payload"]
193
+ unless payload.is_a?(Hash) && payload["authorization"].is_a?(Hash)
194
+ Kernel.raise Mpp::VerificationFailedError.new(reason: "EVM charge only supports x402 EIP-3009 authorization payloads")
195
+ end
196
+
197
+ authorization = payload["authorization"]
198
+ {
199
+ "from" => authorization["from"].to_s,
200
+ "nonce" => authorization["nonce"].to_s,
201
+ "signature" => payload["signature"].to_s,
202
+ "to" => authorization["to"].to_s,
203
+ "type" => "authorization",
204
+ "validAfter" => authorization["validAfter"].to_s,
205
+ "validBefore" => authorization["validBefore"].to_s,
206
+ "value" => authorization["value"].to_s
207
+ }
208
+ end
209
+
210
+ sig { params(left: T.untyped, right: T.untyped).returns(T::Boolean) }
211
+ def canonical_equal?(left, right)
212
+ Mpp::Json.compact_encode(left) == Mpp::Json.compact_encode(right)
213
+ end
214
+
215
+ sig { params(challenge: Mpp::Challenge, body: T.untyped).void }
216
+ def assert_body_digest!(challenge, body)
217
+ return if body.nil? || challenge.digest.nil?
218
+ return if Mpp::BodyDigest.verify(T.must(challenge.digest), body)
219
+
220
+ Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 payment body digest mismatch")
221
+ end
222
+ private_class_method :assert_body_digest!
223
+
224
+ sig { params(actual: T.untyped, expected: T::Hash[T.untyped, T.untyped]).returns(T::Boolean) }
225
+ def contains_extensions?(actual, expected)
226
+ return false unless actual.is_a?(Hash)
227
+
228
+ expected.all? do |key, expected_extension|
229
+ actual_extension = actual[key]
230
+ next false unless actual_extension.is_a?(Hash)
231
+
232
+ canonical_equal?(actual_extension["schema"], expected_extension["schema"]) &&
233
+ canonical_equal?(strip_client_nonce(actual_extension["info"]), expected_extension["info"])
234
+ end
235
+ end
236
+ private_class_method :contains_extensions?
237
+
238
+ sig { params(info: T.untyped).returns(T.untyped) }
239
+ def strip_client_nonce(info)
240
+ return info unless info.is_a?(Hash)
241
+
242
+ info.except("nonce")
243
+ end
244
+ private_class_method :strip_client_nonce
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,33 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module X402
6
+ VERSION = 2
7
+ PAYMENT_METHOD = "evm"
8
+ EXACT_INTENT = "charge"
9
+ SCHEME_EXACT = "exact"
10
+ ASSET_TRANSFER_EIP3009 = "eip3009"
11
+ EVM_NETWORK_PREFIX = "eip155:"
12
+ SYNTHETIC_CHALLENGE_ID_PREFIX = "x402:"
13
+
14
+ PAYMENT_REQUIRED_HEADER = "PAYMENT-REQUIRED"
15
+ PAYMENT_SIGNATURE_HEADER = "PAYMENT-SIGNATURE"
16
+ PAYMENT_RESPONSE_HEADER = "PAYMENT-RESPONSE"
17
+
18
+ MPPX_EXTENSION_KEY = "mppx"
19
+
20
+ MPPX_ROUTE_BINDING_SCHEMA = T.let({
21
+ "additionalProperties" => false,
22
+ "properties" => {
23
+ "_mppx_scope" => {"type" => "string"},
24
+ "digest" => {"type" => "string"},
25
+ "method" => {"type" => "string"},
26
+ "nonce" => {"type" => "string"},
27
+ "opaque" => {"type" => "string"}
28
+ },
29
+ "required" => ["method"],
30
+ "type" => "object"
31
+ }.freeze, T::Hash[String, T.untyped])
32
+ end
33
+ end
data/lib/mpp/x402.rb ADDED
@@ -0,0 +1,12 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module X402
6
+ autoload :Header, "mpp/x402/header"
7
+ autoload :Facilitator, "mpp/x402/facilitator"
8
+ autoload :Server, "mpp/x402/server"
9
+ end
10
+ end
11
+
12
+ require_relative "x402/types"
data/lib/mpp.rb CHANGED
@@ -11,6 +11,9 @@ require_relative "mpp/secure_compare"
11
11
  module Mpp
12
12
  extend T::Sig
13
13
 
14
+ AUTHORIZATION_HEADER = T.let("Authorization", String)
15
+ PAYMENT_AUTHORIZATION_HEADER = T.let("Payment-Authorization", String)
16
+
14
17
  autoload :Challenge, "mpp/challenge"
15
18
  autoload :ChallengeEcho, "mpp/challenge_echo"
16
19
  autoload :Credential, "mpp/credential"
@@ -22,6 +25,8 @@ module Mpp
22
25
  autoload :Events, "mpp/events"
23
26
  autoload :Units, "mpp/units"
24
27
  autoload :MemoryStore, "mpp/store"
28
+ autoload :Http, "mpp/http"
29
+ autoload :X402, "mpp/x402"
25
30
 
26
31
  # Server module (autoloaded)
27
32
  autoload :Server, "mpp/server"
@@ -33,6 +38,7 @@ module Mpp
33
38
  module Methods
34
39
  autoload :Tempo, "mpp/methods/tempo"
35
40
  autoload :Stripe, "mpp/methods/stripe"
41
+ autoload :Evm, "mpp/methods/evm"
36
42
  end
37
43
 
38
44
  # Extensions namespace
@@ -40,9 +46,16 @@ module Mpp
40
46
  autoload :MCP, "mpp/extensions/mcp"
41
47
  end
42
48
 
43
- sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped, events: T.nilable(Mpp::Events::Dispatcher)).returns(T.untyped) }
44
- def self.create(method:, realm: nil, secret_key: nil, events: nil)
45
- Server::MppHandler.create(method: method, realm: realm, secret_key: secret_key, events: events)
49
+ sig { params(method: T.untyped, methods: T.nilable(T::Array[T.untyped]), realm: T.untyped, secret_key: T.untyped, events: T.nilable(Mpp::Events::Dispatcher), requires_auth: T::Boolean).returns(T.untyped) }
50
+ def self.create(method: nil, methods: nil, realm: nil, secret_key: nil, events: nil, requires_auth: false)
51
+ Server::MppHandler.create(
52
+ method: method,
53
+ methods: methods,
54
+ realm: realm,
55
+ secret_key: secret_key,
56
+ events: events,
57
+ requires_auth: requires_auth
58
+ )
46
59
  end
47
60
 
48
61
  # Error hierarchy
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mpp-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stripe
@@ -23,6 +23,34 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: keccak
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.3'
40
+ - !ruby/object:Gem::Dependency
41
+ name: sorbet-runtime
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
26
54
  description: Ruby SDK for the Machine Payments Protocol (MPP) — an HTTP 402 Payment
27
55
  Authentication scheme.
28
56
  executables: []
@@ -50,22 +78,33 @@ files:
50
78
  - lib/mpp/extensions/mcp/errors.rb
51
79
  - lib/mpp/extensions/mcp/types.rb
52
80
  - lib/mpp/extensions/mcp/verify.rb
81
+ - lib/mpp/http.rb
82
+ - lib/mpp/http/headers.rb
53
83
  - lib/mpp/json.rb
84
+ - lib/mpp/methods/evm.rb
85
+ - lib/mpp/methods/evm/assets.rb
86
+ - lib/mpp/methods/evm/authorization.rb
87
+ - lib/mpp/methods/evm/charge_intent.rb
88
+ - lib/mpp/methods/evm/evm_method.rb
54
89
  - lib/mpp/methods/stripe.rb
55
90
  - lib/mpp/methods/stripe/charge_intent.rb
56
91
  - lib/mpp/methods/stripe/client_method.rb
92
+ - lib/mpp/methods/stripe/crypto_payment_recorder.rb
57
93
  - lib/mpp/methods/stripe/defaults.rb
94
+ - lib/mpp/methods/stripe/machine_payments.rb
58
95
  - lib/mpp/methods/stripe/stripe_method.rb
59
96
  - lib/mpp/methods/tempo.rb
60
97
  - lib/mpp/methods/tempo/account.rb
61
98
  - lib/mpp/methods/tempo/attribution.rb
62
99
  - lib/mpp/methods/tempo/client_method.rb
63
100
  - lib/mpp/methods/tempo/defaults.rb
101
+ - lib/mpp/methods/tempo/fee_payer_client.rb
64
102
  - lib/mpp/methods/tempo/fee_payer_envelope.rb
65
103
  - lib/mpp/methods/tempo/fee_payer_policy.rb
66
104
  - lib/mpp/methods/tempo/intents.rb
67
105
  - lib/mpp/methods/tempo/keychain.rb
68
106
  - lib/mpp/methods/tempo/proof.rb
107
+ - lib/mpp/methods/tempo/relay.rb
69
108
  - lib/mpp/methods/tempo/rpc.rb
70
109
  - lib/mpp/methods/tempo/schemas.rb
71
110
  - lib/mpp/methods/tempo/transaction.rb
@@ -73,16 +112,24 @@ files:
73
112
  - lib/mpp/receipt.rb
74
113
  - lib/mpp/secure_compare.rb
75
114
  - lib/mpp/server.rb
115
+ - lib/mpp/server/accept_payment.rb
116
+ - lib/mpp/server/compose.rb
76
117
  - lib/mpp/server/decorator.rb
77
118
  - lib/mpp/server/defaults.rb
78
119
  - lib/mpp/server/intent.rb
79
120
  - lib/mpp/server/method.rb
80
121
  - lib/mpp/server/middleware.rb
81
122
  - lib/mpp/server/mpp_handler.rb
123
+ - lib/mpp/server/result.rb
82
124
  - lib/mpp/server/verify.rb
83
125
  - lib/mpp/store.rb
84
126
  - lib/mpp/units.rb
85
127
  - lib/mpp/version.rb
128
+ - lib/mpp/x402.rb
129
+ - lib/mpp/x402/facilitator.rb
130
+ - lib/mpp/x402/header.rb
131
+ - lib/mpp/x402/server.rb
132
+ - lib/mpp/x402/types.rb
86
133
  homepage: https://github.com/stripe/mpp-rb
87
134
  licenses:
88
135
  - MIT