Class: OpenReceive::Server::Service

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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_currenciesObject (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.message
  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.message
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 list_swap_options(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" => list_swap_options(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.

Raises:

  • (ArgumentError)


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.message
end