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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE +21 -0
- data/README.md +62 -0
- data/lib/openreceive/core.rb +611 -0
- data/lib/openreceive/keccak256.rb +94 -0
- data/lib/openreceive/nwc_ruby.rb +72 -0
- data/lib/openreceive/rates.rb +498 -0
- data/lib/openreceive/swap_address.rb +147 -0
- data/lib/openreceive/version.rb +5 -0
- data/lib/openreceive.rb +7 -0
- metadata +70 -0
|
@@ -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
|