mpp-rb 0.1.5 → 0.1.7

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +150 -0
  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/extensions/mcp/verify.rb +1 -1
  8. data/lib/mpp/methods/evm/charge_intent.rb +38 -17
  9. data/lib/mpp/methods/evm/evm_method.rb +18 -2
  10. data/lib/mpp/methods/evm.rb +5 -2
  11. data/lib/mpp/methods/stripe/analytics_metadata.rb +24 -0
  12. data/lib/mpp/methods/stripe/charge_intent.rb +34 -11
  13. data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +92 -0
  14. data/lib/mpp/methods/stripe/defaults.rb +1 -0
  15. data/lib/mpp/methods/stripe/machine_payments.rb +128 -0
  16. data/lib/mpp/methods/stripe/payment_intent_method.rb +103 -0
  17. data/lib/mpp/methods/stripe/payment_intent_options.rb +81 -0
  18. data/lib/mpp/methods/stripe/stripe_method.rb +30 -3
  19. data/lib/mpp/methods/stripe.rb +26 -0
  20. data/lib/mpp/methods/tempo/client_method.rb +58 -12
  21. data/lib/mpp/methods/tempo/defaults.rb +51 -0
  22. data/lib/mpp/methods/tempo/intents.rb +195 -125
  23. data/lib/mpp/methods/tempo/relay.rb +33 -31
  24. data/lib/mpp/methods/tempo/schemas.rb +3 -4
  25. data/lib/mpp/parsing.rb +29 -4
  26. data/lib/mpp/server/compose.rb +32 -15
  27. data/lib/mpp/server/decorator.rb +3 -1
  28. data/lib/mpp/server/intent.rb +6 -1
  29. data/lib/mpp/server/intent_lifecycle.rb +41 -0
  30. data/lib/mpp/server/method.rb +41 -0
  31. data/lib/mpp/server/middleware.rb +16 -2
  32. data/lib/mpp/server/mpp_handler.rb +87 -21
  33. data/lib/mpp/server/verify.rb +25 -15
  34. data/lib/mpp/server.rb +1 -0
  35. data/lib/mpp/validation.rb +27 -0
  36. data/lib/mpp/version.rb +1 -1
  37. data/lib/mpp.rb +14 -3
  38. metadata +22 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c38b87554f2c1cbef8acfb7d2ba2197b14140500e52bff79276602c76051f8ed
4
- data.tar.gz: c11b04aef7e116a9b298e3b4b4286dfc4194348b6bd2aca60b1d3f235911ac25
3
+ metadata.gz: 04e4e82ccc2c911599a59b725c6e57baf05244119b60fc53c8d04fe2973c55ec
4
+ data.tar.gz: 298544b059706fa7de2b2845f93d3e7e061dd6c9a7cf2e27aae9a485418bf2ac
5
5
  SHA512:
6
- metadata.gz: 795895e5b9b8a768d96468c7f34b468b9a205e694c2b212dc1fca00ecfd2e5906c95ec95455ac79432f1ba95b21a91ba31cd71ccecbb1dbc1226a3cf274ccbf8
7
- data.tar.gz: 81855362a29e248846445b3cebb0a1bce2a4f2cc2164c49e462ac923ade2fc1d30ef527786579313ffe270d02c89ec8b32a0dd0c09a74403bc69820de112ee10
6
+ metadata.gz: e387986b40d742b24fa10c2b6dc455d157ffa325b7bb33efe53b7ab2e2742cbf97840fbb663c652611bf7255c8ea46e6d525458e825529138f4e18437510c9d2
7
+ data.tar.gz: 9879963916f463a68568bcb0b7e219d12402a4344db77d324ba877ded2e95bf2110826aa9cff7cb02328f1dbd9e3905b9a7c3d43787159c8bb568a371017a1e7
data/README.md CHANGED
@@ -42,6 +42,10 @@ if result.is_a?(Mpp::Challenge)
42
42
  # Return 402 with WWW-Authenticate header
43
43
  resp = Mpp::Server::Decorator.make_challenge_response(result, server.realm)
44
44
  # resp["status"], resp["headers"], resp["body"]
45
+ elsif result.is_a?(Mpp::Server::ComposedResult)
46
+ # Several offers (methods or currencies); see "Accepted currencies"
47
+ resp = result.to_response if result.payment_required?
48
+ credential, receipt = result.payment unless result.payment_required?
45
49
  else
46
50
  credential, receipt = result
47
51
  # credential.source — payer address
@@ -49,6 +53,46 @@ else
49
53
  end
50
54
  ```
51
55
 
56
+ ### Accepted currencies
57
+
58
+ A Tempo server method offers one charge per accepted currency, in order, and
59
+ accepts a credential for any of them. With the default `chain_id: 4217` (mainnet) the
60
+ defaults are OUSD then USDC.e; with `chain_id: 42431` (Moderato) they are OUSD
61
+ then pathUSD. Other chains, or an explicit `chain_id: nil`, keep the single pathUSD default.
62
+ Several accepted currencies make `charge` return a `Mpp::Server::ComposedResult`.
63
+
64
+ ```ruby
65
+ Mpp::Methods::Tempo.tempo(
66
+ intents: {"charge" => Mpp::Methods::Tempo::ChargeIntent.new},
67
+ chain_id: 4217,
68
+ recipient: "0x0000000000000000000000000000000000000001",
69
+ currencies: [Mpp::Methods::Tempo::Defaults::USDC] # replaces the defaults
70
+ )
71
+ ```
72
+
73
+ An explicit `currencies:` list replaces the defaults. The deprecated
74
+ `currency:` option accepts exactly one token; pass one or the other. A
75
+ `currency:` passed to `charge` or a compose entry still issues a single offer.
76
+ Sponsored charges choose the fee token independently of the charge currency.
77
+ A local fee payer accepts `fee_payer_allowed_fee_tokens:` when set, otherwise
78
+ pathUSD and the chain's default currency (pathUSD and USDC.e on mainnet,
79
+ pathUSD on Moderato). It pays gas in `fee_token:` when set, otherwise in the
80
+ first allowed token it holds a balance of, otherwise in the first allowed
81
+ token. `fee_token:` is only valid with a local fee payer; hosted sponsors
82
+ choose their own fee token.
83
+
84
+ If the endpoint already uses `Authorization` (API keys, Bearer tokens), create the server with `requires_auth: true`. Challenges then advertise `header="Payment-Authorization"`, and clients send the Payment credential in that header instead of `Authorization`.
85
+
86
+ ```ruby
87
+ server = Mpp.create(method: tempo, requires_auth: true)
88
+
89
+ result = server.charge(
90
+ env["HTTP_AUTHORIZATION"],
91
+ "0.50",
92
+ payment_authorization: env["HTTP_PAYMENT_AUTHORIZATION"],
93
+ )
94
+ ```
95
+
52
96
  ### Multiple methods
53
97
 
54
98
  ```ruby
@@ -62,6 +106,7 @@ paid = server.compose(
62
106
 
63
107
  result = paid.call(
64
108
  authorization: env["HTTP_AUTHORIZATION"],
109
+ payment_authorization: env["HTTP_PAYMENT_AUTHORIZATION"],
65
110
  payment_signature: env["HTTP_PAYMENT_SIGNATURE"],
66
111
  accept_payment: env["HTTP_ACCEPT_PAYMENT"],
67
112
  url: request.url,
@@ -75,6 +120,99 @@ else
75
120
  end
76
121
  ```
77
122
 
123
+ ### Stripe machine payments
124
+
125
+ `Mpp::Methods::Stripe.create` configures Stripe Payment Tokens and static
126
+ crypto deposit addresses from an injected Stripe client. Add the `stripe` gem
127
+ to your application, then configure the methods:
128
+
129
+ ```ruby
130
+ require "stripe"
131
+
132
+ stripe_client = Stripe::StripeClient.new(ENV.fetch("STRIPE_SECRET_KEY"))
133
+ payments = Mpp::Methods::Stripe.create(
134
+ client: stripe_client,
135
+ network_id: ENV.fetch("STRIPE_NETWORK_ID"),
136
+ livemode: false,
137
+ deposit_addresses: {
138
+ tempo: ENV.fetch("TEMPO_DEPOSIT_ADDRESS"),
139
+ base: ENV.fetch("BASE_DEPOSIT_ADDRESS")
140
+ },
141
+ metadata: {"product" => "example"}
142
+ )
143
+
144
+ server = Mpp.create(methods: payments.default_methods)
145
+ ```
146
+
147
+ With no deposit addresses, defaults are Stripe Payment Tokens only.
148
+ `default_methods` adds Tempo when its static address is configured. Add Base
149
+ explicitly with its x402 facilitator:
150
+
151
+ ```ruby
152
+ base = payments.base.charge(
153
+ x402: {facilitator: "https://x402.org/facilitator"}
154
+ )
155
+ server = Mpp.create(methods: [*payments.default_methods, base])
156
+ ```
157
+
158
+ When composed, SPT offers below $0.50 and Tempo/Base offers below one cent are
159
+ not advertised. Successful Tempo and Base payments are best-effort recorded as
160
+ Stripe crypto transaction-verification PaymentIntents. Metadata is included on
161
+ every Stripe PaymentIntent.
162
+
163
+ Stripe machine-payment methods also accept private, request-scoped
164
+ `payment_intent_options` in `charge` or composed-offer input. Supported fields
165
+ match mppx: `customer`, `receipt_email`, `metadata`, and Stripe Tax calculation
166
+ hooks. The value can be a static hash or a deferred callable:
167
+
168
+ ```ruby
169
+ options = lambda do |challenge:, credential:, request:|
170
+ calculation = stripe_client.v1.tax.calculations.create(
171
+ tax_params,
172
+ {idempotency_key: "mpp_tax_#{challenge.id}"}
173
+ )
174
+ {hooks: {inputs: {tax: {calculation: calculation.id}}}}
175
+ end
176
+
177
+ payment = server.compose(
178
+ [payments.tempo.charge, {amount: "0.50", payment_intent_options: options}],
179
+ [payments.spt.charge, {amount: "0.50", payment_intent_options: options}]
180
+ )
181
+ ```
182
+
183
+ For SPT, deferred callables run after credential validation and immediately
184
+ before PaymentIntent creation. For crypto, they run after the rail's `validate`
185
+ and immediately before `broadcast` when supported, or before legacy `verify`
186
+ otherwise. Tempo and Base both support the split path, including relay and x402.
187
+ The options are not serialized in the MPP challenge. Recorded crypto payments
188
+ retry once without optional fields only when Stripe definitively rejects them.
189
+
190
+ Two-phase intents implement `validate(credential, request)` and
191
+ `broadcast(credential, request)`. Validation returns an `Mpp::Validation` record
192
+ with `challenge`, `credential`, `request`, `method`, `intent`, `source`, and
193
+ method-specific `details`, matching mppx's validation-result fields. Failed
194
+ checks raise; validation must not settle a payment or consume replay state.
195
+ Use an empty hash for `details` when none are available; `source` defaults to
196
+ `nil`. The record is not a settlement receipt or independent proof of challenge
197
+ issuance. Broadcast receives
198
+ the original inputs and returns a receipt; the combined lifecycle does not pass
199
+ the validation record into broadcast.
200
+
201
+ Tempo and EVM charge intents expose `validate(credential, request)` and
202
+ `broadcast(credential, request)` using this contract. Validation does not submit
203
+ payments, co-sign transactions, or consume replay state. `verify` is deprecated
204
+ and calls both phases in order.
205
+ Like [mppx](https://github.com/wevm/mppx/tree/43ec92c36f575339cf7c45ae466507048783a0d3/src),
206
+ local Tempo broadcast repeats credential checks, while EVM and relay broadcast
207
+ delegate to settlement without repeating the remote validation call. Validation
208
+ is a preflight check, not a guarantee that settlement will succeed.
209
+
210
+ Tempo retains its existing validation: transaction preflight is best-effort,
211
+ local sponsorship checks run before co-signing, and hosted payers and relays
212
+ retain their own validation policies. This split does not add mppx's stronger
213
+ transaction preflight checks; final transaction acceptance still depends on the
214
+ node or payment service.
215
+
78
216
  `evm.charge` additionally emits `PAYMENT-REQUIRED` and accepts `PAYMENT-SIGNATURE` (x402 v2 exact) when a facilitator is configured:
79
217
 
80
218
  ```ruby
@@ -152,6 +290,18 @@ end
152
290
 
153
291
  Client events are `challenge.received`, `credential.created`, `payment.response`, and `payment.failed`. Server events are `challenge.created`, `payment.success`, and `payment.failed`.
154
292
 
293
+ When a side effect belongs to one payment method, attach it to the method instead. The hook only receives successful payments for that method and intent; an exception in the hook does not invalidate the payment.
294
+
295
+ ```ruby
296
+ method = Mpp::Methods::Tempo.tempo(
297
+ intents: {"charge" => Mpp::Methods::Tempo::ChargeIntent.new},
298
+ recipient: "0x0000000000000000000000000000000000000001",
299
+ on_payment_success: ->(payload) {
300
+ record_payment(payload[:receipt], payload[:request])
301
+ },
302
+ )
303
+ ```
304
+
155
305
  ### Rack Middleware
156
306
 
157
307
  ```ruby
data/lib/mpp/challenge.rb CHANGED
@@ -16,16 +16,30 @@ module Mpp
16
16
  :digest,
17
17
  :expires,
18
18
  :description,
19
- :opaque
19
+ :opaque,
20
+ :header
20
21
  ) do
21
22
  def initialize(id:, method:, intent:, request:, realm: "", request_b64: "", digest: nil, expires: nil,
22
- description: nil, opaque: nil)
23
- super
23
+ description: nil, opaque: nil, header: nil)
24
+ super(
25
+ id: id,
26
+ method: method,
27
+ intent: intent,
28
+ request: request,
29
+ realm: realm,
30
+ request_b64: request_b64,
31
+ digest: digest,
32
+ expires: expires,
33
+ description: description,
34
+ opaque: opaque,
35
+ header: Mpp.advertised_credential_header(header)
36
+ )
24
37
  end
25
38
 
26
39
  # Create a Challenge with an HMAC-bound ID.
27
40
  def self.create(secret_key:, realm:, method:, intent:, request:, expires: nil, digest: nil, description: nil,
28
- meta: nil)
41
+ meta: nil, header: nil)
42
+ header = Mpp.advertised_credential_header(header)
29
43
  challenge_id = Mpp.generate_challenge_id(
30
44
  secret_key: secret_key,
31
45
  realm: realm,
@@ -34,7 +48,8 @@ module Mpp
34
48
  request: request,
35
49
  expires: expires,
36
50
  digest: digest,
37
- opaque: meta
51
+ opaque: meta,
52
+ header: header
38
53
  )
39
54
  request_json = Mpp::Json.compact_encode(request)
40
55
  request_b64 = Mpp.b64url_encode(request_json)
@@ -49,7 +64,8 @@ module Mpp
49
64
  digest: digest,
50
65
  expires: expires,
51
66
  description: description,
52
- opaque: meta
67
+ opaque: meta,
68
+ header: header
53
69
  )
54
70
  end
55
71
 
@@ -152,6 +168,11 @@ module Mpp
152
168
  Mpp::Parsing.format_www_authenticate(self, realm)
153
169
  end
154
170
 
171
+ # HTTP field a client must use for the payment credential.
172
+ def credential_header
173
+ header || Mpp::AUTHORIZATION_HEADER
174
+ end
175
+
155
176
  # Verify the challenge ID matches the expected HMAC.
156
177
  def verify(secret_key, realm)
157
178
  expected_id = Mpp.generate_challenge_id(
@@ -162,7 +183,8 @@ module Mpp
162
183
  request: request,
163
184
  expires: expires,
164
185
  digest: digest,
165
- opaque: opaque
186
+ opaque: opaque,
187
+ header: header
166
188
  )
167
189
  Mpp.secure_compare(id, expected_id)
168
190
  end
@@ -183,7 +205,8 @@ module Mpp
183
205
  request: request_b64,
184
206
  expires: expires,
185
207
  digest: digest,
186
- opaque: opaque_b64
208
+ opaque: opaque_b64,
209
+ header: header
187
210
  )
188
211
  end
189
212
  end
@@ -10,10 +10,21 @@ module Mpp
10
10
  :request,
11
11
  :expires,
12
12
  :digest,
13
- :opaque
13
+ :opaque,
14
+ :header
14
15
  ) do
15
- def initialize(id:, realm:, method:, intent:, request:, expires: nil, digest: nil, opaque: nil)
16
- super
16
+ def initialize(id:, realm:, method:, intent:, request:, expires: nil, digest: nil, opaque: nil, header: nil)
17
+ super(
18
+ id: id,
19
+ realm: realm,
20
+ method: method,
21
+ intent: intent,
22
+ request: request,
23
+ expires: expires,
24
+ digest: digest,
25
+ opaque: opaque,
26
+ header: Mpp.advertised_credential_header(header)
27
+ )
17
28
  end
18
29
  end
19
30
  end
@@ -9,13 +9,18 @@ module Mpp
9
9
 
10
10
  module_function
11
11
 
12
+ # RFC 9110 token used as an HTTP field name.
13
+ HTTP_HEADER_NAME_RE = T.let(/\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/, Regexp)
14
+
12
15
  # Generate HMAC-SHA256 challenge ID per spec.
13
16
  #
14
17
  # HMAC input format: realm|method|intent|request_b64|expires|digest|opaque_b64
15
- # All fields always included; absent optional fields use empty string.
18
+ # Challenges that advertise a credential header insert it immediately before
19
+ # the final opaque slot. Authorization is the implicit default and is never
20
+ # included, preserving the legacy binding.
16
21
  # Output: base64url(HMAC-SHA256(secret_key, input))
17
- sig { params(secret_key: T.untyped, realm: T.untyped, method: T.untyped, intent: T.untyped, request: T.untyped, expires: T.untyped, digest: T.untyped, opaque: T.untyped).returns(String) }
18
- def generate_challenge_id(secret_key:, realm:, method:, intent:, request:, expires: nil, digest: nil, opaque: nil)
22
+ sig { params(secret_key: T.untyped, realm: T.untyped, method: T.untyped, intent: T.untyped, request: T.untyped, expires: T.untyped, digest: T.untyped, opaque: T.untyped, header: T.untyped).returns(String) }
23
+ def generate_challenge_id(secret_key:, realm:, method:, intent:, request:, expires: nil, digest: nil, opaque: nil, header: nil)
19
24
  request_json = Json.compact_encode(request)
20
25
  request_b64 = b64url_encode(request_json)
21
26
 
@@ -32,14 +37,33 @@ module Mpp
32
37
  intent,
33
38
  request_b64,
34
39
  expires || "",
35
- digest || "",
36
- opaque_b64
37
- ].join("|")
40
+ digest || ""
41
+ ]
42
+ advertised = advertised_credential_header(header)
43
+ hmac_input << advertised if advertised
44
+ hmac_input << opaque_b64
45
+ hmac_input = hmac_input.join("|")
38
46
 
39
47
  mac = OpenSSL::HMAC.digest("SHA256", secret_key.encode(Encoding::UTF_8), hmac_input.encode(Encoding::UTF_8))
40
48
  Base64.urlsafe_encode64(mac, padding: false)
41
49
  end
42
50
 
51
+ # True when the credential header is omitted or is the implicit Authorization default.
52
+ sig { params(header: T.untyped).returns(T::Boolean) }
53
+ def default_credential_header?(header)
54
+ header.nil? || header.to_s.empty? || header.to_s.casecmp(AUTHORIZATION_HEADER).zero?
55
+ end
56
+
57
+ # Returns an advertised credential header, or nil for the Authorization default.
58
+ sig { params(header: T.untyped).returns(T.nilable(String)) }
59
+ def advertised_credential_header(header)
60
+ return nil if default_credential_header?(header)
61
+
62
+ name = header.to_s
63
+ Kernel.raise ArgumentError, "Invalid HTTP header name" unless HTTP_HEADER_NAME_RE.match?(name)
64
+ name
65
+ end
66
+
43
67
  # Encode string to base64url without padding.
44
68
  sig { params(data: T.untyped).returns(String) }
45
69
  def b64url_encode(data)
@@ -117,7 +117,7 @@ module Mpp
117
117
  raise
118
118
  end
119
119
 
120
- retry_headers = headers.merge("Authorization" => auth_header)
120
+ retry_headers = headers.merge(challenge.credential_header => auth_header)
121
121
  payment_response = nil
122
122
  begin
123
123
  payment_response = send_request(uri, method, retry_headers, body)
@@ -94,7 +94,7 @@ module Mpp
94
94
  core_credential = mcp_credential.to_core
95
95
 
96
96
  begin
97
- core_receipt = intent.verify(core_credential, request)
97
+ core_receipt = Mpp::Server::IntentLifecycle.call(intent, core_credential, request)
98
98
  rescue Mpp::VerificationError => e
99
99
  Kernel.raise PaymentVerificationError.new(
100
100
  challenges: [new_challenge.call],
@@ -18,7 +18,13 @@ module Mpp
18
18
  @route_binding = route_binding
19
19
  end
20
20
 
21
+ # @deprecated Use #validate followed by #broadcast.
21
22
  def verify(credential, request)
23
+ validate(credential, request)
24
+ broadcast(credential, request)
25
+ end
26
+
27
+ def validate(credential, request)
22
28
  payload = credential.payload
23
29
  unless payload.is_a?(Hash) && payload["type"] == "authorization"
24
30
  raise Mpp::VerificationError, "EVM authorization credentials are not supported for this challenge"
@@ -65,7 +71,36 @@ module Mpp
65
71
  raise Mpp::VerificationFailedError.new(reason: "EVM authorization source mismatch")
66
72
  end
67
73
 
68
- settled = settle(payload, request)
74
+ payment_payload, requirements = facilitator_input(payload, request)
75
+ verified = @facilitator.verify(payment_payload, requirements)
76
+ unless verified["isValid"]
77
+ raise Mpp::VerificationFailedError.new(
78
+ reason: verified["invalidMessage"] || verified["invalidReason"] || "EVM facilitator verify failed"
79
+ )
80
+ end
81
+
82
+ Mpp::Validation.new(
83
+ challenge: credential.challenge,
84
+ credential: credential,
85
+ details: {payer: checksummed_from},
86
+ intent: name,
87
+ method: "evm",
88
+ request: request,
89
+ source: credential.source
90
+ )
91
+ end
92
+
93
+ # Like mppx, settlement relies on a preceding #validate and the
94
+ # facilitator's terminal checks; it does not repeat /verify.
95
+ def broadcast(credential, request)
96
+ payment_payload, requirements = facilitator_input(credential.payload, request)
97
+ settled = @facilitator.settle(payment_payload, requirements)
98
+ unless settled["success"]
99
+ raise Mpp::VerificationFailedError.new(
100
+ reason: settled["errorMessage"] || settled["errorReason"] || "EVM facilitator settlement failed"
101
+ )
102
+ end
103
+
69
104
  Mpp::Receipt.success(settled.fetch("transaction"), method: "evm")
70
105
  end
71
106
 
@@ -79,7 +114,7 @@ module Mpp
79
114
 
80
115
  private
81
116
 
82
- def settle(payload, request)
117
+ def facilitator_input(payload, request)
83
118
  requirements = payment_requirements(request)
84
119
  payment_payload = {
85
120
  "accepted" => requirements,
@@ -97,21 +132,7 @@ module Mpp
97
132
  "x402Version" => Mpp::X402::VERSION
98
133
  }
99
134
 
100
- verified = @facilitator.verify(payment_payload, requirements)
101
- unless verified["isValid"]
102
- raise Mpp::VerificationFailedError.new(
103
- reason: verified["invalidMessage"] || verified["invalidReason"] || "EVM facilitator verify failed"
104
- )
105
- end
106
-
107
- settled = @facilitator.settle(payment_payload, requirements)
108
- unless settled["success"]
109
- raise Mpp::VerificationFailedError.new(
110
- reason: settled["errorMessage"] || settled["errorReason"] || "EVM facilitator settlement failed"
111
- )
112
- end
113
-
114
- settled
135
+ [payment_payload, requirements]
115
136
  end
116
137
 
117
138
  def x402_credential?(credential)
@@ -6,10 +6,18 @@ module Mpp
6
6
  module Evm
7
7
  # EVM payment method. Speaks native Payment-auth and x402 exact.
8
8
  class EvmMethod
9
- attr_reader :name, :currency, :recipient, :decimals, :chain_id, :authorization, :x402
9
+ attr_reader :name, :currency, :recipient, :decimals, :chain_id, :authorization, :x402,
10
+ :on_payment_success
10
11
  attr_accessor :intents
11
12
 
12
- def initialize(currency:, recipient:, decimals:, chain_id:, authorization:, x402:)
13
+ def initialize(currency:, recipient:, decimals:, chain_id:, authorization:, x402:, on_payment_success: nil, can_offer: nil)
14
+ if !on_payment_success.nil? && !on_payment_success.respond_to?(:call)
15
+ raise ArgumentError, "on_payment_success must be callable"
16
+ end
17
+ if !can_offer.nil? && !can_offer.respond_to?(:call)
18
+ raise ArgumentError, "can_offer must be callable"
19
+ end
20
+
13
21
  @name = "evm"
14
22
  @currency = Authorization.checksum_address(currency)
15
23
  @recipient = Authorization.checksum_address(recipient)
@@ -17,9 +25,17 @@ module Mpp
17
25
  @chain_id = chain_id
18
26
  @authorization = authorization
19
27
  @x402 = x402
28
+ @on_payment_success = on_payment_success
29
+ @can_offer = can_offer
20
30
  @intents = {}
21
31
  end
22
32
 
33
+ def can_offer?(request)
34
+ return true unless @can_offer
35
+
36
+ @can_offer.call(request)
37
+ end
38
+
23
39
  def transform_request(request, _credential)
24
40
  method_details = request.fetch("methodDetails", {})
25
41
  method_details = {} unless method_details.is_a?(Hash)
@@ -10,7 +10,8 @@ module Mpp
10
10
  autoload :EvmMethod, "mpp/methods/evm/evm_method"
11
11
 
12
12
  # Factory for a server-side EVM charge method with inline x402 exact support.
13
- def self.charge(currency:, recipient:, x402:, authorization: nil, chain_id: nil, decimals: nil)
13
+ def self.charge(currency:, recipient:, x402:, authorization: nil, chain_id: nil, decimals: nil,
14
+ on_payment_success: nil, can_offer: nil)
14
15
  resolved = Assets.resolve(
15
16
  currency,
16
17
  authorization: authorization,
@@ -32,7 +33,9 @@ module Mpp
32
33
  decimals: resolved.fetch(:decimals),
33
34
  chain_id: resolved.fetch(:chain_id),
34
35
  authorization: resolved.fetch(:authorization),
35
- x402: x402_options
36
+ x402: x402_options,
37
+ on_payment_success: on_payment_success,
38
+ can_offer: can_offer
36
39
  )
37
40
  method.intents = {"charge" => charge_intent}
38
41
  method
@@ -0,0 +1,24 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ # Builds Stripe-owned metadata used to identify MPP payments.
8
+ module AnalyticsMetadata
9
+ LIMIT = 500
10
+
11
+ module_function
12
+
13
+ def build(challenge)
14
+ {
15
+ "machine_payment" => "true",
16
+ "mpp_sdk" => "mpp-rb/#{Mpp::VERSION}",
17
+ "mpp_challenge_id" => challenge.id.to_s,
18
+ "mpp_intent" => challenge.intent.to_s
19
+ }.transform_values { |value| value.each_char.first(LIMIT).join }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -11,11 +11,22 @@ module Mpp
11
11
  class ChargeIntent
12
12
  attr_reader :name
13
13
 
14
- def initialize(secret_key:, api_base: Defaults::STRIPE_API_BASE, client: nil)
14
+ def initialize(secret_key:, api_base: Defaults::STRIPE_API_BASE, client: nil,
15
+ payment_intent_options: nil)
15
16
  @name = "charge"
16
17
  @secret_key = secret_key
17
18
  @api_base = api_base
18
19
  @client = client
20
+ @payment_intent_options = payment_intent_options
21
+ end
22
+
23
+ def with_payment_intent_options(payment_intent_options)
24
+ self.class.new(
25
+ secret_key: @secret_key,
26
+ api_base: @api_base,
27
+ client: @client,
28
+ payment_intent_options: payment_intent_options
29
+ )
19
30
  end
20
31
 
21
32
  def verify(credential, request)
@@ -27,11 +38,11 @@ module Mpp
27
38
  end
28
39
 
29
40
  payload_data = credential.payload
30
- unless payload_data.is_a?(Hash) && payload_data.key?("spt")
31
- raise Mpp::VerificationError, "Invalid credential payload: missing spt"
41
+ spt = payload_data["spt"] if payload_data.is_a?(Hash)
42
+ unless spt.is_a?(String) && !spt.empty?
43
+ raise Mpp::VerificationError, "Invalid credential payload: missing or invalid spt"
32
44
  end
33
45
 
34
- spt = payload_data["spt"]
35
46
  credential_external_id = payload_data["externalId"]
36
47
  request_external_id = request["externalId"]
37
48
  if !request_external_id.nil? && credential_external_id != request_external_id
@@ -52,6 +63,13 @@ module Mpp
52
63
  raise Mpp::VerificationError, "Invalid or missing methodDetails.paymentMethodTypes"
53
64
  end
54
65
 
66
+ payment_intent_options = PaymentIntentOptions.resolve(
67
+ @payment_intent_options,
68
+ challenge: credential.challenge,
69
+ credential: credential,
70
+ request: request
71
+ ) || {}
72
+
55
73
  # Build PaymentIntent params
56
74
  params = {
57
75
  amount: Integer(request["amount"]),
@@ -61,10 +79,14 @@ module Mpp
61
79
  payment_method_types: payment_method_types
62
80
  }
63
81
 
64
- # Include metadata from methodDetails if present
65
- if method_details["metadata"].is_a?(Hash)
66
- params[:metadata] = method_details["metadata"].transform_values(&:to_s)
67
- end
82
+ params[:customer] = payment_intent_options[:customer] if payment_intent_options.key?(:customer)
83
+ params[:hooks] = payment_intent_options[:hooks] if payment_intent_options.key?(:hooks)
84
+ params[:receipt_email] = payment_intent_options[:receipt_email] if payment_intent_options.key?(:receipt_email)
85
+
86
+ metadata = method_details["metadata"].is_a?(Hash) ? method_details["metadata"].transform_values(&:to_s) : {}
87
+ params[:metadata] = AnalyticsMetadata.build(credential.challenge)
88
+ .merge(metadata)
89
+ .merge(payment_intent_options.fetch(:metadata, {}))
68
90
 
69
91
  unless @client
70
92
  begin
@@ -78,15 +100,16 @@ module Mpp
78
100
  client = @client || ::Stripe::StripeClient.new(@secret_key)
79
101
  result = client.v1.payment_intents.create(
80
102
  params,
81
- {idempotency_key: stripe_idempotency_key(credential)}
103
+ {stripe_version: Defaults::MACHINE_PAYMENTS_API_VERSION, idempotency_key: stripe_idempotency_key(credential)}
82
104
  )
83
105
  rescue => e
84
106
  raise Mpp::VerificationError, e.message
85
107
  end
86
108
 
87
109
  # https://docs.stripe.com/error-low-level#idempotency
88
- if result.respond_to?(:last_response) &&
89
- result.last_response&.headers&.[]("idempotent-replayed") == "true"
110
+ last_response = result.last_response if result.respond_to?(:last_response)
111
+ response_headers = last_response.respond_to?(:http_headers) ? last_response.http_headers : last_response&.headers
112
+ if response_headers&.[]("idempotent-replayed") == "true"
90
113
  raise Mpp::VerificationError, "Payment has already been processed."
91
114
  end
92
115