Class: OpenReceive::Server::Service
- Inherits:
-
Object
- Object
- OpenReceive::Server::Service
- Defined in:
- lib/openreceive/server/service.rb
Constant Summary collapse
- PAGE_LIMIT =
OpenReceive::TRANSACTION_PAGE_LIMIT
- MAX_PAGES =
Upper bound on wallet history pages per scan (mirrors JS maxPages): a wallet/relay that keeps returning full pages must not hang the scan.
10_000- INVOICE_EXPIRY_SECONDS =
600- SWAP_INVOICE_EXPIRY_SECONDS =
Default shadow-invoice expiry when a swap provider does not report its own (mirrors the JS default).
600- INVOICE_EXPIRY_TOLERANCE_SECONDS =
Maximum seconds the wallet's returned expiry may deviate from the requested expiry before checkout creation fails closed.
60- SPEND_METHODS =
NIP-47 method names that let a connection move funds out of the wallet (mirrors the JS preflight, including the keysend variants). Preflight compares against already-normalized names from WalletInfo.summarize.
WalletInfo::SPEND_METHODS
Instance Attribute Summary collapse
-
#price_currencies ⇒ Object
readonly
Returns the value of attribute price_currencies.
Instance Method Summary collapse
- #create_checkout(input) ⇒ Object
- #create_swap(input) ⇒ Object
- #get_swap(reference:, payment_hash:, swap_data:) ⇒ Object
-
#initialize(nwc_client:, price_provider: nil, swap_providers: nil, price_currencies: ["USD"], clock: -> { Time.now.to_i }, allow_spend_capable_wallet: false, env: ENV, logger: nil) ⇒ Service
constructor
swap_providers: nil(the default) auto-builds FixedFloat-compatible providers from LSC_URI_PRIMARY / LSC_URI_BACKUP, exactly like the JS createOpenReceive. - #list_rates(input = {}) ⇒ Object
-
#list_swap_options(amount_msats:) ⇒ Object
Amount-aware swap pay-in options for the shared browser widget (mirrors the JS service listSwapOptions + resolveSwapProviderCatalog): exactly one live provider's catalog — primary when healthy, otherwise the first backup that answers — mapped over the full OpenReceive asset list with amount-vs-limit availability.
- #prepare_checkout(input) ⇒ Object
- #quote_swap(input) ⇒ Object
-
#reconcile_payments(input) ⇒ Object
Optional bounds for request-path passes: "max_pages" caps each wallet-history walk (the gated opportunistic pass sends 50, mirroring the JS OPENRECEIVE_RECONCILE_SCAN_MAX_PAGES; default MAX_PAGES), and "deadline" is a monotonic-clock instant checked between page fetches — never mid-request — so a slow wallet cannot hang user-facing requests.
- #refund_swap(reference:, payment_hash:, swap_data:, refund_address:) ⇒ Object
Constructor Details
#initialize(nwc_client:, price_provider: nil, swap_providers: nil, price_currencies: ["USD"], clock: -> { Time.now.to_i }, allow_spend_capable_wallet: false, env: ENV, logger: nil) ⇒ Service
swap_providers: nil (the default) auto-builds FixedFloat-compatible
providers from LSC_URI_PRIMARY / LSC_URI_BACKUP, exactly like the JS
createOpenReceive. Pass an explicit array (possibly empty) to override.
price_provider: nil (the default) uses the built-in cached live
price feed (with OPENRECEIVE_PRICE_FEED_*_URL overrides), mirroring
the JS default; pass a provider to override, or false to run
without rates entirely (the JS priceProviders: []): fiat amounts
and GET /rates then fail with their not-configured errors.
logger: is an optional standard Logger-shaped sink (debug/info/
warn/error) for operational events such as swap-provider API calls.
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/openreceive/server/service.rb', line 39 def initialize(nwc_client:, price_provider: nil, swap_providers: nil, price_currencies: ["USD"], clock: -> { Time.now.to_i }, allow_spend_capable_wallet: false, env: ENV, logger: nil) @nwc = nwc_client @clock = clock @env = env @logger = logger @price_currencies = Array(price_currencies || ["USD"]).map { |value| value.to_s.upcase } @price_provider = price_provider == false ? nil : price_provider || default_price_provider(env) @swap_providers = if swap_providers.nil? Swap.providers_from_environment(env, now: @clock) else Array(swap_providers) end attach_swap_provider_runtime! # The override relaxes only the spend refusal: receive-readiness and # encryption are still enforced, exactly as in the JS preflight. wallet_preflight!( allow_spend_capable: allow_spend_capable_wallet || spend_override_from_env? ) end |
Instance Attribute Details
#price_currencies ⇒ Object (readonly)
Returns the value of attribute price_currencies.
27 28 29 |
# File 'lib/openreceive/server/service.rb', line 27 def price_currencies @price_currencies end |
Instance Method Details
#create_checkout(input) ⇒ Object
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/openreceive/server/service.rb', line 96 def create_checkout(input) # Payer-input validation only: once the wallet has minted, a parse # failure is the wallet's response violating the receive contract, not # a 400 the payer caused — so this rescue must not cover the wallet # call or its normalization. reference, expiry, required_expiry, fiat_quote, request = validating_input do data = stringify(input) reference = required_string(data["reference"], "reference") amount_msats, fiat_quote = resolve_amount(data.fetch("amount")) # A caller-supplied expiry_seconds is a FLOOR (only the swap path sets # it); the library default is a request the wallet may clamp. required_expiry = !data["expiry_seconds"].nil? expiry = Integer(data["expiry_seconds"] || INVOICE_EXPIRY_SECONDS) = stringify(data["metadata"] || {}).merge("reference" => reference) # NIP-47 caps invoice metadata; reject before any wallet call with # the JS service's exact message instead of surfacing the wallet # client's own failure as a 502. if JSON.generate().bytesize > OpenReceive::NWC_METADATA_MAX_BYTES raise ValidationError, "metadata is too large for NIP-47." end request = { "amount_msats" => amount_msats, "expiry" => expiry, "metadata" => } request["description"] = data["memo"] if data["memo"] request["description_hash"] = data["description_hash"] if data["description_hash"] [reference, expiry, required_expiry, fiat_quote, request] end response = call_nwc(:make_invoice, request) begin wallet = OpenReceive.normalize_make_invoice_response(response) created_at = wallet["created_at"] || @clock.call # The ledger row stores the wallet's OWN expires_at, so reuse # buffering, reconciliation, and the expiry+grace close rule all stay # consistent with the real invoice even when the wallet clamps expiry # to its own min/max. A deviation is therefore a warning on the plain # checkout path — refusing would lock every such wallet out entirely. # # A caller-supplied expiry is a FLOOR: only the swap path sets one, # because the shadow invoice must outlive the provider order. A short # invoice fails there. Mirrors the JS create_checkout ruling. requested_expires_at = created_at + expiry expires_at = wallet["expires_at"] || requested_expires_at shortfall = requested_expires_at - expires_at if (expires_at - requested_expires_at).abs > INVOICE_EXPIRY_TOLERANCE_SECONDS # The detailed diagnostic is logged, never sent: the wire carries # the same short form as the JS service. if required_expiry && shortfall > INVOICE_EXPIRY_TOLERANCE_SECONDS @logger&.error( "checkout.invoice_expiry.rejected: The wallet did not honor the " \ "required invoice expiry (required #{expiry}s, got " \ "#{expires_at - created_at}s). Use a wallet whose make_invoice honors expiry." ) raise WalletContractError, "Error with the backing NWC wallet: it did not honor the requested invoice expiry." end @logger&.warn( "checkout.invoice_expiry.adjusted: The wallet clamped the requested " \ "invoice expiry (requested #{expiry}s, got #{expires_at - created_at}s); " \ "the wallet's own expiry is recorded on the attempt." ) end { "reference" => reference, "payment_hash" => wallet.fetch("payment_hash"), "bolt11" => wallet.fetch("invoice"), "amount_msats" => wallet.fetch("amount_msats"), "created_at" => created_at, "expires_at" => expires_at, "fiat_quote" => fiat_quote } rescue KeyError, ArgumentError, TypeError # Never blames the payer, and never puts the raw parse failure # (`key not found: "invoice"`) on the wire. raise WalletContractError end end |
#create_swap(input) ⇒ Object
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 |
# File 'lib/openreceive/server/service.rb', line 252 def create_swap(input) data = stringify(input) asset = parse_pay_in_asset(data["pay_in_asset"]) amount = begin data.fetch("amount") rescue KeyError => e raise ValidationError, e. end provider = select_provider(asset) expiry = provider.respond_to?(:invoice_expiry_seconds) ? provider.invoice_expiry_seconds(pay_in_asset: asset) : SWAP_INVOICE_EXPIRY_SECONDS # The shadow-invoice expiry is provider-mandated: build the checkout # input explicitly from validated fields so no payer-supplied key (e.g. # "expiry_seconds") can override it or smuggle a different order id. checkout = create_checkout( "reference" => data["reference"], "amount" => amount, "memo" => data["memo"], "metadata" => data["metadata"], "expiry_seconds" => expiry ) order = stringify(call_provider(provider, :create_swap, "pay_in_asset" => asset, "bolt11" => checkout.fetch("bolt11"), "invoice_amount_msats" => checkout.fetch("amount_msats"))) swap_data = { "version" => 1, "provider_order" => order.reject { |key, _| key == "raw" } } public_swap(order, checkout.fetch("payment_hash"), checkout.fetch("reference")).merge( "checkout" => checkout, "swap_data" => swap_data ) end |
#get_swap(reference:, payment_hash:, swap_data:) ⇒ Object
286 287 288 289 290 291 292 293 294 |
# File 'lib/openreceive/server/service.rb', line 286 def get_swap(reference:, payment_hash:, swap_data:) recovery = normalize_swap_data(swap_data) provider_name = recovery.fetch("provider_order").fetch("provider") provider = provider_by_name(provider_name) current = stringify(call_provider(provider, :get_status, recovery.fetch("provider_order"))) public_swap(current, normalize_payment_hash(payment_hash), required_string(reference, "reference")) rescue KeyError => e raise ValidationError, e. end |
#list_rates(input = {}) ⇒ Object
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 |
# File 'lib/openreceive/server/service.rb', line 315 def list_rates(input = {}) raise NotImplementedHttpError, "No price provider is configured for rates." if @price_provider.nil? currencies = Array(stringify(input)["currencies"] || @price_currencies).map { |value| value.to_s.strip.upcase } currencies.each do |currency| unless /\A[A-Z]{3}\z/.match?(currency) # Same message as the JS service's payer currencies path; the wire # shape check already fired in the request handler. raise ValidationError, "Invalid currencies entry: #{currency}." end unless @price_currencies.include?(currency) raise ValidationError, "fiat.currency must be one of the configured priceCurrencies: " \ "#{@price_currencies.join(', ')}." end end { "bitcoin" => currencies.to_h { |currency| [currency.downcase, btc_fiat_price_or_unavailable(currency)] } } end |
#list_swap_options(amount_msats:) ⇒ Object
Amount-aware swap pay-in options for the shared browser widget (mirrors the JS service listSwapOptions + resolveSwapProviderCatalog): exactly one live provider's catalog — primary when healthy, otherwise the first backup that answers — mapped over the full OpenReceive asset list with amount-vs-limit availability.
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/openreceive/server/service.rb', line 79 def (amount_msats:) return [] if @swap_providers.empty? normalized_amount = normalize_swap_amount_msats(amount_msats) catalog = resolve_swap_provider_catalog # Providers ARE configured (checked above), so an empty catalog means # every one of them failed its fetch — an outage, not a configuration # gap. Mirrors the JS listSwapOptions ruling. catalog_unreachable = catalog.empty? Swap::Assets.list_info.map do |asset| swap_catalog_option( asset, normalized_amount, catalog[asset.fetch("pay_in_asset")], catalog_unreachable: catalog_unreachable ) end end |
#prepare_checkout(input) ⇒ Object
62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/openreceive/server/service.rb', line 62 def prepare_checkout(input) validating_input do data = stringify(input) amount_msats, fiat_quote = resolve_amount(data.fetch("amount")) { "amount_msats" => amount_msats, "fiat_quote" => fiat_quote, "payment_methods" => (amount_msats: amount_msats) } end end |
#quote_swap(input) ⇒ Object
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 |
# File 'lib/openreceive/server/service.rb', line 231 def quote_swap(input) data = stringify(input) asset = parse_pay_in_asset(data["pay_in_asset"]) amount_msats, = validating_input { resolve_amount(data.fetch("amount")) } provider = select_provider(asset) quote = stringify(call_provider(provider, :quote, "pay_in_asset" => asset, "invoice_amount_msats" => amount_msats)) { "provider" => quote.fetch("provider"), "pay_asset" => quote.fetch("pay_asset"), "available" => quote.fetch("available"), "pay_amount" => quote["pay_amount"], "minimum_pay_amount" => quote["minimum_pay_amount"], "maximum_pay_amount" => quote["maximum_pay_amount"], "minimum_invoice_amount_msats" => quote["minimum_invoice_amount_msats"], "maximum_invoice_amount_msats" => quote["maximum_invoice_amount_msats"], "unavailable_reason" => quote["unavailable_reason"], "unavailable_message" => quote["unavailable_message"] }.compact end |
#reconcile_payments(input) ⇒ Object
Optional bounds for request-path passes: "max_pages" caps each wallet-history walk (the gated opportunistic pass sends 50, mirroring the JS OPENRECEIVE_RECONCILE_SCAN_MAX_PAGES; default MAX_PAGES), and "deadline" is a monotonic-clock instant checked between page fetches — never mid-request — so a slow wallet cannot hang user-facing requests.
180 181 182 183 184 185 186 187 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 |
# File 'lib/openreceive/server/service.rb', line 180 def reconcile_payments(input) data = stringify(input) attempts = Array(data.fetch("attempts")) return [] if attempts.empty? expected = attempts.to_h do |attempt| row = stringify(attempt) [normalize_payment_hash(row.fetch("payment_hash") { row.fetch("paymentHash") }), Integer(row.fetch("created_at") { row.fetch("createdAt") })] end overlap = Integer(data.fetch("overlap_seconds", 60)) # A negative overlap would SHRINK both window ends instead of padding # them, hiding exactly the rows the padding exists to catch. Mirrors # the JS reconcilePaymentAttempts guard. raise ArgumentError, "overlap_seconds must be a non-negative integer" if overlap.negative? from = [expected.values.min - overlap, 0].max # Both ends of the window are padded: `from` against a wallet clock # that lags, `until` against one that runs ahead — an unpadded `until` # on the host clock hides an invoice the wallet just stamped into the # future. until_time = Integer(data["until"] || (@clock.call + overlap)) bounds = { max_pages: data["max_pages"], deadline: data["deadline"] }.compact settled = scan_incoming_transactions( expected: expected.keys, from: from, until_time: until_time, **bounds ) by_hash = settled.fetch(:rows).dup missing = expected.keys.reject { |hash| by_hash.key?(hash) } truncated = false unless missing.empty? inclusive = scan_incoming_transactions( expected: missing, from: from, until_time: until_time, unpaid: true, **bounds ) truncated = settled.fetch(:truncated) || inclusive.fetch(:truncated) inclusive.fetch(:rows).each { |hash, row| by_hash[hash] ||= row } end # A hash the walk could not decide is OMITTED rather than reported # not_found: when the page cap, the pass deadline, or a wallet that # ignored `offset` cut the walk short, absence is unproven, and # reporting not_found would let a caller close a paid attempt. Omitted # hashes are simply retried next pass (mirrors the JS # reconcilePaymentAttempts). expected.keys.filter_map do |hash| if by_hash.key?(hash) payment_result(hash, by_hash.fetch(hash)) elsif !truncated { "payment_hash" => hash, "status" => "not_found" } end end end |
#refund_swap(reference:, payment_hash:, swap_data:, refund_address:) ⇒ Object
296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 |
# File 'lib/openreceive/server/service.rb', line 296 def refund_swap(reference:, payment_hash:, swap_data:, refund_address:) recovery = normalize_swap_data(swap_data) hash = normalize_payment_hash(payment_hash) host_reference = required_string(reference, "reference") address = normalize_refund_address( refund_address, recovery.dig("provider_order", "pay_in_asset") ) provider_name = recovery.fetch("provider_order").fetch("provider") provider = provider_by_name(provider_name) current = stringify(call_provider(provider, :get_status, recovery.fetch("provider_order"))) unless current["state"] == "refund_required" raise ConflictError, "Swap cannot be refunded from provider state #{current['state']}." end call_provider(provider, :request_refund, current, address) get_swap(reference: host_reference, payment_hash: hash, swap_data: recovery) rescue KeyError => e raise ValidationError, e. end |