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.
- checksums.yaml +4 -4
- data/README.md +150 -0
- 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/extensions/mcp/verify.rb +1 -1
- data/lib/mpp/methods/evm/charge_intent.rb +38 -17
- data/lib/mpp/methods/evm/evm_method.rb +18 -2
- data/lib/mpp/methods/evm.rb +5 -2
- data/lib/mpp/methods/stripe/analytics_metadata.rb +24 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +34 -11
- data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +92 -0
- data/lib/mpp/methods/stripe/defaults.rb +1 -0
- data/lib/mpp/methods/stripe/machine_payments.rb +128 -0
- data/lib/mpp/methods/stripe/payment_intent_method.rb +103 -0
- data/lib/mpp/methods/stripe/payment_intent_options.rb +81 -0
- data/lib/mpp/methods/stripe/stripe_method.rb +30 -3
- data/lib/mpp/methods/stripe.rb +26 -0
- data/lib/mpp/methods/tempo/client_method.rb +58 -12
- data/lib/mpp/methods/tempo/defaults.rb +51 -0
- data/lib/mpp/methods/tempo/intents.rb +195 -125
- data/lib/mpp/methods/tempo/relay.rb +33 -31
- data/lib/mpp/methods/tempo/schemas.rb +3 -4
- data/lib/mpp/parsing.rb +29 -4
- data/lib/mpp/server/compose.rb +32 -15
- data/lib/mpp/server/decorator.rb +3 -1
- data/lib/mpp/server/intent.rb +6 -1
- data/lib/mpp/server/intent_lifecycle.rb +41 -0
- data/lib/mpp/server/method.rb +41 -0
- data/lib/mpp/server/middleware.rb +16 -2
- data/lib/mpp/server/mpp_handler.rb +87 -21
- data/lib/mpp/server/verify.rb +25 -15
- data/lib/mpp/server.rb +1 -0
- data/lib/mpp/validation.rb +27 -0
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp.rb +14 -3
- metadata +22 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 04e4e82ccc2c911599a59b725c6e57baf05244119b60fc53c8d04fe2973c55ec
|
|
4
|
+
data.tar.gz: 298544b059706fa7de2b2845f93d3e7e061dd6c9a7cf2e27aae9a485418bf2ac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/mpp/challenge_echo.rb
CHANGED
|
@@ -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
|
data/lib/mpp/challenge_id.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
37
|
-
|
|
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)
|
data/lib/mpp/client/transport.rb
CHANGED
|
@@ -117,7 +117,7 @@ module Mpp
|
|
|
117
117
|
raise
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
-
retry_headers = headers.merge(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
data/lib/mpp/methods/evm.rb
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|