nwc-ruby 0.2.2 → 0.2.4

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: b847b05f148d03753548528e0f4483129cf59887e40329f28e7bbe31f0eda1cd
4
- data.tar.gz: 9c99704df81a6e7cfa9c1bfede014b49270b15bf11a3bba8663b5d6f1f3500fa
3
+ metadata.gz: 33006d633764a07adc713f4e5e6fe49a5108927286258abf0d976076a3f125d1
4
+ data.tar.gz: 4727a1b8968c741a2ec2feb2c4f576b4afe8d4df2066f09ddf26f6eb53ed42a1
5
5
  SHA512:
6
- metadata.gz: 11e62747c2dfa88459d900a2e1f89e7a42df5420b88221508bf62df39b65ebe7f9937db357557b2ab3ee8b95be0b50b5062fa3667cf21002dbf607366f49675d
7
- data.tar.gz: c0ff4c40f5d8fa68d3b12e6b7c745ff1ff5b19da5719c0998c8cc5db6098ee77917c2c914b0ca96dc9e9301ac4240595f7e472019e6001394c996e75ffdcff6a
6
+ metadata.gz: 6fc9796e80a92b9e1d255ee2fdafb85d5171c7a74a6d25b522ddebf2114620081775a7a21d409ff6209a2d522e02451d6f3057627dd07e2f7091bbba779639a4
7
+ data.tar.gz: '087888d35e9b84ad81a8ab1f97ee1914a75b2d32a3632c62972bda323225d83eb082fb3e02df469138a980795e3e62692ba9877a0bb398ed228d6a9337f622bd'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.4] — 2026-07-17
11
+
12
+ ### Security
13
+
14
+ - **Event ids are now recomputed before the signature is trusted.**
15
+ `Event#valid_signature?` verified the signature against the `id` supplied by
16
+ the relay without checking that the `id` actually described the event body.
17
+ Because a Nostr signature only ever commits to the `id`, nothing bound that
18
+ `id` to the event it arrived with: a relay could lift a genuine
19
+ `(id, sig, pubkey)` triple from any real wallet event and re-attach it to
20
+ arbitrary `content`, `tags`, `kind` or `created_at`, and the result verified.
21
+ The id is now recomputed from the canonical serialization first, via the new
22
+ `Event#valid_id?`. Reported by @riccardobl in
23
+ [#1](https://github.com/MegalithicBTC/nwc-ruby/issues/1).
24
+
25
+ The sharpest edge was the kind 13194 info event, whose content and tags are
26
+ plaintext: stripping its `encryption` tag silently downgraded every
27
+ subsequent request from NIP-44 v2 to NIP-04, since the spec reads an absent
28
+ tag as "nip04 only".
29
+
30
+ - **Responses are now bound to the request that asked for them.** `call` trusted
31
+ the relay to honour the `#e` REQ filter and never checked the response's `e`
32
+ tag itself, so a relay could replay an older but genuine response — a past
33
+ "payment succeeded" answering a fresh `pay_invoice`, for instance.
34
+
35
+ - **`fetch_info` now verifies the event author.** It previously checked only
36
+ `valid_signature?` and never compared the pubkey to the wallet's, so a relay
37
+ could sign an info event with its own key and have it accepted — forcing the
38
+ encryption downgrade above without needing to tamper with anything.
39
+
40
+ ### Fixed
41
+
42
+ - The notification listener no longer tears down when a relay delivers an event
43
+ of an unexpected kind. `Notification.parse` raises `ArgumentError` on a
44
+ non-notification kind, but only `EncryptionError` was rescued, so a relay
45
+ echoing a kind 23195 response onto the subscription killed the listener.
46
+ - A rejected info event no longer ends the `fetch_info` read loop, so one junk
47
+ event cannot deny service while a genuine info event is still inbound.
48
+ - Corrected `source_code_uri` and `changelog_uri` in the gemspec, which pointed
49
+ at a `main` branch that does not exist — both links 404'd from the RubyGems
50
+ page. This repo uses `master`; `PUBLISHING.md` has been corrected to match.
51
+ - `PUBLISHING.md` no longer claims releases publish automatically on tag. There
52
+ is no `release.yml` in this repo — only `ci.yml`, which tests but never
53
+ publishes — so releases are manual.
54
+
55
+ ### Added
56
+
57
+ - Documented a gentle `lookup_invoice` backup polling cadence for invoices so
58
+ apps can recover from missed notifications without getting rate limited by the
59
+ backing NWC service.
60
+
61
+ ## [0.2.3] — 2026-04-23
62
+
63
+ ### Fixed
64
+
65
+ - Silenced the noisy `Async::Task` warn log ("Task may have ended with
66
+ unhandled exception") that was emitted every `recycle_interval` (default
67
+ 5 min) by the heartbeat task. The forced recycle used to be signaled by
68
+ raising `TransportError` from inside the heartbeat's child Async task; the
69
+ parent's reconnect loop caught it correctly, but Async's console logger
70
+ logged the unhandled-in-child exception with a full backtrace first.
71
+ The recycle is now signaled by setting an `@recycle_requested` flag and
72
+ closing the websocket, which unblocks the read loop and lets
73
+ `run_one_connection` return normally without any exception crossing a task
74
+ boundary.
75
+
10
76
  ## [0.2.2] — 2026-04-21
11
77
 
12
78
  ### Fixed
data/README.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  A production-grade Ruby client for [Nostr Wallet Connect (NIP-47)][nip47].
4
4
 
5
+ Accept and track Bitcoin Lightning payments in your Rails app without running
6
+ any Lightning infrastructure yourself. Generate Lightning invoices for your
7
+ users to pay, then get notified the instant each one settles — all by talking
8
+ to a Nostr Wallet Connect (NWC) service that someone else operates. The gem
9
+ works with any NWC-compatible backend (Rizful, Alby Hub, Alby Cloud, CoinOs,
10
+ any NIP-47 wallet service), so you're not locked into a single provider and
11
+ can swap vendors by changing one environment variable.
12
+
5
13
  ```ruby
6
14
  require "nwc_ruby"
7
15
 
@@ -216,6 +224,67 @@ to keep middleboxes from idle-closing the socket, reconnects with capped
216
224
  exponential backoff on failure, and force-recycles the connection every 5 minutes
217
225
  as a belt-and-suspenders check against silently dead TCP streams.
218
226
 
227
+ #### Polling as a backup
228
+
229
+ Notifications should be your primary settlement path, but production checkout
230
+ flows should also run a small `lookup_invoice` poller after creating an invoice.
231
+ Treat it as a backup for missed notifications, relay interruptions, app restarts,
232
+ or wallet services that deliver notifications slowly.
233
+
234
+ Do not poll every second until the invoice expires. Backing NWC services often
235
+ enforce rate limits, and aggressive polling can get your app throttled right
236
+ when a customer is trying to pay. Start soon after invoice creation, then poll
237
+ less often as the invoice gets older, and stop when it is paid or expired.
238
+
239
+ One reasonable cadence:
240
+
241
+ ```ruby
242
+ def next_lookup_delay(elapsed)
243
+ return 3.seconds if elapsed < 2.minutes
244
+ return 6.seconds if elapsed < 5.minutes
245
+ 12.seconds
246
+ end
247
+
248
+ invoice = client.make_invoice(amount: 10_000, description: "coffee")
249
+ record = Invoice.create!(
250
+ payment_hash: invoice["payment_hash"],
251
+ bolt11: invoice["invoice"],
252
+ state: "pending",
253
+ expires_at: Time.current + 10.minutes,
254
+ next_lookup_at: Time.current + 3.seconds
255
+ )
256
+
257
+ # In a continuously running rake task / worker:
258
+ loop do
259
+ Invoice.where(state: "pending")
260
+ .where("next_lookup_at <= ?", Time.current)
261
+ .find_each do |inv|
262
+ if inv.expires_at <= Time.current
263
+ inv.update!(state: "expired", next_lookup_at: nil)
264
+ next
265
+ end
266
+
267
+ response = client.lookup_invoice(payment_hash: inv.payment_hash)
268
+ settled = response["settled"] || response["paid"] ||
269
+ response["state"] == "settled" || response["state"] == "SETTLED" ||
270
+ response["settled_at"]
271
+
272
+ if settled
273
+ inv.update!(state: "paid", paid_at: Time.current, next_lookup_at: nil)
274
+ else
275
+ elapsed = Time.current - inv.created_at
276
+ inv.update!(next_lookup_at: Time.current + next_lookup_delay(elapsed))
277
+ end
278
+ end
279
+
280
+ sleep 2
281
+ end
282
+ ```
283
+
284
+ Keep the notification handler and the poller idempotent. Either one might mark
285
+ the invoice paid first, and the other should see an already-paid row and do
286
+ nothing.
287
+
219
288
  #### Resuming after a restart
220
289
 
221
290
  Persist the `created_at` of the last notification you processed and pass it as
@@ -187,8 +187,10 @@ module NwcRuby
187
187
 
188
188
  conn.on_event do |_sub, event_hash|
189
189
  event = Event.from_hash(event_hash)
190
- next unless event.valid_signature?
191
- next unless event.pubkey == @connection_string.wallet_pubkey
190
+ next unless authentic?(event)
191
+ # Notification.parse raises on any other kind, which would tear down
192
+ # the listener; the relay chooses what it sends us, not the filter.
193
+ next unless kinds.include?(event.kind)
192
194
 
193
195
  # Advance the poll watermark so we don't re-fetch old events.
194
196
  last_seen_at = event.created_at if event.created_at && event.created_at > last_seen_at
@@ -219,6 +221,33 @@ module NwcRuby
219
221
 
220
222
  private
221
223
 
224
+ # Every event arriving from a relay must clear this before it is parsed.
225
+ # `valid_signature?` recomputes the id, so a passing event is byte-for-byte
226
+ # what the wallet signed — which is what makes the kind and tag checks
227
+ # below meaningful.
228
+ def authentic?(event)
229
+ event.valid_signature? && event.pubkey == @connection_string.wallet_pubkey
230
+ end
231
+
232
+ # A relay is not trusted to honour the REQ filter it was sent, so the
233
+ # response is bound to the request here. Without this an older but genuine
234
+ # response (say, a past "payment succeeded") can be replayed against a new
235
+ # request.
236
+ def response_to?(event, request_id)
237
+ e_tag = event.tags.find { |t| t[0] == 'e' }
238
+ !e_tag.nil? && e_tag[1] == request_id
239
+ end
240
+
241
+ # Advisory only — the relay decides what it actually sends, so `fetch_info`
242
+ # re-checks the author and kind on whatever comes back.
243
+ def info_filter
244
+ {
245
+ 'authors' => [@connection_string.wallet_pubkey],
246
+ 'kinds' => [NIP47::Methods::KIND_INFO],
247
+ 'limit' => 1
248
+ }
249
+ end
250
+
222
251
  # rubocop:disable Metrics/MethodLength
223
252
  def call(method, params)
224
253
  ensure_supports!(method)
@@ -260,8 +289,9 @@ module NwcRuby
260
289
  next unless parsed[0] == 'EVENT' && parsed[1] == sub_id
261
290
 
262
291
  event = Event.from_hash(parsed[2])
263
- next unless event.valid_signature?
264
- next unless event.pubkey == @connection_string.wallet_pubkey
292
+ next unless authentic?(event)
293
+ next unless event.kind == NIP47::Methods::KIND_RESPONSE
294
+ next unless response_to?(event, request_event.id)
265
295
 
266
296
  result = NIP47::Response.parse(event, @connection_string.secret, @connection_string.wallet_pubkey)
267
297
  break
@@ -290,11 +320,7 @@ module NwcRuby
290
320
  Async do
291
321
  Async::WebSocket::Client.connect(endpoint) do |conn|
292
322
  sub_id = "info-#{SecureRandom.hex(4)}"
293
- conn.write(Protocol::WebSocket::TextMessage.generate(['REQ', sub_id, {
294
- 'authors' => [@connection_string.wallet_pubkey],
295
- 'kinds' => [NIP47::Methods::KIND_INFO],
296
- 'limit' => 1
297
- }]))
323
+ conn.write(Protocol::WebSocket::TextMessage.generate(['REQ', sub_id, info_filter]))
298
324
  conn.flush
299
325
 
300
326
  while (msg = conn.read)
@@ -307,8 +333,12 @@ module NwcRuby
307
333
  end
308
334
 
309
335
  if parsed[0] == 'EVENT' && parsed[1] == sub_id
310
- event = Event.from_hash(parsed[2])
311
- result = NIP47::Info.parse(event) if event.valid_signature?
336
+ event = Event.from_hash(parsed[2])
337
+ # A rejected event must not end the read: a genuine info event
338
+ # may still follow, and the deadline above bounds the wait.
339
+ next unless authentic?(event) && event.kind == NIP47::Methods::KIND_INFO
340
+
341
+ result = NIP47::Info.parse(event)
312
342
  break
313
343
  elsif parsed[0] == 'EOSE' && parsed[1] == sub_id
314
344
  break
@@ -50,8 +50,20 @@ module NwcRuby
50
50
  self
51
51
  end
52
52
 
53
+ # True when the id is the SHA-256 of this event's canonical serialization.
54
+ def valid_id?
55
+ return false unless @id.is_a?(String)
56
+
57
+ @id == OpenSSL::Digest::SHA256.hexdigest(serialize_for_id)
58
+ end
59
+
60
+ # The signature only commits to the id, so the id must be recomputed from
61
+ # the event body before the signature means anything. Without that check a
62
+ # relay can graft a genuine (id, sig) pair from one event onto arbitrary
63
+ # pubkey/created_at/kind/tags/content and still verify.
53
64
  def valid_signature?
54
65
  return false unless @id && @sig && @pubkey
66
+ return false unless valid_id?
55
67
 
56
68
  digest_bytes = Crypto::Keys.hex_to_bytes(@id)
57
69
  Crypto::Schnorr.verify(digest_bytes, @sig, @pubkey)
@@ -146,6 +146,7 @@ module NwcRuby
146
146
  def run_one_connection(top)
147
147
  endpoint = Async::HTTP::Endpoint.parse(@url, alpn_protocols: ['http/1.1'])
148
148
  opened_at = Async::Clock.now
149
+ @recycle_requested = false
149
150
  @logger.info("[nwc] connecting to #{@url}")
150
151
 
151
152
  Async::WebSocket::Client.connect(endpoint) do |conn|
@@ -155,6 +156,7 @@ module NwcRuby
155
156
 
156
157
  @open_cb&.call(self)
157
158
  read_loop(conn)
159
+ @logger.info("[nwc] recycling connection (#{@recycle_interval}s)") if @recycle_requested
158
160
  ensure
159
161
  heartbeat&.stop
160
162
  poll&.stop
@@ -167,6 +169,13 @@ module NwcRuby
167
169
  end
168
170
  end
169
171
 
172
+ # Heartbeat task: sends RFC 6455 ping every @ping_interval seconds and
173
+ # requests a recycle once the connection has been open longer than
174
+ # @recycle_interval. The recycle is signaled via a flag + close rather
175
+ # than by raising across task boundaries, because an exception raised
176
+ # inside a child Async task is logged at warn level by Async's console
177
+ # logger ("Task may have ended with unhandled exception") before the
178
+ # parent's rescue runs — producing a noisy backtrace on every recycle.
170
179
  def start_heartbeat(top, conn, opened_at)
171
180
  top.async do
172
181
  loop do
@@ -176,12 +185,25 @@ module NwcRuby
176
185
 
177
186
  sleep @ping_interval
178
187
  break if @stop
179
-
180
- raise TransportError, "recycle (#{@recycle_interval}s)" if Async::Clock.now - opened_at > @recycle_interval
188
+ break if recycle_due?(opened_at) && request_recycle(conn)
181
189
  end
182
190
  end
183
191
  end
184
192
 
193
+ def recycle_due?(opened_at)
194
+ Async::Clock.now - opened_at > @recycle_interval
195
+ end
196
+
197
+ def request_recycle(conn)
198
+ @recycle_requested = true
199
+ begin
200
+ conn.close
201
+ rescue StandardError
202
+ nil
203
+ end
204
+ true
205
+ end
206
+
185
207
  def start_poll(top)
186
208
  return unless @poll_interval && @poll_cb
187
209
 
@@ -200,7 +222,21 @@ module NwcRuby
200
222
  end
201
223
 
202
224
  def read_loop(conn)
203
- while (message = conn.read)
225
+ loop do
226
+ message =
227
+ begin
228
+ conn.read
229
+ rescue StandardError => e
230
+ # If we asked for the recycle/close ourselves, treat the
231
+ # resulting read error as a clean EOF rather than propagating
232
+ # it up into the reconnect-on-error path (which would log a
233
+ # spurious warning).
234
+ raise unless @recycle_requested || @stop
235
+
236
+ @logger.debug("[nwc] read interrupted by close: #{e.class}")
237
+ nil
238
+ end
239
+ break if message.nil?
204
240
  break if @stop
205
241
 
206
242
  begin
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NwcRuby
4
- VERSION = '0.2.2'
4
+ VERSION = '0.2.4'
5
5
  end
data/nwc-ruby.gemspec CHANGED
@@ -23,9 +23,9 @@ Gem::Specification.new do |spec|
23
23
  spec.required_ruby_version = '>= 3.2.0'
24
24
 
25
25
  spec.metadata['homepage_uri'] = spec.homepage
26
- spec.metadata['source_code_uri'] = "#{spec.homepage}/tree/main"
26
+ spec.metadata['source_code_uri'] = "#{spec.homepage}/tree/master"
27
27
  spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues"
28
- spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
28
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/master/CHANGELOG.md"
29
29
  spec.metadata['rubygems_mfa_required'] = 'true'
30
30
 
31
31
  spec.files = Dir[
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nwc-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - MegalithicBTC
@@ -204,9 +204,9 @@ licenses:
204
204
  - MIT
205
205
  metadata:
206
206
  homepage_uri: https://github.com/MegalithicBTC/nwc-ruby
207
- source_code_uri: https://github.com/MegalithicBTC/nwc-ruby/tree/main
207
+ source_code_uri: https://github.com/MegalithicBTC/nwc-ruby/tree/master
208
208
  bug_tracker_uri: https://github.com/MegalithicBTC/nwc-ruby/issues
209
- changelog_uri: https://github.com/MegalithicBTC/nwc-ruby/blob/main/CHANGELOG.md
209
+ changelog_uri: https://github.com/MegalithicBTC/nwc-ruby/blob/master/CHANGELOG.md
210
210
  rubygems_mfa_required: 'true'
211
211
  rdoc_options: []
212
212
  require_paths:
@@ -222,7 +222,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
222
222
  - !ruby/object:Gem::Version
223
223
  version: '0'
224
224
  requirements: []
225
- rubygems_version: 3.6.8
225
+ rubygems_version: 3.6.9
226
226
  specification_version: 4
227
227
  summary: Ruby client for Nostr Wallet Connect (NIP-47) with safe long-running relay
228
228
  connections.