openreceive-server 0.2.1

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,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openreceive/server/swap/assets"
5
+
6
+ module OpenReceive
7
+ module Server
8
+ module Swap
9
+ # Ruby port of packages/js/node/src/swap/fixedfloat-rates.ts (plus the
10
+ # rates-cache key/TTL constants from rates-cache.ts): the FixedFloat
11
+ # public XML rates export — the bulk feed for all pairs.
12
+ #
13
+ # GET https://ff.io/rates/fixed.xml (and float.xml). No API key, no
14
+ # weight budget. OpenReceive keeps only Lightning-payout pairs that match
15
+ # its small pay-in asset list in disposable process memory and derives
16
+ # indicative quotes / min-max locally. /create remains authoritative.
17
+ #
18
+ # All amount math is exact integer fixed-point (Ruby Integers are
19
+ # arbitrary precision), mirroring the JS BigInt implementation digit for
20
+ # digit — never binary floats.
21
+ module FixedFloatRates
22
+ DECIMAL_PATTERN = /\A[0-9]+(\.[0-9]+)?\z/
23
+ SATS_PER_BTC = 100_000_000
24
+ MAX_SAFE_INTEGER = 9_007_199_254_740_991
25
+
26
+ # How often a warm rates blob is refreshed from the provider bulk feed
27
+ # (mirrors SWAP_RATES_REFRESH_SECONDS / SWAP_RATES_MAX_STALE_SECONDS).
28
+ REFRESH_SECONDS = 15
29
+ MAX_STALE_SECONDS = REFRESH_SECONDS
30
+
31
+ module_function
32
+
33
+ def pair_key(from, to)
34
+ "#{from.to_s.strip.upcase}:#{to.to_s.strip.upcase}"
35
+ end
36
+
37
+ def xml_path(rate_type = "fixed")
38
+ "/rates/#{rate_type}.xml"
39
+ end
40
+
41
+ # Process-local key for a provider's bulk rates snapshot,
42
+ # e.g. "swap_rates:fixedfloat:fixed".
43
+ def rates_meta_key(provider_name, rate_type = "fixed")
44
+ "swap_rates:#{provider_name}:#{rate_type}"
45
+ end
46
+
47
+ # Fetch and parse the XML export. `http` is the injectable transport
48
+ # (see Swap.default_http_request); `now` is a callable returning unix
49
+ # seconds. Raises on transport/HTTP failures with the same messages as
50
+ # the JS fetchFixedFloatRatesIndex.
51
+ def fetch_index(base_url:, now:, http: nil, rate_type: "fixed", request_timeout_ms: 10_000)
52
+ url = "#{base_url.to_s.sub(%r{/+\z}, '')}#{xml_path(rate_type)}"
53
+ transport = http || Swap.method(:default_http_request)
54
+ begin
55
+ response = transport.call(
56
+ method: "GET",
57
+ url: url,
58
+ headers: { "Accept" => "application/xml, text/xml, */*" },
59
+ body: nil,
60
+ timeout_ms: request_timeout_ms
61
+ )
62
+ rescue StandardError => e
63
+ raise "FixedFloat rates #{rate_type}.xml request timed out." if Swap.timeout_error?(e)
64
+
65
+ raise "FixedFloat rates #{rate_type}.xml request failed before a response was received."
66
+ end
67
+ status = response[:status] || response["status"]
68
+ unless (200..299).cover?(status)
69
+ raise "FixedFloat rates #{rate_type}.xml failed with HTTP #{status}."
70
+ end
71
+ xml = (response[:body] || response["body"]).to_s
72
+ {
73
+ # Provider dumps include thousands of non-LN market pairs;
74
+ # OpenReceive only ever pays out over Lightning, so drop everything
75
+ # else before caching.
76
+ "fetched_at" => now.call,
77
+ "pairs" => retain_lightning_payout_pairs(parse_xml(xml))
78
+ }
79
+ end
80
+
81
+ # Keep only pairs whose `to` side is a Lightning BTC payout code.
82
+ def retain_lightning_payout_pairs(pairs)
83
+ pairs.select { |_key, pair| Assets.lightning_network?(pair.fetch("to")) }
84
+ end
85
+
86
+ # Keep only the from→Lightning keys that match resolved OpenReceive
87
+ # pay-in currencies. Typically a handful of pairs out of the dump.
88
+ def retain_pairs_for_keys(index, pair_keys)
89
+ return { "fetched_at" => index.fetch("fetched_at"), "pairs" => {} } if pair_keys.empty?
90
+
91
+ pairs = {}
92
+ pair_keys.each do |key|
93
+ pair = index.fetch("pairs")[key]
94
+ pairs[key] = pair unless pair.nil?
95
+ end
96
+ { "fetched_at" => index.fetch("fetched_at"), "pairs" => pairs }
97
+ end
98
+
99
+ def parse_xml(xml)
100
+ pairs = {}
101
+ match_tags(xml, "item").each do |item_xml|
102
+ from = read_tag_text(item_xml, "from")
103
+ to = read_tag_text(item_xml, "to")
104
+ in_amount = read_tag_text(item_xml, "in")
105
+ out_amount = read_tag_text(item_xml, "out")
106
+ amount = read_tag_text(item_xml, "amount")
107
+ minamount = read_tag_text(item_xml, "minamount")
108
+ maxamount = read_tag_text(item_xml, "maxamount")
109
+ next if [from, to, in_amount, out_amount, amount, minamount, maxamount].any?(&:nil?)
110
+
111
+ tofee = read_tag_text(item_xml, "tofee")
112
+ pair = {
113
+ "from" => from.strip,
114
+ "to" => to.strip,
115
+ "in" => strip_currency_suffix(in_amount),
116
+ "out" => strip_currency_suffix(out_amount),
117
+ "amount" => strip_currency_suffix(amount),
118
+ "minamount" => strip_currency_suffix(minamount),
119
+ "maxamount" => strip_currency_suffix(maxamount)
120
+ }
121
+ pair["tofee"] = tofee.strip unless tofee.nil?
122
+ pairs[pair_key(pair.fetch("from"), pair.fetch("to"))] = pair
123
+ end
124
+ pairs
125
+ end
126
+
127
+ def serialize_index(index)
128
+ JSON.generate("fetched_at" => index.fetch("fetched_at"), "pairs" => index.fetch("pairs"))
129
+ end
130
+
131
+ def deserialize_index(value)
132
+ parsed = JSON.parse(value)
133
+ fetched_at = parsed.is_a?(Hash) ? parsed["fetched_at"] : nil
134
+ raw_pairs = parsed.is_a?(Hash) ? parsed["pairs"] : nil
135
+ unless fetched_at.is_a?(Integer) && raw_pairs.is_a?(Hash)
136
+ raise "Invalid FixedFloat rates cache blob."
137
+ end
138
+ pairs = {}
139
+ raw_pairs.each do |key, raw|
140
+ pair = read_stored_pair(raw)
141
+ pairs[key] = pair unless pair.nil?
142
+ end
143
+ { "fetched_at" => fetched_at, "pairs" => pairs }
144
+ end
145
+
146
+ # Indicative pay-in amount for a Lightning payout of
147
+ # invoice_amount_msats, using the XML reference rate (in/out) and
148
+ # optional BTC tofee.
149
+ #
150
+ # Formula (direction=to): pay_from = (invoice_btc + tofee_btc) × (in / out).
151
+ # Rounds the pay-in amount up at 8 decimal places so the UI never
152
+ # understates what /create is likely to require.
153
+ def quote_pay_amount(pair:, invoice_amount_msats:)
154
+ return nil unless invoice_amount_msats.is_a?(Integer) && invoice_amount_msats.positive?
155
+
156
+ rate_in = parse_positive_decimal(pair["in"])
157
+ rate_out = parse_positive_decimal(pair["out"])
158
+ return nil if rate_in.nil? || rate_out.nil?
159
+
160
+ invoice_sats = (invoice_amount_msats + 999) / 1000
161
+ tofee_sats = parse_tofee_btc_sats(pair["tofee"]) || 0
162
+ total_sats = invoice_sats + tofee_sats
163
+ return nil unless rate_out[0].positive?
164
+
165
+ # pay_from = total_btc * (in/out) = total_sats * in / (out * 1e8).
166
+ # Compute ceil(total_sats * in / out) as an 8-decimal fixed-point
167
+ # integer of the from currency (units of 1e-8), then format.
168
+ pay_at_8dp = ceil_div(
169
+ total_sats * rate_in[0] * rate_out[1],
170
+ rate_in[1] * rate_out[0]
171
+ )
172
+ format_decimal(pay_at_8dp, SATS_PER_BTC, 8)
173
+ end
174
+
175
+ # Maps XML from-side min/max into invoice-side msats using the pair's
176
+ # reference rate. Minimum rounds up, maximum rounds down, so borderline
177
+ # invoices are never reported as inside a range the provider rejects.
178
+ def invoice_limits(pair)
179
+ minimum_pay_amount = pair.fetch("minamount")
180
+ maximum_pay_amount = pair.fetch("maxamount")
181
+ limits = {
182
+ "minimum_pay_amount" => minimum_pay_amount,
183
+ "maximum_pay_amount" => maximum_pay_amount
184
+ }
185
+ minimum = pay_amount_to_invoice_msats(pair, minimum_pay_amount, :ceil)
186
+ maximum = pay_amount_to_invoice_msats(pair, maximum_pay_amount, :floor)
187
+ limits["minimum_invoice_amount_msats"] = minimum unless minimum.nil?
188
+ limits["maximum_invoice_amount_msats"] = maximum unless maximum.nil?
189
+ limits
190
+ end
191
+
192
+ # Compare two positive decimal strings. Returns -1/0/1, or nil when
193
+ # either is not a positive decimal (caller treats that as "cannot
194
+ # compare").
195
+ def compare_decimal_amounts(left, right)
196
+ a = parse_positive_decimal(left)
197
+ b = parse_positive_decimal(right)
198
+ return nil if a.nil? || b.nil?
199
+
200
+ (a[0] * b[1]) <=> (b[0] * a[1])
201
+ end
202
+
203
+ # Inverse of the direction=to quote (ignoring tofee so the reported
204
+ # invoice floor is conservative): invoice_sats = pay_from × out × 1e8 / in.
205
+ def pay_amount_to_invoice_msats(pair, pay_amount, rounding)
206
+ pay = parse_positive_decimal(pay_amount)
207
+ rate_in = parse_positive_decimal(pair["in"])
208
+ rate_out = parse_positive_decimal(pair["out"])
209
+ return nil if pay.nil? || rate_in.nil? || rate_out.nil?
210
+ return nil unless rate_in[0].positive?
211
+
212
+ numerator = pay[0] * rate_out[0] * SATS_PER_BTC * rate_in[1]
213
+ denominator = pay[1] * rate_out[1] * rate_in[0]
214
+ return nil unless denominator.positive?
215
+
216
+ invoice_sats = rounding == :ceil ? ceil_div(numerator, denominator) : numerator / denominator
217
+ return nil unless invoice_sats.positive?
218
+ return nil if invoice_sats > MAX_SAFE_INTEGER
219
+
220
+ msats = invoice_sats * 1000
221
+ msats > MAX_SAFE_INTEGER ? nil : msats
222
+ end
223
+
224
+ def parse_tofee_btc_sats(tofee)
225
+ return nil if tofee.nil?
226
+
227
+ # Examples: "0.0004967000 BTC", "0.0005 BTCLN". Non-BTC fees are
228
+ # ignored — payout is always Lightning BTC, so only BTC network fees
229
+ # fold into pay-in.
230
+ match = tofee.to_s.strip.match(/\A([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]+)?\z/)
231
+ return nil if match.nil?
232
+
233
+ unit = (match[2] || "BTC").upcase
234
+ return nil unless %w[BTC BTCLN].include?(unit)
235
+
236
+ parsed = parse_positive_decimal(match[1])
237
+ return nil if parsed.nil?
238
+
239
+ # Fees carrying more than 8 decimals are reduced to whole sats with
240
+ # ceil rounding — rejecting them would silently treat a real network
241
+ # fee as zero and understate the indicative pay amount.
242
+ ceil_div(parsed[0] * SATS_PER_BTC, parsed[1])
243
+ end
244
+
245
+ # Returns [integer, scale] for a positive decimal string, else nil.
246
+ def parse_positive_decimal(value)
247
+ return nil unless value.is_a?(String) && DECIMAL_PATTERN.match?(value)
248
+
249
+ whole, fraction = value.split(".")
250
+ fraction ||= ""
251
+ integer = "#{whole}#{fraction}".to_i
252
+ return nil unless integer.positive?
253
+
254
+ [integer, 10**fraction.length]
255
+ end
256
+
257
+ def format_decimal(integer, scale, max_fraction_digits)
258
+ whole = integer / scale
259
+ fraction = integer % scale
260
+ # Truncate/pad to max_fraction_digits, rounding up any discarded remainder.
261
+ target_scale = 10**max_fraction_digits
262
+ if scale > target_scale
263
+ divisor = scale / target_scale
264
+ remainder = fraction % divisor
265
+ fraction /= divisor
266
+ fraction += 1 if remainder.positive?
267
+ if fraction >= target_scale
268
+ return format_decimal(whole * target_scale + fraction, target_scale, max_fraction_digits)
269
+ end
270
+ elsif scale < target_scale
271
+ fraction *= target_scale / scale
272
+ end
273
+ fraction_text = fraction.to_s.rjust(max_fraction_digits, "0").sub(/0+\z/, "")
274
+ fraction_text.empty? ? whole.to_s : "#{whole}.#{fraction_text}"
275
+ end
276
+
277
+ def ceil_div(numerator, denominator)
278
+ (numerator + denominator - 1) / denominator
279
+ end
280
+
281
+ def strip_currency_suffix(value)
282
+ match = value.strip.match(/\A([0-9]+(?:\.[0-9]+)?)/)
283
+ match.nil? ? value.strip : match[1]
284
+ end
285
+
286
+ def match_tags(xml, tag)
287
+ xml.scan(%r{<#{tag}\b[^>]*>(.*?)</#{tag}>}im).map { |captures| captures[0] || "" }
288
+ end
289
+
290
+ def read_tag_text(xml, tag)
291
+ match = xml.match(%r{<#{tag}\b[^>]*>(.*?)</#{tag}>}im)
292
+ return nil if match.nil?
293
+
294
+ text = (match[1] || "").strip
295
+ text.empty? ? nil : text
296
+ end
297
+
298
+ def read_stored_pair(value)
299
+ return nil unless value.is_a?(Hash)
300
+
301
+ required = %w[from to in out amount minamount maxamount]
302
+ return nil unless required.all? { |key| value[key].is_a?(String) }
303
+
304
+ pair = required.to_h { |key| [key, value.fetch(key)] }
305
+ pair["tofee"] = value["tofee"] if value["tofee"].is_a?(String)
306
+ pair
307
+ end
308
+ end
309
+ end
310
+ end
311
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module OpenReceive
6
+ module Server
7
+ module Swap
8
+ # Ruby port of packages/js/node/src/swap/limits-cache.ts: a disposable,
9
+ # process-local provider catalog/rate cache. It has no storage adapter.
10
+ #
11
+ # Like the Node in-flight promise map, concurrent resolves of the same
12
+ # key join one fetch: the first caller claims the key under the monitor,
13
+ # runs fetch outside it, and writes back under the monitor; joiners wait
14
+ # on a per-key condition. Different keys never block each other.
15
+ class TransientSwapCache
16
+ MAX_STALE_SECONDS = 48 * 60 * 60
17
+ REFRESH_CLAIM_SECONDS = 60
18
+
19
+ def self.limits_meta_key(provider_name)
20
+ "swap_limits:#{provider_name}"
21
+ end
22
+
23
+ def initialize(clock, warn: nil)
24
+ @clock = clock
25
+ @warn = warn
26
+ @states = {}
27
+ @inflight = {}
28
+ @monitor = Monitor.new
29
+ end
30
+
31
+ def resolve(key, refresh_seconds:, max_stale_seconds:, fetch:, serialize:, deserialize:,
32
+ claim_seconds: REFRESH_CLAIM_SECONDS, serve_stale_on_failure: true)
33
+ now = nil
34
+ state = nil
35
+ claim = nil
36
+ @monitor.synchronize do
37
+ now = @clock.call
38
+ state = @states[key]
39
+ if state && state[:value] && state[:fetched_at] && now - state[:fetched_at] < refresh_seconds
40
+ return deserialize.call(state[:value])
41
+ end
42
+ if state && state[:failed_at] && now - state[:failed_at] < claim_seconds
43
+ return stale_or_raise(key, state, now, max_stale_seconds, serve_stale_on_failure, deserialize)
44
+ end
45
+
46
+ active = @inflight[key]
47
+ if active
48
+ active[:cond].wait_until { active[:settled] }
49
+ raise active[:error] unless active[:error].nil?
50
+
51
+ return active[:value]
52
+ end
53
+ claim = { settled: false, cond: @monitor.new_cond }
54
+ @inflight[key] = claim
55
+ end
56
+
57
+ begin
58
+ result = begin
59
+ value = fetch.call
60
+ @monitor.synchronize { @states[key] = { value: serialize.call(value), fetched_at: now } }
61
+ value
62
+ rescue StandardError => e
63
+ failed = {
64
+ failed_at: now,
65
+ error: e.message
66
+ }
67
+ failed[:value] = state[:value] if state && state[:value]
68
+ failed[:fetched_at] = state[:fetched_at] if state && state[:fetched_at]
69
+ @monitor.synchronize { @states[key] = failed }
70
+ stale_or_raise(key, failed, now, max_stale_seconds, serve_stale_on_failure, deserialize, cause: e)
71
+ end
72
+ settle(key, claim, value: result)
73
+ result
74
+ rescue StandardError => e
75
+ settle(key, claim, error: e)
76
+ raise
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def settle(key, claim, value: nil, error: nil)
83
+ @monitor.synchronize do
84
+ claim[:value] = value
85
+ claim[:error] = error
86
+ claim[:settled] = true
87
+ claim[:cond].broadcast
88
+ @inflight.delete(key) if @inflight[key].equal?(claim)
89
+ end
90
+ end
91
+
92
+ def stale_or_raise(key, state, now, max_stale_seconds, serve_stale_on_failure, deserialize, cause: nil)
93
+ if serve_stale_on_failure && state[:value] && state[:fetched_at] &&
94
+ now - state[:fetched_at] < max_stale_seconds
95
+ @warn&.call("Serving stale swap provider data after refresh failed.",
96
+ "key" => key, "error" => state[:error])
97
+ return deserialize.call(state[:value])
98
+ end
99
+ raise cause unless cause.nil?
100
+
101
+ raise state[:error] || "Swap provider cache refresh failed."
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module OpenReceive
6
+ module Server
7
+ module Swap
8
+ # Raised when a reservation would exceed the process-local weight budget.
9
+ # Marked (weight_budget?) so quote classification can map it to
10
+ # provider_rate_limited, mirroring the JS weightBudget error tag.
11
+ class WeightBudgetError < StandardError
12
+ # The denial diagnostics (provider, path, reason, used/cost/gate,
13
+ # window start, backoff) ride the error itself.
14
+ attr_reader :denial
15
+
16
+ def initialize(message, denial = {})
17
+ super(message)
18
+ @denial = denial
19
+ end
20
+
21
+ def weight_budget?
22
+ true
23
+ end
24
+ end
25
+
26
+ # Ruby port of packages/js/node/src/swap/weight-budget.ts: a disposable
27
+ # per-process request guard; the provider remains the global rate-limit
28
+ # authority.
29
+ class SwapProviderWeightBudget
30
+ WINDOW_SECONDS = 60
31
+ SOFT_CAP = 200
32
+ CREATE_GATE = 150
33
+ CREATE_WEIGHT = 50
34
+ DEFAULT_WEIGHT = 1
35
+ BACKOFF_SECONDS = 60
36
+
37
+ def initialize(provider_id, clock)
38
+ @provider_id = provider_id
39
+ @clock = clock
40
+ @window_start = clock.call
41
+ @used = 0
42
+ @backoff_until = nil
43
+ @monitor = Monitor.new
44
+ end
45
+
46
+ def weight_for_path(path)
47
+ path == "create" ? CREATE_WEIGHT : DEFAULT_WEIGHT
48
+ end
49
+
50
+ def reserve(path)
51
+ @monitor.synchronize do
52
+ roll_window
53
+ now = @clock.call
54
+ cost = weight_for_path(path)
55
+ limit = gate(path)
56
+ if !@backoff_until.nil? && @backoff_until > now
57
+ deny(path, "backoff", cost, limit,
58
+ "Swap provider API is in backoff until #{@backoff_until}.")
59
+ end
60
+ if @used + cost > limit
61
+ deny(path, "exhausted", cost, limit,
62
+ "Swap provider API weight budget exhausted (#{@used}+#{cost} > #{limit}).")
63
+ end
64
+ @used += cost
65
+ end
66
+ nil
67
+ end
68
+
69
+ def mark_rate_limited
70
+ @monitor.synchronize do
71
+ now = @clock.call
72
+ @used = [@used, SOFT_CAP].max
73
+ @backoff_until = now + BACKOFF_SECONDS
74
+ end
75
+ nil
76
+ end
77
+
78
+ private
79
+
80
+ # The weight window rolls; the 429 backoff does NOT ride along with it.
81
+ # Clearing it here cut a backoff arbitrarily short — mark_rate_limited
82
+ # at second 59 of the window was forgiven one second later — so the
83
+ # backoff expires on its own clock, checked in reserve. Mirrors JS.
84
+ def roll_window
85
+ now = @clock.call
86
+ return if now - @window_start < WINDOW_SECONDS
87
+
88
+ @window_start = now
89
+ @used = 0
90
+ end
91
+
92
+ def gate(path)
93
+ path == "create" ? CREATE_GATE : SOFT_CAP
94
+ end
95
+
96
+ # The denial carries its own diagnostics on the raised error: there is
97
+ # no observer hook, because there was never a caller for one. Mirrors JS.
98
+ def deny(path, reason, cost, limit, message)
99
+ denial = {
100
+ "provider" => @provider_id,
101
+ "path" => path,
102
+ "reason" => reason,
103
+ "message" => message,
104
+ "used" => @used,
105
+ "cost" => cost,
106
+ "gate" => limit,
107
+ "window_start" => @window_start,
108
+ **(@backoff_until.nil? ? {} : { "backoff_until" => @backoff_until })
109
+ }
110
+ raise WeightBudgetError.new(message, denial)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "timeout"
5
+ require "uri"
6
+
7
+ require "openreceive/server/lsc_uri"
8
+ require "openreceive/server/swap/assets"
9
+ require "openreceive/server/swap/rates_feed"
10
+ require "openreceive/server/swap/transient_cache"
11
+ require "openreceive/server/swap/weight_budget"
12
+ require "openreceive/server/swap/fixedfloat"
13
+
14
+ module OpenReceive
15
+ module Server
16
+ # Automated swaps: the FixedFloat(-compatible) provider, the asset
17
+ # catalog, rates/limits caching, and the LSC connection factories.
18
+ # Ruby port of packages/js/node/src/swap/ plus the LSC provider factory
19
+ # from packages/js/node/src/lsc-uri.ts.
20
+ module Swap
21
+ module_function
22
+
23
+ def fixedfloat_provider(**options)
24
+ FixedFloatProvider.new(**options)
25
+ end
26
+
27
+ # Build one provider per parsed LSC connection (the hashes produced by
28
+ # OpenReceive::Server::LscUri.parse / read_environment).
29
+ def providers_from_connections(connections, http: nil, now: nil)
30
+ Array(connections).map do |connection|
31
+ FixedFloatProvider.new(
32
+ id: connection.fetch("provider_id"),
33
+ base_url: connection.fetch("base_url"),
34
+ key: connection.fetch("key"),
35
+ secret: connection.fetch("secret"),
36
+ http: http,
37
+ now: now
38
+ )
39
+ end
40
+ end
41
+
42
+ # Mirror of createLscSwapProvidersFromEnvironment: LSC_URI_PRIMARY first,
43
+ # LSC_URI_BACKUP second — the order the service fails over in.
44
+ def providers_from_environment(env = ENV, http: nil, now: nil)
45
+ providers_from_connections(LscUri.read_environment(env), http: http, now: now)
46
+ end
47
+
48
+ # Default HTTP transport on stdlib Net::HTTP. Injectable replacements
49
+ # must be callable as call(method:, url:, headers:, body:, timeout_ms:)
50
+ # and return a Hash with :status (Integer) and :body (String).
51
+ def default_http_request(method:, url:, headers:, body: nil, timeout_ms: nil)
52
+ uri = URI.parse(url)
53
+ http = Net::HTTP.new(uri.host, uri.port)
54
+ http.use_ssl = uri.scheme == "https"
55
+ unless timeout_ms.nil?
56
+ seconds = timeout_ms / 1000.0
57
+ http.open_timeout = seconds
58
+ http.read_timeout = seconds
59
+ http.write_timeout = seconds if http.respond_to?(:write_timeout=)
60
+ end
61
+ request =
62
+ if method.to_s.upcase == "POST"
63
+ Net::HTTP::Post.new(uri.request_uri)
64
+ else
65
+ Net::HTTP::Get.new(uri.request_uri)
66
+ end
67
+ headers.each { |key, value| request[key] = value }
68
+ request.body = body unless body.nil?
69
+ response = http.start { |connection| connection.request(request) }
70
+ { status: Integer(response.code), body: response.body.to_s }
71
+ end
72
+
73
+ # Timeout classification shared by the API client and the rates feed
74
+ # (the Ruby analogue of the JS AbortError check).
75
+ def timeout_error?(error)
76
+ return true if defined?(Net::OpenTimeout) && error.is_a?(Net::OpenTimeout)
77
+ return true if defined?(Net::ReadTimeout) && error.is_a?(Net::ReadTimeout)
78
+ return true if error.is_a?(Timeout::Error)
79
+
80
+ error.is_a?(StandardError) && error.message.to_s.downcase.include?("abort")
81
+ end
82
+
83
+ def weight_budget_error?(error)
84
+ error.respond_to?(:weight_budget?) && error.weight_budget? == true
85
+ end
86
+
87
+ # Payer-facing copy per availability reason (mirrors
88
+ # fixedFloatAvailabilityMessage).
89
+ def availability_message(reason)
90
+ return "This invoice is below the provider minimum." if reason == "amount_too_small"
91
+ return "This invoice is above the provider maximum." if reason == "amount_too_large"
92
+ return "The swap provider is rate limited." if reason == "provider_rate_limited"
93
+ return "The swap provider is temporarily unreachable." if reason == "provider_unreachable"
94
+
95
+ "This payment route is temporarily unavailable."
96
+ end
97
+
98
+ # Map a quote-path failure to a SwapAvailabilityReason. The Ruby quote
99
+ # math reads its limits with fetch, so unlike the JS twin (whose
100
+ # pair math returns an unavailable quote instead of raising) this path
101
+ # is reachable and keeps its classifier.
102
+ def classify_fixedfloat_quote_error(error)
103
+ return "provider_rate_limited" if weight_budget_error?(error)
104
+
105
+ if error.is_a?(FixedFloatApiError)
106
+ return "provider_rate_limited" if error.kind == "rate_limited" || error.http_status == 429
107
+ if %w[timeout network invalid_json].include?(error.kind) ||
108
+ (!error.http_status.nil? && error.http_status >= 500)
109
+ return "provider_unreachable"
110
+ end
111
+ message = (error.fixedfloat_message || error.message).downcase
112
+ return "amount_too_small" if amount_too_small_message?(message)
113
+ return "amount_too_large" if amount_too_large_message?(message)
114
+
115
+ return "pair_temporarily_unavailable"
116
+ end
117
+
118
+ message = (error.is_a?(StandardError) ? error.message : error.to_s).downcase
119
+ if message.include?("rate") || message.include?("429") || message.include?("weight budget")
120
+ return "provider_rate_limited"
121
+ end
122
+ if message.include?("fetch") || message.include?("network") || message.include?("timeout")
123
+ return "provider_unreachable"
124
+ end
125
+ return "amount_too_small" if amount_too_small_message?(message)
126
+ return "amount_too_large" if amount_too_large_message?(message)
127
+
128
+ "pair_temporarily_unavailable"
129
+ end
130
+
131
+ def amount_too_small_message?(message)
132
+ message.include?("min") || message.include?("small") ||
133
+ message.include?("out of limits") || message.include?("limit_min")
134
+ end
135
+
136
+ def amount_too_large_message?(message)
137
+ message.include?("max") || message.include?("large") || message.include?("limit_max")
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenReceive
4
+ module Server
5
+ VERSION = "0.2.1"
6
+ end
7
+ end