nwc-ruby 0.2.4 → 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: 33006d633764a07adc713f4e5e6fe49a5108927286258abf0d976076a3f125d1
4
- data.tar.gz: 4727a1b8968c741a2ec2feb2c4f576b4afe8d4df2066f09ddf26f6eb53ed42a1
3
+ metadata.gz: 9b1fb35da37743ef772eac5368822e3656d24a8f1b081fa02174f52084ea0d03
4
+ data.tar.gz: '086309565529631e6525c95a22736b86423969216bc12f7ea2e63c65c9cc713e'
5
5
  SHA512:
6
- metadata.gz: 6fc9796e80a92b9e1d255ee2fdafb85d5171c7a74a6d25b522ddebf2114620081775a7a21d409ff6209a2d522e02451d6f3057627dd07e2f7091bbba779639a4
7
- data.tar.gz: '087888d35e9b84ad81a8ab1f97ee1914a75b2d32a3632c62972bda323225d83eb082fb3e02df469138a980795e3e62692ba9877a0bb398ed228d6a9337f622bd'
6
+ metadata.gz: 54b83902f9b0f3a0cd3df5f453d07d90e31a72b18cb81c888b9635ea44c7d34f804b15a0c6d77ab5fca3bad85be99adb73cf91cc22fb76fed94da35300d63f37
7
+ data.tar.gz: 1871b89d77fbac48fb8731ed8ae56c99a3555289313af614b2b1bf13704fca12a64c4233b92ac0fafcd469db1b0fb501b841eba202b21b528698f919eee06183
data/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ 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
+
10
50
  ## [0.2.4] — 2026-07-17
11
51
 
12
52
  ### Security
data/README.md CHANGED
@@ -522,6 +522,12 @@ Constructor:
522
522
  | `Client.from_uri(uri)` | `Client` — parses the `nostr+walletconnect://` URI |
523
523
  | `Client.new(connection_string)` | `Client` — if you already have a `ConnectionString` |
524
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
+
525
531
  Introspection:
526
532
 
527
533
  | Method | Returns |
@@ -565,19 +571,42 @@ Listener:
565
571
 
566
572
  ### Errors
567
573
 
568
- 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`.
569
577
 
570
578
  - `InvalidConnectionStringError` — the URI couldn't be parsed.
571
579
  - `EncryptionError` — bad MAC / bad padding / unknown version byte / bad key.
572
580
  - `InvalidSignatureError` — an event's signature did not verify.
573
- - `TransportError` — the WebSocket couldn't connect or died unrecoverably.
574
- - `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.
575
590
  - `UnsupportedMethodError` — wallet service doesn't advertise this method.
591
+ A configuration problem; retrying won't help.
576
592
  - `WalletServiceError` — the wallet returned an error envelope. Check `#code`
577
593
  for `RATE_LIMITED`, `NOT_IMPLEMENTED`, `INSUFFICIENT_BALANCE`,
578
594
  `QUOTA_EXCEEDED`, `RESTRICTED`, `UNAUTHORIZED`, `INTERNAL`,
579
595
  `UNSUPPORTED_ENCRYPTION`, `PAYMENT_FAILED`, `NOT_FOUND`, or `OTHER`.
580
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
+
581
610
  ---
582
611
 
583
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
@@ -248,124 +259,203 @@ module NwcRuby
248
259
  }
249
260
  end
250
261
 
251
- # rubocop:disable Metrics/MethodLength
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.
252
275
  def call(method, params)
253
- ensure_supports!(method)
254
- encryption = info.preferred_encryption
255
-
256
- deadline = Time.now + @request_timeout
257
- result = nil
258
-
259
- Async do
260
- endpoint = Async::HTTP::Endpoint.parse(@connection_string.relays.first, alpn_protocols: ['http/1.1'])
261
- Async::WebSocket::Client.connect(endpoint) do |conn|
262
- request_event = NIP47::Request.build(
263
- method: method,
264
- params: params,
265
- client_privkey: @connection_string.secret,
266
- wallet_pubkey: @connection_string.wallet_pubkey,
267
- encryption: encryption
268
- )
269
-
270
- sub_id = "rsp-#{SecureRandom.hex(4)}"
271
- conn.write(Protocol::WebSocket::TextMessage.generate(['REQ', sub_id, {
272
- 'authors' => [@connection_string.wallet_pubkey],
273
- 'kinds' => [NIP47::Methods::KIND_RESPONSE],
274
- '#e' => [request_event.id],
275
- '#p' => [@connection_string.client_pubkey]
276
- }]))
277
- conn.write(Protocol::WebSocket::TextMessage.generate(['EVENT', request_event.to_h]))
278
- conn.flush
279
-
280
- while (msg = conn.read)
281
- break if Time.now > deadline
282
-
283
- parsed = begin
284
- JSON.parse(msg.buffer)
285
- rescue JSON::ParserError
286
- next
287
- end
288
-
289
- next unless parsed[0] == 'EVENT' && parsed[1] == sub_id
290
-
291
- event = Event.from_hash(parsed[2])
292
- next unless authentic?(event)
293
- next unless event.kind == NIP47::Methods::KIND_RESPONSE
294
- next unless response_to?(event, request_event.id)
295
-
296
- result = NIP47::Response.parse(event, @connection_string.secret, @connection_string.wallet_pubkey)
297
- break
298
- end
299
- ensure
300
- begin
301
- conn&.close
302
- rescue StandardError
303
- nil
304
- end
305
- end
306
- end.wait
307
-
308
- raise TimeoutError, "no response to #{method} within #{@request_timeout}s" if result.nil?
309
- raise WalletServiceError.new(result.error_code || 'UNKNOWN', result.error_message || '') unless result.success?
310
-
311
- result.result
312
- end
313
- # rubocop:enable Metrics/MethodLength
314
-
315
- def fetch_info
316
- endpoint = Async::HTTP::Endpoint.parse(@connection_string.relays.first, alpn_protocols: ['http/1.1'])
317
- deadline = Time.now + @request_timeout
318
- result = nil
319
-
320
- Async do
321
- Async::WebSocket::Client.connect(endpoint) do |conn|
322
- sub_id = "info-#{SecureRandom.hex(4)}"
323
- conn.write(Protocol::WebSocket::TextMessage.generate(['REQ', sub_id, info_filter]))
324
- conn.flush
325
-
326
- while (msg = conn.read)
327
- break if Time.now > deadline
328
-
329
- parsed = begin
330
- JSON.parse(msg.buffer)
331
- rescue JSON::ParserError
332
- next
333
- end
334
-
335
- if parsed[0] == 'EVENT' && parsed[1] == sub_id
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)
342
- break
343
- elsif parsed[0] == 'EOSE' && parsed[1] == sub_id
344
- break
345
- end
346
- end
347
- ensure
348
- begin
349
- conn&.close
350
- rescue StandardError
351
- nil
352
- 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]}"
353
326
  end
354
- end.wait
355
327
 
356
- if result.nil?
357
- raise TransportError,
358
- "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)
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)
359
346
  end
347
+ return info if info
360
348
 
361
- result
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)}"
362
352
  end
363
353
 
364
- def ensure_supports!(method)
365
- return if info.supports?(method)
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
365
+ end
366
+
367
+ def ensure_supports!(method, deadline)
368
+ @info ||= fetch_info(deadline)
369
+ return if @info.supports?(method)
366
370
 
367
371
  raise UnsupportedMethodError,
368
- "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
369
459
  end
370
460
 
371
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
@@ -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.4'
4
+ VERSION = '0.3.0'
5
5
  end
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.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - MegalithicBTC