mpp-rb 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +128 -1
- data/lib/mpp/challenge.rb +31 -8
- data/lib/mpp/challenge_echo.rb +14 -3
- data/lib/mpp/challenge_id.rb +30 -6
- data/lib/mpp/client/transport.rb +1 -1
- data/lib/mpp/errors.rb +22 -0
- data/lib/mpp/http/headers.rb +61 -0
- data/lib/mpp/http.rb +8 -0
- data/lib/mpp/methods/evm/assets.rb +103 -0
- data/lib/mpp/methods/evm/authorization.rb +140 -0
- data/lib/mpp/methods/evm/charge_intent.rb +129 -0
- data/lib/mpp/methods/evm/evm_method.rb +133 -0
- data/lib/mpp/methods/evm.rb +67 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +10 -8
- data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +55 -0
- data/lib/mpp/methods/stripe/defaults.rb +1 -0
- data/lib/mpp/methods/stripe/machine_payments.rb +130 -0
- data/lib/mpp/methods/stripe/stripe_method.rb +22 -3
- data/lib/mpp/methods/stripe.rb +23 -0
- data/lib/mpp/methods/tempo/attribution.rb +3 -9
- data/lib/mpp/methods/tempo/client_method.rb +35 -6
- data/lib/mpp/methods/tempo/fee_payer_client.rb +120 -0
- data/lib/mpp/methods/tempo/intents.rb +80 -48
- data/lib/mpp/methods/tempo/proof.rb +39 -12
- data/lib/mpp/methods/tempo/relay.rb +257 -0
- data/lib/mpp/methods/tempo.rb +2 -0
- data/lib/mpp/parsing.rb +22 -2
- data/lib/mpp/receipt.rb +4 -3
- data/lib/mpp/server/accept_payment.rb +194 -0
- data/lib/mpp/server/compose.rb +399 -0
- data/lib/mpp/server/decorator.rb +30 -7
- data/lib/mpp/server/method.rb +15 -0
- data/lib/mpp/server/middleware.rb +160 -18
- data/lib/mpp/server/mpp_handler.rb +322 -34
- data/lib/mpp/server/result.rb +100 -0
- data/lib/mpp/server/verify.rb +24 -14
- data/lib/mpp/server.rb +4 -0
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp/x402/facilitator.rb +127 -0
- data/lib/mpp/x402/header.rb +131 -0
- data/lib/mpp/x402/server.rb +247 -0
- data/lib/mpp/x402/types.rb +33 -0
- data/lib/mpp/x402.rb +12 -0
- data/lib/mpp.rb +16 -3
- metadata +48 -1
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,114 @@ 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
|
+
|
|
64
|
+
### Multiple methods
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
server = Mpp.create(methods: [tempo, evm, stripe])
|
|
68
|
+
|
|
69
|
+
paid = server.compose(
|
|
70
|
+
[tempo, {amount: "0.01"}],
|
|
71
|
+
[evm, {amount: "0.01"}],
|
|
72
|
+
[stripe, {amount: "0.01", currency: "usd"}]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
result = paid.call(
|
|
76
|
+
authorization: env["HTTP_AUTHORIZATION"],
|
|
77
|
+
payment_authorization: env["HTTP_PAYMENT_AUTHORIZATION"],
|
|
78
|
+
payment_signature: env["HTTP_PAYMENT_SIGNATURE"],
|
|
79
|
+
accept_payment: env["HTTP_ACCEPT_PAYMENT"],
|
|
80
|
+
url: request.url,
|
|
81
|
+
http_method: request.request_method
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if result.payment_required?
|
|
85
|
+
resp = result.to_response
|
|
86
|
+
else
|
|
87
|
+
credential, receipt = result.payment
|
|
88
|
+
end
|
|
89
|
+
```
|
|
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
|
+
|
|
131
|
+
`evm.charge` additionally emits `PAYMENT-REQUIRED` and accepts `PAYMENT-SIGNATURE` (x402 v2 exact) when a facilitator is configured:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# Public / testnet facilitator
|
|
135
|
+
x402: {facilitator: "https://x402.org/facilitator"}
|
|
136
|
+
|
|
137
|
+
# Per-request headers (bearer token, CDP JWT, etc.)
|
|
138
|
+
x402: {facilitator: {url: facilitator_url, headers: -> { {"Authorization" => "Bearer #{token}"} }}}
|
|
139
|
+
|
|
140
|
+
# The proc may take the request path (`/verify`, `/settle`) when headers differ per call
|
|
141
|
+
x402: {facilitator: {url: cdp_url, headers: ->(path) { cdp_headers(path) }}}
|
|
142
|
+
|
|
143
|
+
# Any client with #verify / #settle
|
|
144
|
+
x402: {facilitator: cdp_client}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Tempo charge can sponsor gas through a hosted fee payer, or skip local RPC by sending credentials to a Tempo API-compatible relay. Both use the same `{url:, headers:}` shape as the x402 facilitator:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
# Hosted fee payer (JSON-RPC eth_signRawTransaction)
|
|
151
|
+
fee_payer: {url: sponsor_url, headers: -> { {"Authorization" => "Bearer #{token}"} }}
|
|
152
|
+
|
|
153
|
+
# Local co-sign
|
|
154
|
+
fee_payer: Mpp::Methods::Tempo::Account.from_key(ENV.fetch("FEE_PAYER_KEY"))
|
|
155
|
+
|
|
156
|
+
# Relay (POST /v1/mpp/validate then /v1/mpp/broadcast)
|
|
157
|
+
relay: {url: "https://api.tempo.xyz", headers: -> { {"tempo-api-key" => ENV.fetch("TEMPO_API_KEY")} }}
|
|
158
|
+
```
|
|
159
|
+
|
|
52
160
|
### Client
|
|
53
161
|
|
|
54
162
|
```ruby
|
|
@@ -97,6 +205,18 @@ end
|
|
|
97
205
|
|
|
98
206
|
Client events are `challenge.received`, `credential.created`, `payment.response`, and `payment.failed`. Server events are `challenge.created`, `payment.success`, and `payment.failed`.
|
|
99
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
|
+
|
|
100
220
|
### Rack Middleware
|
|
101
221
|
|
|
102
222
|
```ruby
|
|
@@ -122,6 +242,10 @@ env["mpp.charge"] = { amount: "0.50", description: "Paid endpoint" }
|
|
|
122
242
|
|---------|-------------|
|
|
123
243
|
| [tempo_charge](./examples/tempo_charge/) | Tempo testnet payments via Sinatra |
|
|
124
244
|
| [stripe_charge](./examples/stripe_charge/) | Stripe payments via Shared Payment Tokens |
|
|
245
|
+
| [compose](./examples/compose/) | Tempo + Base USDC + Stripe SPTs on one endpoint |
|
|
246
|
+
| [evm_x402](./examples/evm_x402/) | EVM charge with x402 exact compatibility |
|
|
247
|
+
| [tempo_feepayer](./examples/tempo_feepayer/) | Tempo charge with a hosted fee-payer `{url:, headers:}` |
|
|
248
|
+
| [tempo_relay](./examples/tempo_relay/) | Tempo charge delegated to an MPP relay `{url:, headers:}` |
|
|
125
249
|
|
|
126
250
|
Each example is a standalone Sinatra app with `/free` and `/paid` endpoints. To run one:
|
|
127
251
|
|
|
@@ -143,8 +267,11 @@ npx mppx http://localhost:4567/paid
|
|
|
143
267
|
|--------|---------------|---------------|
|
|
144
268
|
| Tempo | Yes | Yes |
|
|
145
269
|
| Stripe | Yes | Yes |
|
|
270
|
+
| EVM (`evm.charge`, x402 exact) | No | Yes |
|
|
271
|
+
|
|
272
|
+
Tempo charge transaction construction is implemented directly in Ruby. Runtime dependency: `keccak` (Tempo attribution memos). Optional dependencies: `eth` (account signing, EIP-3009 recovery) and `rlp` (fee payer envelope).
|
|
146
273
|
|
|
147
|
-
|
|
274
|
+
`Mpp.create` accepts a single `method:` (unchanged) or `methods:` to register several payment methods. `server.compose` presents every method as multiple `WWW-Authenticate` challenges; `evm.charge` also emits `PAYMENT-REQUIRED` and accepts `PAYMENT-SIGNATURE` when a facilitator is configured. The Ruby HTTP client does not yet sign EVM or x402 credentials.
|
|
148
275
|
|
|
149
276
|
## Protocol
|
|
150
277
|
|
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)
|
data/lib/mpp/errors.rb
CHANGED
|
@@ -30,6 +30,7 @@ module Mpp
|
|
|
30
30
|
end
|
|
31
31
|
subclass.instance_variable_set(:@title, to_title(name)) unless subclass.instance_variable_defined?(:@title)
|
|
32
32
|
subclass.instance_variable_set(:@status, 402) unless subclass.instance_variable_defined?(:@status)
|
|
33
|
+
subclass.instance_variable_set(:@hint, nil) unless subclass.instance_variable_defined?(:@hint)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
class << self
|
|
@@ -44,6 +45,9 @@ module Mpp
|
|
|
44
45
|
sig { returns(T.nilable(String)) }
|
|
45
46
|
attr_reader :title
|
|
46
47
|
|
|
48
|
+
sig { returns(T.nilable(String)) }
|
|
49
|
+
attr_reader :hint
|
|
50
|
+
|
|
47
51
|
private
|
|
48
52
|
|
|
49
53
|
sig { params(name: String).returns(String) }
|
|
@@ -60,6 +64,7 @@ module Mpp
|
|
|
60
64
|
@status = T.let(402, Integer)
|
|
61
65
|
@type = T.let("#{BASE_URI}/payment-error", String)
|
|
62
66
|
@title = T.let("Payment Error", String)
|
|
67
|
+
@hint = T.let(nil, T.nilable(String))
|
|
63
68
|
|
|
64
69
|
sig { returns(T.untyped) }
|
|
65
70
|
def status = self.class.status
|
|
@@ -67,6 +72,8 @@ module Mpp
|
|
|
67
72
|
def type = self.class.type
|
|
68
73
|
sig { returns(T.untyped) }
|
|
69
74
|
def title = self.class.title
|
|
75
|
+
sig { returns(T.untyped) }
|
|
76
|
+
def hint = self.class.hint
|
|
70
77
|
|
|
71
78
|
# Convert to RFC 9457 Problem Details format.
|
|
72
79
|
sig { params(challenge_id: T.untyped).returns(T::Hash[T.untyped, T.untyped]) }
|
|
@@ -77,14 +84,26 @@ module Mpp
|
|
|
77
84
|
"status" => status,
|
|
78
85
|
"detail" => message
|
|
79
86
|
}
|
|
87
|
+
details["hint"] = hint if hint
|
|
80
88
|
details["challengeId"] = challenge_id if challenge_id
|
|
81
89
|
details
|
|
82
90
|
end
|
|
83
91
|
end
|
|
84
92
|
|
|
93
|
+
# Default hint messages for payment errors.
|
|
94
|
+
module Hints
|
|
95
|
+
PAYMENT_REQUIRED = "Use a supported wallet to pay for this resource using one of the supported " \
|
|
96
|
+
"payment methods returned in the WWW-Authenticate header. See https://mpp.dev/tools/wallet.md"
|
|
97
|
+
MALFORMED_CREDENTIAL = "Use a supported wallet to construct valid credentials for one of the supported " \
|
|
98
|
+
"payment methods returned in the WWW-Authenticate header. See https://mpp.dev/tools/wallet.md"
|
|
99
|
+
METHOD_UNSUPPORTED = PAYMENT_REQUIRED
|
|
100
|
+
end
|
|
101
|
+
|
|
85
102
|
class PaymentRequiredError < PaymentError
|
|
86
103
|
extend T::Sig
|
|
87
104
|
|
|
105
|
+
@hint = T.let(Hints::PAYMENT_REQUIRED, T.nilable(String))
|
|
106
|
+
|
|
88
107
|
sig { params(realm: T.untyped, description: T.untyped).void }
|
|
89
108
|
def initialize(realm: nil, description: nil)
|
|
90
109
|
parts = ["Payment is required"]
|
|
@@ -97,6 +116,8 @@ module Mpp
|
|
|
97
116
|
class MalformedCredentialError < PaymentError
|
|
98
117
|
extend T::Sig
|
|
99
118
|
|
|
119
|
+
@hint = T.let(Hints::MALFORMED_CREDENTIAL, T.nilable(String))
|
|
120
|
+
|
|
100
121
|
sig { params(reason: T.untyped).void }
|
|
101
122
|
def initialize(reason: nil)
|
|
102
123
|
msg = reason ? "Credential is malformed: #{reason}." : "Credential is malformed."
|
|
@@ -173,6 +194,7 @@ module Mpp
|
|
|
173
194
|
@status = T.let(400, Integer)
|
|
174
195
|
@type = T.let("#{BASE_URI}/method-unsupported", String)
|
|
175
196
|
@title = T.let("Method Unsupported", String)
|
|
197
|
+
@hint = T.let(Hints::METHOD_UNSUPPORTED, T.nilable(String))
|
|
176
198
|
|
|
177
199
|
sig { params(method: T.untyped).void }
|
|
178
200
|
def initialize(method: nil)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Http
|
|
6
|
+
# Shared `{url:, headers:}` helpers used by the x402 facilitator and Tempo
|
|
7
|
+
# fee-payer and relay HTTP clients.
|
|
8
|
+
#
|
|
9
|
+
# `headers` may be:
|
|
10
|
+
# * a Hash of static header names/values
|
|
11
|
+
# * a zero-arity proc returning that Hash
|
|
12
|
+
# * a proc receiving the request path (or JSON-RPC method) and returning
|
|
13
|
+
# that Hash
|
|
14
|
+
# * a nested Hash keyed by operation (`"verify"`, `"broadcast"`, …)
|
|
15
|
+
module Headers
|
|
16
|
+
extend T::Sig
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
sig { params(url: T.untyped).returns(String) }
|
|
21
|
+
def normalize_base_url(url)
|
|
22
|
+
base = url.to_s
|
|
23
|
+
base = base.chomp("/") while base.end_with?("/")
|
|
24
|
+
base
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { params(config: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
|
28
|
+
def symbolize(config)
|
|
29
|
+
config.each_with_object({}) do |(key, value), acc|
|
|
30
|
+
acc[key.to_sym] = value
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { params(headers: T.nilable(T::Hash[T.untyped, T.untyped])).returns(T::Hash[String, String]) }
|
|
35
|
+
def stringify(headers)
|
|
36
|
+
return {} if headers.nil?
|
|
37
|
+
|
|
38
|
+
headers.each_with_object({}) do |(key, value), acc|
|
|
39
|
+
acc[key.to_s] = value.to_s
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sig { params(source: T.untyped, path: String).returns(T::Hash[String, String]) }
|
|
44
|
+
def resolve(source, path)
|
|
45
|
+
return {} if source.nil?
|
|
46
|
+
|
|
47
|
+
if source.respond_to?(:call)
|
|
48
|
+
arity = source.respond_to?(:arity) ? source.arity : 1
|
|
49
|
+
source = (arity == 0) ? source.call : source.call(path)
|
|
50
|
+
end
|
|
51
|
+
return {} unless source.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
operation = path.to_s.delete_prefix("/")
|
|
54
|
+
last = T.let(operation.split("/").last, T.nilable(String))
|
|
55
|
+
keyed = source[operation] || source[operation.to_sym]
|
|
56
|
+
keyed ||= source[last] || source[last.to_sym] if last && last != operation
|
|
57
|
+
stringify(keyed.is_a?(Hash) ? keyed : source)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/mpp/http.rb
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Methods
|
|
6
|
+
module Evm
|
|
7
|
+
# Known EIP-3009 assets plus helpers to resolve a currency config.
|
|
8
|
+
module Assets
|
|
9
|
+
Asset = Struct.new(:address, :decimals, :chain_id, :name, :version, keyword_init: true) do
|
|
10
|
+
def network
|
|
11
|
+
"eip155:#{chain_id}"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.hex_address(*chunks)
|
|
16
|
+
"0x#{chunks.join}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
BASE_USDC = Asset.new(
|
|
20
|
+
address: hex_address("833589fCD6eDb6E08f", "4c7C32D4f71b54bdA02913"),
|
|
21
|
+
decimals: 6,
|
|
22
|
+
chain_id: 8453,
|
|
23
|
+
name: "USD Coin",
|
|
24
|
+
version: "2"
|
|
25
|
+
)
|
|
26
|
+
BASE_SEPOLIA_USDC = Asset.new(
|
|
27
|
+
address: hex_address("036CbD53842c542663", "4e7929541eC2318f3dCF7e"),
|
|
28
|
+
decimals: 6,
|
|
29
|
+
chain_id: 84532,
|
|
30
|
+
name: "USDC",
|
|
31
|
+
version: "2"
|
|
32
|
+
)
|
|
33
|
+
CELO_USDC = Asset.new(
|
|
34
|
+
address: hex_address("cebA9300f2b948710d", "2653dD7B07f33A8B32118C"),
|
|
35
|
+
decimals: 6,
|
|
36
|
+
chain_id: 42220,
|
|
37
|
+
name: "USDC",
|
|
38
|
+
version: "2"
|
|
39
|
+
)
|
|
40
|
+
CELO_USDT = Asset.new(
|
|
41
|
+
address: hex_address("48065fbBE25f71C928", "2ddf5e1cD6D6A887483D5e"),
|
|
42
|
+
decimals: 6,
|
|
43
|
+
chain_id: 42220,
|
|
44
|
+
name: "Tether USD",
|
|
45
|
+
version: "1"
|
|
46
|
+
)
|
|
47
|
+
CELO_SEPOLIA_USDC = Asset.new(
|
|
48
|
+
address: hex_address("01C5C0122039549AD1", "493B8220cABEdD739BC44E"),
|
|
49
|
+
decimals: 6,
|
|
50
|
+
chain_id: 11142220,
|
|
51
|
+
name: "USDC",
|
|
52
|
+
version: "2"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
private_class_method :hex_address
|
|
56
|
+
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
def resolve(currency, authorization: nil, chain_id: nil, decimals: nil)
|
|
60
|
+
if currency.is_a?(Asset)
|
|
61
|
+
auth = authorization_hash(authorization) || {"name" => currency.name, "version" => currency.version}
|
|
62
|
+
return {
|
|
63
|
+
address: checksum(currency.address),
|
|
64
|
+
chain_id: chain_id || currency.chain_id,
|
|
65
|
+
decimals: decimals || currency.decimals,
|
|
66
|
+
authorization: auth
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
address = currency.to_s
|
|
71
|
+
Kernel.raise ArgumentError, "EVM currency must be a known asset or 0x-prefixed address" unless address.match?(/\A0x[a-fA-F0-9]{40}\z/)
|
|
72
|
+
Kernel.raise ArgumentError, "EVM authorization requires `chain_id`." if chain_id.nil?
|
|
73
|
+
Kernel.raise ArgumentError, "EVM authorization requires `decimals`." if decimals.nil?
|
|
74
|
+
|
|
75
|
+
auth = authorization_hash(authorization)
|
|
76
|
+
Kernel.raise ArgumentError, "EVM authorization requires `authorization` metadata." unless auth
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
address: checksum(address),
|
|
80
|
+
chain_id: Kernel.Integer(chain_id),
|
|
81
|
+
decimals: Kernel.Integer(decimals),
|
|
82
|
+
authorization: auth
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def checksum(address)
|
|
87
|
+
Authorization.checksum_address(address)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def authorization_hash(authorization)
|
|
91
|
+
return nil if authorization.nil?
|
|
92
|
+
|
|
93
|
+
name = authorization[:name] || authorization["name"]
|
|
94
|
+
version = authorization[:version] || authorization["version"]
|
|
95
|
+
Kernel.raise ArgumentError, "authorization requires name and version" if name.to_s.empty? || version.to_s.empty?
|
|
96
|
+
|
|
97
|
+
{"name" => name.to_s, "version" => version.to_s}
|
|
98
|
+
end
|
|
99
|
+
private_class_method :authorization_hash
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|