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,831 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "openreceive"
|
|
6
|
+
require "openreceive/server/swap/assets"
|
|
7
|
+
require "openreceive/server/swap/rates_feed"
|
|
8
|
+
require "openreceive/server/swap/transient_cache"
|
|
9
|
+
require "openreceive/server/swap/weight_budget"
|
|
10
|
+
|
|
11
|
+
module OpenReceive
|
|
12
|
+
module Server
|
|
13
|
+
module Swap
|
|
14
|
+
# Ruby port of the FixedFloatApiError from
|
|
15
|
+
# packages/js/node/src/swap/fixedfloat.ts. Deliberately does NOT expose
|
|
16
|
+
# #status/#code: the request handler duck-types those for wire mapping,
|
|
17
|
+
# and a provider failure must reach the payer as the redacted 500
|
|
18
|
+
# "Internal server error." exactly like the JS engine.
|
|
19
|
+
class FixedFloatApiError < StandardError
|
|
20
|
+
KINDS = %w[api http invalid_json network rate_limited timeout].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :path, :kind, :http_status, :fixedfloat_code, :fixedfloat_message
|
|
23
|
+
|
|
24
|
+
def initialize(path:, kind:, message:, http_status: nil, fixedfloat_code: nil,
|
|
25
|
+
fixedfloat_message: nil)
|
|
26
|
+
super(message)
|
|
27
|
+
@path = path
|
|
28
|
+
@kind = kind
|
|
29
|
+
@http_status = http_status
|
|
30
|
+
@fixedfloat_code = fixedfloat_code
|
|
31
|
+
@fixedfloat_message = fixedfloat_message
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.from_transport_error(path, error)
|
|
35
|
+
aborted = Swap.timeout_error?(error)
|
|
36
|
+
new(
|
|
37
|
+
path: path,
|
|
38
|
+
kind: aborted ? "timeout" : "network",
|
|
39
|
+
message: aborted ? "FixedFloat #{path} request timed out."
|
|
40
|
+
: "FixedFloat #{path} request failed before a response was received."
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Ruby port of packages/js/node/src/swap/fixedfloat.ts: the production
|
|
46
|
+
# FixedFloat(-compatible) swap provider. HMAC-signed API calls over an
|
|
47
|
+
# injectable HTTP transport, quote/create/status/refund flows, and the
|
|
48
|
+
# same order/state normalization as the JS engine.
|
|
49
|
+
#
|
|
50
|
+
# Orders are plain string-keyed hashes with the JS SwapOrder field names
|
|
51
|
+
# (provider, provider_order_id, provider_token, pay_in_asset,
|
|
52
|
+
# deposit_address, deposit_amount, expires_at, state, ...).
|
|
53
|
+
class FixedFloatProvider
|
|
54
|
+
DEFAULT_BASE_URL = "https://ff.io"
|
|
55
|
+
DEFAULT_CCIES_CACHE_SECONDS = 24 * 60 * 60
|
|
56
|
+
DEFAULT_RATES_CACHE_SECONDS = FixedFloatRates::REFRESH_SECONDS
|
|
57
|
+
DEFAULT_REQUEST_TIMEOUT_MS = 10_000
|
|
58
|
+
DEFAULT_DEPOSIT_WINDOW_SECONDS = 10 * 60
|
|
59
|
+
DEFAULT_SETTLEMENT_SLA_SECONDS = 15 * 60
|
|
60
|
+
# Margin above deposit_window + settlement_sla. Five minutes keeps the
|
|
61
|
+
# shadow invoice alive through a plausible 30-minute provider order.
|
|
62
|
+
DEFAULT_INVOICE_EXPIRY_MARGIN_SECONDS = 5 * 60
|
|
63
|
+
PROVIDER_ID_PATTERN = /\A[a-z0-9][a-z0-9_-]{0,63}\z/
|
|
64
|
+
|
|
65
|
+
attr_reader :name
|
|
66
|
+
|
|
67
|
+
def initialize(key:, secret:, id: "fixedfloat", base_url: nil, lightning_ccy: nil,
|
|
68
|
+
http: nil, now: nil, cache_seconds: nil, rates_cache_seconds: nil,
|
|
69
|
+
request_timeout_ms: nil, invoice_expiry_seconds: nil,
|
|
70
|
+
deposit_window_seconds: nil, settlement_sla_seconds: nil,
|
|
71
|
+
invoice_expiry_margin_seconds: nil)
|
|
72
|
+
@name = self.class.read_provider_id(id)
|
|
73
|
+
raise ArgumentError, "FixedFloat-compatible API key must not be empty." if key.to_s.strip.empty?
|
|
74
|
+
if secret.to_s.strip.empty?
|
|
75
|
+
raise ArgumentError, "FixedFloat-compatible API secret must not be empty."
|
|
76
|
+
end
|
|
77
|
+
@key = key
|
|
78
|
+
@secret = secret
|
|
79
|
+
@base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
80
|
+
normalized_lightning = lightning_ccy.to_s.strip
|
|
81
|
+
@lightning_ccy = normalized_lightning.empty? ? nil : normalized_lightning
|
|
82
|
+
@http = http || Swap.method(:default_http_request)
|
|
83
|
+
@now = now || -> { Time.now.to_i }
|
|
84
|
+
@cache_seconds = cache_seconds || DEFAULT_CCIES_CACHE_SECONDS
|
|
85
|
+
@rates_cache_seconds = rates_cache_seconds || DEFAULT_RATES_CACHE_SECONDS
|
|
86
|
+
unless @rates_cache_seconds.is_a?(Integer) && @rates_cache_seconds.positive?
|
|
87
|
+
raise ArgumentError, "FixedFloat rates_cache_seconds must be a positive safe integer."
|
|
88
|
+
end
|
|
89
|
+
@request_timeout_ms = request_timeout_ms || DEFAULT_REQUEST_TIMEOUT_MS
|
|
90
|
+
unless @request_timeout_ms.is_a?(Integer) && @request_timeout_ms.positive?
|
|
91
|
+
raise ArgumentError, "FixedFloat request_timeout_ms must be a positive safe integer."
|
|
92
|
+
end
|
|
93
|
+
deposit_window = deposit_window_seconds || DEFAULT_DEPOSIT_WINDOW_SECONDS
|
|
94
|
+
settlement_sla = settlement_sla_seconds || DEFAULT_SETTLEMENT_SLA_SECONDS
|
|
95
|
+
expiry_margin = invoice_expiry_margin_seconds || DEFAULT_INVOICE_EXPIRY_MARGIN_SECONDS
|
|
96
|
+
{
|
|
97
|
+
"FixedFloat deposit_window_seconds" => deposit_window,
|
|
98
|
+
"FixedFloat settlement_sla_seconds" => settlement_sla,
|
|
99
|
+
"FixedFloat invoice_expiry_margin_seconds" => expiry_margin
|
|
100
|
+
}.each do |label, value|
|
|
101
|
+
unless value.is_a?(Integer) && value >= 0
|
|
102
|
+
raise ArgumentError, "#{label} must be a non-negative safe integer."
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
minimum_expiry = deposit_window + settlement_sla + expiry_margin
|
|
106
|
+
@invoice_expiry_seconds = invoice_expiry_seconds || minimum_expiry
|
|
107
|
+
unless @invoice_expiry_seconds.is_a?(Integer) && @invoice_expiry_seconds >= minimum_expiry
|
|
108
|
+
raise ArgumentError,
|
|
109
|
+
"FixedFloat provider #{@name.inspect}: invoice_expiry_seconds " \
|
|
110
|
+
"(#{@invoice_expiry_seconds}) must be at least #{minimum_expiry} = " \
|
|
111
|
+
"deposit_window(#{deposit_window}) + settlement_sla(#{settlement_sla}) + " \
|
|
112
|
+
"margin(#{expiry_margin}). Omit invoice_expiry_seconds to auto-derive it, " \
|
|
113
|
+
"or raise it above that floor."
|
|
114
|
+
end
|
|
115
|
+
@cache = nil
|
|
116
|
+
@weight_budget = nil
|
|
117
|
+
@api_request_logger = nil
|
|
118
|
+
@api_response_logger = nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.read_provider_id(id)
|
|
122
|
+
normalized = id.to_s.strip
|
|
123
|
+
unless PROVIDER_ID_PATTERN.match?(normalized)
|
|
124
|
+
raise ArgumentError,
|
|
125
|
+
"FixedFloat-compatible provider id must use lowercase letters, numbers, " \
|
|
126
|
+
"underscores, or hyphens."
|
|
127
|
+
end
|
|
128
|
+
normalized
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Attach a disposable process-local cache for provider catalogs/rates.
|
|
132
|
+
def attach_swap_cache(cache)
|
|
133
|
+
@cache = cache
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Sinks for outbound API requests/responses. The caller is responsible
|
|
138
|
+
# for sanitizing nested secrets (e.g. the order token on status/refund
|
|
139
|
+
# bodies); the API key and HMAC signature live in headers and are
|
|
140
|
+
# deliberately never logged.
|
|
141
|
+
def attach_api_request_logger(logger)
|
|
142
|
+
@api_request_logger = logger
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def attach_api_response_logger(logger)
|
|
147
|
+
@api_response_logger = logger
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def attach_weight_budget(budget)
|
|
152
|
+
@weight_budget = budget
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def supported_pay_in_assets
|
|
157
|
+
resolve_currencies.fetch("pay_in").keys
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def pay_in_asset_catalog
|
|
161
|
+
resolution = resolve_currencies
|
|
162
|
+
# /ccies reports only availability and display metadata per currency
|
|
163
|
+
# — it carries no amount limits. Per-pair min/max come from the
|
|
164
|
+
# public XML rates export, cached in this process so the
|
|
165
|
+
# payment-method screen never hits /price.
|
|
166
|
+
rates = resolve_rates_index(resolution)
|
|
167
|
+
resolution.fetch("pay_in").map do |pay_in_asset, currency|
|
|
168
|
+
pair = rates.fetch("pairs")[
|
|
169
|
+
FixedFloatRates.pair_key(currency.fetch("code"), resolution.fetch("lightning").fetch("code"))
|
|
170
|
+
]
|
|
171
|
+
if pair.nil?
|
|
172
|
+
{
|
|
173
|
+
"pay_asset" => pay_in_asset,
|
|
174
|
+
"available" => false,
|
|
175
|
+
"unavailable_reason" => "pair_temporarily_unavailable",
|
|
176
|
+
"unavailable_message" => Swap.availability_message("pair_temporarily_unavailable")
|
|
177
|
+
}
|
|
178
|
+
else
|
|
179
|
+
{ "pay_asset" => pay_in_asset }.merge(FixedFloatRates.invoice_limits(pair))
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def invoice_expiry_seconds(pay_in_asset: nil)
|
|
185
|
+
@invoice_expiry_seconds
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def quote(pay_in_asset:, invoice_amount_msats:)
|
|
189
|
+
# Indicative quote from the process-local XML rates cache. /create is
|
|
190
|
+
# still the binding rate. Rates refresh failures raise (fail closed)
|
|
191
|
+
# so the service can skip this provider and try the next configured
|
|
192
|
+
# LSC connection.
|
|
193
|
+
resolution = resolve_currencies
|
|
194
|
+
from_ccy = required_currency(resolution, pay_in_asset)
|
|
195
|
+
rates = resolve_rates_index(resolution)
|
|
196
|
+
begin
|
|
197
|
+
pair = rates.fetch("pairs")[
|
|
198
|
+
FixedFloatRates.pair_key(from_ccy, resolution.fetch("lightning").fetch("code"))
|
|
199
|
+
]
|
|
200
|
+
if pair.nil?
|
|
201
|
+
return unavailable_quote(pay_in_asset, "pair_temporarily_unavailable")
|
|
202
|
+
end
|
|
203
|
+
limits = FixedFloatRates.invoice_limits(pair)
|
|
204
|
+
pay_amount = FixedFloatRates.quote_pay_amount(
|
|
205
|
+
pair: pair, invoice_amount_msats: invoice_amount_msats
|
|
206
|
+
)
|
|
207
|
+
if pay_amount.nil?
|
|
208
|
+
return unavailable_quote(pay_in_asset, "pair_temporarily_unavailable", limits)
|
|
209
|
+
end
|
|
210
|
+
# Prefer invoice-side limits when conversion succeeded; also
|
|
211
|
+
# compare the indicative pay amount to XML min/max so padded <out>
|
|
212
|
+
# decimals cannot leave a below-min asset selectable.
|
|
213
|
+
pay_below_min =
|
|
214
|
+
FixedFloatRates.compare_decimal_amounts(pay_amount, limits.fetch("minimum_pay_amount")) == -1
|
|
215
|
+
pay_above_max =
|
|
216
|
+
FixedFloatRates.compare_decimal_amounts(pay_amount, limits.fetch("maximum_pay_amount")) == 1
|
|
217
|
+
minimum_msats = limits["minimum_invoice_amount_msats"]
|
|
218
|
+
maximum_msats = limits["maximum_invoice_amount_msats"]
|
|
219
|
+
amount_too_small = pay_below_min || (!minimum_msats.nil? && invoice_amount_msats < minimum_msats)
|
|
220
|
+
amount_too_large = pay_above_max || (!maximum_msats.nil? && invoice_amount_msats > maximum_msats)
|
|
221
|
+
if amount_too_small || amount_too_large
|
|
222
|
+
reason = amount_too_small ? "amount_too_small" : "amount_too_large"
|
|
223
|
+
return unavailable_quote(pay_in_asset, reason, limits)
|
|
224
|
+
end
|
|
225
|
+
{
|
|
226
|
+
"pay_amount" => pay_amount,
|
|
227
|
+
"pay_asset" => pay_in_asset,
|
|
228
|
+
"available" => true,
|
|
229
|
+
"provider" => @name
|
|
230
|
+
}.merge(limits)
|
|
231
|
+
rescue StandardError => e
|
|
232
|
+
# Pair-math / limit errors stay as unavailable quotes. Rates and
|
|
233
|
+
# network failures already raised above from resolve_rates_index
|
|
234
|
+
# and must not be swallowed here.
|
|
235
|
+
reason = Swap.classify_fixedfloat_quote_error(e)
|
|
236
|
+
unavailable_quote(pay_in_asset, reason)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def create_swap(pay_in_asset:, bolt11:, invoice_amount_msats:)
|
|
241
|
+
resolution = resolve_currencies
|
|
242
|
+
from_ccy = required_currency(resolution, pay_in_asset)
|
|
243
|
+
to_ccy = resolution.fetch("lightning").fetch("code")
|
|
244
|
+
data = post("create",
|
|
245
|
+
"type" => "fixed",
|
|
246
|
+
"fromCcy" => from_ccy,
|
|
247
|
+
"toCcy" => to_ccy,
|
|
248
|
+
"direction" => "to",
|
|
249
|
+
"amount" => self.class.amount_msats_to_btc_string(invoice_amount_msats),
|
|
250
|
+
"toAddress" => bolt11)
|
|
251
|
+
order = normalize_order(data, pay_in_asset: pay_in_asset)
|
|
252
|
+
# FixedFloat order objects do not always carry the USD equivalents
|
|
253
|
+
# (from.usd / to.usd) that explain the swap fee, so backfill them
|
|
254
|
+
# from a best-effort /price lookup for the same trade. A failure
|
|
255
|
+
# just leaves the fee off the deposit panel.
|
|
256
|
+
return order unless order["fee"].nil?
|
|
257
|
+
|
|
258
|
+
fee = fetch_order_fee(from_ccy, to_ccy, invoice_amount_msats)
|
|
259
|
+
fee.nil? ? order : order.merge("fee" => fee)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def get_status(order)
|
|
263
|
+
stored = OpenReceive.stringify(order)
|
|
264
|
+
data = post("order",
|
|
265
|
+
"id" => stored.fetch("provider_order_id"),
|
|
266
|
+
"token" => stored.fetch("provider_token"))
|
|
267
|
+
stored.merge(
|
|
268
|
+
normalize_order(data, pay_in_asset: stored["pay_in_asset"], fallback: stored)
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def request_refund(order, refund_address)
|
|
273
|
+
stored = OpenReceive.stringify(order)
|
|
274
|
+
post("emergency",
|
|
275
|
+
"id" => stored.fetch("provider_order_id"),
|
|
276
|
+
"token" => stored.fetch("provider_token"),
|
|
277
|
+
"choice" => "REFUND",
|
|
278
|
+
"address" => refund_address)
|
|
279
|
+
nil
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private
|
|
283
|
+
|
|
284
|
+
def unavailable_quote(pay_in_asset, reason, limits = {})
|
|
285
|
+
{
|
|
286
|
+
"pay_asset" => pay_in_asset,
|
|
287
|
+
"available" => false,
|
|
288
|
+
"unavailable_reason" => reason,
|
|
289
|
+
"unavailable_message" => Swap.availability_message(reason),
|
|
290
|
+
"provider" => @name
|
|
291
|
+
}.merge(limits)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def fetch_order_fee(from_ccy, to_ccy, invoice_amount_msats)
|
|
295
|
+
data = post("price",
|
|
296
|
+
"type" => "fixed",
|
|
297
|
+
"fromCcy" => from_ccy,
|
|
298
|
+
"toCcy" => to_ccy,
|
|
299
|
+
"direction" => "to",
|
|
300
|
+
"amount" => self.class.amount_msats_to_btc_string(invoice_amount_msats))
|
|
301
|
+
self.class.read_order_fee(self.class.as_record(data))
|
|
302
|
+
rescue StandardError
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def post(path, body)
|
|
307
|
+
@weight_budget&.reserve(path)
|
|
308
|
+
body_string = JSON.generate(body)
|
|
309
|
+
# Surface every outbound request before the call. The host sink is
|
|
310
|
+
# responsible for sanitizing nested secrets; the API key and HMAC
|
|
311
|
+
# signature live in headers and are deliberately never logged.
|
|
312
|
+
log_api_request(path, body)
|
|
313
|
+
begin
|
|
314
|
+
response = @http.call(
|
|
315
|
+
method: "POST",
|
|
316
|
+
url: "#{@base_url}/api/v2/#{path}",
|
|
317
|
+
headers: {
|
|
318
|
+
"Content-Type" => "application/json; charset=UTF-8",
|
|
319
|
+
"X-API-KEY" => @key,
|
|
320
|
+
"X-API-SIGN" => OpenSSL::HMAC.hexdigest("SHA256", @secret, body_string)
|
|
321
|
+
},
|
|
322
|
+
body: body_string,
|
|
323
|
+
timeout_ms: @request_timeout_ms
|
|
324
|
+
)
|
|
325
|
+
rescue StandardError => e
|
|
326
|
+
api_error = FixedFloatApiError.from_transport_error(path, e)
|
|
327
|
+
log_api_response(path: path, status: 0, ok: false, msg: api_error.message)
|
|
328
|
+
raise api_error
|
|
329
|
+
end
|
|
330
|
+
status = Integer(response[:status] || response["status"])
|
|
331
|
+
text = (response[:body] || response["body"]).to_s
|
|
332
|
+
ok = (200..299).cover?(status)
|
|
333
|
+
begin
|
|
334
|
+
parsed = text.strip.empty? ? {} : JSON.parse(text)
|
|
335
|
+
parsed = {} unless parsed.is_a?(Hash)
|
|
336
|
+
rescue JSON::ParserError
|
|
337
|
+
log_api_response(path: path, status: status, ok: false,
|
|
338
|
+
msg: "FixedFloat #{path} returned invalid JSON.")
|
|
339
|
+
raise FixedFloatApiError.new(
|
|
340
|
+
path: path, kind: "invalid_json", http_status: status,
|
|
341
|
+
message: "FixedFloat #{path} returned invalid JSON."
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
# Surface every response (including API-error envelopes) before any
|
|
345
|
+
# raise. The host sink sanitizes nested secrets — notably the order
|
|
346
|
+
# token in a create/order response — so this must not pre-redact.
|
|
347
|
+
log_api_response(path: path, status: status, ok: ok,
|
|
348
|
+
code: parsed["code"], msg: parsed["msg"], data: parsed["data"])
|
|
349
|
+
unless ok
|
|
350
|
+
@weight_budget&.mark_rate_limited if status == 429
|
|
351
|
+
raise FixedFloatApiError.new(
|
|
352
|
+
path: path,
|
|
353
|
+
kind: status == 429 ? "rate_limited" : "http",
|
|
354
|
+
http_status: status,
|
|
355
|
+
fixedfloat_message: self.class.read_string(parsed["msg"]),
|
|
356
|
+
message: self.class.format_api_error_message(path, status, parsed["msg"])
|
|
357
|
+
)
|
|
358
|
+
end
|
|
359
|
+
if parsed["code"] != 0
|
|
360
|
+
raise FixedFloatApiError.new(
|
|
361
|
+
path: path,
|
|
362
|
+
kind: "api",
|
|
363
|
+
fixedfloat_code: parsed["code"],
|
|
364
|
+
fixedfloat_message: self.class.read_string(parsed["msg"]),
|
|
365
|
+
message: parsed["msg"].is_a?(String) ? parsed["msg"] : "FixedFloat #{path} failed."
|
|
366
|
+
)
|
|
367
|
+
end
|
|
368
|
+
parsed["data"]
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def log_api_request(path, body = {})
|
|
372
|
+
@api_request_logger&.call("provider" => @name, "path" => path, "body" => body)
|
|
373
|
+
rescue StandardError
|
|
374
|
+
nil
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def log_api_response(path:, status:, ok:, code: nil, msg: nil, data: nil)
|
|
378
|
+
@api_response_logger&.call(
|
|
379
|
+
"provider" => @name, "path" => path, "status" => status, "ok" => ok,
|
|
380
|
+
"code" => code, "msg" => msg, "data" => data
|
|
381
|
+
)
|
|
382
|
+
rescue StandardError
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def resolve_currencies
|
|
387
|
+
cache = @cache
|
|
388
|
+
if cache.nil?
|
|
389
|
+
# No transient cache attached (e.g. tests / standalone use):
|
|
390
|
+
# fetch fresh each call.
|
|
391
|
+
return fetch_currency_resolution
|
|
392
|
+
end
|
|
393
|
+
cache.resolve(
|
|
394
|
+
TransientSwapCache.limits_meta_key(@name),
|
|
395
|
+
refresh_seconds: @cache_seconds,
|
|
396
|
+
max_stale_seconds: [TransientSwapCache::MAX_STALE_SECONDS, @cache_seconds].max,
|
|
397
|
+
fetch: -> { fetch_currency_resolution },
|
|
398
|
+
serialize: ->(resolution) { self.class.serialize_currency_resolution(resolution) },
|
|
399
|
+
deserialize: ->(value) { self.class.deserialize_currency_resolution(value) }
|
|
400
|
+
)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def resolve_rates_index(resolution)
|
|
404
|
+
cache = @cache
|
|
405
|
+
return fetch_rates_index(resolution) if cache.nil?
|
|
406
|
+
|
|
407
|
+
cache.resolve(
|
|
408
|
+
FixedFloatRates.rates_meta_key(@name, "fixed"),
|
|
409
|
+
refresh_seconds: @rates_cache_seconds,
|
|
410
|
+
max_stale_seconds: [FixedFloatRates::MAX_STALE_SECONDS, @rates_cache_seconds].max,
|
|
411
|
+
# Crypto rates must not linger after a failed refresh — fail
|
|
412
|
+
# closed so the service can skip this provider and try the next
|
|
413
|
+
# configured LSC connection.
|
|
414
|
+
serve_stale_on_failure: false,
|
|
415
|
+
fetch: -> { fetch_rates_index(resolution) },
|
|
416
|
+
serialize: ->(index) { FixedFloatRates.serialize_index(index) },
|
|
417
|
+
deserialize: ->(value) { FixedFloatRates.deserialize_index(value) }
|
|
418
|
+
)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def fetch_rates_index(resolution)
|
|
422
|
+
path = FixedFloatRates.xml_path("fixed").sub(%r{\A/}, "")
|
|
423
|
+
log_api_request(path)
|
|
424
|
+
begin
|
|
425
|
+
fetched = FixedFloatRates.fetch_index(
|
|
426
|
+
base_url: @base_url,
|
|
427
|
+
rate_type: "fixed",
|
|
428
|
+
http: @http,
|
|
429
|
+
now: @now,
|
|
430
|
+
request_timeout_ms: @request_timeout_ms
|
|
431
|
+
)
|
|
432
|
+
index = FixedFloatRates.retain_pairs_for_keys(
|
|
433
|
+
fetched,
|
|
434
|
+
self.class.rate_pair_keys(resolution)
|
|
435
|
+
)
|
|
436
|
+
log_api_response(path: path, status: 200, ok: true,
|
|
437
|
+
data: { "pair_count" => index.fetch("pairs").length })
|
|
438
|
+
index
|
|
439
|
+
rescue StandardError => e
|
|
440
|
+
log_api_response(path: path, status: 0, ok: false, msg: e.message)
|
|
441
|
+
raise
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def fetch_currency_resolution
|
|
446
|
+
now = @now.call
|
|
447
|
+
data = post("ccies", {})
|
|
448
|
+
currencies = self.class.read_currencies(data)
|
|
449
|
+
pay_in = {}
|
|
450
|
+
Assets.list_info.each do |asset|
|
|
451
|
+
found = currencies.find do |currency|
|
|
452
|
+
currency.fetch("coin").upcase == asset.fetch("coin") &&
|
|
453
|
+
Assets.network_matches?(asset.fetch("network"), currency.fetch("network")) &&
|
|
454
|
+
# /ccies recv=false means the provider will not accept deposits
|
|
455
|
+
# for this currency — omit it rather than failing at /create.
|
|
456
|
+
currency["recv"] != false
|
|
457
|
+
end
|
|
458
|
+
pay_in[asset.fetch("pay_in_asset")] = found unless found.nil?
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
lightning =
|
|
462
|
+
if @lightning_ccy.nil?
|
|
463
|
+
currencies.find do |currency|
|
|
464
|
+
currency.fetch("coin").upcase == "BTC" &&
|
|
465
|
+
Assets.lightning_network?(currency.fetch("network")) &&
|
|
466
|
+
# Payout side must be sendable to the merchant's bolt11.
|
|
467
|
+
currency["send"] != false
|
|
468
|
+
end
|
|
469
|
+
else
|
|
470
|
+
currencies.find do |currency|
|
|
471
|
+
currency.fetch("code") == @lightning_ccy && currency["send"] != false
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
if lightning.nil?
|
|
475
|
+
raise "FixedFloat /ccies did not include a BTC Lightning payout currency."
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
{ "fetched_at" => now, "pay_in" => pay_in, "lightning" => lightning }
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def required_currency(resolution, pay_in_asset)
|
|
482
|
+
currency = resolution.fetch("pay_in")[pay_in_asset]
|
|
483
|
+
if currency.nil?
|
|
484
|
+
label = Assets.pay_in_asset?(pay_in_asset) ? Assets.info(pay_in_asset).fetch("pay_in_asset") : pay_in_asset
|
|
485
|
+
raise "FixedFloat does not currently support #{label}."
|
|
486
|
+
end
|
|
487
|
+
currency.fetch("code")
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def normalize_order(data, pay_in_asset:, fallback: nil)
|
|
491
|
+
fallback ||= {}
|
|
492
|
+
record = self.class.as_record(data)
|
|
493
|
+
from = self.class.as_record(record["from"])
|
|
494
|
+
time = self.class.as_record(record["time"])
|
|
495
|
+
refund_tx_id =
|
|
496
|
+
self.class.read_nested_string(record, %w[back tx id]) ||
|
|
497
|
+
self.class.read_nested_string(record, %w[refund tx id]) ||
|
|
498
|
+
fallback["refund_tx_id"]
|
|
499
|
+
raw_status = self.class.read_string(record["status"])
|
|
500
|
+
# A thin poll body with no "status" keeps the state we already
|
|
501
|
+
# persisted VERBATIM. normalize_status speaks FixedFloat statuses,
|
|
502
|
+
# not OpenReceive states — re-normalizing "awaiting_deposit" would
|
|
503
|
+
# map it to attention. Mirrors the JS persistedStatus branch.
|
|
504
|
+
normalized_status =
|
|
505
|
+
if raw_status.nil? && !fallback.empty?
|
|
506
|
+
self.class.persisted_status(fallback)
|
|
507
|
+
else
|
|
508
|
+
self.class.normalize_status(
|
|
509
|
+
raw_status || "NEW",
|
|
510
|
+
self.class.as_record(record["emergency"]),
|
|
511
|
+
refund_tx_id
|
|
512
|
+
)
|
|
513
|
+
end
|
|
514
|
+
order = {
|
|
515
|
+
"provider" => @name,
|
|
516
|
+
"provider_order_id" =>
|
|
517
|
+
self.class.read_string(record["id"]) ||
|
|
518
|
+
fallback["provider_order_id"] ||
|
|
519
|
+
self.class.required_string(record["id"], "id"),
|
|
520
|
+
"provider_token" =>
|
|
521
|
+
self.class.read_string(record["token"]) ||
|
|
522
|
+
fallback["provider_token"] ||
|
|
523
|
+
self.class.required_string(record["token"], "token"),
|
|
524
|
+
"pay_in_asset" => pay_in_asset,
|
|
525
|
+
"deposit_address" =>
|
|
526
|
+
self.class.read_string(from["address"]) ||
|
|
527
|
+
fallback["deposit_address"] ||
|
|
528
|
+
self.class.required_string(from["address"], "from.address"),
|
|
529
|
+
"deposit_amount" =>
|
|
530
|
+
self.class.read_string(from["amount"]) ||
|
|
531
|
+
fallback["deposit_amount"] ||
|
|
532
|
+
self.class.required_string(from["amount"], "from.amount"),
|
|
533
|
+
# No invented deadline: the provider states the expiry, and on a thin
|
|
534
|
+
# poll body the one we already persisted stands. A create body with
|
|
535
|
+
# neither is a provider contract break.
|
|
536
|
+
"expires_at" =>
|
|
537
|
+
self.class.required_expires_at(
|
|
538
|
+
self.class.read_unix_seconds(time["expiration"]) || fallback["expires_at"]
|
|
539
|
+
),
|
|
540
|
+
"state" => normalized_status.fetch("state")
|
|
541
|
+
}
|
|
542
|
+
order.merge!(optional_order_fields(record, normalized_status, refund_tx_id, fallback))
|
|
543
|
+
order["raw"] = data
|
|
544
|
+
order
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Every order field that is OMITTED rather than sent as null when the
|
|
548
|
+
# provider did not report it — on the payer-facing wire body and in the
|
|
549
|
+
# persisted recovery payload alike. Compacted in one place so a new
|
|
550
|
+
# optional field cannot accidentally ship as an explicit null.
|
|
551
|
+
def optional_order_fields(record, normalized_status, refund_tx_id, fallback)
|
|
552
|
+
from = self.class.as_record(record["from"])
|
|
553
|
+
emergency_repeat =
|
|
554
|
+
self.class.read_emergency_repeat(self.class.as_record(record["emergency"]))
|
|
555
|
+
{
|
|
556
|
+
"deposit_memo" => self.class.read_string(from["tag"]) || fallback["deposit_memo"],
|
|
557
|
+
"deposit_tx_id" =>
|
|
558
|
+
self.class.read_nested_string(record, %w[from tx id]) || fallback["deposit_tx_id"],
|
|
559
|
+
"payout_tx_id" =>
|
|
560
|
+
self.class.read_nested_string(record, %w[to tx id]) || fallback["payout_tx_id"],
|
|
561
|
+
"refund_tx_id" => refund_tx_id,
|
|
562
|
+
"attention" => normalized_status["attention"],
|
|
563
|
+
"attention_reason" => normalized_status["attention_reason"],
|
|
564
|
+
"refund_reason" =>
|
|
565
|
+
normalized_status["refund_reason"] ||
|
|
566
|
+
(self.class.refund_path_state?(normalized_status.fetch("state")) ? fallback["refund_reason"] : nil),
|
|
567
|
+
"deposit_received_amount" =>
|
|
568
|
+
self.class.read_decimal_amount(
|
|
569
|
+
self.class.read_nested_string(record, %w[from tx amount]), "from.tx.amount"
|
|
570
|
+
) || fallback["deposit_received_amount"],
|
|
571
|
+
"refund_amount" =>
|
|
572
|
+
self.class.read_decimal_amount(
|
|
573
|
+
self.class.read_nested_string(record, %w[back amount]), "back.amount"
|
|
574
|
+
) || fallback["refund_amount"],
|
|
575
|
+
"emergency_repeat" =>
|
|
576
|
+
emergency_repeat.nil? ? fallback["emergency_repeat"] : emergency_repeat,
|
|
577
|
+
"fee" => self.class.read_order_fee(record) || fallback["fee"]
|
|
578
|
+
}.compact
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
class << self
|
|
582
|
+
def amount_msats_to_btc_string(amount_msats)
|
|
583
|
+
unless amount_msats.is_a?(Integer) && amount_msats.positive?
|
|
584
|
+
raise ArgumentError, "invoice_amount_msats must be a positive safe integer."
|
|
585
|
+
end
|
|
586
|
+
sats = (amount_msats + 999) / 1000
|
|
587
|
+
whole_btc = sats / 100_000_000
|
|
588
|
+
fractional = (sats % 100_000_000).to_s.rjust(8, "0").sub(/0+\z/, "")
|
|
589
|
+
fractional.empty? ? whole_btc.to_s : "#{whole_btc}.#{fractional}"
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def format_api_error_message(path, status, msg)
|
|
593
|
+
fixedfloat_message = read_string(msg)
|
|
594
|
+
if fixedfloat_message.nil?
|
|
595
|
+
"FixedFloat #{path} failed with HTTP #{status}."
|
|
596
|
+
else
|
|
597
|
+
"FixedFloat #{path} failed with HTTP #{status}: #{fixedfloat_message}"
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# FixedFloat reports the USD equivalents of both sides of the
|
|
602
|
+
# exchange; their gap is the swap fee the payer absorbs, so both are
|
|
603
|
+
# surfaced to explain the price.
|
|
604
|
+
def read_order_fee(record)
|
|
605
|
+
pay_in_fiat = read_nested_string(record, %w[from usd])
|
|
606
|
+
payout_fiat = read_nested_string(record, %w[to usd])
|
|
607
|
+
return nil if pay_in_fiat.nil? || payout_fiat.nil?
|
|
608
|
+
|
|
609
|
+
{ "currency" => "USD", "pay_in_fiat" => pay_in_fiat, "payout_fiat" => payout_fiat }
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def normalize_status(status, emergency, refund_tx_id)
|
|
613
|
+
normalized = status.to_s.upcase
|
|
614
|
+
if !refund_tx_id.nil? && %w[DONE FINISHED].include?(normalized)
|
|
615
|
+
return { "state" => "refunded" }
|
|
616
|
+
end
|
|
617
|
+
case normalized
|
|
618
|
+
when "NEW" then return { "state" => "awaiting_deposit" }
|
|
619
|
+
when "PENDING" then return { "state" => "confirming" }
|
|
620
|
+
when "EXCHANGE" then return { "state" => "exchanging" }
|
|
621
|
+
when "WITHDRAW" then return { "state" => "paying_invoice" }
|
|
622
|
+
when "DONE" then return { "state" => "completed" }
|
|
623
|
+
when "EXPIRED" then return { "state" => "expired" }
|
|
624
|
+
end
|
|
625
|
+
if normalized == "EMERGENCY"
|
|
626
|
+
choice = read_string(emergency["choice"])&.upcase
|
|
627
|
+
statuses = read_string_array(emergency["status"]).map(&:upcase)
|
|
628
|
+
refund_reason = refund_reason_from_emergency_statuses(statuses)
|
|
629
|
+
if choice == "REFUND" && !refund_tx_id.nil?
|
|
630
|
+
result = { "state" => "refunded" }
|
|
631
|
+
result["refund_reason"] = refund_reason unless refund_reason.nil?
|
|
632
|
+
return result
|
|
633
|
+
end
|
|
634
|
+
if choice == "REFUND"
|
|
635
|
+
result = { "state" => "refund_pending" }
|
|
636
|
+
result["refund_reason"] = refund_reason unless refund_reason.nil?
|
|
637
|
+
return result
|
|
638
|
+
end
|
|
639
|
+
if choice == "EXCHANGE"
|
|
640
|
+
return {
|
|
641
|
+
"state" => "attention", "attention" => true,
|
|
642
|
+
"attention_reason" => "provider_reported_emergency"
|
|
643
|
+
}
|
|
644
|
+
end
|
|
645
|
+
if (statuses & %w[MORE OVER OVERPAID]).any?
|
|
646
|
+
return {
|
|
647
|
+
"state" => "attention", "attention" => true,
|
|
648
|
+
"attention_reason" => "provider_reported_emergency"
|
|
649
|
+
}
|
|
650
|
+
end
|
|
651
|
+
result = { "state" => "refund_required" }
|
|
652
|
+
result["refund_reason"] = refund_reason unless refund_reason.nil?
|
|
653
|
+
return result
|
|
654
|
+
end
|
|
655
|
+
return { "state" => "failed" } if normalized.include?("FAIL")
|
|
656
|
+
|
|
657
|
+
# An unrecognized status is NOT a provider-reported emergency:
|
|
658
|
+
# label it as unknown so operators land on the right runbook section.
|
|
659
|
+
{
|
|
660
|
+
"state" => "attention", "attention" => true,
|
|
661
|
+
"attention_reason" => "provider_status_unrecognized"
|
|
662
|
+
}
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def refund_reason_from_emergency_statuses(statuses)
|
|
666
|
+
less = statuses.include?("LESS")
|
|
667
|
+
expired = statuses.include?("EXPIRED")
|
|
668
|
+
return "underpaid_and_late" if less && expired
|
|
669
|
+
return "underpaid" if less
|
|
670
|
+
return "late_deposit" if expired
|
|
671
|
+
|
|
672
|
+
nil
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
def refund_path_state?(state)
|
|
676
|
+
%w[refund_required refund_pending refunded].include?(state)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Absent means absent; present-but-unparsable is a provider contract
|
|
680
|
+
# break and raises rather than dropping the amount from the order.
|
|
681
|
+
def read_decimal_amount(value, label)
|
|
682
|
+
return nil if value.nil?
|
|
683
|
+
unless /\A[0-9]+(\.[0-9]+)?\z/.match?(value)
|
|
684
|
+
raise "FixedFloat #{label} is not a decimal amount."
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
value
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def required_expires_at(expires_at)
|
|
691
|
+
raise "FixedFloat order is missing time.expiration." if expires_at.nil?
|
|
692
|
+
|
|
693
|
+
expires_at
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# The persisted order's own state fields, carried through a thin poll body.
|
|
697
|
+
def persisted_status(fallback)
|
|
698
|
+
{
|
|
699
|
+
"state" => fallback["state"],
|
|
700
|
+
"attention" => fallback["attention"],
|
|
701
|
+
"attention_reason" => fallback["attention_reason"],
|
|
702
|
+
"refund_reason" => fallback["refund_reason"]
|
|
703
|
+
}.compact
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def read_currencies(data)
|
|
707
|
+
record = as_record(data)
|
|
708
|
+
items =
|
|
709
|
+
if data.is_a?(Array)
|
|
710
|
+
data
|
|
711
|
+
elsif record["ccies"].is_a?(Array)
|
|
712
|
+
record["ccies"]
|
|
713
|
+
elsif record["currencies"].is_a?(Array)
|
|
714
|
+
record["currencies"]
|
|
715
|
+
else
|
|
716
|
+
[]
|
|
717
|
+
end
|
|
718
|
+
currencies = []
|
|
719
|
+
items.each do |item|
|
|
720
|
+
row = as_record(item)
|
|
721
|
+
code = read_string(row["code"]) || read_string(row["ticker"])
|
|
722
|
+
coin = read_string(row["coin"]) || read_string(row["currency"]) || read_string(row["symbol"])
|
|
723
|
+
network =
|
|
724
|
+
read_string(row["network"]) || read_string(row["chain"]) ||
|
|
725
|
+
read_string(row["networkName"]) || read_string(row["name"])
|
|
726
|
+
next if code.nil? || coin.nil? || network.nil?
|
|
727
|
+
|
|
728
|
+
currency = { "code" => code, "coin" => coin.upcase, "network" => network }
|
|
729
|
+
currency["recv"] = row["recv"] if [true, false].include?(row["recv"])
|
|
730
|
+
currency["send"] = row["send"] if [true, false].include?(row["send"])
|
|
731
|
+
currencies << currency
|
|
732
|
+
end
|
|
733
|
+
currencies
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def read_emergency_repeat(emergency)
|
|
737
|
+
value = emergency["repeat"]
|
|
738
|
+
return value if [true, false].include?(value)
|
|
739
|
+
return false if value == 0 || value == "0" # rubocop:disable Style/NumericPredicate
|
|
740
|
+
return true if value == 1 || value == "1"
|
|
741
|
+
|
|
742
|
+
nil
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def serialize_currency_resolution(resolution)
|
|
746
|
+
JSON.generate(
|
|
747
|
+
"fetched_at" => resolution.fetch("fetched_at"),
|
|
748
|
+
"pay_in" => resolution.fetch("pay_in").to_a,
|
|
749
|
+
"lightning" => resolution.fetch("lightning")
|
|
750
|
+
)
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def deserialize_currency_resolution(value)
|
|
754
|
+
parsed = JSON.parse(value)
|
|
755
|
+
{
|
|
756
|
+
"fetched_at" => parsed.fetch("fetched_at"),
|
|
757
|
+
"pay_in" => parsed.fetch("pay_in").to_h,
|
|
758
|
+
"lightning" => parsed.fetch("lightning")
|
|
759
|
+
}
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def rate_pair_keys(resolution)
|
|
763
|
+
lightning_code = resolution.fetch("lightning").fetch("code")
|
|
764
|
+
resolution.fetch("pay_in").values.map do |currency|
|
|
765
|
+
FixedFloatRates.pair_key(currency.fetch("code"), lightning_code)
|
|
766
|
+
end.uniq
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def as_record(value)
|
|
770
|
+
value.is_a?(Hash) ? value : {}
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def read_nested_string(value, path)
|
|
774
|
+
current = value
|
|
775
|
+
path.each do |key|
|
|
776
|
+
current = as_record(current)[key]
|
|
777
|
+
end
|
|
778
|
+
read_string(current)
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
def read_string(value)
|
|
782
|
+
return value if value.is_a?(String) && !value.empty?
|
|
783
|
+
if value.is_a?(Numeric) && (!value.respond_to?(:finite?) || value.finite?)
|
|
784
|
+
return OpenReceive::Rates.number_to_plain_decimal_string(value)
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
nil
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def read_string_array(value)
|
|
791
|
+
if value.is_a?(Array)
|
|
792
|
+
return value.filter_map { |item| read_string(item) }
|
|
793
|
+
end
|
|
794
|
+
string = read_string(value)
|
|
795
|
+
string.nil? ? [] : [string]
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def required_string(value, field)
|
|
799
|
+
string = read_string(value)
|
|
800
|
+
raise "FixedFloat response missing #{field}." if string.nil?
|
|
801
|
+
|
|
802
|
+
string
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def read_unix_seconds(value)
|
|
806
|
+
numeric =
|
|
807
|
+
if value.is_a?(String)
|
|
808
|
+
begin
|
|
809
|
+
Integer(value, 10)
|
|
810
|
+
rescue ArgumentError
|
|
811
|
+
begin
|
|
812
|
+
rational = Rational(value)
|
|
813
|
+
rational.denominator == 1 ? rational.numerator : nil
|
|
814
|
+
rescue ArgumentError, ZeroDivisionError
|
|
815
|
+
nil
|
|
816
|
+
end
|
|
817
|
+
end
|
|
818
|
+
else
|
|
819
|
+
value
|
|
820
|
+
end
|
|
821
|
+
return nil unless numeric.is_a?(Numeric)
|
|
822
|
+
return nil unless numeric == numeric.to_i && numeric >= 0
|
|
823
|
+
return nil if numeric.to_i > FixedFloatRates::MAX_SAFE_INTEGER
|
|
824
|
+
|
|
825
|
+
numeric.to_i
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
end
|