Module: OpenReceive::Rates

Defined in:
lib/openreceive/rates.rb

Overview

Ruby port of packages/js/core/src/rates/index.ts: the built-in BTC price feed (static provider plus cached live feed with primary/fallback failover). Constants are hand-written and drift-checked against spec/data/rates/price-sources.json by test/rates_test.rb, exactly like the JS constants are checked by tests/rates.test.mjs.

Defined Under Namespace

Classes: CachedPriceFeed, HttpSimplePriceProvider, StaticPriceProvider

Constant Summary collapse

PRICE_FEED_CACHE_SECONDS =

How long a cached price-feed read stays usable before a live refresh.

60
INVOICE_QUOTE_TTL_SECONDS =
600
PRICE_FEED_CLOCK_SKEW_SECONDS =

A cache stamp this far in the future means the clock stepped backwards: treat the stamp as stale rather than "fresh until wall-clock catches up".

5
PRICE_FEED_PRIMARY_TIMEOUT_MS =

The primary feed must answer within this window before the fallback is tried.

5000
PRICE_SOURCE_IDS =
%w[static_mock primary fallback].freeze
STATIC_PRICE_SOURCE_ID =
"static_mock"
STATIC_BTC_FIAT_RATES =
{
  "bitcoin" => {
    "usd" => "50000.00"
  }.freeze
}.freeze
PRICE_FEED_VS_CURRENCIES =

The fixed fiat list both live feeds price Bitcoin against. Hard-coded so the primary and fallback URLs always request the same currencies.

"usd,aed,ars,aud,bdt,bhd,bmd,brl,cad,chf,clp,cny,czk,dkk,eur,gbp,gel,hkd,huf,idr,ils,inr,jpy,krw,kwd,lkr,mmk,mxn,myr,ngn,nok,nzd,php,pkr,pln,rub,sar,sek,sgd,thb,try,twd,uah,vef,vnd,zar"
PRICE_FEED_CURRENCIES =
PRICE_FEED_VS_CURRENCIES.split(",").freeze
SIMPLE_PRICE_BASE_URL =
"https://api.coingecko.com/api/v3/simple/price"
PRIMARY_PRICE_FEED_URL =

Primary live feed: the canonical public Simple Price endpoint.

"#{SIMPLE_PRICE_BASE_URL}?ids=bitcoin&vs_currencies=#{PRICE_FEED_VS_CURRENCIES}"
FALLBACK_PRICE_FEED_URL =

Fallback live feed: the OpenReceive mirror, in the same response shape.

"https://openreceive.org/api/v3/simple/price?ids=bitcoin&vs_currencies=#{PRICE_FEED_VS_CURRENCIES}"
PRICE_FEED_PRIMARY_URL_ENV =

Dev override env var names. Hosts read these (see read_price_feed_url_overrides) and pass any override into the factory; the feed itself never reads the environment.

"OPENRECEIVE_PRICE_FEED_PRIMARY_URL"
PRICE_FEED_FALLBACK_URL_ENV =
"OPENRECEIVE_PRICE_FEED_FALLBACK_URL"
CURRENCY_PATTERN =
/\A[A-Z]{3}\z/
RATE_KEY_PATTERN =
/\A[a-z]{3}\z/

Class Method Summary collapse

Class Method Details

.as_record(value) ⇒ Object

Raises:

  • (ArgumentError)


140
141
142
143
# File 'lib/openreceive/rates.rb', line 140

def as_record(value)
  raise ArgumentError, "expected object" unless value.is_a?(Hash)
  value
end

.create_cached_live_price_feed(currencies:, http: nil, clock: nil, cache_seconds: nil, primary_url: nil, fallback_url: nil, primary_timeout_ms: nil) ⇒ Object

Wires the hard-coded (or overridden) feeds to a disposable local cache.



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/openreceive/rates.rb', line 164

def create_cached_live_price_feed(currencies:, http: nil, clock: nil, cache_seconds: nil,
                                  primary_url: nil, fallback_url: nil, primary_timeout_ms: nil)
  providers = create_live_price_feed_providers(
    http: http,
    primary_url: primary_url,
    fallback_url: fallback_url,
    primary_timeout_ms: primary_timeout_ms
  )
  CachedPriceFeed.new(
    currencies: currencies,
    primary: providers.fetch(:primary),
    fallback: providers.fetch(:fallback),
    cache_seconds: cache_seconds,
    clock: clock
  )
end

.create_live_price_feed_providers(http: nil, primary_url: nil, fallback_url: nil, primary_timeout_ms: nil) ⇒ Object

Builds the primary and fallback live feed providers from the hard-coded URLs (or caller overrides). The primary provider carries the 5s timeout.



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/openreceive/rates.rb', line 147

def create_live_price_feed_providers(http: nil, primary_url: nil, fallback_url: nil, primary_timeout_ms: nil)
  {
    primary: HttpSimplePriceProvider.new(
      url: primary_url || PRIMARY_PRICE_FEED_URL,
      source: "primary",
      http: http,
      timeout_ms: primary_timeout_ms || PRICE_FEED_PRIMARY_TIMEOUT_MS
    ),
    fallback: HttpSimplePriceProvider.new(
      url: fallback_url || FALLBACK_PRICE_FEED_URL,
      source: "fallback",
      http: http
    )
  }
end

.default_http_get(url, headers, timeout_ms) ⇒ Object

Default HTTP transport on stdlib Net::HTTP. Injectable replacements must be callable as call(url, headers, timeout_ms) and return a Hash with :status (Integer) and :body (String).



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/openreceive/rates.rb', line 198

def default_http_get(url, headers, timeout_ms)
  uri = URI.parse(url)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme == "https"
  unless timeout_ms.nil?
    seconds = timeout_ms / 1000.0
    http.open_timeout = seconds
    http.read_timeout = seconds
    http.write_timeout = seconds if http.respond_to?(:write_timeout=)
  end
  request = Net::HTTP::Get.new(uri.request_uri)
  headers.each { |key, value| request[key] = value }
  response = http.start { |connection| connection.request(request) }
  { status: Integer(response.code), body: response.body.to_s }
end

.normalize_btc_fiat_rate(value, field) ⇒ Object

Raises:

  • (ArgumentError)


113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/openreceive/rates.rb', line 113

def normalize_btc_fiat_rate(value, field)
  if value.is_a?(Numeric)
    unless value.finite? && value.positive?
      raise ArgumentError, "#{field} must be a positive number"
    end
    normalized = number_to_plain_decimal_string(value)
    Money.decimal(normalized, field)
    return normalized
  end

  if value.is_a?(String)
    Money.decimal(value, field)
    return value
  end

  raise ArgumentError, "#{field} must be a number or decimal string"
end

.normalize_fiat_currency(currency) ⇒ Object



68
69
70
71
72
73
# File 'lib/openreceive/rates.rb', line 68

def normalize_fiat_currency(currency)
  unless currency.is_a?(String) && CURRENCY_PATTERN.match?(currency)
    raise ArgumentError, "fiat.currency must be an ISO 4217 uppercase code"
  end
  currency.downcase
end

.number_to_plain_decimal_string(value) ⇒ Object

Expand any JSON number an upstream price source returns to plain decimal notation (never exponent form), matching JS numberToPlainDecimalString.



133
134
135
136
137
138
# File 'lib/openreceive/rates.rb', line 133

def number_to_plain_decimal_string(value)
  return value.to_s if value.is_a?(Integer)
  decimal = BigDecimal(value.to_s)
  text = decimal.to_s("F")
  decimal.frac.zero? ? text.sub(/\.0+\z/, "") : text
end

.parse_available_simple_price_response(response) ⇒ Object

Tolerant parse for caching the whole feed: keeps every well-formed currency the response carries and skips ones an upstream returned unusably (so a single dropped currency never fails the refresh). Raises only when the response is not Simple Price shaped or carries no usable rate at all.



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/openreceive/rates.rb', line 97

def parse_available_simple_price_response(response)
  bitcoin = as_record(as_record(response)["bitcoin"])
  rates = {}
  bitcoin.each do |key, value|
    rate_key = key.to_s.downcase
    next unless RATE_KEY_PATTERN.match?(rate_key)
    begin
      rates[rate_key] = normalize_btc_fiat_rate(value, "bitcoin.#{key}")
    rescue ArgumentError
      # Skip a currency the upstream returned in an unusable form.
    end
  end
  raise ArgumentError, "price response contained no usable BTC fiat rates" if rates.empty?
  { "bitcoin" => rates }
end

.parse_simple_price_response(response, currencies) ⇒ Object

Strict select: every requested currency must be present and well formed.



82
83
84
85
86
87
88
89
90
# File 'lib/openreceive/rates.rb', line 82

def parse_simple_price_response(response, currencies)
  bitcoin = as_record(as_record(response)["bitcoin"])
  rates = {}
  currencies.each do |currency|
    key = normalize_fiat_currency(currency)
    rates[key] = normalize_btc_fiat_rate(bitcoin[key], "bitcoin.#{key}")
  end
  { "bitcoin" => rates }
end

.presence(value) ⇒ Object



190
191
192
193
# File 'lib/openreceive/rates.rb', line 190

def presence(value)
  text = value.to_s.strip
  text.empty? ? nil : text
end

.read_price_feed_url_overrides(env = ENV) ⇒ Object

Host-side helper (the Ruby analogue of the node service's env reader): returns non-empty URL overrides from the well-known env var names.



183
184
185
186
187
188
# File 'lib/openreceive/rates.rb', line 183

def read_price_feed_url_overrides(env = ENV)
  {
    primary_url: presence(env[PRICE_FEED_PRIMARY_URL_ENV]),
    fallback_url: presence(env[PRICE_FEED_FALLBACK_URL_ENV])
  }
end

.static_btc_fiat_price(currency) ⇒ Object

Raises:

  • (ArgumentError)


75
76
77
78
79
# File 'lib/openreceive/rates.rb', line 75

def static_btc_fiat_price(currency)
  rate = STATIC_BTC_FIAT_RATES.fetch("bitcoin")[normalize_fiat_currency(currency)]
  raise ArgumentError, "unsupported static fiat currency: #{currency}" if rate.nil?
  rate
end