openreceive 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,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenReceive
4
+ # Keccak-256 (the pre-NIST-padding variant Ethereum uses, which is NOT
5
+ # Digest::SHA3). Needed only to verify EIP-55 checksums on refund addresses,
6
+ # so this is a compact reference implementation rather than a dependency —
7
+ # the core gem stays dependency-free.
8
+ module Keccak256
9
+ ROUNDS = 24
10
+ RATE_BYTES = 136 # 1088-bit rate for Keccak-256.
11
+ MASK = 0xffffffffffffffff
12
+
13
+ ROUND_CONSTANTS = [
14
+ 0x0000000000000001, 0x0000000000008082, 0x800000000000808a, 0x8000000080008000,
15
+ 0x000000000000808b, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009,
16
+ 0x000000000000008a, 0x0000000000000088, 0x0000000080008009, 0x000000008000000a,
17
+ 0x000000008000808b, 0x800000000000008b, 0x8000000000008089, 0x8000000000008003,
18
+ 0x8000000000008002, 0x8000000000000080, 0x000000000000800a, 0x800000008000000a,
19
+ 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008
20
+ ].freeze
21
+
22
+ ROTATION_OFFSETS = [
23
+ [0, 36, 3, 41, 18],
24
+ [1, 44, 10, 45, 2],
25
+ [62, 6, 43, 15, 61],
26
+ [28, 55, 25, 21, 56],
27
+ [27, 20, 39, 8, 14]
28
+ ].freeze
29
+
30
+ module_function
31
+
32
+ def digest(message)
33
+ state = Array.new(25, 0)
34
+ padded = pad(message.to_s.b)
35
+ padded.bytes.each_slice(RATE_BYTES) do |block|
36
+ block.each_slice(8).with_index do |lane_bytes, lane|
37
+ state[lane] ^= lane_bytes.each_with_index.sum { |byte, index| byte << (8 * index) }
38
+ end
39
+ keccak_f!(state)
40
+ end
41
+ # Keccak-256 output is the first 32 bytes of the rate portion.
42
+ state[0, 4].flat_map { |lane| (0...8).map { |index| (lane >> (8 * index)) & 0xff } }.pack("C*")
43
+ end
44
+
45
+ def pad(message)
46
+ # Keccak padding is 0x01 … 0x80 (SHA-3 would use 0x06 here).
47
+ padding_length = RATE_BYTES - (message.bytesize % RATE_BYTES)
48
+ padding = +"\x01" + ("\x00" * (padding_length - 1))
49
+ padding[-1] = (padding[-1].ord | 0x80).chr
50
+ message + padding
51
+ end
52
+
53
+ def keccak_f!(state)
54
+ ROUNDS.times do |round|
55
+ theta!(state)
56
+ rho_pi_chi!(state)
57
+ state[0] ^= ROUND_CONSTANTS[round]
58
+ end
59
+ state
60
+ end
61
+
62
+ def theta!(state)
63
+ columns = (0...5).map do |x|
64
+ (0...5).reduce(0) { |acc, y| acc ^ state[x + 5 * y] }
65
+ end
66
+ (0...5).each do |x|
67
+ d = columns[(x + 4) % 5] ^ rotl(columns[(x + 1) % 5], 1)
68
+ (0...5).each { |y| state[x + 5 * y] ^= d }
69
+ end
70
+ end
71
+
72
+ def rho_pi_chi!(state)
73
+ rotated = Array.new(25, 0)
74
+ (0...5).each do |x|
75
+ (0...5).each do |y|
76
+ rotated[y + 5 * ((2 * x + 3 * y) % 5)] = rotl(state[x + 5 * y], ROTATION_OFFSETS[x][y])
77
+ end
78
+ end
79
+ (0...5).each do |y|
80
+ row = (0...5).map { |x| rotated[x + 5 * y] }
81
+ (0...5).each do |x|
82
+ state[x + 5 * y] = row[x] ^ (~row[(x + 1) % 5] & MASK & row[(x + 2) % 5])
83
+ end
84
+ end
85
+ end
86
+
87
+ def rotl(value, offset)
88
+ offset %= 64
89
+ return value if offset.zero?
90
+
91
+ ((value << offset) | (value >> (64 - offset))) & MASK
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # require_relative (not `require "openreceive/core"`): the gemspec loads
4
+ # version.rb only, and a load-path require here could resolve to an installed
5
+ # copy of the gem instead of this working tree. The core file — never the
6
+ # `openreceive` umbrella, which loads this adapter — carries everything the
7
+ # adapter calls, so requiring this file directly keeps working.
8
+ require_relative "core"
9
+
10
+ module OpenReceive
11
+ # Thin adapter binding the engine to the nwc-ruby gem (NwcRuby::Client).
12
+ # The engine speaks NIP-47 wire names in string-keyed hashes; nwc-ruby
13
+ # declares snake_case keyword arguments, and spells the list_transactions
14
+ # window `until_ts` because `until` is a Ruby keyword.
15
+ class NwcRubyReceiveClient
16
+ attr_reader :redacted_connection_uri
17
+
18
+ def initialize(client:, connection_uri: nil)
19
+ @client = client
20
+ @redacted_connection_uri = connection_uri.nil? ? nil : OpenReceive.redact_nwc_uri(connection_uri)
21
+ OpenReceive.parse_nwc_uri(connection_uri) unless connection_uri.nil?
22
+ end
23
+
24
+ def make_invoice(request)
25
+ params = symbolize_keys(OpenReceive.make_invoice_nip47_request(request))
26
+ OpenReceive.normalize_make_invoice_response(@client.make_invoice(**params))
27
+ end
28
+
29
+ def list_transactions(request)
30
+ params = symbolize_keys(OpenReceive.list_transactions_nip47_request(request))
31
+ params[:until_ts] = params.delete(:until) if params.key?(:until)
32
+ OpenReceive.normalize_list_transactions_response(@client.list_transactions(**params))
33
+ end
34
+
35
+ def preflight
36
+ OpenReceive.stringify(@client.get_info)
37
+ end
38
+
39
+ # Opt-in NWC-02 notifications, forwarded from the wrapped client.
40
+ #
41
+ # nwc-ruby yields a NwcRuby::NIP47::Notification value object, while every
42
+ # other method in that gem returns the NIP-47 payload as a hash and
43
+ # OpenReceive.listen_for_notifications! consumes the NWC-02 wire shape
44
+ # (`notification_type` plus the transaction-shaped `notification`). This
45
+ # translates the object back to that shape, so the shared settlement rule
46
+ # reads the same fields it reads on a list_transactions row —
47
+ # `state`/`settled_at`, never a preimage alone. Every notification type is
48
+ # forwarded; the engine filters `payment_received` itself, because an
49
+ # NWC-02 subscription is not type-filtered — the wallet decides what it
50
+ # publishes.
51
+ #
52
+ # Returns whatever subscribe_to_notifications returns; nwc-ruby's blocks
53
+ # until the subscription ends, which is the contract
54
+ # listen_for_notifications! documents for blocking clients.
55
+ def subscribe_notifications(&handler)
56
+ @client.subscribe_to_notifications do |notification|
57
+ handler.call(
58
+ "notification_type" => notification.type.to_s,
59
+ "notification" => OpenReceive.stringify(notification.data)
60
+ )
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def symbolize_keys(value)
67
+ value.each_pair.each_with_object({}) do |(key, item), result|
68
+ result[key.to_sym] = item
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,498 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ module OpenReceive
9
+ # Raised when a live price feed cannot serve a usable rate (network failure,
10
+ # bad HTTP status, refresh fail-closed windows). Mirrors the plain Error the
11
+ # JS feed throws; validation problems raise ArgumentError instead (the Ruby
12
+ # spelling of the JS RangeError).
13
+ class PriceFeedError < StandardError; end
14
+
15
+ # Ruby port of packages/js/core/src/rates/index.ts: the built-in BTC price
16
+ # feed (static provider plus cached live feed with primary/fallback
17
+ # failover). Constants are hand-written and drift-checked against
18
+ # spec/data/rates/price-sources.json by test/rates_test.rb, exactly like the
19
+ # JS constants are checked by tests/rates.test.mjs.
20
+ module Rates
21
+ # How long a cached price-feed read stays usable before a live refresh.
22
+ PRICE_FEED_CACHE_SECONDS = 60
23
+ INVOICE_QUOTE_TTL_SECONDS = 600
24
+ # A cache stamp this far in the future means the clock stepped backwards:
25
+ # treat the stamp as stale rather than "fresh until wall-clock catches up".
26
+ PRICE_FEED_CLOCK_SKEW_SECONDS = 5
27
+
28
+ # The primary feed must answer within this window before the fallback is tried.
29
+ PRICE_FEED_PRIMARY_TIMEOUT_MS = 5000
30
+
31
+ PRICE_SOURCE_IDS = %w[static_mock primary fallback].freeze
32
+ STATIC_PRICE_SOURCE_ID = "static_mock"
33
+
34
+ STATIC_BTC_FIAT_RATES = {
35
+ "bitcoin" => {
36
+ "usd" => "50000.00"
37
+ }.freeze
38
+ }.freeze
39
+
40
+ # The fixed fiat list both live feeds price Bitcoin against. Hard-coded so
41
+ # the primary and fallback URLs always request the same currencies.
42
+ PRICE_FEED_VS_CURRENCIES =
43
+ "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"
44
+
45
+ PRICE_FEED_CURRENCIES = PRICE_FEED_VS_CURRENCIES.split(",").freeze
46
+
47
+ SIMPLE_PRICE_BASE_URL = "https://api.coingecko.com/api/v3/simple/price"
48
+
49
+ # Primary live feed: the canonical public Simple Price endpoint.
50
+ PRIMARY_PRICE_FEED_URL =
51
+ "#{SIMPLE_PRICE_BASE_URL}?ids=bitcoin&vs_currencies=#{PRICE_FEED_VS_CURRENCIES}"
52
+
53
+ # Fallback live feed: the OpenReceive mirror, in the same response shape.
54
+ FALLBACK_PRICE_FEED_URL =
55
+ "https://openreceive.org/api/v3/simple/price?ids=bitcoin&vs_currencies=#{PRICE_FEED_VS_CURRENCIES}"
56
+
57
+ # Dev override env var names. Hosts read these (see
58
+ # read_price_feed_url_overrides) and pass any override into the factory;
59
+ # the feed itself never reads the environment.
60
+ PRICE_FEED_PRIMARY_URL_ENV = "OPENRECEIVE_PRICE_FEED_PRIMARY_URL"
61
+ PRICE_FEED_FALLBACK_URL_ENV = "OPENRECEIVE_PRICE_FEED_FALLBACK_URL"
62
+
63
+ CURRENCY_PATTERN = /\A[A-Z]{3}\z/
64
+ RATE_KEY_PATTERN = /\A[a-z]{3}\z/
65
+
66
+ module_function
67
+
68
+ def normalize_fiat_currency(currency)
69
+ unless currency.is_a?(String) && CURRENCY_PATTERN.match?(currency)
70
+ raise ArgumentError, "fiat.currency must be an ISO 4217 uppercase code"
71
+ end
72
+ currency.downcase
73
+ end
74
+
75
+ def static_btc_fiat_price(currency)
76
+ rate = STATIC_BTC_FIAT_RATES.fetch("bitcoin")[normalize_fiat_currency(currency)]
77
+ raise ArgumentError, "unsupported static fiat currency: #{currency}" if rate.nil?
78
+ rate
79
+ end
80
+
81
+ # Strict select: every requested currency must be present and well formed.
82
+ def parse_simple_price_response(response, currencies)
83
+ bitcoin = as_record(as_record(response)["bitcoin"])
84
+ rates = {}
85
+ currencies.each do |currency|
86
+ key = normalize_fiat_currency(currency)
87
+ rates[key] = normalize_btc_fiat_rate(bitcoin[key], "bitcoin.#{key}")
88
+ end
89
+ { "bitcoin" => rates }
90
+ end
91
+
92
+ # Tolerant parse for caching the whole feed: keeps every well-formed
93
+ # currency the response carries and skips ones an upstream returned
94
+ # unusably (so a single dropped currency never fails the refresh). Raises
95
+ # only when the response is not Simple Price shaped or carries no usable
96
+ # rate at all.
97
+ def parse_available_simple_price_response(response)
98
+ bitcoin = as_record(as_record(response)["bitcoin"])
99
+ rates = {}
100
+ bitcoin.each do |key, value|
101
+ rate_key = key.to_s.downcase
102
+ next unless RATE_KEY_PATTERN.match?(rate_key)
103
+ begin
104
+ rates[rate_key] = normalize_btc_fiat_rate(value, "bitcoin.#{key}")
105
+ rescue ArgumentError
106
+ # Skip a currency the upstream returned in an unusable form.
107
+ end
108
+ end
109
+ raise ArgumentError, "price response contained no usable BTC fiat rates" if rates.empty?
110
+ { "bitcoin" => rates }
111
+ end
112
+
113
+ def normalize_btc_fiat_rate(value, field)
114
+ if value.is_a?(Numeric)
115
+ unless value.finite? && value.positive?
116
+ raise ArgumentError, "#{field} must be a positive number"
117
+ end
118
+ normalized = number_to_plain_decimal_string(value)
119
+ Money.decimal(normalized, field)
120
+ return normalized
121
+ end
122
+
123
+ if value.is_a?(String)
124
+ Money.decimal(value, field)
125
+ return value
126
+ end
127
+
128
+ raise ArgumentError, "#{field} must be a number or decimal string"
129
+ end
130
+
131
+ # Expand any JSON number an upstream price source returns to plain decimal
132
+ # notation (never exponent form), matching JS numberToPlainDecimalString.
133
+ def number_to_plain_decimal_string(value)
134
+ return value.to_s if value.is_a?(Integer)
135
+ decimal = BigDecimal(value.to_s)
136
+ text = decimal.to_s("F")
137
+ decimal.frac.zero? ? text.sub(/\.0+\z/, "") : text
138
+ end
139
+
140
+ def as_record(value)
141
+ raise ArgumentError, "expected object" unless value.is_a?(Hash)
142
+ value
143
+ end
144
+
145
+ # Builds the primary and fallback live feed providers from the hard-coded
146
+ # URLs (or caller overrides). The primary provider carries the 5s timeout.
147
+ def create_live_price_feed_providers(http: nil, primary_url: nil, fallback_url: nil, primary_timeout_ms: nil)
148
+ {
149
+ primary: HttpSimplePriceProvider.new(
150
+ url: primary_url || PRIMARY_PRICE_FEED_URL,
151
+ source: "primary",
152
+ http: http,
153
+ timeout_ms: primary_timeout_ms || PRICE_FEED_PRIMARY_TIMEOUT_MS
154
+ ),
155
+ fallback: HttpSimplePriceProvider.new(
156
+ url: fallback_url || FALLBACK_PRICE_FEED_URL,
157
+ source: "fallback",
158
+ http: http
159
+ )
160
+ }
161
+ end
162
+
163
+ # Wires the hard-coded (or overridden) feeds to a disposable local cache.
164
+ def create_cached_live_price_feed(currencies:, http: nil, clock: nil, cache_seconds: nil,
165
+ primary_url: nil, fallback_url: nil, primary_timeout_ms: nil)
166
+ providers = create_live_price_feed_providers(
167
+ http: http,
168
+ primary_url: primary_url,
169
+ fallback_url: fallback_url,
170
+ primary_timeout_ms: primary_timeout_ms
171
+ )
172
+ CachedPriceFeed.new(
173
+ currencies: currencies,
174
+ primary: providers.fetch(:primary),
175
+ fallback: providers.fetch(:fallback),
176
+ cache_seconds: cache_seconds,
177
+ clock: clock
178
+ )
179
+ end
180
+
181
+ # Host-side helper (the Ruby analogue of the node service's env reader):
182
+ # returns non-empty URL overrides from the well-known env var names.
183
+ def read_price_feed_url_overrides(env = ENV)
184
+ {
185
+ primary_url: presence(env[PRICE_FEED_PRIMARY_URL_ENV]),
186
+ fallback_url: presence(env[PRICE_FEED_FALLBACK_URL_ENV])
187
+ }
188
+ end
189
+
190
+ def presence(value)
191
+ text = value.to_s.strip
192
+ text.empty? ? nil : text
193
+ end
194
+
195
+ # Default HTTP transport on stdlib Net::HTTP. Injectable replacements must
196
+ # be callable as call(url, headers, timeout_ms) and return a Hash with
197
+ # :status (Integer) and :body (String).
198
+ def default_http_get(url, headers, timeout_ms)
199
+ uri = URI.parse(url)
200
+ http = Net::HTTP.new(uri.host, uri.port)
201
+ http.use_ssl = uri.scheme == "https"
202
+ unless timeout_ms.nil?
203
+ seconds = timeout_ms / 1000.0
204
+ http.open_timeout = seconds
205
+ http.read_timeout = seconds
206
+ http.write_timeout = seconds if http.respond_to?(:write_timeout=)
207
+ end
208
+ request = Net::HTTP::Get.new(uri.request_uri)
209
+ headers.each { |key, value| request[key] = value }
210
+ response = http.start { |connection| connection.request(request) }
211
+ { status: Integer(response.code), body: response.body.to_s }
212
+ end
213
+
214
+ # Serves the fixed static_mock table (same semantics as JS
215
+ # StaticPriceProvider). Satisfies the openreceive-server price_provider
216
+ # contract via btc_fiat_price(currency).
217
+ class StaticPriceProvider
218
+ def source
219
+ STATIC_PRICE_SOURCE_ID
220
+ end
221
+
222
+ def btc_fiat_rates(currencies)
223
+ rates = {}
224
+ currencies.each do |currency|
225
+ rates[Rates.normalize_fiat_currency(currency)] = Rates.static_btc_fiat_price(currency)
226
+ end
227
+ { "bitcoin" => rates }
228
+ end
229
+
230
+ def btc_fiat_rates_with_source(currencies)
231
+ { "source" => source, "rates" => btc_fiat_rates(currencies) }
232
+ end
233
+
234
+ def btc_fiat_price(currency)
235
+ Rates.static_btc_fiat_price(currency)
236
+ end
237
+ end
238
+
239
+ # Fetches a Simple Price compatible HTTP endpoint and selects the
240
+ # requested fiat currencies. When timeout_ms is set, a slow endpoint fails
241
+ # within that window so the caller can fall through to another feed.
242
+ class HttpSimplePriceProvider
243
+ attr_reader :url, :source, :timeout_ms
244
+
245
+ def initialize(url:, source:, http: nil, timeout_ms: nil)
246
+ @url = url
247
+ @source = source
248
+ @http = http
249
+ @timeout_ms = timeout_ms
250
+ end
251
+
252
+ def btc_fiat_rates(currencies)
253
+ Rates.parse_simple_price_response(fetch_json, currencies)
254
+ end
255
+
256
+ # Returns every well-formed currency the endpoint carries, for caching
257
+ # the whole feed in one read.
258
+ def all_btc_fiat_rates
259
+ Rates.parse_available_simple_price_response(fetch_json)
260
+ end
261
+
262
+ private
263
+
264
+ def fetch_json
265
+ response = perform_request
266
+ status = response[:status] || response["status"]
267
+ body = response[:body] || response["body"]
268
+ unless (200..299).cover?(status)
269
+ raise PriceFeedError, "price source #{@source} returned HTTP #{status}"
270
+ end
271
+ JSON.parse(body.to_s)
272
+ end
273
+
274
+ def perform_request
275
+ transport = @http || Rates.method(:default_http_get)
276
+ transport.call(@url, { "accept" => "application/json" }, @timeout_ms)
277
+ rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error => e
278
+ raise PriceFeedError, timeout_message(e)
279
+ rescue PriceFeedError
280
+ raise
281
+ rescue StandardError => e
282
+ raise PriceFeedError, "price source #{@source} request failed: #{e.message}"
283
+ end
284
+
285
+ def timeout_message(error)
286
+ return "price source #{@source} did not respond within #{@timeout_ms}ms" unless @timeout_ms.nil?
287
+ "price source #{@source} request failed: #{error.message}"
288
+ end
289
+ end
290
+
291
+ # Serves BTC fiat rates from a disposable process-local cache, refreshing
292
+ # from the primary feed first and the fallback second. Port of the JS
293
+ # CachedPriceFeed state machine: fresh entries are served for
294
+ # cache_seconds; a refresh failure fails closed for cache_seconds; a
295
+ # concurrent in-flight refresh serves the stale entry only while it is
296
+ # younger than the invoice quote TTL. Satisfies the openreceive-server
297
+ # price_provider contract via btc_fiat_price(currency).
298
+ class CachedPriceFeed
299
+ # Representative source for the plain source reader; the true origin is
300
+ # reported per-call by btc_fiat_rates_with_source.
301
+ attr_reader :source
302
+
303
+ def initialize(currencies:, primary:, fallback:, cache_seconds: nil, clock: nil)
304
+ raise ArgumentError, "CachedPriceFeed requires at least one currency" if currencies.empty?
305
+ cache_seconds ||= PRICE_FEED_CACHE_SECONDS
306
+ unless cache_seconds.is_a?(Integer) && cache_seconds.positive?
307
+ raise ArgumentError, "CachedPriceFeed cache_seconds must be a positive integer"
308
+ end
309
+ # A cache window wider than the quote TTL would let a read be reported
310
+ # as fresh that is already too old to price an invoice.
311
+ if cache_seconds > INVOICE_QUOTE_TTL_SECONDS
312
+ raise ArgumentError,
313
+ "CachedPriceFeed cache_seconds must not exceed the #{INVOICE_QUOTE_TTL_SECONDS}s invoice quote TTL"
314
+ end
315
+ @currencies = currencies.map(&:to_s).freeze
316
+ @primary = primary
317
+ @fallback = fallback
318
+ @cache_seconds = cache_seconds
319
+ @clock = clock || -> { Time.now.to_i }
320
+ @source = "primary"
321
+ @mutex = Mutex.new
322
+ @refresh_done = ConditionVariable.new
323
+ @state = nil
324
+ @in_flight = nil
325
+ end
326
+
327
+ def btc_fiat_rates(currencies)
328
+ btc_fiat_rates_with_source(currencies).fetch("rates")
329
+ end
330
+
331
+ def btc_fiat_rates_with_source(currencies)
332
+ now = @clock.call
333
+ claim = read_or_claim_refresh(now)
334
+ entry =
335
+ case claim.fetch(:status)
336
+ when :served then claim.fetch(:entry)
337
+ when :pending then await_refresh(claim.fetch(:pending))
338
+ else tracked_refresh(now, claim.fetch(:previous_entry), claim.fetch(:pending))
339
+ end
340
+ {
341
+ "source" => entry.fetch("source"),
342
+ "rates" => Rates.parse_simple_price_response(entry.fetch("rates"), currencies)
343
+ }
344
+ end
345
+
346
+ # The openreceive-server price_provider contract: one decimal price
347
+ # string for one uppercase ISO 4217 currency.
348
+ def btc_fiat_price(currency)
349
+ btc_fiat_rates([currency]).fetch("bitcoin").fetch(Rates.normalize_fiat_currency(currency))
350
+ end
351
+
352
+ # Forces a live refresh, ignoring the cache, for explicit operational
353
+ # probes. Raises if both feeds fail. Tolerant of an upstream that drops
354
+ # an individual currency (pass no currencies to get everything cached).
355
+ def health_check(currencies = nil)
356
+ now = @clock.call
357
+ pending = { "owner" => Thread.current, "done" => false }
358
+ previous_entry = @mutex.synchronize do
359
+ @in_flight = pending
360
+ @state && @state["entry"]
361
+ end
362
+ entry = tracked_refresh(now, previous_entry, pending)
363
+ rates =
364
+ if currencies.nil? || currencies.empty?
365
+ entry.fetch("rates")
366
+ else
367
+ Rates.parse_simple_price_response(entry.fetch("rates"), currencies)
368
+ end
369
+ { "source" => entry.fetch("source"), "rates" => rates }
370
+ end
371
+
372
+ private
373
+
374
+ def read_or_claim_refresh(now)
375
+ @mutex.synchronize do
376
+ state = @state
377
+ entry = state && state["entry"]
378
+
379
+ entry_age = entry && stamp_age(entry.fetch("fetched_at"), now)
380
+ if entry_age && entry_age < @cache_seconds
381
+ return { status: :served, entry: entry }
382
+ end
383
+
384
+ # Stale-while-revalidate is bounded by the invoice quote TTL: a rate
385
+ # observed longer ago than a quote may live must never price a new
386
+ # invoice — fail closed instead of serving it.
387
+ quotable = entry if entry_age && entry_age < INVOICE_QUOTE_TTL_SECONDS
388
+
389
+ if state && recent?(state["refresh_failed_at"], now)
390
+ # One failed refresh must not hard-down quoting for the whole
391
+ # backoff while a still-quotable observation is in hand.
392
+ return { status: :served, entry: quotable } unless quotable.nil?
393
+ message = "price feed refresh already failed within #{@cache_seconds}s"
394
+ message += ": #{state["refresh_error"]}" unless state["refresh_error"].to_s.empty?
395
+ raise PriceFeedError, message
396
+ end
397
+
398
+ if state && recent?(state["refresh_started_at"], now)
399
+ return { status: :served, entry: quotable } unless quotable.nil?
400
+ # Cold cache: join the refresh already running in this process
401
+ # rather than failing every concurrent caller but the one that
402
+ # claimed it. The claiming thread itself cannot wait on its own
403
+ # refresh, so a re-entrant read still fails closed.
404
+ pending = @in_flight
405
+ if !pending.nil? && !pending["owner"].equal?(Thread.current)
406
+ return { status: :pending, pending: pending }
407
+ end
408
+ raise PriceFeedError, "price feed refresh already started within #{@cache_seconds}s"
409
+ end
410
+
411
+ claimed = { "refresh_started_at" => now }
412
+ claimed["entry"] = entry unless entry.nil?
413
+ @state = claimed
414
+ pending = { "owner" => Thread.current, "done" => false }
415
+ @in_flight = pending
416
+ { status: :claimed, previous_entry: entry, pending: pending }
417
+ end
418
+ end
419
+
420
+ def recent?(timestamp, now)
421
+ age = stamp_age(timestamp, now)
422
+ !age.nil? && age < @cache_seconds
423
+ end
424
+
425
+ # Age of a cache stamp, or nil when the stamp is unusable because it sits
426
+ # beyond the clock-skew tolerance in the future (mirrors the JS cache).
427
+ def stamp_age(timestamp, now)
428
+ return nil if timestamp.nil?
429
+
430
+ age = now - timestamp
431
+ return nil if age < -PRICE_FEED_CLOCK_SKEW_SECONDS
432
+
433
+ age.negative? ? 0 : age
434
+ end
435
+
436
+ def tracked_refresh(now, previous_entry, pending)
437
+ entry = refresh(now, previous_entry)
438
+ settle(pending, entry, nil)
439
+ entry
440
+ rescue StandardError => e
441
+ settle(pending, nil, e)
442
+ raise
443
+ end
444
+
445
+ def settle(pending, entry, error)
446
+ @mutex.synchronize do
447
+ pending["entry"] = entry
448
+ pending["error"] = error
449
+ pending["done"] = true
450
+ @in_flight = nil if @in_flight.equal?(pending)
451
+ @refresh_done.broadcast
452
+ end
453
+ end
454
+
455
+ def await_refresh(pending)
456
+ @mutex.synchronize do
457
+ @refresh_done.wait(@mutex) until pending["done"]
458
+ end
459
+ error = pending["error"]
460
+ raise error unless error.nil?
461
+ pending["entry"]
462
+ end
463
+
464
+ def refresh(now, previous_entry)
465
+ failures = []
466
+ [@primary, @fallback].each do |provider|
467
+ begin
468
+ rates = fetch_provider_rates(provider)
469
+ entry = { "rates" => rates, "source" => provider.source, "fetched_at" => now }
470
+ @mutex.synchronize { @state = { "entry" => entry } }
471
+ return entry
472
+ rescue StandardError => e
473
+ failures << "#{provider.source}: #{e.message}"
474
+ end
475
+ end
476
+
477
+ message = "all price feeds failed: #{failures.join("; ")}"
478
+ @mutex.synchronize do
479
+ failed = {
480
+ "refresh_started_at" => now,
481
+ "refresh_failed_at" => now,
482
+ "refresh_error" => message
483
+ }
484
+ failed["entry"] = previous_entry unless previous_entry.nil?
485
+ @state = failed
486
+ end
487
+ raise PriceFeedError, message
488
+ end
489
+
490
+ # Cache the whole feed when the provider can serve it tolerantly;
491
+ # otherwise request just the configured currencies.
492
+ def fetch_provider_rates(provider)
493
+ return provider.all_btc_fiat_rates if provider.respond_to?(:all_btc_fiat_rates)
494
+ provider.btc_fiat_rates(@currencies)
495
+ end
496
+ end
497
+ end
498
+ end