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 +4 -4
- data/CHANGELOG.md +93 -0
- data/README.md +93 -3
- data/lib/nwc_ruby/client.rb +233 -113
- data/lib/nwc_ruby/errors.rb +35 -8
- data/lib/nwc_ruby/event.rb +12 -0
- 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
- data/nwc-ruby.gemspec +2 -2
- metadata +4 -4
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,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
|
|
513
|
-
|
|
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
|
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
|
|
@@ -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
|
|
191
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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/event.rb
CHANGED
|
@@ -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)
|
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
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/
|
|
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/
|
|
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.
|
|
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/
|
|
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/
|
|
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.
|
|
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.
|