openreceive-rails 0.4.10 → 0.4.13

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.
@@ -2,17 +2,15 @@
2
2
 
3
3
  require "json"
4
4
  require "openreceive/server"
5
+ require "openreceive/reconcile_scan"
5
6
 
6
7
  module OpenReceive
7
8
  # Floor for the durable reconcile-gate interval (seconds); stretched by
8
- # invoice age (2s while any pending invoice is under 2 minutes old, 6s under
9
+ # invoice age (3s while any pending invoice is under 2 minutes old, 6s under
9
10
  # 5 minutes, else 12s). Mirrors the JS OPENRECEIVE_MIN_RECONCILE_INTERVAL_SECONDS.
10
- MIN_RECONCILE_INTERVAL_SECONDS = 2
11
- # Wall-clock bound on an awaited request-path pass. Enforced as a deadline
12
- # the wallet scan checks between page fetches rather than a Timeout.timeout:
13
- # Thread#raise at an arbitrary point could tear down an ActiveRecord
14
- # connection or kill a host's on_paid fulfillment mid-flight, on every
15
- # winning request. A pass that runs out of budget simply stops walking.
11
+ MIN_RECONCILE_INTERVAL_SECONDS = 3
12
+ # The deadline reaches the wallet adapter, which bounds only network I/O.
13
+ # Never interrupt the whole pass: it also runs host/database transactions.
16
14
  RECONCILE_SCAN_TIMEOUT_SECONDS = 9
17
15
  # Wallet-history pages a request-path pass may walk, mirroring the JS
18
16
  # OPENRECEIVE_RECONCILE_SCAN_MAX_PAGES.
@@ -29,8 +27,8 @@ module OpenReceive
29
27
  # from a successful wallet scan result observed at or after expiry plus
30
28
  # OpenReceive::Server::Reconciliation::EXPIRY_GRACE_SECONDS — a local clock
31
29
  # alone never closes a row, because a payment could have settled while the
32
- # application was offline. A wallet failure raises and leaves every row
33
- # pending for the next pass, and a hash absent from the pass results (a
30
+ # application was offline. A later wallet failure preserves already committed
31
+ # finality and leaves unresolved rows pending. A hash absent from pass results (a
34
32
  # truncated scan never proved it absent) is no information — the attempt
35
33
  # stays untouched.
36
34
  #
@@ -41,34 +39,6 @@ module OpenReceive
41
39
  # { "payment_hash", "status", "paid_at"?, "details"? } hashes) so callers —
42
40
  # notably payments/check — can serve a requested hash straight from the
43
41
  # pass instead of adding a second per-invoice wallet walk.
44
- def reconcile!(overlap_seconds: 60, now: nil, max_pages: nil, deadline: nil)
45
- attempts = OpenReceivePayment.reconcilable_attempts
46
- return [] if attempts.empty?
47
-
48
- observed_at = Integer(now || Time.now.to_i)
49
- request = {
50
- "attempts" => attempts,
51
- "overlap_seconds" => overlap_seconds,
52
- "until" => observed_at + overlap_seconds
53
- }
54
- request["max_pages"] = max_pages unless max_pages.nil?
55
- request["deadline"] = deadline unless deadline.nil?
56
- results = config.service.reconcile_payments(request)
57
- log_reconcile_pass(attempts, results, overlap_seconds, observed_at)
58
- by_hash = attempts.to_h { |attempt| [attempt.fetch("payment_hash"), attempt] }
59
- results.each do |checked|
60
- attempt = by_hash[checked.fetch("payment_hash")]
61
- next if attempt.nil?
62
-
63
- if checked["status"] == "settled" && checked["paid_at"]
64
- settle_attempt(checked)
65
- else
66
- record_attempt_transition(attempt, checked, observed_at)
67
- end
68
- end
69
- results
70
- end
71
-
72
42
  # Opportunistic settlement discovery, piggybacked on any OpenReceive call
73
43
  # (the engine's around_action runs it before every mounted route): skip
74
44
  # without a wallet call when nothing is pending, try the durable
@@ -87,30 +57,104 @@ module OpenReceive
87
57
  setting = config.opportunistic_reconcile
88
58
  return { "reason" => "disabled" } if setting == false
89
59
 
60
+ gated_reconcile!(now: now)
61
+ end
62
+
63
+ def reconcile!(overlap_seconds: 60, now: nil)
64
+ gated_reconcile!(overlap_seconds: overlap_seconds, now: now).fetch("checks", [])
65
+ end
66
+
67
+ def gated_reconcile!(overlap_seconds: 60, now: nil)
90
68
  attempts = OpenReceivePayment.reconcilable_attempts
91
69
  return { "reason" => "no_pending" } if attempts.empty?
92
70
 
93
71
  observed_at = Integer(now || Time.now.to_i)
94
- interval = reconcile_gate_interval_seconds(attempts, observed_at, setting)
95
- unless OpenReceiveMeta.claim_reconcile_gate(now: observed_at, interval_seconds: interval)
96
- # Another worker scanned within the interval; this request pays nothing.
97
- openreceive_logger&.debug(
98
- "[openreceive] opportunistic reconcile: gate_busy " \
99
- "(#{attempts.length} pending, interval #{interval}s)"
100
- )
101
- return { "reason" => "gate_busy" }
72
+ interval = reconcile_gate_interval_seconds(attempts, observed_at, config.opportunistic_reconcile)
73
+ claim = OpenReceiveMeta.claim_reconcile_gate(now: observed_at, interval_seconds: interval)
74
+ return { "reason" => "gate_busy" } if claim.nil?
75
+
76
+ scheduler = claim.fetch("scheduler")
77
+ windows = scheduler.fetch("windows")
78
+ if windows.length < 2
79
+ candidates = OpenReceivePayment.reconcilable_attempts(after: scheduler["cursor"])
80
+ if candidates.empty?
81
+ scheduler["cursor"] = nil
82
+ candidates = OpenReceivePayment.reconcilable_attempts
83
+ end
84
+ unless candidates.empty?
85
+ last = candidates.last
86
+ scheduler["cursor"] = candidates.length < Server::RECONCILE_BATCH_SIZE ? nil : last.slice("created_at", "payment_hash")
87
+ queued = windows.flat_map { |w| w.fetch("attempts").map { |a| a.fetch("payment_hash") } }
88
+ cohort = candidates.reject { |a| queued.include?(a.fetch("payment_hash")) }
89
+ # A host-clock attempt's window spans the whole wallet history. It
90
+ # gets its own window, so it never drags wallet-timed attempts into
91
+ # that walk; one the cap leaves out returns on cursor wrap.
92
+ cohort.partition { |a| a["created_at_source"] == "wallet" }.each do |group|
93
+ windows << ReconcileScan.new_window(group, observed_at, overlap_seconds) unless group.empty? || windows.length >= 2
94
+ end
95
+ end
96
+ end
97
+ window = windows.shift
98
+ # Checkpoint without the active window, so failures and process loss free
99
+ # its slot. Pending rows return on cursor wrap; successful slices resume.
100
+ checkpoint_now = now.nil? ? Time.now.to_i : observed_at
101
+ return { "reason" => "gate_busy" } unless OpenReceiveMeta.checkpoint_reconcile_gate(claim, scheduler, now: checkpoint_now)
102
+ if window.nil?
103
+ OpenReceiveMeta.checkpoint_reconcile_gate(claim, scheduler, now: checkpoint_now, release: true)
104
+ return { "reason" => "no_pending" }
102
105
  end
103
106
 
104
- checks = reconcile!(
105
- now: observed_at,
107
+ by_hash = window.fetch("attempts").to_h { |a| [a.fetch("payment_hash"), a] }
108
+ delivered = {}
109
+ before_scan = JSON.parse(JSON.generate(scheduler))
110
+ deliver_finality = lambda do |checked|
111
+ hash = checked.fetch("payment_hash")
112
+ current = now.nil? ? Time.now.to_i : observed_at
113
+ unless OpenReceiveMeta.checkpoint_reconcile_gate(claim, before_scan, now: current)
114
+ delivered[hash] = false
115
+ next
116
+ end
117
+ if checked["status"] == "settled" && checked["paid_at"]
118
+ delivered[hash] = settle_attempt(checked)
119
+ else
120
+ record_attempt_transition(by_hash.fetch(hash), checked, observed_at)
121
+ delivered[hash] = true
122
+ end
123
+ end
124
+ checks, complete, stalled = ReconcileScan.slice(config.service, window,
106
125
  max_pages: RECONCILE_SCAN_MAX_PAGES,
107
- deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + RECONCILE_SCAN_TIMEOUT_SECONDS
108
- )
109
- { "reason" => "ran", "checks" => checks }
126
+ deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + RECONCILE_SCAN_TIMEOUT_SECONDS, on_finality: deliver_finality)
127
+ lease_owned = OpenReceiveMeta.checkpoint_reconcile_gate(claim, before_scan, now: now.nil? ? Time.now.to_i : observed_at)
128
+ committed = checks.filter_map do |checked|
129
+ if delivered.key?(checked.fetch("payment_hash"))
130
+ next unless delivered.fetch(checked.fetch("payment_hash"))
131
+ elsif checked["status"] == "settled" && checked["paid_at"]
132
+ next
133
+ else
134
+ next unless lease_owned
135
+
136
+ record_attempt_transition(by_hash.fetch(checked.fetch("payment_hash")), checked,
137
+ checked.fetch("_coverage_started_at", observed_at))
138
+ end
139
+ checked.reject { |key, _| key.start_with?("_") }
140
+ end
141
+ unless complete || stalled
142
+ times = window.fetch("attempts").map { |a| a.fetch("created_at") }.uniq.sort
143
+ if windows.empty? && times.length > 1 && window.fetch("attempts").all? { |a| a["created_at_source"] == "wallet" }
144
+ middle = times[times.length / 2]
145
+ window.fetch("attempts").partition { |a| a.fetch("created_at") < middle }.each do |half|
146
+ windows << ReconcileScan.new_window(half, observed_at, overlap_seconds)
147
+ end
148
+ else
149
+ windows << window
150
+ end
151
+ end
152
+ checkpoint_now = now.nil? ? Time.now.to_i : observed_at
153
+ OpenReceiveMeta.checkpoint_reconcile_gate(claim, scheduler, now: checkpoint_now, release: true)
154
+ log_reconcile_pass(window.fetch("attempts"), committed, window)
155
+ { "reason" => "ran", "checks" => committed }
110
156
  rescue StandardError => e
111
- openreceive_logger&.warn(
112
- "[openreceive] opportunistic reconcile failed (will retry): #{sanitize_failure_message(e)}"
113
- )
157
+ openreceive_logger&.warn("[openreceive] reconciliation failed (will retry): #{sanitize_failure_message(e)}")
114
158
  { "reason" => "scan_failed" }
115
159
  end
116
160
 
@@ -172,13 +216,12 @@ module OpenReceive
172
216
  # `openreceive:notifications` worker — the process most likely to see a
173
217
  # connect error — logs failures of its own.
174
218
  def sanitize_failure_message(error)
175
- "#{error.class}: #{error.message}"
176
- .gsub(/nostr\+walletconnect:[^\s"'`<>]+/, "[REDACTED_NWC]")
177
- .gsub(/lightning\+swapconnect:[^\s"'`<>]+/, "[REDACTED_LSC]")
219
+ OpenReceive::Nwc.redact_error_text("#{error.class}: #{error.message}")
178
220
  end
179
221
 
180
222
  private
181
223
 
224
+
182
225
  # Rails.logger when the engine runs inside Rails; nil in bare-gem tests.
183
226
  # Settlement behavior never depends on logging.
184
227
  def openreceive_logger
@@ -196,11 +239,13 @@ module OpenReceive
196
239
  "paid_at" => checked.fetch("paid_at"),
197
240
  "details" => checked["details"]
198
241
  )
242
+ OpenReceivePayment.where(payment_hash: checked.fetch("payment_hash"), status: "settled").exists?
199
243
  rescue StandardError => e
200
244
  openreceive_logger&.warn(
201
245
  "[openreceive] settlement for #{checked.fetch('payment_hash')} failed " \
202
246
  "(will retry next pass): #{sanitize_failure_message(e)}"
203
247
  )
248
+ false
204
249
  end
205
250
 
206
251
  # Closure is decided by the shared reconciliation rules from a scan result
@@ -226,14 +271,14 @@ module OpenReceive
226
271
  )
227
272
  end
228
273
 
229
- # Info, not debug: passes are durably gated (min 2s apart, and only while
274
+ # Info, not debug: passes are durably gated (min 3s apart, and only while
230
275
  # attempts are pending), so operators can watch settlement discovery and
231
276
  # the batched list_transactions window without raising the log level. All
232
277
  # pending attempts share one creation-time window walked at most twice —
233
278
  # never one wallet call per invoice. One short line per poll: this fires
234
279
  # on every status poll while a payer waits. Mirrors the JS
235
280
  # payment.reconcile.completed line.
236
- def log_reconcile_pass(attempts, results, overlap_seconds, observed_at)
281
+ def log_reconcile_pass(attempts, results, window)
237
282
  logger = openreceive_logger
238
283
  return if logger.nil?
239
284
 
@@ -245,10 +290,9 @@ module OpenReceive
245
290
  decided = ["0 decided"] if decided.empty?
246
291
  # Attempts scanned vs hashes decided: a gap is how a truncated scan shows up.
247
292
  scanned = results.length == attempts.length ? "" : " of #{attempts.length} attempts"
248
- window_from = [attempts.map { |attempt| Integer(attempt.fetch("created_at")) }.min - overlap_seconds, 0].max
249
293
  logger.info(
250
294
  "[openreceive] payment.reconcile.completed: #{decided.join(', ')}#{scanned} " \
251
- "attempt_count=#{attempts.length} window=#{window_from}..#{observed_at + overlap_seconds}"
295
+ "attempt_count=#{attempts.length} window=#{window.fetch("from")}..#{window["until"] || "unbounded"}"
252
296
  )
253
297
  rescue StandardError
254
298
  # Diagnostics must never affect the pass.
@@ -257,7 +301,7 @@ module OpenReceive
257
301
 
258
302
  # The gate interval for the current pending set: the configured floor
259
303
  # (config.opportunistic_reconcile min_interval_seconds), stretched by
260
- # invoice age — 2s while any pending invoice is under 2 minutes old, 6s
304
+ # invoice age — 3s while any pending invoice is under 2 minutes old, 6s
261
305
  # under 5 minutes, else 12s. Mirrors the JS reconcile gate.
262
306
  def reconcile_gate_interval_seconds(attempts, now, setting)
263
307
  floor = MIN_RECONCILE_INTERVAL_SECONDS
@@ -268,7 +312,7 @@ module OpenReceive
268
312
  age_stretch = attempts.map do |attempt|
269
313
  elapsed = [now - Integer(attempt.fetch("created_at")), 0].max
270
314
  if elapsed < 120
271
- 2
315
+ 3
272
316
  elsif elapsed < 300
273
317
  6
274
318
  else
@@ -303,7 +347,7 @@ module OpenReceive
303
347
  payment_hash = transaction["payment_hash"].to_s.downcase
304
348
  return false if payment_hash.empty?
305
349
 
306
- return false unless OpenReceivePayment.pending.where(payment_hash: payment_hash).exists?
350
+ return false if OpenReceivePayment.find_pending_attempt(payment_hash).nil?
307
351
 
308
352
  observed_at = Time.now.to_i
309
353
  config.settlement_hook.call(
@@ -315,7 +359,7 @@ module OpenReceive
315
359
  "paid_at_source" => transaction["settled_at"] ? "settled_at" : "observed_at"
316
360
  }
317
361
  )
318
- true
362
+ OpenReceivePayment.where(payment_hash: payment_hash, status: "settled").exists?
319
363
  rescue StandardError
320
364
  # A direct-settlement failure falls back to the scan-based safety net.
321
365
  false
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module OpenReceive
7
+ # Durable scan slices store only identities, offsets and minimal classifications.
8
+ module ReconcileScan
9
+ module_function
10
+
11
+ def new_window(attempts, now, overlap)
12
+ trusted = attempts.all? { |a| a["created_at_source"] == "wallet" }
13
+ {
14
+ "attempts" => attempts,
15
+ "from" => trusted ? [attempts.map { |a| a.fetch("created_at") }.min - overlap, 0].max : 0,
16
+ "until" => trusted ? attempts.map { |a| a.fetch("created_at") }.max + overlap : nil,
17
+ "view" => "default", "offset" => 0, "anchor_offset" => nil, "fingerprint" => nil,
18
+ "started_at" => now, "absence_safe" => true, "observations" => {}
19
+ }
20
+ end
21
+
22
+ def slice(service, window, max_pages:, deadline:, on_finality: nil)
23
+ results = {}
24
+ expected = window.fetch("attempts").map { |a| a.fetch("payment_hash") }
25
+ resumed = window.fetch("offset").positive? || window.fetch("view") != "default"
26
+ window["absence_safe"] = false if resumed
27
+ anchor = resumed ? window["anchor_offset"] : nil
28
+ replaying = !anchor.nil?
29
+ previous = window["fingerprint"]
30
+ max_pages.times do |page_number|
31
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
32
+
33
+ offset = replaying ? anchor : window.fetch("offset")
34
+ request = { "type" => "incoming", "limit" => 20, "offset" => offset, "from" => window.fetch("from") }
35
+ request["until"] = window["until"] unless window["until"].nil?
36
+ request["unpaid"] = true if window.fetch("view") == "inclusive"
37
+ request["_deadline"] = deadline
38
+ page = begin
39
+ OpenReceive.normalize_list_transactions_response(service.send(:call_nwc, :list_transactions, request))
40
+ rescue Server::WalletFailureError
41
+ # The client cuts the in-flight page at this deadline. After at least
42
+ # one answered page that ends the slice like a page answered late:
43
+ # the completed pages keep their progress. Failing the pass would drop
44
+ # the resume offset, and a wallet too slow to finish the walk in one
45
+ # slice would then re-walk the same first pages on every pass. A cut
46
+ # first page made no progress and still fails the pass, so a wallet
47
+ # that never answers stays visible.
48
+ raise if page_number.zero? || Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
49
+
50
+ return [results.values, false, false]
51
+ end
52
+ return [results.values, false, false] if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
53
+
54
+ rows = page.fetch("transactions")
55
+ physical = rows.length + page.fetch("skipped_rows", 0)
56
+ fingerprint = Digest::SHA256.hexdigest(JSON.generate(rows.map { |row| row["payment_hash"] }))
57
+ rows.each do |row|
58
+ hash = row["payment_hash"]
59
+ next unless expected.include?(hash) && [nil, "incoming"].include?(row["type"])
60
+ next if window.fetch("observations").dig(hash, "status") == "settled"
61
+
62
+ status = OpenReceive::Settlement.status(row)
63
+ if %w[settled expired failed].include?(status)
64
+ results[hash] = service.send(:payment_result, hash, row)
65
+ on_finality&.call(results[hash]) if Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
66
+ end
67
+ window.fetch("observations")[hash] = { "status" => status, "transaction_state" => row["transaction_state"] }
68
+ end
69
+ if replaying
70
+ replaying = false
71
+ window["offset"] = offset + physical
72
+ previous = fingerprint
73
+ unless physical.zero?
74
+ window["anchor_offset"] = offset
75
+ window["fingerprint"] = fingerprint
76
+ next
77
+ end
78
+ end
79
+ if physical.zero?
80
+ if window.fetch("view") == "default"
81
+ window.merge!("view" => "inclusive", "offset" => 0, "anchor_offset" => nil, "fingerprint" => nil)
82
+ previous = nil
83
+ next
84
+ end
85
+ if window.fetch("absence_safe")
86
+ expected.each do |hash|
87
+ observation = window.fetch("observations")[hash]
88
+ next if results.key?(hash) || %w[settled expired failed].include?(observation&.fetch("status"))
89
+
90
+ result = { "payment_hash" => hash, "status" => observation.nil? ? "not_found" : observation.fetch("status"), "_coverage_started_at" => window.fetch("started_at") }
91
+ result["details"] = { "transaction" => { "transaction_state" => observation["transaction_state"] } } unless observation.nil?
92
+ results[hash] = result
93
+ end
94
+ end
95
+ return [results.values, true, false]
96
+ end
97
+ return [results.values, false, true] if fingerprint == previous
98
+
99
+ window.merge!("anchor_offset" => offset, "fingerprint" => fingerprint, "offset" => offset + physical)
100
+ previous = fingerprint
101
+ return [results.values, true, false] if expected.all? { |hash| %w[settled expired failed].include?(window.fetch("observations").dig(hash, "status")) }
102
+ end
103
+ [results.values, false, false]
104
+ end
105
+ end
106
+ end
@@ -49,7 +49,7 @@ same diagnostics redacted, always exit 0 — safe to share.
49
49
  ## 4. Paid but never settles
50
50
 
51
51
  - Settlement is opportunistic: any OpenReceive request runs one reconcile pass
52
- through a durable gate (min 2s between wallet scans, stretched by invoice
52
+ through a durable gate (min 3s between wallet scans, stretched by invoice
53
53
  age). A quiet server settles on the next request — or run the optional
54
54
  notification worker. No timer is missing; that is the design.
55
55
  - An unpaid attempt closes only after a successful wallet scan at/after expiry
@@ -34,9 +34,10 @@ code** (`NWC_URI`).
34
34
  - BTCPay Server: [references/btcpay.md](references/btcpay.md) — a plugin,
35
35
  configured in BTCPay's store UI or Greenfield API; no application code,
36
36
  no npm packages, no gem. The rest of this file is about the library.
37
- 3. Follow its **Step 0** first: confirm `NWC_URI` is set in the server
38
- environment before writing code. Never print the value; never invent a
39
- placeholder.
37
+ 3. Follow its **Step 0** first: before writing code or searching the machine,
38
+ ask the user for the receive-only NWC code (then the swap URI), one question
39
+ per message, and store each pasted code in the project's env file yourself.
40
+ Never print the value; never invent a placeholder.
40
41
 
41
42
  Install, per adapter — Express: `npm install @openreceive/express @openreceive/react`;
42
43
  Fastify: `npm install @openreceive/fastify @openreceive/react`; Next.js:
@@ -1,6 +1,6 @@
1
1
  # OpenReceive agent directions (BTCPay Server)
2
2
 
3
- These directions describe OpenReceive 0.4.10.
3
+ These directions describe OpenReceive 0.4.13.
4
4
 
5
5
  Connect a BTCPay Server store to a receive-only NWC wallet with the OpenReceive
6
6
  plugin, and optionally let payers pay BTCPay invoices with USDT, USDC, ETH or
@@ -35,10 +35,12 @@ refund path on the same checkout screen.
35
35
  1. Confirm the BTCPay Server version is 2.4.4 or later (Server Settings →
36
36
  About, or `GET /api/v1/server/info`). The plugin declares that minimum and
37
37
  BTCPay refuses to load it below.
38
- 2. Check whether the plugin is installed (Server Settings → Plugins, or the
39
- store navigation shows an "OpenReceive" entry). If not, install it from the
40
- BTCPay plugin directory (Server Settings → Plugins, search "OpenReceive"),
41
- as the quickstart says; do not invent an installer command.
38
+ 2. Check whether the plugin is installed (the Plugins menu — the plug icon in
39
+ the top-right corner — under Installed Plugins, or the store navigation
40
+ shows an "OpenReceive" entry). If not, install it from the BTCPay plugin
41
+ directory (the same Plugins menu → Plugin Directory, search "openreceive",
42
+ then Install and Restart now), as the quickstart says; do not invent an
43
+ installer command.
42
44
  3. Check whether the store already has an OpenReceive connection:
43
45
  `GET /api/v1/stores/{storeId}/openreceive/settings` returns
44
46
  `lightningNodeIsOpenReceive`. If true, the wallet step is done — go to
@@ -119,6 +121,8 @@ enough; drop the `.md` for the same page a person would read.
119
121
  Questions, or a problem with the plugin itself:
120
122
  https://openreceive.org/contact
121
123
 
124
+ - https://openreceive.org/guides/payment-safety-upgrade.md — coordinated upgrades and reviewed repair of existing attempts
125
+
122
126
  ---
123
127
 
124
128
  ## The quickstart, in full
@@ -131,15 +135,15 @@ passes. The page it comes from is https://openreceive.org/guides/quickstart-btcp
131
135
  Requires BTCPay Server ≥ 2.4.4.
132
136
 
133
137
  The OpenReceive plugin makes a receive-only NWC wallet the Lightning node of a
134
- BTCPay store. BTCPay mints every Lightning invoice in that wallet and records
135
- payments through its own settlement machinery. Optionally, payers can pay a
136
- BTCPay invoice with USDT, USDC, ETH or SOL through a Lightning Swap Connect
137
- provider; the swap settles into the same wallet. The store's internal node is
138
+ BTCPay store. BTCPay creates every Lightning invoice in that wallet. It records
139
+ payments the same way it records any other payment. You can also let payers pay
140
+ a BTCPay invoice with USDT, USDC, ETH or SOL through a Lightning Swap Connect
141
+ provider. The swap pays into the same wallet. The store's internal node is
138
142
  never used.
139
143
 
140
144
  This is not the Node or Rails library. There are no hooks, no
141
- `openreceive_payments` table and no OpenReceive HTTP routes: BTCPay's
142
- invoices, checkout, webhooks and Greenfield API are the host.
145
+ `openreceive_payments` table and no OpenReceive HTTP routes. BTCPay's own
146
+ invoices, checkout, webhooks and Greenfield API do that work.
143
147
 
144
148
  ### 1. Prerequisites
145
149
 
@@ -155,39 +159,66 @@ invoices, checkout, webhooks and Greenfield API are the host.
155
159
 
156
160
  ### 2. Install the plugin
157
161
 
158
- In BTCPay, open **Server Settings → Plugins**, search the plugin directory
159
- for **OpenReceive**, click **Install**, and restart BTCPay when prompted.
160
- BTCPay creates the plugin's one table (`openreceive_swaps`, schema
161
- `BTCPayServer.Plugins.OpenReceive`) in its own Postgres at startup; nothing
162
- else is created.
162
+ Sign in as a **server administrator**. If someone else hosts your server, ask
163
+ them to install the plugin for you.
164
+
165
+ **1. Open the Plugins menu.** It is the plug icon in the top-right corner.
166
+
167
+ **2. Click Plugin Directory.**
168
+
169
+ **3. Search for `openreceive`** and click the **OpenReceive** result.
170
+
171
+ **4. Click Install in BTCPay Server.** Confirm when prompted, then click
172
+ **Restart now** and wait for BTCPay to come back.
173
+
174
+ At startup, BTCPay creates the plugin's two tables in its own Postgres
175
+ database: `openreceive_invoices` and `openreceive_swaps`, in the schema
176
+ `BTCPayServer.Plugins.OpenReceive`. Nothing else is created.
163
177
 
164
178
  To build the plugin from source instead, follow
165
179
  [the .NET workspace README](https://github.com/OpenReceive/openreceive/blob/master/packages/dotnet/README.md).
166
180
 
167
181
  ### 3. Connect the wallet
168
182
 
169
- Follow the plugin README's illustrated walkthrough:
170
- [OpenReceive for BTCPay Server](https://github.com/OpenReceive/openreceive/blob/master/packages/dotnet/BTCPayServer.Plugins.OpenReceive/README.md).
171
- It opens the **OpenReceive** page in the store's sidebar, saves the
172
- receive-only NWC code, optionally saves the LSC code to turn swaps on, and
173
- creates a first test invoice. There is nothing else to configure: you never
174
- open BTCPay's Lightning node screen, and the plugin never reads the internal
175
- node.
183
+ 1. Select your store and open **OpenReceive** in its sidebar, under Wallets.
184
+ 2. Paste your receive-only NWC code. To see what the wallet supports first,
185
+ click **Test connection**.
186
+ 3. Click **Save NWC Code**.
187
+ 4. To turn swaps on, paste a Lightning Swap Connect code and click **Save swap
188
+ settings**.
176
189
 
177
- Saving fails closed if the wallet advertises a spend method such as
178
- `pay_invoice`. Mint a receive-only code instead; the override for a wallet
179
- that cannot is a deliberate, logged choice. Swaps raise the store's invoice
180
- expiration to 60 minutes when it is shorter, because a swap needs at least 45
181
- minutes of invoice life.
190
+ The page then shows **Wallet connected**. If you set up a provider, it also
191
+ shows **Swaps on**. There is nothing else to configure. You never open BTCPay's
192
+ Lightning node screen, and the plugin never reads the internal node.
193
+
194
+ Screenshots for each of those steps, and for creating a first test invoice,
195
+ are in the plugin's
196
+ [README](https://github.com/OpenReceive/openreceive/blob/master/packages/dotnet/BTCPayServer.Plugins.OpenReceive/README.md).
197
+
198
+ The plugin refuses to save a code whose wallet advertises a spend method such
199
+ as `pay_invoice`. Create a receive-only code instead. If your wallet cannot
200
+ make one, there is an override, but using it is a deliberate choice and the
201
+ plugin logs it.
202
+
203
+ Turning swaps on raises the store's invoice expiration to 60 minutes if it is
204
+ shorter. A swap needs the invoice to stay open for at least 45 minutes.
182
205
 
183
206
  ### 4. Check it
184
207
 
185
- **Run a health check** on the OpenReceive page runs every probe in place:
186
- the connection, the wallet preflight, payment notifications, the last wallet
187
- scan, the swap provider and its assets, the invoice expiration, and swaps
188
- that need a human. Each failing probe carries a fix link.
208
+ Click **Run a health check** on the OpenReceive page. It runs every check
209
+ right there:
210
+
211
+ - the connection
212
+ - the wallet preflight
213
+ - payment notifications
214
+ - the last wallet scan
215
+ - the swap provider and its assets
216
+ - the invoice expiration
217
+ - swaps that need a human
218
+
219
+ Each failing check comes with a link to the fix.
189
220
 
190
- Every setting, Greenfield route, swap state, log event and probe is in the
191
- [BTCPay plugin reference](https://openreceive.org/guides/btcpay-reference.md), including what is
192
- unsupported by design: every send-side feature, top-up invoices, and a bare
193
- `nostr+walletconnect://` string in BTCPay's Lightning node screen.
221
+ The [BTCPay plugin reference](https://openreceive.org/guides/btcpay-reference.md) lists every setting,
222
+ Greenfield route, swap state, log event and check. It also lists what the
223
+ plugin does not support by design: every send-side feature, top-up invoices,
224
+ and a bare `nostr+walletconnect://` string in BTCPay's Lightning node screen.