Module: OpenReceive

Defined in:
lib/openreceive/generated/fulfillment_note.rb,
lib/openreceive/engine.rb,
lib/openreceive/reconcile.rb,
lib/openreceive/configuration.rb,
lib/openreceive/rails/version.rb,
lib/openreceive/fulfillment_note.rb,
app/jobs/openreceive/reconcile_job.rb,
app/controllers/openreceive/rates_controller.rb,
app/controllers/openreceive/swaps_controller.rb,
app/controllers/openreceive/payments_controller.rb,
app/controllers/openreceive/checkouts_controller.rb,
app/controllers/openreceive/application_controller.rb,
lib/generators/openreceive/install/install_generator.rb

Overview

GENERATED FILE — DO NOT EDIT. Source: spec/data/fulfillment-note.txt (npm run generate:models). The JS twin is packages/js/core/src/generated/fulfillment-note-text.ts; both render the same text, so the Rails install generator and the scaffold CLI cannot give different advice.

Defined Under Namespace

Modules: FulfillmentNote, Generated, Generators, Rails Classes: ApplicationController, CheckoutsController, Configuration, ConfigurationError, Engine, PaymentSettlement, PaymentsController, RatesController, ReconcileJob, SwapsController

Constant Summary collapse

MIN_RECONCILE_INTERVAL_SECONDS =

Floor for the durable reconcile-gate interval (seconds); stretched by invoice age (2s while any pending invoice is under 2 minutes old, 6s under 5 minutes, else 12s). Mirrors the JS OPENRECEIVE_MIN_RECONCILE_INTERVAL_SECONDS.

2
RECONCILE_SCAN_TIMEOUT_SECONDS =

Wall-clock bound on an awaited request-path pass. Enforced as a deadline the wallet scan checks between page fetches rather than a Timeout.timeout: Thread#raise at an arbitrary point could tear down an ActiveRecord connection or kill a host's on_paid fulfillment mid-flight, on every winning request. A pass that runs out of budget simply stops walking.

9
RECONCILE_SCAN_MAX_PAGES =

Wallet-history pages a request-path pass may walk, mirroring the JS OPENRECEIVE_RECONCILE_SCAN_MAX_PAGES.

50
NOTIFICATIONS_MAX_BACKOFF_SECONDS =

Cap on the openreceive:notifications worker's resubscribe backoff, and the subscription lifetime past which the ramp resets to 1s.

60
LOGGING_ON_PAID =

The generated initializer's placeholder config.on_paid: it logs the settlement and fulfills nothing. Kept as a named constant so the engine can detect it at boot and warn while a host still ships it — orders recorded as settled without ever being fulfilled must not pass silently.

lambda do |settlement|
  ::Rails.logger.info(
    "[openreceive] order #{settlement.reference} paid (payment_hash #{settlement.payment_hash})"
  )
end
ASSET_BUILD_TASKS =

Rake tasks that are an ASSET BUILD, never a serving boot.

%w[assets:precompile assets:clean assets:clobber].freeze

Class Method Summary collapse

Class Method Details

.asset_build?Boolean

An asset build, by the two signals that are actually reliable. Rails.const_defined?(:Console)-style sniffing is not: the console constant exists in a serving boot too.

Returns:

  • (Boolean)


431
432
433
434
435
436
437
438
439
440
441
442
443
444
# File 'lib/openreceive/configuration.rb', line 431

def asset_build?
  # Rails' own convention for "a production boot with a throwaway secret",
  # set by its generated Dockerfile alongside `rails assets:precompile`.
  return true unless ENV["SECRET_KEY_BASE_DUMMY"].to_s.empty?

  # The honest test for older/hand-written build shapes: what was actually
  # invoked. `SECRET_KEY_BASE=dummy rails assets:precompile` lands here.
  return false unless defined?(::Rake)

  ::Rake.application.top_level_tasks.any? { |task| ASSET_BUILD_TASKS.include?(task.to_s) }
rescue StandardError
  # Rake present but without a usable application: not an asset build.
  false
end

.configObject



446
447
448
# File 'lib/openreceive/configuration.rb', line 446

def config
  @config ||= Configuration.new
end

.configure {|config| ... } ⇒ Object

Yields:



394
395
396
397
398
# File 'lib/openreceive/configuration.rb', line 394

def configure
  @configured = true
  yield(config) if block_given?
  config.reset_runtime!
end

.configured?Boolean

True once the host ran OpenReceive.configure — the engine's boot-time preflight only makes sense for a configured install (the gem may sit in a Gemfile before the installer has been run).

Returns:

  • (Boolean)


403
404
405
# File 'lib/openreceive/configuration.rb', line 403

def configured?
  @configured == true
end

.eager_preflight?Boolean

Whether the engine's production boot preflight should run (see Engine).

It must not run during rails assets:precompile. That is a production boot by RAILS_ENV, but it happens inside an image build where no wallet secrets are mounted — they arrive at deploy time — so the preflight would fail the BUILD, long before the deploy it exists to protect. Rails' own generated Dockerfile has exactly this shape.

Returns:

  • (Boolean)


414
415
416
# File 'lib/openreceive/configuration.rb', line 414

def eager_preflight?
  preflight_skip_reason.nil?
end

.listen_for_notifications!(overlap_seconds: 60) ⇒ Object

Opt-in NWC-02 notifications: subscribe to the configured NWC client's payment_received notifications. Notifications are authenticated wallet data — a payload that satisfies the shared settlement rule (settled_at or a settled transaction state; never a preimage alone) and matches a pending attempt settles that attempt directly through the engine's write-once settlement path (mark_paid_once! + on_paid), with no redundant wallet scan for that invoice. Anything less — no finality signal, an unknown hash, or a direct-settlement failure — falls back to one bounded OpenReceive.reconcile! pass. Polling (OpenReceive::ReconcileJob / bin/rails openreceive:reconcile) remains the safety net for notifications missed while offline. Direct settlement assumes the NWC client binds notification decryption to the connection's wallet pubkey; a client that skips author verification must not be granted it.

The client contract is one method, subscribe_notifications(&handler), yielding NWC-02 wire payloads (notification_type plus the transaction-shaped notification) — the shape NwcRubyReceiveClient adapts nwc-ruby's notification object to. The handler filters payment_received itself, like the Node listener: an NWC-02 subscription is not type-filtered, the wallet decides what it publishes. Returns whatever the client's subscribe call returns; blocking clients simply do not return until the subscription ends. Raises OpenReceive::ConfigurationError when the client does not support notifications.



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/openreceive/reconcile.rb', line 141

def listen_for_notifications!(overlap_seconds: 60)
  client = config.send(:resolved_nwc_client)
  unless client.respond_to?(:subscribe_notifications)
    raise ConfigurationError,
          "The configured NWC client does not support NWC-02 notifications " \
          "(no subscribe_notifications method). Notifications are optional; " \
          "keep polling with OpenReceive::ReconcileJob or " \
          "`bin/rails openreceive:reconcile`."
  end

  client.subscribe_notifications do |notification|
    next unless payment_received_notification?(notification)

    reconcile!(overlap_seconds: overlap_seconds) unless settle_from_notification!(notification)
  end
end

.maybe_reconcile!(now: nil) ⇒ Object

Opportunistic settlement discovery, piggybacked on any OpenReceive call (the engine's around_action runs it before every mounted route): skip without a wallet call when nothing is pending, try the durable openreceive_meta gate shared by every Puma worker ("gate_busy" means another worker just scanned — skip the wallet), otherwise AWAIT one bounded reconcile! pass and return its per-hash results. Never raises: a failed or timed-out scan warns and returns "scan_failed" — the caller's own request must not fail because a settlement sweep did, and claimed_at stays in place so a broken wallet cannot stampede.

Returns { "reason" => "ran", "checks" => [...] } or { "reason" => "disabled" | "no_pending" | "gate_busy" | "scan_failed" }. Exported for host code too: host-only routes (e.g. an app's own POST /orders) never auto-run it, but may call OpenReceive.maybe_reconcile!.



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/openreceive/reconcile.rb', line 86

def maybe_reconcile!(now: nil)
  setting = config.opportunistic_reconcile
  return { "reason" => "disabled" } if setting == false

  attempts = OpenReceivePayment.reconcilable_attempts
  return { "reason" => "no_pending" } if attempts.empty?

  observed_at = Integer(now || Time.now.to_i)
  interval = reconcile_gate_interval_seconds(attempts, observed_at, setting)
  unless OpenReceiveMeta.claim_reconcile_gate(now: observed_at, interval_seconds: interval)
    # Another worker scanned within the interval; this request pays nothing.
    openreceive_logger&.debug(
      "[openreceive] opportunistic reconcile: gate_busy " \
      "(#{attempts.length} pending, interval #{interval}s)"
    )
    return { "reason" => "gate_busy" }
  end

  checks = reconcile!(
    now: observed_at,
    max_pages: RECONCILE_SCAN_MAX_PAGES,
    deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + RECONCILE_SCAN_TIMEOUT_SECONDS
  )
  { "reason" => "ran", "checks" => checks }
rescue StandardError => e
  openreceive_logger&.warn(
    "[openreceive] opportunistic reconcile failed (will retry): #{sanitize_failure_message(e)}"
  )
  { "reason" => "scan_failed" }
end

.notifications_retry_delay(previous_delay, subscribed_seconds) ⇒ Object

Retry delay for the openreceive:notifications worker's subscribe loop: doubles per consecutive failure up to NOTIFICATIONS_MAX_BACKOFF_SECONDS, and a subscription that stayed up at least that long was healthy, so the next drop starts the ramp from scratch (mirrors the JS notifications worker, which reconnects fresh per subscription).



163
164
165
166
167
# File 'lib/openreceive/reconcile.rb', line 163

def notifications_retry_delay(previous_delay, subscribed_seconds)
  return 1 if previous_delay.nil? || subscribed_seconds >= NOTIFICATIONS_MAX_BACKOFF_SECONDS

  [previous_delay * 2, NOTIFICATIONS_MAX_BACKOFF_SECONDS].min
end

.preflight_skip_reasonObject

nil when the boot preflight should run; otherwise the short reason the engine logs, so an operator who expected a fail-closed boot and did not get one can see why in the same log line.



421
422
423
424
425
426
# File 'lib/openreceive/configuration.rb', line 421

def preflight_skip_reason
  return "config.eager_preflight = false" unless config.eager_preflight
  return "asset build" if asset_build?

  nil
end

.reconcile!(overlap_seconds: 60, now: nil, max_pages: nil, deadline: nil) ⇒ Object

One bounded reconciliation pass over the engine-owned payment ledger: scan the wallet for every pending attempt, deliver settlements through the settlement hook (write-once + on_paid), and persist terminal transitions so closed attempts leave the scan set. Attempt closure only ever happens from a successful wallet scan result observed at or after expiry plus OpenReceive::Server::Reconciliation::EXPIRY_GRACE_SECONDS — a local clock alone never closes a row, because a payment could have settled while the application was offline. A wallet failure raises and leaves every row pending for the next pass, and a hash absent from the pass results (a truncated scan never proved it absent) is no information — the attempt stays untouched.

Runs on any OpenReceive call via maybe_reconcile! (default), from the optional bin/rails openreceive:notifications worker, or one-shot from OpenReceive::ReconcileJob / bin/rails openreceive:reconcile. Returns the per-hash check results of the pass (an array of { "payment_hash", "status", "paid_at"?, "details"? } hashes) so callers — notably payments/check — can serve a requested hash straight from the pass instead of adding a second per-invoice wallet walk.



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/openreceive/reconcile.rb', line 44

def reconcile!(overlap_seconds: 60, now: nil, max_pages: nil, deadline: nil)
  attempts = OpenReceivePayment.reconcilable_attempts
  return [] if attempts.empty?

  observed_at = Integer(now || Time.now.to_i)
  request = {
    "attempts" => attempts,
    "overlap_seconds" => overlap_seconds,
    "until" => observed_at + overlap_seconds
  }
  request["max_pages"] = max_pages unless max_pages.nil?
  request["deadline"] = deadline unless deadline.nil?
  results = config.service.reconcile_payments(request)
  log_reconcile_pass(attempts, results, overlap_seconds, observed_at)
  by_hash = attempts.to_h { |attempt| [attempt.fetch("payment_hash"), attempt] }
  results.each do |checked|
    attempt = by_hash[checked.fetch("payment_hash")]
    next if attempt.nil?

    if checked["status"] == "settled" && checked["paid_at"]
      settle_attempt(checked)
    else
      record_attempt_transition(attempt, checked, observed_at)
    end
  end
  results
end

.reset_config!Object



450
451
452
453
# File 'lib/openreceive/configuration.rb', line 450

def reset_config!
  @config = nil
  @configured = false
end

.sanitize_failure_message(error) ⇒ Object

Failure text can embed wallet credentials (an NWC URI inside a connect error); redact them before the message reaches the host log, mirroring the JS redactSecrets URI patterns. Public because the long-lived openreceive:notifications worker — the process most likely to see a connect error — logs failures of its own.



174
175
176
177
178
# File 'lib/openreceive/reconcile.rb', line 174

def sanitize_failure_message(error)
  "#{error.class}: #{error.message}"
    .gsub(/nostr\+walletconnect:[^\s"'`<>]+/, "[REDACTED_NWC]")
    .gsub(/lightning\+swapconnect:[^\s"'`<>]+/, "[REDACTED_LSC]")
end