mpp-rb 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +128 -1
  3. data/lib/mpp/challenge.rb +31 -8
  4. data/lib/mpp/challenge_echo.rb +14 -3
  5. data/lib/mpp/challenge_id.rb +30 -6
  6. data/lib/mpp/client/transport.rb +1 -1
  7. data/lib/mpp/errors.rb +22 -0
  8. data/lib/mpp/http/headers.rb +61 -0
  9. data/lib/mpp/http.rb +8 -0
  10. data/lib/mpp/methods/evm/assets.rb +103 -0
  11. data/lib/mpp/methods/evm/authorization.rb +140 -0
  12. data/lib/mpp/methods/evm/charge_intent.rb +129 -0
  13. data/lib/mpp/methods/evm/evm_method.rb +133 -0
  14. data/lib/mpp/methods/evm.rb +67 -0
  15. data/lib/mpp/methods/stripe/charge_intent.rb +10 -8
  16. data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +55 -0
  17. data/lib/mpp/methods/stripe/defaults.rb +1 -0
  18. data/lib/mpp/methods/stripe/machine_payments.rb +130 -0
  19. data/lib/mpp/methods/stripe/stripe_method.rb +22 -3
  20. data/lib/mpp/methods/stripe.rb +23 -0
  21. data/lib/mpp/methods/tempo/attribution.rb +3 -9
  22. data/lib/mpp/methods/tempo/client_method.rb +35 -6
  23. data/lib/mpp/methods/tempo/fee_payer_client.rb +120 -0
  24. data/lib/mpp/methods/tempo/intents.rb +80 -48
  25. data/lib/mpp/methods/tempo/proof.rb +39 -12
  26. data/lib/mpp/methods/tempo/relay.rb +257 -0
  27. data/lib/mpp/methods/tempo.rb +2 -0
  28. data/lib/mpp/parsing.rb +22 -2
  29. data/lib/mpp/receipt.rb +4 -3
  30. data/lib/mpp/server/accept_payment.rb +194 -0
  31. data/lib/mpp/server/compose.rb +399 -0
  32. data/lib/mpp/server/decorator.rb +30 -7
  33. data/lib/mpp/server/method.rb +15 -0
  34. data/lib/mpp/server/middleware.rb +160 -18
  35. data/lib/mpp/server/mpp_handler.rb +322 -34
  36. data/lib/mpp/server/result.rb +100 -0
  37. data/lib/mpp/server/verify.rb +24 -14
  38. data/lib/mpp/server.rb +4 -0
  39. data/lib/mpp/version.rb +1 -1
  40. data/lib/mpp/x402/facilitator.rb +127 -0
  41. data/lib/mpp/x402/header.rb +131 -0
  42. data/lib/mpp/x402/server.rb +247 -0
  43. data/lib/mpp/x402/types.rb +33 -0
  44. data/lib/mpp/x402.rb +12 -0
  45. data/lib/mpp.rb +16 -3
  46. metadata +48 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c71efa5e58021d08e4ed10c0156ed9b140923b0f3ebeb5ce6b42839594c27dd6
4
- data.tar.gz: d40e3c4994086eea6f2c11023f47fda635637ae6815dc263d63c12ca10235ae8
3
+ metadata.gz: d028d7ba9d07e95173c4d3618a688dd87d4674284195a58ecfa4d6d43a311bf7
4
+ data.tar.gz: 3a342589c1478477c42077297f11244550ce035e4d66f9e4afde2e9a125d091c
5
5
  SHA512:
6
- metadata.gz: af6ba306f236fb01dd140f63ce51c764fdee655a7f1282d7adad87d8586e4838d43f096ca79a4053dbab703afdc6fa1806d3e20ea6b6afa75c7c70bf6d3885f3
7
- data.tar.gz: db9bc204d61dcdb4c3e9d0b8f9034f935dd38d4291b71b37e61a29889240b27667400e5c48333d2cf39e2d6513b5af70368a354a8a20fd5a543558b07b68f700
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
- Tempo charge transaction construction is implemented directly in Ruby. Optional dependencies: `eth` (account signing) and `rlp` (fee payer envelope).
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
@@ -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)
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,8 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Http
6
+ autoload :Headers, "mpp/http/headers"
7
+ end
8
+ end
@@ -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