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 +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +32 -3
- data/lib/nwc_ruby/client.rb +202 -112
- data/lib/nwc_ruby/errors.rb +35 -8
- data/lib/nwc_ruby/test_runner.rb +1 -1
- data/lib/nwc_ruby/transport/relay_connection.rb +1 -1
- data/lib/nwc_ruby/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b1fb35da37743ef772eac5368822e3656d24a8f1b081fa02174f52084ea0d03
|
|
4
|
+
data.tar.gz: '086309565529631e6525c95a22736b86423969216bc12f7ea2e63c65c9cc713e'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
574
|
-
|
|
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
|
data/lib/nwc_ruby/client.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
|
365
|
-
|
|
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
|
data/lib/nwc_ruby/errors.rb
CHANGED
|
@@ -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
|
|
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
|
|
17
|
-
#
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/nwc_ruby/test_runner.rb
CHANGED
|
@@ -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
|
|
123
|
+
raise NotSentError, 'not connected' unless @conn
|
|
124
124
|
|
|
125
125
|
@conn.write(Protocol::WebSocket::TextMessage.generate(message))
|
|
126
126
|
@conn.flush
|
data/lib/nwc_ruby/version.rb
CHANGED