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:notificationsworker'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
-
.asset_build? ⇒ Boolean
An asset build, by the two signals that are actually reliable.
- .config ⇒ Object
- .configure {|config| ... } ⇒ Object
-
.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).
-
.eager_preflight? ⇒ Boolean
Whether the engine's production boot preflight should run (see Engine).
-
.listen_for_notifications!(overlap_seconds: 60) ⇒ Object
Opt-in NWC-02 notifications: subscribe to the configured NWC client's
payment_receivednotifications. -
.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.
-
.notifications_retry_delay(previous_delay, subscribed_seconds) ⇒ Object
Retry delay for the
openreceive:notificationsworker'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). -
.preflight_skip_reason ⇒ Object
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.
-
.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.
- .reset_config! ⇒ Object
-
.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.
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.
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 |
.config ⇒ Object
446 447 448 |
# File 'lib/openreceive/configuration.rb', line 446 def config @config ||= Configuration.new end |
.configure {|config| ... } ⇒ Object
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).
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.
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): #{(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_reason ⇒ Object
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 (error) "#{error.class}: #{error.}" .gsub(/nostr\+walletconnect:[^\s"'`<>]+/, "[REDACTED_NWC]") .gsub(/lightning\+swapconnect:[^\s"'`<>]+/, "[REDACTED_LSC]") end |