mpp-rb 0.1.5 → 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 +65 -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/methods/evm/evm_method.rb +18 -2
- data/lib/mpp/methods/evm.rb +5 -2
- data/lib/mpp/methods/stripe/charge_intent.rb +6 -7
- 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/client_method.rb +22 -4
- data/lib/mpp/methods/tempo/intents.rb +53 -41
- data/lib/mpp/parsing.rb +20 -2
- data/lib/mpp/server/compose.rb +22 -10
- data/lib/mpp/server/decorator.rb +3 -1
- data/lib/mpp/server/method.rb +15 -0
- data/lib/mpp/server/middleware.rb +16 -2
- data/lib/mpp/server/mpp_handler.rb +66 -12
- data/lib/mpp/server/verify.rb +24 -14
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp.rb +13 -3
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d028d7ba9d07e95173c4d3618a688dd87d4674284195a58ecfa4d6d43a311bf7
|
|
4
|
+
data.tar.gz: 3a342589c1478477c42077297f11244550ce035e4d66f9e4afde2e9a125d091c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf7ad2c6e7db109dc6244609e24eaf42f444396f0df9a69dd428017635dca2abb001271d89cd6c1e715fa3c58bf352ee267455ea0e38623cfd6dc608cd2da609
|
|
7
|
+
data.tar.gz: 6f090b293910e66a99054316767d36913a1a258c9ae5e1989fd74b16f67c8bad236b2e5351f6c4582140f5f48a0f707f2395245196470ec4c2206b0fb41418ad
|
data/README.md
CHANGED
|
@@ -49,6 +49,18 @@ else
|
|
|
49
49
|
end
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
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`.
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
server = Mpp.create(method: tempo, requires_auth: true)
|
|
56
|
+
|
|
57
|
+
result = server.charge(
|
|
58
|
+
env["HTTP_AUTHORIZATION"],
|
|
59
|
+
"0.50",
|
|
60
|
+
payment_authorization: env["HTTP_PAYMENT_AUTHORIZATION"],
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
52
64
|
### Multiple methods
|
|
53
65
|
|
|
54
66
|
```ruby
|
|
@@ -62,6 +74,7 @@ paid = server.compose(
|
|
|
62
74
|
|
|
63
75
|
result = paid.call(
|
|
64
76
|
authorization: env["HTTP_AUTHORIZATION"],
|
|
77
|
+
payment_authorization: env["HTTP_PAYMENT_AUTHORIZATION"],
|
|
65
78
|
payment_signature: env["HTTP_PAYMENT_SIGNATURE"],
|
|
66
79
|
accept_payment: env["HTTP_ACCEPT_PAYMENT"],
|
|
67
80
|
url: request.url,
|
|
@@ -75,6 +88,46 @@ else
|
|
|
75
88
|
end
|
|
76
89
|
```
|
|
77
90
|
|
|
91
|
+
### Stripe machine payments
|
|
92
|
+
|
|
93
|
+
`Mpp::Methods::Stripe.create` configures Stripe Payment Tokens and static
|
|
94
|
+
crypto deposit addresses from an injected Stripe client. Add the `stripe` gem
|
|
95
|
+
to your application, then configure the methods:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
require "stripe"
|
|
99
|
+
|
|
100
|
+
stripe_client = Stripe::StripeClient.new(ENV.fetch("STRIPE_SECRET_KEY"))
|
|
101
|
+
payments = Mpp::Methods::Stripe.create(
|
|
102
|
+
client: stripe_client,
|
|
103
|
+
network_id: ENV.fetch("STRIPE_NETWORK_ID"),
|
|
104
|
+
livemode: false,
|
|
105
|
+
deposit_addresses: {
|
|
106
|
+
tempo: ENV.fetch("TEMPO_DEPOSIT_ADDRESS"),
|
|
107
|
+
base: ENV.fetch("BASE_DEPOSIT_ADDRESS")
|
|
108
|
+
},
|
|
109
|
+
metadata: {"product" => "example"}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
server = Mpp.create(methods: payments.default_methods)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
With no deposit addresses, defaults are Stripe Payment Tokens only.
|
|
116
|
+
`default_methods` adds Tempo when its static address is configured. Add Base
|
|
117
|
+
explicitly with its x402 facilitator:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
base = payments.base.charge(
|
|
121
|
+
x402: {facilitator: "https://x402.org/facilitator"}
|
|
122
|
+
)
|
|
123
|
+
server = Mpp.create(methods: [*payments.default_methods, base])
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
When composed, SPT offers below $0.50 and Tempo/Base offers below one cent are
|
|
127
|
+
not advertised. Successful Tempo and Base payments are best-effort recorded as
|
|
128
|
+
Stripe crypto transaction-verification PaymentIntents. Metadata is included on
|
|
129
|
+
every Stripe PaymentIntent.
|
|
130
|
+
|
|
78
131
|
`evm.charge` additionally emits `PAYMENT-REQUIRED` and accepts `PAYMENT-SIGNATURE` (x402 v2 exact) when a facilitator is configured:
|
|
79
132
|
|
|
80
133
|
```ruby
|
|
@@ -152,6 +205,18 @@ end
|
|
|
152
205
|
|
|
153
206
|
Client events are `challenge.received`, `credential.created`, `payment.response`, and `payment.failed`. Server events are `challenge.created`, `payment.success`, and `payment.failed`.
|
|
154
207
|
|
|
208
|
+
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.
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
method = Mpp::Methods::Tempo.tempo(
|
|
212
|
+
intents: {"charge" => Mpp::Methods::Tempo::ChargeIntent.new},
|
|
213
|
+
recipient: "0x0000000000000000000000000000000000000001",
|
|
214
|
+
on_payment_success: ->(payload) {
|
|
215
|
+
record_payment(payload[:receipt], payload[:request])
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
```
|
|
219
|
+
|
|
155
220
|
### Rack Middleware
|
|
156
221
|
|
|
157
222
|
```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)
|
|
@@ -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
|
|
@@ -61,10 +61,8 @@ module Mpp
|
|
|
61
61
|
payment_method_types: payment_method_types
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
params[:metadata] = method_details["metadata"].transform_values(&:to_s)
|
|
67
|
-
end
|
|
64
|
+
metadata = method_details["metadata"].is_a?(Hash) ? method_details["metadata"].transform_values(&:to_s) : {}
|
|
65
|
+
params[:metadata] = metadata.merge("machine_payment" => "true")
|
|
68
66
|
|
|
69
67
|
unless @client
|
|
70
68
|
begin
|
|
@@ -78,15 +76,16 @@ module Mpp
|
|
|
78
76
|
client = @client || ::Stripe::StripeClient.new(@secret_key)
|
|
79
77
|
result = client.v1.payment_intents.create(
|
|
80
78
|
params,
|
|
81
|
-
{idempotency_key: stripe_idempotency_key(credential)}
|
|
79
|
+
{stripe_version: Defaults::MACHINE_PAYMENTS_API_VERSION, idempotency_key: stripe_idempotency_key(credential)}
|
|
82
80
|
)
|
|
83
81
|
rescue => e
|
|
84
82
|
raise Mpp::VerificationError, e.message
|
|
85
83
|
end
|
|
86
84
|
|
|
87
85
|
# https://docs.stripe.com/error-low-level#idempotency
|
|
88
|
-
if result.respond_to?(:last_response)
|
|
89
|
-
|
|
86
|
+
last_response = result.last_response if result.respond_to?(:last_response)
|
|
87
|
+
response_headers = last_response.respond_to?(:http_headers) ? last_response.http_headers : last_response&.headers
|
|
88
|
+
if response_headers&.[]("idempotent-replayed") == "true"
|
|
90
89
|
raise Mpp::VerificationError, "Payment has already been processed."
|
|
91
90
|
end
|
|
92
91
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Methods
|
|
6
|
+
module Stripe
|
|
7
|
+
# Records an already-verified crypto transfer as a Stripe PaymentIntent.
|
|
8
|
+
class CryptoPaymentRecorder
|
|
9
|
+
RAW_UNITS_PER_CENT = 10_000
|
|
10
|
+
|
|
11
|
+
def initialize(client:, network:, metadata: nil)
|
|
12
|
+
@client = client
|
|
13
|
+
@network = network
|
|
14
|
+
@metadata = metadata
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(payload)
|
|
18
|
+
reference = payload[:receipt]&.reference
|
|
19
|
+
return unless reference.is_a?(String)
|
|
20
|
+
|
|
21
|
+
amount = Integer(payload.fetch(:request).fetch("amount"))
|
|
22
|
+
cents = (amount + (RAW_UNITS_PER_CENT / 2)) / RAW_UNITS_PER_CENT
|
|
23
|
+
return if cents < 1
|
|
24
|
+
|
|
25
|
+
params = {
|
|
26
|
+
amount: cents,
|
|
27
|
+
currency: "usd",
|
|
28
|
+
confirm: true,
|
|
29
|
+
payment_method_data: {type: "crypto"},
|
|
30
|
+
payment_method_types: ["crypto"],
|
|
31
|
+
payment_method_options: {
|
|
32
|
+
crypto: {
|
|
33
|
+
mode: "transaction_verification",
|
|
34
|
+
transaction_verification_options: {network: @network, transaction_hash: reference}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
metadata = @metadata.is_a?(Hash) ? @metadata.transform_values(&:to_s) : {}
|
|
39
|
+
params[:metadata] = metadata.merge("machine_payment" => "true")
|
|
40
|
+
|
|
41
|
+
@client.v1.payment_intents.create(
|
|
42
|
+
params,
|
|
43
|
+
{stripe_version: Defaults::MACHINE_PAYMENTS_API_VERSION, idempotency_key: reference}
|
|
44
|
+
)
|
|
45
|
+
rescue => error
|
|
46
|
+
Kernel.warn(
|
|
47
|
+
"[stripe] failed to record crypto payment " \
|
|
48
|
+
"network=#{@network.inspect} transaction_hash=#{reference.inspect}: #{error.class}: #{error.message}"
|
|
49
|
+
)
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Methods
|
|
6
|
+
module Stripe
|
|
7
|
+
# Configures Stripe-backed MPP methods with static deposit addresses.
|
|
8
|
+
class MachinePayments
|
|
9
|
+
SPT_MINIMUM_MINOR_UNITS = 50
|
|
10
|
+
|
|
11
|
+
attr_reader :spt, :tempo, :base
|
|
12
|
+
|
|
13
|
+
def initialize(network_id:, livemode:, client:, deposit_addresses: nil, metadata: nil)
|
|
14
|
+
raise ArgumentError, "client must respond to #v1" unless client.respond_to?(:v1)
|
|
15
|
+
|
|
16
|
+
addresses = normalize_deposit_addresses(deposit_addresses)
|
|
17
|
+
@spt = SptFactory.new(network_id: network_id, client: client, metadata: metadata)
|
|
18
|
+
@tempo = TempoFactory.new(livemode: livemode, client: client, recipient: addresses[:tempo], metadata: metadata)
|
|
19
|
+
@base = BaseFactory.new(livemode: livemode, client: client, recipient: addresses[:base], metadata: metadata)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Tempo is preferred when configured; Base requires an explicit facilitator.
|
|
23
|
+
def default_methods
|
|
24
|
+
methods = [spt.charge]
|
|
25
|
+
methods.unshift(tempo.charge) if tempo.configured?
|
|
26
|
+
methods
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.minimum_amount(minimum)
|
|
30
|
+
lambda do |request|
|
|
31
|
+
Integer(request.fetch("amount")) >= minimum
|
|
32
|
+
rescue ArgumentError, TypeError, KeyError
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def normalize_deposit_addresses(deposit_addresses)
|
|
40
|
+
return {} if deposit_addresses.nil?
|
|
41
|
+
raise ArgumentError, "deposit_addresses must be a Hash" unless deposit_addresses.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
deposit_addresses.each_with_object({}) do |(network, address), result|
|
|
44
|
+
unless [:tempo, :base].include?(network)
|
|
45
|
+
raise ArgumentError, "unsupported deposit address network: #{network}"
|
|
46
|
+
end
|
|
47
|
+
unless address.is_a?(String) && address.match?(/\A0x[0-9a-fA-F]{40}\z/)
|
|
48
|
+
raise ArgumentError, "deposit_addresses[:#{network}] must be a 0x-prefixed 40-hex-character address"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
result[network] = address.dup.freeze
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class SptFactory
|
|
57
|
+
def initialize(network_id:, client:, metadata:)
|
|
58
|
+
@network_id = network_id
|
|
59
|
+
@client = client
|
|
60
|
+
@metadata = metadata
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def charge
|
|
64
|
+
method = Mpp::Methods::Stripe::StripeMethod.new(
|
|
65
|
+
secret_key: nil,
|
|
66
|
+
network_id: @network_id,
|
|
67
|
+
payment_methods: ["card", "link"],
|
|
68
|
+
metadata: @metadata,
|
|
69
|
+
can_offer: MachinePayments.minimum_amount(MachinePayments::SPT_MINIMUM_MINOR_UNITS)
|
|
70
|
+
)
|
|
71
|
+
method.intents = {"charge" => Mpp::Methods::Stripe::ChargeIntent.new(secret_key: nil, client: @client)}
|
|
72
|
+
method
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class TempoFactory
|
|
77
|
+
def initialize(livemode:, client:, recipient:, metadata:)
|
|
78
|
+
@livemode = livemode
|
|
79
|
+
@client = client
|
|
80
|
+
@recipient = recipient
|
|
81
|
+
@metadata = metadata
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def configured?
|
|
85
|
+
!@recipient.nil?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def charge
|
|
89
|
+
raise ArgumentError, "deposit_addresses[:tempo] is required for Tempo payments" unless configured?
|
|
90
|
+
|
|
91
|
+
chain_id = @livemode ? Mpp::Methods::Tempo::Defaults::CHAIN_ID : Mpp::Methods::Tempo::Defaults::TESTNET_CHAIN_ID
|
|
92
|
+
currency = @livemode ? Mpp::Methods::Tempo::Defaults::USDC : Mpp::Methods::Tempo::Defaults::PATH_USD
|
|
93
|
+
recorder = CryptoPaymentRecorder.new(client: @client, network: "tempo", metadata: @metadata)
|
|
94
|
+
Mpp::Methods::Tempo.tempo(
|
|
95
|
+
intents: {"charge" => Mpp::Methods::Tempo::ChargeIntent.new(chain_id: chain_id)},
|
|
96
|
+
chain_id: chain_id,
|
|
97
|
+
currency: currency,
|
|
98
|
+
recipient: @recipient,
|
|
99
|
+
decimals: Mpp::Methods::Tempo::Defaults::PATH_USD_DECIMALS,
|
|
100
|
+
can_offer: MachinePayments.minimum_amount(CryptoPaymentRecorder::RAW_UNITS_PER_CENT),
|
|
101
|
+
on_payment_success: ->(payload) { recorder.call(payload) }
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
class BaseFactory
|
|
107
|
+
def initialize(livemode:, client:, recipient:, metadata:)
|
|
108
|
+
@livemode = livemode
|
|
109
|
+
@client = client
|
|
110
|
+
@recipient = recipient
|
|
111
|
+
@metadata = metadata
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def charge(x402:)
|
|
115
|
+
raise ArgumentError, "deposit_addresses[:base] is required for Base payments" if @recipient.nil?
|
|
116
|
+
|
|
117
|
+
currency = @livemode ? Mpp::Methods::Evm::Assets::BASE_USDC : Mpp::Methods::Evm::Assets::BASE_SEPOLIA_USDC
|
|
118
|
+
recorder = CryptoPaymentRecorder.new(client: @client, network: "base", metadata: @metadata)
|
|
119
|
+
Mpp::Methods::Evm.charge(
|
|
120
|
+
currency: currency,
|
|
121
|
+
recipient: @recipient,
|
|
122
|
+
x402: x402,
|
|
123
|
+
can_offer: MachinePayments.minimum_amount(CryptoPaymentRecorder::RAW_UNITS_PER_CENT),
|
|
124
|
+
on_payment_success: ->(payload) { recorder.call(payload) }
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -9,17 +9,24 @@ module Mpp
|
|
|
9
9
|
# Stripe payment method implementation.
|
|
10
10
|
# Handles SPT-based payments through Stripe's Business Network.
|
|
11
11
|
class StripeMethod
|
|
12
|
-
attr_reader :name, :currency, :recipient, :decimals
|
|
12
|
+
attr_reader :name, :currency, :recipient, :decimals, :on_payment_success
|
|
13
13
|
attr_accessor :intents
|
|
14
14
|
|
|
15
15
|
def initialize(secret_key:, network_id:, payment_methods: nil,
|
|
16
16
|
metadata: nil, currency: Defaults::DEFAULT_CURRENCY,
|
|
17
|
-
decimals: Defaults::DEFAULT_DECIMALS, external_id: nil
|
|
17
|
+
decimals: Defaults::DEFAULT_DECIMALS, external_id: nil,
|
|
18
|
+
on_payment_success: nil, can_offer: nil)
|
|
18
19
|
unless payment_methods.is_a?(Array) &&
|
|
19
20
|
payment_methods.any? &&
|
|
20
21
|
payment_methods.all? { |type| type.is_a?(String) && !type.strip.empty? }
|
|
21
22
|
raise ArgumentError, "payment_methods must be a non-empty array of Stripe payment method type strings"
|
|
22
23
|
end
|
|
24
|
+
unless on_payment_success.nil? || on_payment_success.respond_to?(:call)
|
|
25
|
+
raise ArgumentError, "on_payment_success must be callable"
|
|
26
|
+
end
|
|
27
|
+
unless can_offer.nil? || can_offer.respond_to?(:call)
|
|
28
|
+
raise ArgumentError, "can_offer must be callable"
|
|
29
|
+
end
|
|
23
30
|
|
|
24
31
|
@name = "stripe"
|
|
25
32
|
@secret_key = secret_key
|
|
@@ -27,12 +34,20 @@ module Mpp
|
|
|
27
34
|
@payment_methods = payment_methods
|
|
28
35
|
@metadata = metadata
|
|
29
36
|
@external_id = external_id
|
|
37
|
+
@on_payment_success = on_payment_success
|
|
38
|
+
@can_offer = can_offer
|
|
30
39
|
@currency = currency
|
|
31
40
|
@recipient = network_id
|
|
32
41
|
@decimals = decimals
|
|
33
42
|
@intents = {}
|
|
34
43
|
end
|
|
35
44
|
|
|
45
|
+
def can_offer?(request)
|
|
46
|
+
return true unless @can_offer
|
|
47
|
+
|
|
48
|
+
@can_offer.call(request)
|
|
49
|
+
end
|
|
50
|
+
|
|
36
51
|
# Transform request - injects Stripe-specific methodDetails.
|
|
37
52
|
def transform_request(request, _credential)
|
|
38
53
|
method_details = request.fetch("methodDetails", {})
|
|
@@ -53,6 +68,8 @@ module Mpp
|
|
|
53
68
|
metadata: nil, currency: Defaults::DEFAULT_CURRENCY,
|
|
54
69
|
decimals: Defaults::DEFAULT_DECIMALS,
|
|
55
70
|
external_id: nil,
|
|
71
|
+
on_payment_success: nil,
|
|
72
|
+
can_offer: nil,
|
|
56
73
|
api_base: Defaults::STRIPE_API_BASE)
|
|
57
74
|
charge_intent = ChargeIntent.new(secret_key: secret_key, api_base: api_base)
|
|
58
75
|
|
|
@@ -63,7 +80,9 @@ module Mpp
|
|
|
63
80
|
metadata: metadata,
|
|
64
81
|
currency: currency,
|
|
65
82
|
decimals: decimals,
|
|
66
|
-
external_id: external_id
|
|
83
|
+
external_id: external_id,
|
|
84
|
+
on_payment_success: on_payment_success,
|
|
85
|
+
can_offer: can_offer
|
|
67
86
|
)
|
|
68
87
|
|
|
69
88
|
method.intents = {"charge" => charge_intent}
|
data/lib/mpp/methods/stripe.rb
CHANGED
|
@@ -4,11 +4,34 @@
|
|
|
4
4
|
module Mpp
|
|
5
5
|
module Methods
|
|
6
6
|
module Stripe
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
7
9
|
autoload :Defaults, "mpp/methods/stripe/defaults"
|
|
8
10
|
# Eagerly require stripe_method so the Stripe.stripe factory method is available
|
|
9
11
|
require_relative "stripe/stripe_method"
|
|
10
12
|
autoload :ChargeIntent, "mpp/methods/stripe/charge_intent"
|
|
11
13
|
autoload :ClientMethod, "mpp/methods/stripe/client_method"
|
|
14
|
+
autoload :CryptoPaymentRecorder, "mpp/methods/stripe/crypto_payment_recorder"
|
|
15
|
+
autoload :MachinePayments, "mpp/methods/stripe/machine_payments"
|
|
16
|
+
|
|
17
|
+
sig do
|
|
18
|
+
params(
|
|
19
|
+
network_id: String,
|
|
20
|
+
livemode: T::Boolean,
|
|
21
|
+
client: T.untyped,
|
|
22
|
+
deposit_addresses: T.untyped,
|
|
23
|
+
metadata: T.untyped
|
|
24
|
+
).returns(MachinePayments)
|
|
25
|
+
end
|
|
26
|
+
def self.create(network_id:, livemode:, client:, deposit_addresses: nil, metadata: nil)
|
|
27
|
+
MachinePayments.new(
|
|
28
|
+
network_id: network_id,
|
|
29
|
+
livemode: livemode,
|
|
30
|
+
client: client,
|
|
31
|
+
deposit_addresses: deposit_addresses,
|
|
32
|
+
metadata: metadata
|
|
33
|
+
)
|
|
34
|
+
end
|
|
12
35
|
end
|
|
13
36
|
end
|
|
14
37
|
end
|