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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +33 -0
- data/app/models/open_receive_meta.rb +49 -28
- data/app/models/open_receive_payment.rb +107 -13
- data/lib/generators/openreceive/install/templates/initializer.rb +1 -1
- data/lib/openreceive/configuration.rb +3 -1
- data/lib/openreceive/generated/fulfillment_note.rb +17 -8
- data/lib/openreceive/rails/version.rb +1 -1
- data/lib/openreceive/reconcile.rb +108 -64
- data/lib/openreceive/reconcile_scan.rb +106 -0
- data/skills/debug-openreceive-payment/SKILL.md +1 -1
- data/skills/integrate-openreceive/SKILL.md +4 -3
- data/skills/integrate-openreceive/references/btcpay.md +67 -36
- data/skills/integrate-openreceive/references/django.md +291 -244
- data/skills/integrate-openreceive/references/fastapi.md +206 -155
- data/skills/integrate-openreceive/references/fastify.md +200 -147
- data/skills/integrate-openreceive/references/laravel.md +294 -234
- data/skills/integrate-openreceive/references/next.md +210 -162
- data/skills/integrate-openreceive/references/node.md +188 -138
- data/skills/integrate-openreceive/references/php.md +248 -193
- data/skills/integrate-openreceive/references/rails.md +262 -218
- data/skills/integrate-openreceive/references/woocommerce.md +68 -51
- metadata +6 -5
|
@@ -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 (
|
|
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 =
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
|
33
|
-
#
|
|
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,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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=#{
|
|
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 —
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
38
|
-
|
|
39
|
-
|
|
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.
|
|
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 (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
135
|
-
payments
|
|
136
|
-
BTCPay invoice with USDT, USDC, ETH or SOL through a Lightning Swap Connect
|
|
137
|
-
provider
|
|
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
|
|
142
|
-
invoices, checkout, webhooks and Greenfield API
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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.
|