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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +46 -0
- data/lib/openreceive/server/client_ip.rb +73 -0
- data/lib/openreceive/server/config.rb +46 -0
- data/lib/openreceive/server/errors.rb +221 -0
- data/lib/openreceive/server/lsc_uri.rb +104 -0
- data/lib/openreceive/server/rack_app.rb +92 -0
- data/lib/openreceive/server/reconciliation.rb +55 -0
- data/lib/openreceive/server/request_handler.rb +598 -0
- data/lib/openreceive/server/service.rb +775 -0
- data/lib/openreceive/server/swap/assets.rb +88 -0
- data/lib/openreceive/server/swap/fixedfloat.rb +831 -0
- data/lib/openreceive/server/swap/rates_feed.rb +311 -0
- data/lib/openreceive/server/swap/transient_cache.rb +106 -0
- data/lib/openreceive/server/swap/weight_budget.rb +115 -0
- data/lib/openreceive/server/swap.rb +141 -0
- data/lib/openreceive/server/version.rb +7 -0
- data/lib/openreceive/server/wallet_info.rb +69 -0
- data/lib/openreceive/server.rb +39 -0
- metadata +83 -0
|
@@ -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
|