mpp-rb 0.1.4 → 0.1.5

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.
@@ -0,0 +1,257 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "json"
6
+ require "net/http"
7
+ require "time"
8
+ require "uri"
9
+ require_relative "../../http/headers"
10
+ require_relative "attribution"
11
+
12
+ module Mpp
13
+ module Methods
14
+ module Tempo
15
+ # HTTP client for a Tempo API-compatible MPP relay.
16
+ #
17
+ # `resolve` accepts:
18
+ # * a URL string (unauthenticated relay)
19
+ # * a config hash (`url:` plus optional `headers:`, or mppx-style
20
+ # `api_base_url:` / `api_key:`)
21
+ # * any object that responds to `#validate` and `#broadcast`
22
+ class Relay
23
+ DEFAULT_API_BASE_URL = "https://api.tempo.xyz"
24
+ VALIDATE_PATH = "/v1/mpp/validate"
25
+ BROADCAST_PATH = "/v1/mpp/broadcast"
26
+ DEFAULT_TIMEOUT = Rpc::DEFAULT_TIMEOUT
27
+
28
+ ERROR_CODES = %w[
29
+ already_used
30
+ broadcast_failed
31
+ expired
32
+ invalid_payment
33
+ insufficient_funds
34
+ policy_denied
35
+ screen_rejected
36
+ simulation_failed
37
+ temporarily_unavailable
38
+ unsupported
39
+ unknown
40
+ ].freeze
41
+
42
+ attr_reader :base_url
43
+
44
+ def initialize(url, headers: nil)
45
+ @base_url = Mpp::Http::Headers.normalize_base_url(url)
46
+ raise ArgumentError, "relay url is required" if @base_url.empty?
47
+
48
+ @headers = headers
49
+ end
50
+
51
+ def self.resolve_optional(relay)
52
+ return if relay.nil? || relay == false
53
+
54
+ resolve(relay)
55
+ end
56
+
57
+ def self.resolve(relay)
58
+ return relay if relay.is_a?(Relay)
59
+ return new(relay) if relay.is_a?(String) && !relay.empty?
60
+ return from_config(relay) if relay.is_a?(Hash)
61
+ return relay if duck_type?(relay)
62
+
63
+ raise ArgumentError, "relay must be a URL, {url:, headers:}, or an object that implements #validate and #broadcast"
64
+ end
65
+
66
+ def self.from_config(config)
67
+ cfg = Mpp::Http::Headers.symbolize(config)
68
+ url = cfg[:url] || cfg[:api_base_url] || cfg[:apiBaseUrl] || cfg[:base_url] || cfg[:baseUrl]
69
+ url = DEFAULT_API_BASE_URL if url.nil? || url.to_s.empty?
70
+ headers = merge_api_key_headers(cfg[:headers], cfg[:api_key] || cfg[:apiKey])
71
+ new(url.to_s, headers: headers)
72
+ end
73
+
74
+ def self.duck_type?(relay)
75
+ !relay.nil? && relay.respond_to?(:validate) && relay.respond_to?(:broadcast)
76
+ end
77
+
78
+ def self.merge_api_key_headers(headers, api_key)
79
+ return headers if api_key.nil? || api_key.to_s.empty?
80
+
81
+ defaults = {"tempo-api-key" => api_key.to_s}
82
+ return defaults if headers.nil?
83
+
84
+ if headers.respond_to?(:call)
85
+ original = headers
86
+ ->(path) {
87
+ extra = (original.arity == 0) ? original.call : original.call(path)
88
+ extra = {} unless extra.is_a?(Hash)
89
+ defaults.merge(Mpp::Http::Headers.stringify(extra))
90
+ }
91
+ elsif headers.is_a?(Hash)
92
+ defaults.merge(Mpp::Http::Headers.stringify(headers))
93
+ else
94
+ defaults
95
+ end
96
+ end
97
+ private_class_method :merge_api_key_headers
98
+
99
+ def verify(credential, _request = nil)
100
+ input = to_relay_input(credential)
101
+ validate(input)
102
+ broadcast(input)
103
+ end
104
+
105
+ def validate(input)
106
+ response = post(VALIDATE_PATH, input)
107
+ raise failure(response) unless success?(response)
108
+
109
+ true
110
+ end
111
+
112
+ def broadcast(input)
113
+ response = post(BROADCAST_PATH, input, extra_headers: {
114
+ "Idempotency-Key" => idempotency_key(input)
115
+ })
116
+ raise failure(response) unless broadcast_success?(response)
117
+
118
+ to_receipt(response.fetch("receipt"))
119
+ end
120
+
121
+ private
122
+
123
+ def post(path, input, extra_headers: {})
124
+ uri = URI.parse("#{@base_url}#{path}")
125
+ http = Net::HTTP.new(uri.host, uri.port)
126
+ http.use_ssl = uri.scheme == "https"
127
+ http.read_timeout = DEFAULT_TIMEOUT
128
+
129
+ request = Net::HTTP::Post.new(uri)
130
+ request["Accept"] = "application/json"
131
+ request["Content-Type"] = "application/json"
132
+ Mpp::Http::Headers.resolve(@headers, path).each { |key, value| request[key] = value }
133
+ extra_headers.each { |key, value| request[key] = value }
134
+ request.body = JSON.generate(input)
135
+
136
+ response = http.request(request)
137
+ unless response.is_a?(Net::HTTPSuccess)
138
+ raise Mpp::VerificationFailedError.new(reason: "relay #{path} returned HTTP #{response.code}")
139
+ end
140
+
141
+ body = response.body.to_s
142
+ if body.empty?
143
+ raise Mpp::VerificationFailedError.new(reason: "relay #{path} returned an empty body")
144
+ end
145
+
146
+ parsed = JSON.parse(body)
147
+ unless parsed.is_a?(Hash)
148
+ raise Mpp::VerificationFailedError.new(reason: "relay #{path} returned JSON that is not an object")
149
+ end
150
+
151
+ parsed
152
+ rescue JSON::ParserError
153
+ raise Mpp::VerificationFailedError.new(reason: "relay #{path} returned invalid JSON")
154
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
155
+ raise Mpp::VerificationFailedError.new(reason: "relay request failed: #{e.message}")
156
+ end
157
+
158
+ def to_relay_input(credential)
159
+ echo = credential.challenge
160
+ challenge = {
161
+ "id" => echo.id,
162
+ "realm" => echo.realm,
163
+ "method" => echo.method,
164
+ "intent" => echo.intent,
165
+ "request" => decode_request(echo.request)
166
+ }
167
+ challenge["expires"] = echo.expires if echo.expires
168
+ challenge["digest"] = echo.digest if echo.digest
169
+ challenge["opaque"] = echo.opaque if echo.opaque
170
+
171
+ input = {
172
+ "challenge" => challenge,
173
+ "payload" => credential.payload
174
+ }
175
+ input["source"] = credential.source if credential.source
176
+ input
177
+ end
178
+
179
+ def decode_request(request)
180
+ return request if request.is_a?(Hash)
181
+ return request unless request.is_a?(String) && !request.empty?
182
+
183
+ Mpp::Parsing.b64_decode(request)
184
+ rescue Mpp::ParseError
185
+ request
186
+ end
187
+
188
+ def idempotency_key(input)
189
+ payload = input["payload"]
190
+ if payload.is_a?(Hash) && payload["type"] == "transaction" && payload["signature"].is_a?(String)
191
+ hex = payload["signature"].delete_prefix("0x")
192
+ if hex.match?(/\A[0-9a-fA-F]+\z/) && hex.length.even?
193
+ hash = Attribution.keccak256([hex].pack("H*"))
194
+ return "mpp_0x#{hash.unpack1("H*")}"
195
+ end
196
+ end
197
+
198
+ digest = Digest::SHA256.hexdigest(Mpp::Json.compact_encode(input))
199
+ "mpp_0x#{digest}"
200
+ end
201
+
202
+ def success?(response)
203
+ response.is_a?(Hash) && response["success"] == true
204
+ end
205
+
206
+ def broadcast_success?(response)
207
+ success?(response) && relay_receipt?(response["receipt"])
208
+ end
209
+
210
+ def relay_receipt?(value)
211
+ value.is_a?(Hash) &&
212
+ value["method"].is_a?(String) &&
213
+ value["reference"].is_a?(String) &&
214
+ value["timestamp"].is_a?(String) &&
215
+ (value["externalId"].nil? || value["externalId"].is_a?(String))
216
+ end
217
+
218
+ def to_receipt(receipt)
219
+ timestamp = begin
220
+ Time.iso8601(receipt["timestamp"].to_s.gsub("Z", "+00:00"))
221
+ rescue ArgumentError
222
+ raise failure
223
+ end
224
+
225
+ Mpp::Receipt.success(
226
+ receipt["reference"],
227
+ timestamp: timestamp,
228
+ method: receipt["method"],
229
+ external_id: receipt["externalId"]
230
+ )
231
+ end
232
+
233
+ def failure(response = nil)
234
+ code = relay_error_code(response)
235
+ return Mpp::PaymentExpiredError.new if code == "expired"
236
+
237
+ reason = case code
238
+ when "already_used", "broadcast_failed", "insufficient_funds", "invalid_payment",
239
+ "simulation_failed", "unsupported", "temporarily_unavailable"
240
+ code.tr("_", " ")
241
+ end
242
+ Mpp::VerificationFailedError.new(reason: reason)
243
+ end
244
+
245
+ def relay_error_code(value)
246
+ return unless value.is_a?(Hash)
247
+
248
+ error = value["error"]
249
+ return unless error.is_a?(Hash)
250
+
251
+ code = error["code"]
252
+ code if ERROR_CODES.include?(code)
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end
@@ -11,6 +11,8 @@ module Mpp
11
11
  autoload :Rpc, "mpp/methods/tempo/rpc"
12
12
  autoload :Transaction, "mpp/methods/tempo/transaction"
13
13
  autoload :FeePayerPolicy, "mpp/methods/tempo/fee_payer_policy"
14
+ autoload :FeePayerClient, "mpp/methods/tempo/fee_payer_client"
15
+ autoload :Relay, "mpp/methods/tempo/relay"
14
16
  autoload :Schemas, "mpp/methods/tempo/schemas"
15
17
  # Eagerly require client_method so the Tempo.tempo factory method is available
16
18
  require_relative "tempo/client_method"
data/lib/mpp/parsing.rb CHANGED
@@ -235,6 +235,7 @@ module Mpp
235
235
  reference: data["reference"].to_s,
236
236
  method: method,
237
237
  external_id: data["externalId"]&.to_s,
238
+ subscription_id: data["subscriptionId"]&.to_s,
238
239
  extra: extra
239
240
  )
240
241
  end
@@ -256,6 +257,7 @@ module Mpp
256
257
  "timestamp" => timestamp_str
257
258
  }
258
259
  payload["externalId"] = receipt.external_id if receipt.external_id
260
+ payload["subscriptionId"] = receipt.subscription_id if receipt.subscription_id
259
261
  payload["extra"] = receipt.extra if receipt.extra
260
262
 
261
263
  b64_encode(payload)
data/lib/mpp/receipt.rb CHANGED
@@ -2,8 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Mpp
5
- Receipt = Data.define(:status, :timestamp, :reference, :method, :external_id, :extra) do
6
- def initialize(status:, timestamp:, reference:, method: "", external_id: nil, extra: nil)
5
+ Receipt = Data.define(:status, :timestamp, :reference, :method, :external_id, :extra, :subscription_id) do
6
+ def initialize(status:, timestamp:, reference:, method: "", external_id: nil, extra: nil, subscription_id: nil)
7
7
  super
8
8
  end
9
9
 
@@ -18,13 +18,14 @@ module Mpp
18
18
  end
19
19
 
20
20
  # Create a success receipt with current timestamp.
21
- def self.success(reference, timestamp: nil, method: "tempo", external_id: nil, extra: nil)
21
+ def self.success(reference, timestamp: nil, method: "tempo", external_id: nil, extra: nil, subscription_id: nil)
22
22
  new(
23
23
  status: "success",
24
24
  timestamp: timestamp || Time.now.utc,
25
25
  reference: reference,
26
26
  method: method,
27
27
  external_id: external_id,
28
+ subscription_id: subscription_id,
28
29
  extra: extra
29
30
  )
30
31
  end
@@ -0,0 +1,194 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Server
6
+ # Parse, format, and rank the Accept-Payment client-preference header.
7
+ #
8
+ # Syntax mirrors HTTP content negotiation: `method/intent[;q=value]`,
9
+ # comma-separated. Wildcards (`*` / `tempo/*` / `*/charge`) are allowed.
10
+ # Entries with q=0 are excluded. If the header is empty, invalid, or
11
+ # filters out every offer, the original offer list is returned.
12
+ module AcceptPayment
13
+ extend T::Sig
14
+
15
+ TOKEN_RE = /\A(?:\*|[a-z0-9-]+)\z/
16
+ ENTRY_RE = %r{\A(?<method>[^/;\s]+|\*)\s*/\s*(?<intent>[^/;\s]+|\*)(?<params>(?:\s*;\s*.+)?)\z}
17
+ PARAM_RE = /\A(?<name>[A-Za-z0-9_-]+)\s*=\s*(?<value>\S+)\z/
18
+ QVALUE_RE = /\A(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)\z/
19
+
20
+ Entry = T.type_alias { T::Hash[Symbol, T.untyped] }
21
+
22
+ module_function
23
+
24
+ # Parse an Accept-Payment header. Raises ArgumentError on malformed input.
25
+ sig { params(header: String).returns(T::Array[Entry]) }
26
+ def parse(header)
27
+ parts = header.split(",").map(&:strip).reject(&:empty?)
28
+ Kernel.raise ArgumentError, "Accept-Payment header is empty." if parts.empty?
29
+
30
+ parts.each_with_index.map { |part, index| parse_entry(part, index) }
31
+ end
32
+
33
+ # Filter and reorder offers by Accept-Payment. Returns `offers` unchanged
34
+ # when the header is missing, malformed, or matches nothing.
35
+ sig { params(offers: T::Array[T.untyped], header: T.nilable(String)).returns(T::Array[T.untyped]) }
36
+ def apply(offers, header)
37
+ return offers if header.nil? || header.strip.empty?
38
+
39
+ begin
40
+ preferences = parse(header)
41
+ rescue ArgumentError
42
+ return offers
43
+ end
44
+
45
+ ranked = rank(offers, preferences)
46
+ ranked.empty? ? offers : ranked
47
+ end
48
+
49
+ # Order offers by the best matching client preference.
50
+ # More specific matches win before comparing q-values.
51
+ sig { params(offers: T::Array[T.untyped], preferences: T::Array[Entry]).returns(T::Array[T.untyped]) }
52
+ def rank(offers, preferences)
53
+ scored = []
54
+ offers.each_with_index do |offer, index|
55
+ match = best_match(offer, preferences)
56
+ next unless match && T.unsafe(match[:q]) > 0
57
+
58
+ scored << {match: match, offer: offer, index: index}
59
+ end
60
+
61
+ scored.sort_by! { |row| [-T.unsafe(row[:match][:q]), T.unsafe(row[:index])] }
62
+ scored.map { |row| row[:offer] }
63
+ end
64
+
65
+ sig { params(value: T.untyped).returns(String) }
66
+ def key_of(value)
67
+ method, intent = method_intent(value)
68
+ Kernel.raise ArgumentError, "Missing payment method name." if method.empty?
69
+
70
+ "#{method}/#{intent}"
71
+ end
72
+
73
+ sig { params(part: String, index: Integer).returns(Entry) }
74
+ def parse_entry(part, index)
75
+ match = ENTRY_RE.match(part)
76
+ Kernel.raise ArgumentError, "Invalid Accept-Payment entry: #{part}" unless match
77
+
78
+ method = T.must(match[:method])
79
+ intent = T.must(match[:intent])
80
+ assert_token!(method, "method")
81
+ assert_token!(intent, "intent")
82
+
83
+ q = 1.0
84
+ split_parameters(match[:params]).each do |param|
85
+ next if param.empty?
86
+
87
+ parameter_match = PARAM_RE.match(param)
88
+ Kernel.raise ArgumentError, "Invalid Accept-Payment parameter: #{param}" unless parameter_match
89
+
90
+ next unless parameter_match[:name] == "q"
91
+
92
+ q = parse_header_q(T.must(parameter_match[:value]), %(Accept-Payment entry "#{part}"))
93
+ end
94
+
95
+ {method: method, intent: intent, q: q, index: index}
96
+ end
97
+ private_class_method :parse_entry
98
+
99
+ sig { params(offer: T.untyped, preferences: T::Array[Entry]).returns(T.nilable(Entry)) }
100
+ def best_match(offer, preferences)
101
+ method, intent = method_intent(offer)
102
+ best = T.let(nil, T.nilable(Entry))
103
+
104
+ preferences.each do |preference|
105
+ next unless matches?(method, intent, preference)
106
+
107
+ candidate = preference.merge(specificity: specificity(preference))
108
+ if better_match?(candidate, best)
109
+ best = candidate
110
+ end
111
+ end
112
+
113
+ best
114
+ end
115
+ private_class_method :best_match
116
+
117
+ sig { params(candidate: Entry, best: T.nilable(Entry)).returns(T::Boolean) }
118
+ def better_match?(candidate, best)
119
+ return true if best.nil?
120
+ return true if T.unsafe(candidate[:specificity]) > T.unsafe(best[:specificity])
121
+ return true if T.unsafe(candidate[:specificity]) == T.unsafe(best[:specificity]) &&
122
+ T.unsafe(candidate[:q]) > T.unsafe(best[:q])
123
+ return true if T.unsafe(candidate[:specificity]) == T.unsafe(best[:specificity]) &&
124
+ T.unsafe(candidate[:q]) == T.unsafe(best[:q]) &&
125
+ T.unsafe(candidate[:index]) < T.unsafe(best[:index])
126
+
127
+ false
128
+ end
129
+ private_class_method :better_match?
130
+
131
+ sig { params(method: String, intent: String, preference: Entry).returns(T::Boolean) }
132
+ def matches?(method, intent, preference)
133
+ (preference[:method] == "*" || preference[:method] == method) &&
134
+ (preference[:intent] == "*" || preference[:intent] == intent)
135
+ end
136
+ private_class_method :matches?
137
+
138
+ sig { params(preference: Entry).returns(Integer) }
139
+ def specificity(preference)
140
+ ((preference[:method] == "*") ? 0 : 1) + ((preference[:intent] == "*") ? 0 : 1)
141
+ end
142
+ private_class_method :specificity
143
+
144
+ sig { params(offer: T.untyped).returns([String, String]) }
145
+ def method_intent(offer)
146
+ if offer.is_a?(Hash)
147
+ [(offer[:method] || offer["method"]).to_s, (offer[:intent] || offer["intent"]).to_s]
148
+ else
149
+ [offer.method.to_s, offer.intent.to_s]
150
+ end
151
+ end
152
+ private_class_method :method_intent
153
+
154
+ sig { params(value: T.nilable(String)).returns(T::Array[String]) }
155
+ def split_parameters(value)
156
+ return [] if value.nil? || value.empty?
157
+
158
+ value.split(";").map(&:strip).reject(&:empty?)
159
+ end
160
+ private_class_method :split_parameters
161
+
162
+ sig { params(value: String, context: String).returns(Float) }
163
+ def parse_header_q(value, context)
164
+ unless QVALUE_RE.match?(value)
165
+ Kernel.raise ArgumentError, "Invalid q-value for #{context}. Expected an HTTP qvalue."
166
+ end
167
+
168
+ assert_q(Kernel.Float(value), context)
169
+ end
170
+ private_class_method :parse_header_q
171
+
172
+ sig { params(value: Float, context: String).returns(Float) }
173
+ def assert_q(value, context)
174
+ Kernel.raise ArgumentError, "Invalid q-value for #{context}. Expected a value between 0 and 1." if value.negative? || value > 1
175
+
176
+ rounded = (value * 1000).round
177
+ if (value * 1000 - rounded).abs > 1e-9
178
+ Kernel.raise ArgumentError, "Invalid q-value for #{context}. Expected at most 3 decimal places."
179
+ end
180
+
181
+ rounded / 1000.0
182
+ end
183
+ private_class_method :assert_q
184
+
185
+ sig { params(value: String, label: String).void }
186
+ def assert_token!(value, label)
187
+ return if TOKEN_RE.match?(value)
188
+
189
+ Kernel.raise ArgumentError, "Invalid Accept-Payment #{label}: #{value}"
190
+ end
191
+ private_class_method :assert_token!
192
+ end
193
+ end
194
+ end