Class: OpenReceive::Server::Swap::FixedFloatProvider

Inherits:
Object
  • Object
show all
Defined in:
lib/openreceive/server/swap/fixedfloat.rb

Overview

Ruby port of packages/js/node/src/swap/fixedfloat.ts: the production FixedFloat(-compatible) swap provider. HMAC-signed API calls over an injectable HTTP transport, quote/create/status/refund flows, and the same order/state normalization as the JS engine.

Orders are plain string-keyed hashes with the JS SwapOrder field names (provider, provider_order_id, provider_token, pay_in_asset, deposit_address, deposit_amount, expires_at, state, ...).

Constant Summary collapse

DEFAULT_BASE_URL =
"https://ff.io"
DEFAULT_CCIES_CACHE_SECONDS =
24 * 60 * 60
DEFAULT_RATES_CACHE_SECONDS =
FixedFloatRates::REFRESH_SECONDS
DEFAULT_REQUEST_TIMEOUT_MS =
10_000
DEFAULT_DEPOSIT_WINDOW_SECONDS =
10 * 60
DEFAULT_SETTLEMENT_SLA_SECONDS =
15 * 60
DEFAULT_INVOICE_EXPIRY_MARGIN_SECONDS =

Margin above deposit_window + settlement_sla. Five minutes keeps the shadow invoice alive through a plausible 30-minute provider order.

5 * 60
PROVIDER_ID_PATTERN =
/\A[a-z0-9][a-z0-9_-]{0,63}\z/

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(key:, secret:, id: "fixedfloat", base_url: nil, lightning_ccy: nil, http: nil, now: nil, cache_seconds: nil, rates_cache_seconds: nil, request_timeout_ms: nil, invoice_expiry_seconds: nil, deposit_window_seconds: nil, settlement_sla_seconds: nil, invoice_expiry_margin_seconds: nil) ⇒ FixedFloatProvider

Returns a new instance of FixedFloatProvider.

Raises:

  • (ArgumentError)


67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 67

def initialize(key:, secret:, id: "fixedfloat", base_url: nil, lightning_ccy: nil,
               http: nil, now: nil, cache_seconds: nil, rates_cache_seconds: nil,
               request_timeout_ms: nil, invoice_expiry_seconds: nil,
               deposit_window_seconds: nil, settlement_sla_seconds: nil,
               invoice_expiry_margin_seconds: nil)
  @name = self.class.read_provider_id(id)
  raise ArgumentError, "FixedFloat-compatible API key must not be empty." if key.to_s.strip.empty?
  if secret.to_s.strip.empty?
    raise ArgumentError, "FixedFloat-compatible API secret must not be empty."
  end
  @key = key
  @secret = secret
  @base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
  normalized_lightning = lightning_ccy.to_s.strip
  @lightning_ccy = normalized_lightning.empty? ? nil : normalized_lightning
  @http = http || Swap.method(:default_http_request)
  @now = now || -> { Time.now.to_i }
  @cache_seconds = cache_seconds || DEFAULT_CCIES_CACHE_SECONDS
  @rates_cache_seconds = rates_cache_seconds || DEFAULT_RATES_CACHE_SECONDS
  unless @rates_cache_seconds.is_a?(Integer) && @rates_cache_seconds.positive?
    raise ArgumentError, "FixedFloat rates_cache_seconds must be a positive safe integer."
  end
  @request_timeout_ms = request_timeout_ms || DEFAULT_REQUEST_TIMEOUT_MS
  unless @request_timeout_ms.is_a?(Integer) && @request_timeout_ms.positive?
    raise ArgumentError, "FixedFloat request_timeout_ms must be a positive safe integer."
  end
  deposit_window = deposit_window_seconds || DEFAULT_DEPOSIT_WINDOW_SECONDS
  settlement_sla = settlement_sla_seconds || DEFAULT_SETTLEMENT_SLA_SECONDS
  expiry_margin = invoice_expiry_margin_seconds || DEFAULT_INVOICE_EXPIRY_MARGIN_SECONDS
  {
    "FixedFloat deposit_window_seconds" => deposit_window,
    "FixedFloat settlement_sla_seconds" => settlement_sla,
    "FixedFloat invoice_expiry_margin_seconds" => expiry_margin
  }.each do |label, value|
    unless value.is_a?(Integer) && value >= 0
      raise ArgumentError, "#{label} must be a non-negative safe integer."
    end
  end
  minimum_expiry = deposit_window + settlement_sla + expiry_margin
  @invoice_expiry_seconds = invoice_expiry_seconds || minimum_expiry
  unless @invoice_expiry_seconds.is_a?(Integer) && @invoice_expiry_seconds >= minimum_expiry
    raise ArgumentError,
          "FixedFloat provider #{@name.inspect}: invoice_expiry_seconds " \
          "(#{@invoice_expiry_seconds}) must be at least #{minimum_expiry} = " \
          "deposit_window(#{deposit_window}) + settlement_sla(#{settlement_sla}) + " \
          "margin(#{expiry_margin}). Omit invoice_expiry_seconds to auto-derive it, " \
          "or raise it above that floor."
  end
  @cache = nil
  @weight_budget = nil
  @api_request_logger = nil
  @api_response_logger = nil
end

Instance Attribute Details

#nameObject (readonly)

Returns the value of attribute name.



65
66
67
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 65

def name
  @name
end

Class Method Details

.amount_msats_to_btc_string(amount_msats) ⇒ Object



582
583
584
585
586
587
588
589
590
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 582

def amount_msats_to_btc_string(amount_msats)
  unless amount_msats.is_a?(Integer) && amount_msats.positive?
    raise ArgumentError, "invoice_amount_msats must be a positive safe integer."
  end
  sats = (amount_msats + 999) / 1000
  whole_btc = sats / 100_000_000
  fractional = (sats % 100_000_000).to_s.rjust(8, "0").sub(/0+\z/, "")
  fractional.empty? ? whole_btc.to_s : "#{whole_btc}.#{fractional}"
end

.as_record(value) ⇒ Object



769
770
771
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 769

def as_record(value)
  value.is_a?(Hash) ? value : {}
end

.deserialize_currency_resolution(value) ⇒ Object



753
754
755
756
757
758
759
760
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 753

def deserialize_currency_resolution(value)
  parsed = JSON.parse(value)
  {
    "fetched_at" => parsed.fetch("fetched_at"),
    "pay_in" => parsed.fetch("pay_in").to_h,
    "lightning" => parsed.fetch("lightning")
  }
end

.format_api_error_message(path, status, msg) ⇒ Object



592
593
594
595
596
597
598
599
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 592

def format_api_error_message(path, status, msg)
  fixedfloat_message = read_string(msg)
  if fixedfloat_message.nil?
    "FixedFloat #{path} failed with HTTP #{status}."
  else
    "FixedFloat #{path} failed with HTTP #{status}: #{fixedfloat_message}"
  end
end

.normalize_status(status, emergency, refund_tx_id) ⇒ Object



612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 612

def normalize_status(status, emergency, refund_tx_id)
  normalized = status.to_s.upcase
  if !refund_tx_id.nil? && %w[DONE FINISHED].include?(normalized)
    return { "state" => "refunded" }
  end
  case normalized
  when "NEW" then return { "state" => "awaiting_deposit" }
  when "PENDING" then return { "state" => "confirming" }
  when "EXCHANGE" then return { "state" => "exchanging" }
  when "WITHDRAW" then return { "state" => "paying_invoice" }
  when "DONE" then return { "state" => "completed" }
  when "EXPIRED" then return { "state" => "expired" }
  end
  if normalized == "EMERGENCY"
    choice = read_string(emergency["choice"])&.upcase
    statuses = read_string_array(emergency["status"]).map(&:upcase)
    refund_reason = refund_reason_from_emergency_statuses(statuses)
    if choice == "REFUND" && !refund_tx_id.nil?
      result = { "state" => "refunded" }
      result["refund_reason"] = refund_reason unless refund_reason.nil?
      return result
    end
    if choice == "REFUND"
      result = { "state" => "refund_pending" }
      result["refund_reason"] = refund_reason unless refund_reason.nil?
      return result
    end
    if choice == "EXCHANGE"
      return {
        "state" => "attention", "attention" => true,
        "attention_reason" => "provider_reported_emergency"
      }
    end
    if (statuses & %w[MORE OVER OVERPAID]).any?
      return {
        "state" => "attention", "attention" => true,
        "attention_reason" => "provider_reported_emergency"
      }
    end
    result = { "state" => "refund_required" }
    result["refund_reason"] = refund_reason unless refund_reason.nil?
    return result
  end
  return { "state" => "failed" } if normalized.include?("FAIL")

  # An unrecognized status is NOT a provider-reported emergency:
  # label it as unknown so operators land on the right runbook section.
  {
    "state" => "attention", "attention" => true,
    "attention_reason" => "provider_status_unrecognized"
  }
end

.persisted_status(fallback) ⇒ Object

The persisted order's own state fields, carried through a thin poll body.



697
698
699
700
701
702
703
704
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 697

def persisted_status(fallback)
  {
    "state" => fallback["state"],
    "attention" => fallback["attention"],
    "attention_reason" => fallback["attention_reason"],
    "refund_reason" => fallback["refund_reason"]
  }.compact
end

.rate_pair_keys(resolution) ⇒ Object



762
763
764
765
766
767
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 762

def rate_pair_keys(resolution)
  lightning_code = resolution.fetch("lightning").fetch("code")
  resolution.fetch("pay_in").values.map do |currency|
    FixedFloatRates.pair_key(currency.fetch("code"), lightning_code)
  end.uniq
end

.read_currencies(data) ⇒ Object



706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 706

def read_currencies(data)
  record = as_record(data)
  items =
    if data.is_a?(Array)
      data
    elsif record["ccies"].is_a?(Array)
      record["ccies"]
    elsif record["currencies"].is_a?(Array)
      record["currencies"]
    else
      []
    end
  currencies = []
  items.each do |item|
    row = as_record(item)
    code = read_string(row["code"]) || read_string(row["ticker"])
    coin = read_string(row["coin"]) || read_string(row["currency"]) || read_string(row["symbol"])
    network =
      read_string(row["network"]) || read_string(row["chain"]) ||
      read_string(row["networkName"]) || read_string(row["name"])
    next if code.nil? || coin.nil? || network.nil?

    currency = { "code" => code, "coin" => coin.upcase, "network" => network }
    currency["recv"] = row["recv"] if [true, false].include?(row["recv"])
    currency["send"] = row["send"] if [true, false].include?(row["send"])
    currencies << currency
  end
  currencies
end

.read_decimal_amount(value, label) ⇒ Object

Absent means absent; present-but-unparsable is a provider contract break and raises rather than dropping the amount from the order.



681
682
683
684
685
686
687
688
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 681

def read_decimal_amount(value, label)
  return nil if value.nil?
  unless /\A[0-9]+(\.[0-9]+)?\z/.match?(value)
    raise "FixedFloat #{label} is not a decimal amount."
  end

  value
end

.read_emergency_repeat(emergency) ⇒ Object



736
737
738
739
740
741
742
743
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 736

def read_emergency_repeat(emergency)
  value = emergency["repeat"]
  return value if [true, false].include?(value)
  return false if value == 0 || value == "0" # rubocop:disable Style/NumericPredicate
  return true if value == 1 || value == "1"

  nil
end

.read_nested_string(value, path) ⇒ Object



773
774
775
776
777
778
779
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 773

def read_nested_string(value, path)
  current = value
  path.each do |key|
    current = as_record(current)[key]
  end
  read_string(current)
end

.read_order_fee(record) ⇒ Object

FixedFloat reports the USD equivalents of both sides of the exchange; their gap is the swap fee the payer absorbs, so both are surfaced to explain the price.



604
605
606
607
608
609
610
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 604

def read_order_fee(record)
  pay_in_fiat = read_nested_string(record, %w[from usd])
  payout_fiat = read_nested_string(record, %w[to usd])
  return nil if pay_in_fiat.nil? || payout_fiat.nil?

  { "currency" => "USD", "pay_in_fiat" => pay_in_fiat, "payout_fiat" => payout_fiat }
end

.read_provider_id(id) ⇒ Object



121
122
123
124
125
126
127
128
129
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 121

def self.read_provider_id(id)
  normalized = id.to_s.strip
  unless PROVIDER_ID_PATTERN.match?(normalized)
    raise ArgumentError,
          "FixedFloat-compatible provider id must use lowercase letters, numbers, " \
          "underscores, or hyphens."
  end
  normalized
end

.read_string(value) ⇒ Object



781
782
783
784
785
786
787
788
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 781

def read_string(value)
  return value if value.is_a?(String) && !value.empty?
  if value.is_a?(Numeric) && (!value.respond_to?(:finite?) || value.finite?)
    return OpenReceive::Rates.number_to_plain_decimal_string(value)
  end

  nil
end

.read_string_array(value) ⇒ Object



790
791
792
793
794
795
796
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 790

def read_string_array(value)
  if value.is_a?(Array)
    return value.filter_map { |item| read_string(item) }
  end
  string = read_string(value)
  string.nil? ? [] : [string]
end

.read_unix_seconds(value) ⇒ Object



805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 805

def read_unix_seconds(value)
  numeric =
    if value.is_a?(String)
      begin
        Integer(value, 10)
      rescue ArgumentError
        begin
          rational = Rational(value)
          rational.denominator == 1 ? rational.numerator : nil
        rescue ArgumentError, ZeroDivisionError
          nil
        end
      end
    else
      value
    end
  return nil unless numeric.is_a?(Numeric)
  return nil unless numeric == numeric.to_i && numeric >= 0
  return nil if numeric.to_i > FixedFloatRates::MAX_SAFE_INTEGER

  numeric.to_i
end

.refund_path_state?(state) ⇒ Boolean

Returns:

  • (Boolean)


675
676
677
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 675

def refund_path_state?(state)
  %w[refund_required refund_pending refunded].include?(state)
end

.refund_reason_from_emergency_statuses(statuses) ⇒ Object



665
666
667
668
669
670
671
672
673
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 665

def refund_reason_from_emergency_statuses(statuses)
  less = statuses.include?("LESS")
  expired = statuses.include?("EXPIRED")
  return "underpaid_and_late" if less && expired
  return "underpaid" if less
  return "late_deposit" if expired

  nil
end

.required_expires_at(expires_at) ⇒ Object



690
691
692
693
694
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 690

def required_expires_at(expires_at)
  raise "FixedFloat order is missing time.expiration." if expires_at.nil?

  expires_at
end

.required_string(value, field) ⇒ Object



798
799
800
801
802
803
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 798

def required_string(value, field)
  string = read_string(value)
  raise "FixedFloat response missing #{field}." if string.nil?

  string
end

.serialize_currency_resolution(resolution) ⇒ Object



745
746
747
748
749
750
751
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 745

def serialize_currency_resolution(resolution)
  JSON.generate(
    "fetched_at" => resolution.fetch("fetched_at"),
    "pay_in" => resolution.fetch("pay_in").to_a,
    "lightning" => resolution.fetch("lightning")
  )
end

Instance Method Details

#attach_api_request_logger(logger) ⇒ Object

Sinks for outbound API requests/responses. The caller is responsible for sanitizing nested secrets (e.g. the order token on status/refund bodies); the API key and HMAC signature live in headers and are deliberately never logged.



141
142
143
144
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 141

def attach_api_request_logger(logger)
  @api_request_logger = logger
  nil
end

#attach_api_response_logger(logger) ⇒ Object



146
147
148
149
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 146

def attach_api_response_logger(logger)
  @api_response_logger = logger
  nil
end

#attach_swap_cache(cache) ⇒ Object

Attach a disposable process-local cache for provider catalogs/rates.



132
133
134
135
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 132

def attach_swap_cache(cache)
  @cache = cache
  nil
end

#attach_weight_budget(budget) ⇒ Object



151
152
153
154
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 151

def attach_weight_budget(budget)
  @weight_budget = budget
  nil
end

#create_swap(pay_in_asset:, bolt11:, invoice_amount_msats:) ⇒ Object



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 240

def create_swap(pay_in_asset:, bolt11:, invoice_amount_msats:)
  resolution = resolve_currencies
  from_ccy = required_currency(resolution, pay_in_asset)
  to_ccy = resolution.fetch("lightning").fetch("code")
  data = post("create",
              "type" => "fixed",
              "fromCcy" => from_ccy,
              "toCcy" => to_ccy,
              "direction" => "to",
              "amount" => self.class.amount_msats_to_btc_string(invoice_amount_msats),
              "toAddress" => bolt11)
  order = normalize_order(data, pay_in_asset: pay_in_asset)
  # FixedFloat order objects do not always carry the USD equivalents
  # (from.usd / to.usd) that explain the swap fee, so backfill them
  # from a best-effort /price lookup for the same trade. A failure
  # just leaves the fee off the deposit panel.
  return order unless order["fee"].nil?

  fee = fetch_order_fee(from_ccy, to_ccy, invoice_amount_msats)
  fee.nil? ? order : order.merge("fee" => fee)
end

#get_status(order) ⇒ Object



262
263
264
265
266
267
268
269
270
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 262

def get_status(order)
  stored = OpenReceive.stringify(order)
  data = post("order",
              "id" => stored.fetch("provider_order_id"),
              "token" => stored.fetch("provider_token"))
  stored.merge(
    normalize_order(data, pay_in_asset: stored["pay_in_asset"], fallback: stored)
  )
end

#invoice_expiry_seconds(pay_in_asset: nil) ⇒ Object



184
185
186
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 184

def invoice_expiry_seconds(pay_in_asset: nil)
  @invoice_expiry_seconds
end

#pay_in_asset_catalogObject



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 160

def pay_in_asset_catalog
  resolution = resolve_currencies
  # /ccies reports only availability and display metadata per currency
  # — it carries no amount limits. Per-pair min/max come from the
  # public XML rates export, cached in this process so the
  # payment-method screen never hits /price.
  rates = resolve_rates_index(resolution)
  resolution.fetch("pay_in").map do |pay_in_asset, currency|
    pair = rates.fetch("pairs")[
      FixedFloatRates.pair_key(currency.fetch("code"), resolution.fetch("lightning").fetch("code"))
    ]
    if pair.nil?
      {
        "pay_asset" => pay_in_asset,
        "available" => false,
        "unavailable_reason" => "pair_temporarily_unavailable",
        "unavailable_message" => Swap.availability_message("pair_temporarily_unavailable")
      }
    else
      { "pay_asset" => pay_in_asset }.merge(FixedFloatRates.invoice_limits(pair))
    end
  end
end

#quote(pay_in_asset:, invoice_amount_msats:) ⇒ Object



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 188

def quote(pay_in_asset:, invoice_amount_msats:)
  # Indicative quote from the process-local XML rates cache. /create is
  # still the binding rate. Rates refresh failures raise (fail closed)
  # so the service can skip this provider and try the next configured
  # LSC connection.
  resolution = resolve_currencies
  from_ccy = required_currency(resolution, pay_in_asset)
  rates = resolve_rates_index(resolution)
  begin
    pair = rates.fetch("pairs")[
      FixedFloatRates.pair_key(from_ccy, resolution.fetch("lightning").fetch("code"))
    ]
    if pair.nil?
      return unavailable_quote(pay_in_asset, "pair_temporarily_unavailable")
    end
    limits = FixedFloatRates.invoice_limits(pair)
    pay_amount = FixedFloatRates.quote_pay_amount(
      pair: pair, invoice_amount_msats: invoice_amount_msats
    )
    if pay_amount.nil?
      return unavailable_quote(pay_in_asset, "pair_temporarily_unavailable", limits)
    end
    # Prefer invoice-side limits when conversion succeeded; also
    # compare the indicative pay amount to XML min/max so padded <out>
    # decimals cannot leave a below-min asset selectable.
    pay_below_min =
      FixedFloatRates.compare_decimal_amounts(pay_amount, limits.fetch("minimum_pay_amount")) == -1
    pay_above_max =
      FixedFloatRates.compare_decimal_amounts(pay_amount, limits.fetch("maximum_pay_amount")) == 1
    minimum_msats = limits["minimum_invoice_amount_msats"]
    maximum_msats = limits["maximum_invoice_amount_msats"]
    amount_too_small = pay_below_min || (!minimum_msats.nil? && invoice_amount_msats < minimum_msats)
    amount_too_large = pay_above_max || (!maximum_msats.nil? && invoice_amount_msats > maximum_msats)
    if amount_too_small || amount_too_large
      reason = amount_too_small ? "amount_too_small" : "amount_too_large"
      return unavailable_quote(pay_in_asset, reason, limits)
    end
    {
      "pay_amount" => pay_amount,
      "pay_asset" => pay_in_asset,
      "available" => true,
      "provider" => @name
    }.merge(limits)
  rescue StandardError => e
    # Pair-math / limit errors stay as unavailable quotes. Rates and
    # network failures already raised above from resolve_rates_index
    # and must not be swallowed here.
    reason = Swap.classify_fixedfloat_quote_error(e)
    unavailable_quote(pay_in_asset, reason)
  end
end

#request_refund(order, refund_address) ⇒ Object



272
273
274
275
276
277
278
279
280
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 272

def request_refund(order, refund_address)
  stored = OpenReceive.stringify(order)
  post("emergency",
       "id" => stored.fetch("provider_order_id"),
       "token" => stored.fetch("provider_token"),
       "choice" => "REFUND",
       "address" => refund_address)
  nil
end

#supported_pay_in_assetsObject



156
157
158
# File 'lib/openreceive/server/swap/fixedfloat.rb', line 156

def supported_pay_in_assets
  resolve_currencies.fetch("pay_in").keys
end