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,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