openreceive-server 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c474debc4035b3cb2f6a5b4f3b7e4ee3088c99abfead02e92e23a37d4a26b5db
4
- data.tar.gz: ba690e555070b4349fff546f99b2fc737f2787607593c6577fb7ef3bdc849007
3
+ metadata.gz: c33517376bf957f25550f721694be4652218083b8026847ce2836c7dbd586464
4
+ data.tar.gz: 14988254c0ec4cb7cbccc4a44733c20ef7a3860f870242b0184e1f3a895f4173
5
5
  SHA512:
6
- metadata.gz: cf6536250d18edf1a741d32cdb30aac45a309b1663c9ae2e219df5897987e7d53f0f6dc522a6309bd73faec98f65bdbb45f4882779ad5e9a1b369da04b04e0c0
7
- data.tar.gz: 4526bd34483b0b3d9480cf25d13c291cecc0a7d3bae9155be76fa677e07a17ed99c8d5d0eb27bc3fac010fdf12c9b09c23492cffdc0e0c17f5b1ddc3f37635ed
6
+ metadata.gz: a95e7527885746968412329f1bcde0a810533dc6c970f4aa7fb620e3fad02087d97c99551771f7c7c08e108044a1501a650f6e234e3da3d073ae3e0a1fe65e76
7
+ data.tar.gz: 493ae52bcf9179b3af779d50697f8b9fe03e62dfd0277797056ad50f268e80a6a2f5f77f51583e642a4d07b8387c41960a295aa33065165b36c97d65ac74800c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.13 - 2026-10-01
4
+
5
+ Release in lockstep with the 0.4.13 reconciliation fixes (in
6
+ `openreceive-rails`: a slow wallet no longer livelocks the reconcile pass,
7
+ host-clock attempts get their own scan window, the gate floor is 3 s). No
8
+ change to this gem's API.
9
+
10
+ ## 0.4.12 - 2026-10-01
11
+
12
+ Release in lockstep with the 0.4.12 checkout fixes. The swap deposit
13
+ warning text the browser shows no longer carries the "pay with one method
14
+ only" sentence; it is built in the browser package, not here. No Ruby server
15
+ API or payment behavior changes.
16
+
17
+ ## 0.4.11 - 2026-09-21
18
+
19
+ The mounted HTTP routes mint invoices with the host's own description. The
20
+ display string `amount_for` returns beside the price is now the default
21
+ `make_invoice` memo, so a host that writes no invoice code stops minting
22
+ BOLT11s with an empty description. An explicit request-body `memo` still wins
23
+ and still carries the length cap; a host that returns no description still
24
+ mints without one.
25
+
26
+ Payment safety and disclosure fixes: a checkout's `created_at_source` (whether
27
+ the creation time came from the wallet or from us) is internal and is stripped
28
+ from every public body, error responses carry a redacted message and no
29
+ arbitrary internal `details`, unexpected errors are reported to `Rails.error`
30
+ detached from their cause and backtrace, a host callback that returns an
31
+ invalid amount raises `InternalHostError` instead of leaking a validation
32
+ failure, and a refund address is rejected unless the attempt has a saved,
33
+ supported pay-in asset. A host that refuses an attempt now answers 409 with
34
+ instructions withheld, while genuine storage failure stays a retryable 503.
35
+
3
36
  ## 0.4.10 - 2026-09-16
4
37
 
5
38
  Release in lockstep with the 0.4.10 stablecoin checkout fix. The swap fee
@@ -97,7 +97,7 @@ module OpenReceive
97
97
  class HostPersistenceError < StandardError
98
98
  attr_reader :status, :code, :retryable
99
99
 
100
- def initialize(message = "The host could not persist this payment attempt; " \
100
+ def initialize(message = "Payment storage is unavailable; " \
101
101
  "payer instructions were withheld. Please retry.")
102
102
  super(message)
103
103
  @status = 503
@@ -88,7 +88,7 @@ module OpenReceive
88
88
  else
89
89
  @service.create_checkout(
90
90
  "reference" => reference, "amount" => required_amount(resolved),
91
- "memo" => validated_memo(body), "metadata" => body["metadata"]
91
+ "memo" => mint_memo(body, resolved), "metadata" => body["metadata"]
92
92
  )
93
93
  end
94
94
  commit(checkout, nil, request) unless resolved["payment_hash"]
@@ -99,7 +99,7 @@ module OpenReceive
99
99
  # committed invoice amount — and served on the re-fetch path too, so
100
100
  # a repeated create answers with the same shape it first did.
101
101
  payment_methods = @service.list_swap_options(amount_msats: checkout["amount_msats"])
102
- body = { "checkout" => checkout, "payment_methods" => payment_methods }
102
+ body = { "checkout" => public_checkout(checkout), "payment_methods" => payment_methods }
103
103
  description = resolved_description(resolved)
104
104
  body["description"] = description if description
105
105
  success(201, body, request_id)
@@ -180,7 +180,7 @@ module OpenReceive
180
180
  "reference" => reference,
181
181
  "amount" => required_amount(resolved),
182
182
  "pay_in_asset" => asset,
183
- "memo" => validated_memo(body),
183
+ "memo" => mint_memo(body, resolved),
184
184
  "metadata" => body["metadata"]
185
185
  )
186
186
  end
@@ -255,9 +255,9 @@ module OpenReceive
255
255
  retryable = Nwc::RETRYABLE_ERROR_CODES.include?(code) if retryable.nil?
256
256
  status = retryable ? 503 : 502
257
257
  end
258
- body = { "code" => code, "message" => error.message, "request_id" => request_id }
258
+ body = { "code" => code, "message" => Nwc.redact_error_text(error.message), "request_id" => request_id }
259
259
  body["retryable"] = retryable unless retryable.nil?
260
- body["details"] = error.details if error.respond_to?(:details) && error.details.is_a?(Hash)
260
+ # Arbitrary internal details and causes have no public projection.
261
261
  response_headers = headers(request_id)
262
262
  # Mirrors the JS handler: a Retry-After hint (whole seconds, minimum 1)
263
263
  # rides along with retryable throttling errors.
@@ -271,6 +271,10 @@ module OpenReceive
271
271
 
272
272
  private
273
273
 
274
+ def public_checkout(checkout)
275
+ checkout.reject { |key, _| key.to_s == "created_at_source" }
276
+ end
277
+
274
278
  # Status refresh with no request-level pass: one-attempt reconcile_payments,
275
279
  # delivering settlement inline. A truncated walk raises WalletUnavailableError
276
280
  # (retryable 503) rather than reporting not_found.
@@ -391,11 +395,11 @@ module OpenReceive
391
395
  # (database down, bug): retryable 503, never a payer-blaming conflict
392
396
  # — mirrors the JS handler's commit().
393
397
  raise e if e.respond_to?(:status) && e.respond_to?(:code)
394
- raise HostPersistenceError
398
+ raise ConflictError, "The host did not accept this payment attempt; payer instructions were withheld."
395
399
  end
396
400
 
397
401
  def public_swap(swap)
398
- swap.reject { |key, _| key == "swap_data" }
402
+ swap.reject { |key, _| key == "swap_data" }.merge("checkout" => public_checkout(swap.fetch("checkout")))
399
403
  end
400
404
 
401
405
  # Payer-facing subset of a settlement's wallet details, whitelisted
@@ -442,7 +446,9 @@ module OpenReceive
442
446
  # preimages.
443
447
  def report_unexpected_error(error, request_id)
444
448
  if defined?(::Rails) && ::Rails.respond_to?(:error) && ::Rails.error
445
- ::Rails.error.report(error, handled: true, source: "openreceive")
449
+ # Detached diagnostic: no cause, stack or attached provider configuration.
450
+ safe_error = RuntimeError.new(OpenReceive::Nwc.redact_error_text(error.message))
451
+ ::Rails.error.report(safe_error, handled: true, source: "openreceive")
446
452
  else
447
453
  origin = Array(error.backtrace).first
448
454
  line = "[openreceive] unexpected #{error.class} (request_id=#{request_id})" \
@@ -550,10 +556,24 @@ module OpenReceive
550
556
  memo
551
557
  end
552
558
 
559
+ # The description that goes INTO the invoice. An explicit body memo wins,
560
+ # so a client that writes its own copy keeps it; otherwise the host's
561
+ # display string is it. Without the fallback a host on the mounted routes
562
+ # mints BOLT11s with no description at all and the payer's wallet shows a
563
+ # blank line next to the amount. Only the body memo carries the length
564
+ # cap: the cap bounds client input, and the host description is host data
565
+ # exactly like the price beside it.
566
+ def mint_memo(body, resolved)
567
+ memo = validated_memo(body)
568
+ blank = memo.nil? || (memo.is_a?(String) && memo.strip.empty?)
569
+ blank ? resolved_description(resolved) : memo
570
+ end
571
+
553
572
  # What the payer is buying, in the host's own words: one optional display
554
- # string the host returns beside the price. Response only — it is never
555
- # read from a request body, because the payer does not get to write the
556
- # copy next to the amount. Blank is the same as absent.
573
+ # string the host returns beside the price. Never read from a request
574
+ # body, because the payer does not get to write the copy next to the
575
+ # amount; it rides the prepare and create responses and, via mint_memo,
576
+ # defaults the invoice description. Blank is the same as absent.
557
577
  def resolved_description(resolved)
558
578
  value = resolved["description"]
559
579
  return nil unless value.is_a?(String)
@@ -162,6 +162,7 @@ module OpenReceive
162
162
  "bolt11" => wallet.fetch("invoice"),
163
163
  "amount_msats" => wallet.fetch("amount_msats"),
164
164
  "created_at" => created_at,
165
+ "created_at_source" => wallet["created_at"].nil? ? "host" : "wallet",
165
166
  "expires_at" => expires_at,
166
167
  "fiat_quote" => fiat_quote
167
168
  }
@@ -563,6 +564,12 @@ module OpenReceive
563
564
  end
564
565
 
565
566
  def resolve_amount(input)
567
+ resolve_amount_value(input)
568
+ rescue ArgumentError, TypeError, KeyError, ValidationError
569
+ raise InternalHostError, "The host supplied an invalid payment amount."
570
+ end
571
+
572
+ def resolve_amount_value(input)
566
573
  amount = stringify(input)
567
574
  if amount.key?("sats")
568
575
  return [OpenReceive::Money.direct_to_msats(currency: "SATS", value: amount.fetch("sats")), nil]
@@ -639,6 +646,9 @@ module OpenReceive
639
646
  # checksum — a false accept here sends the payer's money somewhere
640
647
  # unrecoverable. Mirrors the JS normalizeRefundAddress exactly.
641
648
  def normalize_refund_address(value, pay_in_asset)
649
+ unless OpenReceive::Server::Swap::Assets::PAY_IN_ASSETS.include?(pay_in_asset)
650
+ raise InternalHostError, "Swap recovery requires a supported pay-in asset/network."
651
+ end
642
652
  normalized = value.to_s.strip
643
653
  if normalized.empty? || normalized.length > 300
644
654
  raise ValidationError, "refundAddress is invalid."
@@ -306,9 +306,7 @@ module OpenReceive
306
306
  def post(path, body)
307
307
  @weight_budget&.reserve(path)
308
308
  body_string = JSON.generate(body)
309
- # Surface every outbound request before the call. The host sink is
310
- # responsible for sanitizing nested secrets; the API key and HMAC
311
- # signature live in headers and are deliberately never logged.
309
+ # Host diagnostic sinks receive summaries only, before any provider I/O.
312
310
  log_api_request(path, body)
313
311
  begin
314
312
  response = @http.call(
@@ -341,9 +339,7 @@ module OpenReceive
341
339
  message: "FixedFloat #{path} returned invalid JSON."
342
340
  )
343
341
  end
344
- # Surface every response (including API-error envelopes) before any
345
- # raise. The host sink sanitizes nested secrets — notably the order
346
- # token in a create/order response — so this must not pre-redact.
342
+ # Summarize before invoking any host sink, including error envelopes.
347
343
  log_api_response(path: path, status: status, ok: ok,
348
344
  code: parsed["code"], msg: parsed["msg"], data: parsed["data"])
349
345
  unless ok
@@ -369,7 +365,10 @@ module OpenReceive
369
365
  end
370
366
 
371
367
  def log_api_request(path, body = {})
372
- @api_request_logger&.call("provider" => @name, "path" => path, "body" => body)
368
+ @api_request_logger&.call(
369
+ "provider" => @name, "path" => path, "has_body" => !body.empty?,
370
+ "has_token" => body.is_a?(Hash) && !body["token"].nil?
371
+ )
373
372
  rescue StandardError
374
373
  nil
375
374
  end
@@ -377,7 +376,7 @@ module OpenReceive
377
376
  def log_api_response(path:, status:, ok:, code: nil, msg: nil, data: nil)
378
377
  @api_response_logger&.call(
379
378
  "provider" => @name, "path" => path, "status" => status, "ok" => ok,
380
- "code" => code, "msg" => msg, "data" => data
379
+ "code" => code.is_a?(Numeric) ? code : nil, "has_data" => !data.nil?
381
380
  )
382
381
  rescue StandardError
383
382
  nil
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OpenReceive
4
4
  module Server
5
- VERSION = "0.4.10"
5
+ VERSION = "0.4.13"
6
6
  end
7
7
  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.