nwc-ruby 0.2.3 → 0.3.0

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: d541ad9af31629ad26492d8da61d56b0a63980cc8597a460cca9949df56f382d
4
- data.tar.gz: 8d48fbbe532bdcf56d889f68f9d7878fa2958381c1ea285fb572aebb70d17d7e
3
+ metadata.gz: 9b1fb35da37743ef772eac5368822e3656d24a8f1b081fa02174f52084ea0d03
4
+ data.tar.gz: '086309565529631e6525c95a22736b86423969216bc12f7ea2e63c65c9cc713e'
5
5
  SHA512:
6
- metadata.gz: bbc5e4a3efdac9b24a4a2c5fc183f3945ba9987fd5312c78a7e8c4ba8cff625af5c08521094cc10101fd24a0bef5f35261eb29720d533d7e0167a236ee59d106
7
- data.tar.gz: fb2c7a1ed8d7785a886b38e250432466354b257cef1e4c8552ef24c493c304dbd446bf4c17e57debcce8bc6875666686c16f901d29474ef08541194e6e2a97f3
6
+ metadata.gz: 54b83902f9b0f3a0cd3df5f453d07d90e31a72b18cb81c888b9635ea44c7d34f804b15a0c6d77ab5fca3bad85be99adb73cf91cc22fb76fed94da35300d63f37
7
+ data.tar.gz: 1871b89d77fbac48fb8731ed8ae56c99a3555289313af614b2b1bf13704fca12a64c4233b92ac0fafcd469db1b0fb501b841eba202b21b528698f919eee06183
data/CHANGELOG.md CHANGED
@@ -7,6 +7,99 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] — 2026-10-05
11
+
12
+ ### Changed
13
+
14
+ - **Failures now say whether the request reached the wallet.** Anything that
15
+ fails before the request EVENT is written to the relay — DNS, TCP connect,
16
+ TLS, the websocket upgrade, the info fetch — raises the new
17
+ `NwcRuby::NotSentError` (a `TransportError` subclass, so existing rescues
18
+ still match), with the original exception as `#cause` and only the relay
19
+ host in the message. Failures after the write stay ambiguous:
20
+ `TransportError` or `TimeoutError`. Previously a DNS failure surfaced as a
21
+ raw `Socket::ResolutionError`, so callers could not tell "never sent, safe
22
+ to retry" from "maybe paid".
23
+ - Public `Client` methods no longer leak raw `SocketError` / `Errno::*` /
24
+ `OpenSSL::SSL::SSLError` / `Async::TimeoutError` / `Protocol::WebSocket`
25
+ exceptions; everything is an `NwcRuby::Error`.
26
+ - A relay closing the connection after the request was sent now raises
27
+ `TransportError` instead of a misleading `TimeoutError`.
28
+ - A relay answering the request with `["OK", id, false, reason]` now raises
29
+ `TransportError` immediately instead of waiting out `request_timeout`. It is
30
+ still treated as maybe-sent, since the relay is untrusted.
31
+ - "No info event" is now `InfoUnavailableError` (a `NotSentError`) instead of a
32
+ plain `TransportError`.
33
+ - `request_timeout` now bounds the whole call, including connecting and the
34
+ info fetch. Before, a relay that went silent could block a request
35
+ indefinitely because the deadline was only checked when a message arrived.
36
+ - Errors are classified inside the Async task, so Async no longer logs
37
+ "Task may have ended with unhandled exception" for request failures.
38
+
39
+ ### Added
40
+
41
+ - `connect_retries:` (default 2): pre-send failures are retried with 0.25 s /
42
+ 1 s backoff inside `request_timeout`. Nothing is retried after the request is
43
+ written.
44
+ - Requests and info fetches fall through to the next `relay` in the connection
45
+ string on a pre-send failure (previously only the first relay was used).
46
+ - `NwcRuby::Error#sent?` — `false` for `NotSentError` and
47
+ `UnsupportedMethodError`, `true` for `TransportError`, `TimeoutError` and
48
+ `WalletServiceError`, `nil` where it doesn't apply.
49
+
50
+ ## [0.2.4] — 2026-07-17
51
+
52
+ ### Security
53
+
54
+ - **Event ids are now recomputed before the signature is trusted.**
55
+ `Event#valid_signature?` verified the signature against the `id` supplied by
56
+ the relay without checking that the `id` actually described the event body.
57
+ Because a Nostr signature only ever commits to the `id`, nothing bound that
58
+ `id` to the event it arrived with: a relay could lift a genuine
59
+ `(id, sig, pubkey)` triple from any real wallet event and re-attach it to
60
+ arbitrary `content`, `tags`, `kind` or `created_at`, and the result verified.
61
+ The id is now recomputed from the canonical serialization first, via the new
62
+ `Event#valid_id?`. Reported by @riccardobl in
63
+ [#1](https://github.com/MegalithicBTC/nwc-ruby/issues/1).
64
+
65
+ The sharpest edge was the kind 13194 info event, whose content and tags are
66
+ plaintext: stripping its `encryption` tag silently downgraded every
67
+ subsequent request from NIP-44 v2 to NIP-04, since the spec reads an absent
68
+ tag as "nip04 only".
69
+
70
+ - **Responses are now bound to the request that asked for them.** `call` trusted
71
+ the relay to honour the `#e` REQ filter and never checked the response's `e`
72
+ tag itself, so a relay could replay an older but genuine response — a past
73
+ "payment succeeded" answering a fresh `pay_invoice`, for instance.
74
+
75
+ - **`fetch_info` now verifies the event author.** It previously checked only
76
+ `valid_signature?` and never compared the pubkey to the wallet's, so a relay
77
+ could sign an info event with its own key and have it accepted — forcing the
78
+ encryption downgrade above without needing to tamper with anything.
79
+
80
+ ### Fixed
81
+
82
+ - The notification listener no longer tears down when a relay delivers an event
83
+ of an unexpected kind. `Notification.parse` raises `ArgumentError` on a
84
+ non-notification kind, but only `EncryptionError` was rescued, so a relay
85
+ echoing a kind 23195 response onto the subscription killed the listener.
86
+ - A rejected info event no longer ends the `fetch_info` read loop, so one junk
87
+ event cannot deny service while a genuine info event is still inbound.
88
+ - Corrected `source_code_uri` and `changelog_uri` in the gemspec, which pointed
89
+ at a `main` branch that does not exist — both links 404'd from the RubyGems
90
+ page. This repo uses `master`; `PUBLISHING.md` has been corrected to match.
91
+ - `PUBLISHING.md` no longer claims releases publish automatically on tag. There
92
+ is no `release.yml` in this repo — only `ci.yml`, which tests but never
93
+ publishes — so releases are manual.
94
+
95
+ ### Added
96
+
97
+ - Documented a gentle `lookup_invoice` backup polling cadence for invoices so
98
+ apps can recover from missed notifications without getting rate limited by the
99
+ backing NWC service.
100
+
101
+ ## [0.2.3] — 2026-04-23
102
+
10
103
  ### Fixed
11
104
 
12
105
  - Silenced the noisy `Async::Task` warn log ("Task may have ended with
data/README.md CHANGED
@@ -224,6 +224,67 @@ to keep middleboxes from idle-closing the socket, reconnects with capped
224
224
  exponential backoff on failure, and force-recycles the connection every 5 minutes
225
225
  as a belt-and-suspenders check against silently dead TCP streams.
226
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
+
227
288
  #### Resuming after a restart
228
289
 
229
290
  Persist the `created_at` of the last notification you processed and pass it as
@@ -461,6 +522,12 @@ Constructor:
461
522
  | `Client.from_uri(uri)` | `Client` — parses the `nostr+walletconnect://` URI |
462
523
  | `Client.new(connection_string)` | `Client` — if you already have a `ConnectionString` |
463
524
 
525
+ Both accept `request_timeout:` (seconds, default 30, covering the info fetch,
526
+ connect and response wait) and `connect_retries:` (default 2). Failures before
527
+ the request is written to the relay are retried across every `relay` in the
528
+ connection string, with 0.25 s / 1 s backoff, inside `request_timeout`.
529
+ Nothing is ever retried after the request is written.
530
+
464
531
  Introspection:
465
532
 
466
533
  | Method | Returns |
@@ -504,19 +571,42 @@ Listener:
504
571
 
505
572
  ### Errors
506
573
 
507
- All gem errors inherit from `NwcRuby::Error`:
574
+ All gem errors inherit from `NwcRuby::Error`. Public `Client` methods never
575
+ let a raw `SocketError` / `Errno::*` / `OpenSSL` / `Async` exception escape;
576
+ the original is kept as `#cause`.
508
577
 
509
578
  - `InvalidConnectionStringError` — the URI couldn't be parsed.
510
579
  - `EncryptionError` — bad MAC / bad padding / unknown version byte / bad key.
511
580
  - `InvalidSignatureError` — an event's signature did not verify.
512
- - `TransportError` — the WebSocket couldn't connect or died unrecoverably.
513
- - `TimeoutError` — no response within the timeout window.
581
+ - `TransportError` — the connection failed **after** the request was written
582
+ to the relay. The wallet may have acted on it: reconcile with
583
+ `lookup_invoice` / `list_transactions` rather than retrying a payment.
584
+ - `NotSentError` — the request never left this process (DNS, connect, TLS,
585
+ websocket upgrade, info fetch). Always safe to retry.
586
+ - `InfoUnavailableError` — the relay holds no kind 13194 info event for
587
+ this wallet; usually a wrong relay or pubkey.
588
+ - `TimeoutError` — the request was sent but no response arrived in time. The
589
+ wallet may have acted on it.
514
590
  - `UnsupportedMethodError` — wallet service doesn't advertise this method.
591
+ A configuration problem; retrying won't help.
515
592
  - `WalletServiceError` — the wallet returned an error envelope. Check `#code`
516
593
  for `RATE_LIMITED`, `NOT_IMPLEMENTED`, `INSUFFICIENT_BALANCE`,
517
594
  `QUOTA_EXCEEDED`, `RESTRICTED`, `UNAUTHORIZED`, `INTERNAL`,
518
595
  `UNSUPPORTED_ENCRYPTION`, `PAYMENT_FAILED`, `NOT_FOUND`, or `OTHER`.
519
596
 
597
+ Every error also answers `#sent?`: `false` means the request provably never
598
+ reached the wallet; `true` or `nil` mean it may have.
599
+
600
+ ```ruby
601
+ begin
602
+ client.pay_invoice(invoice: bolt11)
603
+ rescue NwcRuby::NotSentError
604
+ # Nothing reached the wallet. Safe to retry later.
605
+ rescue NwcRuby::TransportError, NwcRuby::TimeoutError
606
+ # Ambiguous: the payment may have gone out. Reconcile before retrying.
607
+ end
608
+ ```
609
+
520
610
  ---
521
611
 
522
612
  ## Security notes
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'async'
4
+ require 'async/clock'
4
5
  require 'async/http/endpoint'
5
6
  require 'async/websocket/client'
6
7
 
@@ -19,7 +20,10 @@ module NwcRuby
19
20
  # puts "Got #{notification.amount_msats} msats"
20
21
  # end
21
22
  class Client
22
- DEFAULT_TIMEOUT = 30
23
+ DEFAULT_TIMEOUT = 30
24
+ DEFAULT_CONNECT_RETRIES = 2
25
+ # Pause before each retry round; the last value repeats.
26
+ CONNECT_BACKOFF = [0.25, 1.0].freeze
23
27
 
24
28
  attr_reader :connection_string, :logger
25
29
 
@@ -27,10 +31,16 @@ module NwcRuby
27
31
  new(ConnectionString.parse(uri_string), **)
28
32
  end
29
33
 
30
- def initialize(connection_string, logger: nil, request_timeout: DEFAULT_TIMEOUT)
34
+ # @param request_timeout [Numeric] overall budget per public call, covering
35
+ # the info fetch, connecting, and waiting for the wallet's response.
36
+ # @param connect_retries [Integer] extra rounds through the relay list when
37
+ # a request fails before it is sent. Never applies after sending.
38
+ def initialize(connection_string, logger: nil, request_timeout: DEFAULT_TIMEOUT,
39
+ connect_retries: DEFAULT_CONNECT_RETRIES)
31
40
  @connection_string = connection_string
32
41
  @logger = logger || default_logger
33
42
  @request_timeout = request_timeout
43
+ @connect_retries = connect_retries
34
44
  @info = nil
35
45
  end
36
46
 
@@ -38,10 +48,11 @@ module NwcRuby
38
48
 
39
49
  # Fetch and cache the kind 13194 info event. This tells us which methods
40
50
  # the wallet service supports and which encryption schemes it accepts.
51
+ # Raises NotSentError (never anything lower-level) if it can't be fetched.
41
52
  def info(refresh: false)
42
53
  return @info if @info && !refresh
43
54
 
44
- @info = fetch_info
55
+ @info = fetch_info(new_deadline)
45
56
  end
46
57
 
47
58
  def capabilities
@@ -187,8 +198,10 @@ module NwcRuby
187
198
 
188
199
  conn.on_event do |_sub, event_hash|
189
200
  event = Event.from_hash(event_hash)
190
- next unless event.valid_signature?
191
- next unless event.pubkey == @connection_string.wallet_pubkey
201
+ next unless authentic?(event)
202
+ # Notification.parse raises on any other kind, which would tear down
203
+ # the listener; the relay chooses what it sends us, not the filter.
204
+ next unless kinds.include?(event.kind)
192
205
 
193
206
  # Advance the poll watermark so we don't re-fetch old events.
194
207
  last_seen_at = event.created_at if event.created_at && event.created_at > last_seen_at
@@ -219,123 +232,230 @@ module NwcRuby
219
232
 
220
233
  private
221
234
 
222
- # rubocop:disable Metrics/MethodLength
235
+ # Every event arriving from a relay must clear this before it is parsed.
236
+ # `valid_signature?` recomputes the id, so a passing event is byte-for-byte
237
+ # what the wallet signed — which is what makes the kind and tag checks
238
+ # below meaningful.
239
+ def authentic?(event)
240
+ event.valid_signature? && event.pubkey == @connection_string.wallet_pubkey
241
+ end
242
+
243
+ # A relay is not trusted to honour the REQ filter it was sent, so the
244
+ # response is bound to the request here. Without this an older but genuine
245
+ # response (say, a past "payment succeeded") can be replayed against a new
246
+ # request.
247
+ def response_to?(event, request_id)
248
+ e_tag = event.tags.find { |t| t[0] == 'e' }
249
+ !e_tag.nil? && e_tag[1] == request_id
250
+ end
251
+
252
+ # Advisory only — the relay decides what it actually sends, so `fetch_info`
253
+ # re-checks the author and kind on whatever comes back.
254
+ def info_filter
255
+ {
256
+ 'authors' => [@connection_string.wallet_pubkey],
257
+ 'kinds' => [NIP47::Methods::KIND_INFO],
258
+ 'limit' => 1
259
+ }
260
+ end
261
+
262
+ def response_filter(request_id)
263
+ {
264
+ 'authors' => [@connection_string.wallet_pubkey],
265
+ 'kinds' => [NIP47::Methods::KIND_RESPONSE],
266
+ '#e' => [request_id],
267
+ '#p' => [@connection_string.client_pubkey]
268
+ }
269
+ end
270
+
271
+ # Every request has two phases. Phase 1 (info fetch, DNS, connect, TLS,
272
+ # upgrade, REQ write) cannot have reached the wallet, so its failures raise
273
+ # NotSentError and are retried. Phase 2 starts at the EVENT write: the
274
+ # wallet may have acted, so failures there are ambiguous and never retried.
223
275
  def call(method, params)
224
- ensure_supports!(method)
225
- encryption = info.preferred_encryption
226
-
227
- deadline = Time.now + @request_timeout
228
- result = nil
229
-
230
- Async do
231
- endpoint = Async::HTTP::Endpoint.parse(@connection_string.relays.first, alpn_protocols: ['http/1.1'])
232
- Async::WebSocket::Client.connect(endpoint) do |conn|
233
- request_event = NIP47::Request.build(
234
- method: method,
235
- params: params,
236
- client_privkey: @connection_string.secret,
237
- wallet_pubkey: @connection_string.wallet_pubkey,
238
- encryption: encryption
239
- )
240
-
241
- sub_id = "rsp-#{SecureRandom.hex(4)}"
242
- conn.write(Protocol::WebSocket::TextMessage.generate(['REQ', sub_id, {
243
- 'authors' => [@connection_string.wallet_pubkey],
244
- 'kinds' => [NIP47::Methods::KIND_RESPONSE],
245
- '#e' => [request_event.id],
246
- '#p' => [@connection_string.client_pubkey]
247
- }]))
248
- conn.write(Protocol::WebSocket::TextMessage.generate(['EVENT', request_event.to_h]))
249
- conn.flush
250
-
251
- while (msg = conn.read)
252
- break if Time.now > deadline
253
-
254
- parsed = begin
255
- JSON.parse(msg.buffer)
256
- rescue JSON::ParserError
257
- next
258
- end
259
-
260
- next unless parsed[0] == 'EVENT' && parsed[1] == sub_id
261
-
262
- event = Event.from_hash(parsed[2])
263
- next unless event.valid_signature?
264
- next unless event.pubkey == @connection_string.wallet_pubkey
265
-
266
- result = NIP47::Response.parse(event, @connection_string.secret, @connection_string.wallet_pubkey)
267
- break
268
- end
269
- ensure
270
- begin
271
- conn&.close
272
- rescue StandardError
273
- nil
274
- end
275
- end
276
- end.wait
277
-
278
- raise TimeoutError, "no response to #{method} within #{@request_timeout}s" if result.nil?
279
- raise WalletServiceError.new(result.error_code || 'UNKNOWN', result.error_message || '') unless result.success?
280
-
281
- result.result
282
- end
283
- # rubocop:enable Metrics/MethodLength
284
-
285
- def fetch_info
286
- endpoint = Async::HTTP::Endpoint.parse(@connection_string.relays.first, alpn_protocols: ['http/1.1'])
287
- deadline = Time.now + @request_timeout
288
- result = nil
289
-
290
- Async do
291
- Async::WebSocket::Client.connect(endpoint) do |conn|
292
- 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
- }]))
298
- conn.flush
299
-
300
- while (msg = conn.read)
301
- break if Time.now > deadline
302
-
303
- parsed = begin
304
- JSON.parse(msg.buffer)
305
- rescue JSON::ParserError
306
- next
307
- end
308
-
309
- 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?
312
- break
313
- elsif parsed[0] == 'EOSE' && parsed[1] == sub_id
314
- break
315
- end
316
- end
317
- ensure
318
- begin
319
- conn&.close
320
- rescue StandardError
321
- nil
322
- end
276
+ deadline = new_deadline
277
+ ensure_supports!(method, deadline)
278
+
279
+ # Built once so every relay and retry carries the same event id.
280
+ request_event = NIP47::Request.build(
281
+ method: method,
282
+ params: params,
283
+ client_privkey: @connection_string.secret,
284
+ wallet_pubkey: @connection_string.wallet_pubkey,
285
+ encryption: @info.preferred_encryption
286
+ )
287
+
288
+ response = with_relays(deadline) { |url| send_request(url, deadline, method, request_event) }
289
+ unless response.success?
290
+ raise WalletServiceError.new(response.error_code || 'UNKNOWN', response.error_message || '')
291
+ end
292
+
293
+ response.result
294
+ end
295
+
296
+ def send_request(url, deadline, method, request_event)
297
+ sent = false
298
+ response, failure = websocket_session(url, deadline) do |conn|
299
+ sub_id = "rsp-#{SecureRandom.hex(4)}"
300
+ write_frame(conn, ['REQ', sub_id, response_filter(request_event.id)])
301
+ # Set before the write: a write that fails part-way may still deliver.
302
+ sent = true
303
+ write_frame(conn, ['EVENT', request_event.to_h])
304
+ read_response(conn, sub_id, request_event.id)
305
+ end
306
+ return response if response
307
+
308
+ raise_not_sent(url, failure) unless sent
309
+ raise failure if failure.is_a?(Error)
310
+ if failure.is_a?(Async::TimeoutError)
311
+ raise TimeoutError, "no response to #{method} within #{@request_timeout}s", cause: failure
312
+ end
313
+
314
+ raise TransportError,
315
+ "connection to relay #{relay_host(url)} failed after #{method} was sent " \
316
+ "(#{failure.class}: #{failure.message})",
317
+ cause: failure
318
+ end
319
+
320
+ def read_response(conn, sub_id, request_id)
321
+ while (msg = conn.read)
322
+ parsed = parse_frame(msg)
323
+ # Still ambiguous: the relay is untrusted and may have forwarded it anyway.
324
+ if parsed && parsed[0] == 'OK' && parsed[1] == request_id && parsed[2] == false
325
+ raise TransportError, "relay rejected the request: #{parsed[3].to_s[0, 200]}"
323
326
  end
324
- end.wait
325
327
 
326
- if result.nil?
327
- raise TransportError,
328
- "wallet service published no info event (kind 13194) on #{@connection_string.relays.first}"
328
+ event = relay_event(parsed, sub_id)
329
+ next unless event && event.kind == NIP47::Methods::KIND_RESPONSE && response_to?(event, request_id)
330
+
331
+ return NIP47::Response.parse(event, @connection_string.secret, @connection_string.wallet_pubkey)
329
332
  end
333
+ raise TransportError, 'relay closed the connection before the wallet responded'
334
+ end
335
+
336
+ # Entirely phase 1: nothing has been sent to the wallet yet.
337
+ def fetch_info(deadline)
338
+ with_relays(deadline) { |url| fetch_info_from(url, deadline) }
339
+ end
340
+
341
+ def fetch_info_from(url, deadline)
342
+ info, failure = websocket_session(url, deadline) do |conn|
343
+ sub_id = "info-#{SecureRandom.hex(4)}"
344
+ write_frame(conn, ['REQ', sub_id, info_filter])
345
+ read_info(conn, sub_id)
346
+ end
347
+ return info if info
348
+
349
+ raise_not_sent(url, failure) if failure
350
+ raise InfoUnavailableError,
351
+ "wallet service published no info event (kind 13194) on relay #{relay_host(url)}"
352
+ end
330
353
 
331
- result
354
+ def read_info(conn, sub_id)
355
+ while (msg = conn.read)
356
+ parsed = parse_frame(msg)
357
+ return nil if parsed && parsed[0] == 'EOSE' && parsed[1] == sub_id
358
+
359
+ # A rejected event must not end the read: a genuine info event may
360
+ # still follow, and the deadline bounds the wait.
361
+ event = relay_event(parsed, sub_id)
362
+ return NIP47::Info.parse(event) if event&.kind == NIP47::Methods::KIND_INFO
363
+ end
364
+ nil
332
365
  end
333
366
 
334
- def ensure_supports!(method)
335
- return if info.supports?(method)
367
+ def ensure_supports!(method, deadline)
368
+ @info ||= fetch_info(deadline)
369
+ return if @info.supports?(method)
336
370
 
337
371
  raise UnsupportedMethodError,
338
- "wallet service does not advertise `#{method}`. Supported: #{info.methods.join(', ')}"
372
+ "wallet service does not advertise `#{method}`. Supported: #{@info.methods.join(', ')}"
373
+ end
374
+
375
+ # Tries each relay in turn, then up to @connect_retries more rounds with
376
+ # backoff, all inside `deadline`. Only NotSentError is retried.
377
+ def with_relays(deadline)
378
+ last_failure = nil
379
+ (@connect_retries + 1).times do |round|
380
+ if round.positive?
381
+ pause = CONNECT_BACKOFF[round - 1] || CONNECT_BACKOFF.last
382
+ break if Async::Clock.now + pause >= deadline
383
+
384
+ sleep pause
385
+ end
386
+
387
+ @connection_string.relays.each do |url|
388
+ break if Async::Clock.now >= deadline
389
+
390
+ return yield(url)
391
+ rescue NotSentError => e
392
+ last_failure = e
393
+ @logger.warn("[nwc] #{e.message}")
394
+ end
395
+ end
396
+ raise last_failure if last_failure
397
+
398
+ raise NotSentError, "not sent: #{@request_timeout}s deadline passed before a relay could be tried"
399
+ end
400
+
401
+ # One websocket session bounded by `deadline`. Exceptions are rescued
402
+ # inside the task and returned so Async never logs them as unhandled.
403
+ # @return [Array(Object, Exception)] the block's value and any failure
404
+ def websocket_session(url, deadline)
405
+ value = nil
406
+ failure = nil
407
+ Sync do |task|
408
+ task.with_timeout(remaining(deadline)) do
409
+ endpoint = Async::HTTP::Endpoint.parse(url, alpn_protocols: ['http/1.1'])
410
+ Async::WebSocket::Client.connect(endpoint) { |conn| value = yield(conn) }
411
+ end
412
+ rescue StandardError => e
413
+ failure = e
414
+ end
415
+ [value, failure]
416
+ end
417
+
418
+ def raise_not_sent(url, failure)
419
+ raise failure if failure.is_a?(NotSentError)
420
+
421
+ reason = failure.is_a?(Async::TimeoutError) ? 'timed out' : "#{failure.class}: #{failure.message}"
422
+ raise NotSentError, "not sent: relay #{relay_host(url)} failed before the request was written (#{reason})",
423
+ cause: failure
424
+ end
425
+
426
+ # An authentic wallet event delivered on `sub_id`, or nil.
427
+ def relay_event(parsed, sub_id)
428
+ return unless parsed && parsed[0] == 'EVENT' && parsed[1] == sub_id && parsed[2].is_a?(Hash)
429
+
430
+ event = Event.from_hash(parsed[2])
431
+ event if authentic?(event)
432
+ end
433
+
434
+ def parse_frame(msg)
435
+ parsed = JSON.parse(msg.buffer)
436
+ parsed if parsed.is_a?(Array)
437
+ rescue JSON::ParserError
438
+ nil
439
+ end
440
+
441
+ def write_frame(conn, message)
442
+ conn.write(Protocol::WebSocket::TextMessage.generate(message))
443
+ conn.flush
444
+ end
445
+
446
+ # Host only: relay URLs can carry auth tokens in the path or query.
447
+ def relay_host(url)
448
+ URI.parse(url).host || 'unknown'
449
+ rescue URI::InvalidURIError
450
+ 'unparseable relay URL'
451
+ end
452
+
453
+ def new_deadline
454
+ Async::Clock.now + @request_timeout
455
+ end
456
+
457
+ def remaining(deadline)
458
+ [deadline - Async::Clock.now, 0].max
339
459
  end
340
460
 
341
461
  def default_logger
@@ -2,7 +2,11 @@
2
2
 
3
3
  module NwcRuby
4
4
  # Base class for all gem errors. Rescue this to catch anything the gem raises.
5
- class Error < StandardError; end
5
+ class Error < StandardError
6
+ # Whether the request reached the relay. Only `false` is a safe-to-retry
7
+ # signal; `true` and `nil` mean the wallet may have acted on it.
8
+ def sent? = nil # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
9
+ end
6
10
 
7
11
  # The connection string is missing or malformed.
8
12
  class InvalidConnectionStringError < Error; end
@@ -13,9 +17,24 @@ module NwcRuby
13
17
  # Signature verification failed on an inbound event.
14
18
  class InvalidSignatureError < Error; end
15
19
 
16
- # The relay could not be reached, or the connection died and did not recover
17
- # within the configured timeout.
18
- class TransportError < Error; end
20
+ # The connection to the relay failed after the request was written to it.
21
+ # The wallet may have acted on the request, so do not blindly retry
22
+ # payments; reconcile with `lookup_invoice` / `list_transactions` instead.
23
+ # (`NotSentError` below is the safe-to-retry subclass.)
24
+ class TransportError < Error
25
+ def sent? = true
26
+ end
27
+
28
+ # The request never left this process: DNS, TCP connect, TLS, the websocket
29
+ # upgrade, or the info fetch failed first. Always safe to retry. `#cause`
30
+ # holds the original low-level exception.
31
+ class NotSentError < TransportError
32
+ def sent? = false
33
+ end
34
+
35
+ # The relay answered but holds no kind 13194 info event for this wallet.
36
+ # Usually a wrong relay or wallet pubkey rather than a transient fault.
37
+ class InfoUnavailableError < NotSentError; end
19
38
 
20
39
  # The wallet service returned an error envelope. `#code` is the NIP-47 error
21
40
  # code (one of RATE_LIMITED, NOT_IMPLEMENTED, INSUFFICIENT_BALANCE,
@@ -28,12 +47,20 @@ module NwcRuby
28
47
  @code = code
29
48
  super("#{code}: #{message}")
30
49
  end
50
+
51
+ def sent? = true
31
52
  end
32
53
 
33
- # A request was sent but no response arrived within the timeout window.
34
- class TimeoutError < Error; end
54
+ # A request was sent but no response arrived within the timeout window. The
55
+ # wallet may have acted on it.
56
+ class TimeoutError < Error
57
+ def sent? = true
58
+ end
35
59
 
36
60
  # The wallet service does not support the method we tried to call. Check
37
- # `Client#capabilities` first, or use a read+write NWC string.
38
- class UnsupportedMethodError < Error; end
61
+ # `Client#capabilities` first, or use a read+write NWC string. A
62
+ # configuration problem: retrying will not help.
63
+ class UnsupportedMethodError < Error
64
+ def sent? = false
65
+ end
39
66
  end
@@ -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)
@@ -303,7 +303,7 @@ module NwcRuby
303
303
  rescue TimeoutError => e
304
304
  fail!("#{label}: #{e.message}")
305
305
  fail!(' → The wallet service accepted the request but never responded. The service may be down or overloaded.')
306
- rescue UnsupportedMethodError => e
306
+ rescue UnsupportedMethodError, TransportError => e
307
307
  fail!("#{label}: #{e.message}")
308
308
  rescue EncryptionError => e
309
309
  fail!("#{label}: decryption failed — #{e.message}")
@@ -120,7 +120,7 @@ module NwcRuby
120
120
  # Send raw client->relay message (e.g. REQ, EVENT, CLOSE). Safe to call
121
121
  # from within on_open / on_event callbacks.
122
122
  def send_message(message)
123
- raise TransportError, 'not connected' unless @conn
123
+ raise NotSentError, 'not connected' unless @conn
124
124
 
125
125
  @conn.write(Protocol::WebSocket::TextMessage.generate(message))
126
126
  @conn.flush
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NwcRuby
4
- VERSION = '0.2.3'
4
+ VERSION = '0.3.0'
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.3
4
+ version: 0.3.0
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.