nwc-ruby 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +61 -0
- data/lib/nwc_ruby/client.rb +41 -11
- data/lib/nwc_ruby/event.rb +12 -0
- 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: 33006d633764a07adc713f4e5e6fe49a5108927286258abf0d976076a3f125d1
|
|
4
|
+
data.tar.gz: 4727a1b8968c741a2ec2feb2c4f576b4afe8d4df2066f09ddf26f6eb53ed42a1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6fc9796e80a92b9e1d255ee2fdafb85d5171c7a74a6d25b522ddebf2114620081775a7a21d409ff6209a2d522e02451d6f3057627dd07e2f7091bbba779639a4
|
|
7
|
+
data.tar.gz: '087888d35e9b84ad81a8ab1f97ee1914a75b2d32a3632c62972bda323225d83eb082fb3e02df469138a980795e3e62692ba9877a0bb398ed228d6a9337f622bd'
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.4] — 2026-07-17
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- **Event ids are now recomputed before the signature is trusted.**
|
|
15
|
+
`Event#valid_signature?` verified the signature against the `id` supplied by
|
|
16
|
+
the relay without checking that the `id` actually described the event body.
|
|
17
|
+
Because a Nostr signature only ever commits to the `id`, nothing bound that
|
|
18
|
+
`id` to the event it arrived with: a relay could lift a genuine
|
|
19
|
+
`(id, sig, pubkey)` triple from any real wallet event and re-attach it to
|
|
20
|
+
arbitrary `content`, `tags`, `kind` or `created_at`, and the result verified.
|
|
21
|
+
The id is now recomputed from the canonical serialization first, via the new
|
|
22
|
+
`Event#valid_id?`. Reported by @riccardobl in
|
|
23
|
+
[#1](https://github.com/MegalithicBTC/nwc-ruby/issues/1).
|
|
24
|
+
|
|
25
|
+
The sharpest edge was the kind 13194 info event, whose content and tags are
|
|
26
|
+
plaintext: stripping its `encryption` tag silently downgraded every
|
|
27
|
+
subsequent request from NIP-44 v2 to NIP-04, since the spec reads an absent
|
|
28
|
+
tag as "nip04 only".
|
|
29
|
+
|
|
30
|
+
- **Responses are now bound to the request that asked for them.** `call` trusted
|
|
31
|
+
the relay to honour the `#e` REQ filter and never checked the response's `e`
|
|
32
|
+
tag itself, so a relay could replay an older but genuine response — a past
|
|
33
|
+
"payment succeeded" answering a fresh `pay_invoice`, for instance.
|
|
34
|
+
|
|
35
|
+
- **`fetch_info` now verifies the event author.** It previously checked only
|
|
36
|
+
`valid_signature?` and never compared the pubkey to the wallet's, so a relay
|
|
37
|
+
could sign an info event with its own key and have it accepted — forcing the
|
|
38
|
+
encryption downgrade above without needing to tamper with anything.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- The notification listener no longer tears down when a relay delivers an event
|
|
43
|
+
of an unexpected kind. `Notification.parse` raises `ArgumentError` on a
|
|
44
|
+
non-notification kind, but only `EncryptionError` was rescued, so a relay
|
|
45
|
+
echoing a kind 23195 response onto the subscription killed the listener.
|
|
46
|
+
- A rejected info event no longer ends the `fetch_info` read loop, so one junk
|
|
47
|
+
event cannot deny service while a genuine info event is still inbound.
|
|
48
|
+
- Corrected `source_code_uri` and `changelog_uri` in the gemspec, which pointed
|
|
49
|
+
at a `main` branch that does not exist — both links 404'd from the RubyGems
|
|
50
|
+
page. This repo uses `master`; `PUBLISHING.md` has been corrected to match.
|
|
51
|
+
- `PUBLISHING.md` no longer claims releases publish automatically on tag. There
|
|
52
|
+
is no `release.yml` in this repo — only `ci.yml`, which tests but never
|
|
53
|
+
publishes — so releases are manual.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- Documented a gentle `lookup_invoice` backup polling cadence for invoices so
|
|
58
|
+
apps can recover from missed notifications without getting rate limited by the
|
|
59
|
+
backing NWC service.
|
|
60
|
+
|
|
61
|
+
## [0.2.3] — 2026-04-23
|
|
62
|
+
|
|
10
63
|
### Fixed
|
|
11
64
|
|
|
12
65
|
- 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
|
data/lib/nwc_ruby/client.rb
CHANGED
|
@@ -187,8 +187,10 @@ module NwcRuby
|
|
|
187
187
|
|
|
188
188
|
conn.on_event do |_sub, event_hash|
|
|
189
189
|
event = Event.from_hash(event_hash)
|
|
190
|
-
next unless event
|
|
191
|
-
|
|
190
|
+
next unless authentic?(event)
|
|
191
|
+
# Notification.parse raises on any other kind, which would tear down
|
|
192
|
+
# the listener; the relay chooses what it sends us, not the filter.
|
|
193
|
+
next unless kinds.include?(event.kind)
|
|
192
194
|
|
|
193
195
|
# Advance the poll watermark so we don't re-fetch old events.
|
|
194
196
|
last_seen_at = event.created_at if event.created_at && event.created_at > last_seen_at
|
|
@@ -219,6 +221,33 @@ module NwcRuby
|
|
|
219
221
|
|
|
220
222
|
private
|
|
221
223
|
|
|
224
|
+
# Every event arriving from a relay must clear this before it is parsed.
|
|
225
|
+
# `valid_signature?` recomputes the id, so a passing event is byte-for-byte
|
|
226
|
+
# what the wallet signed — which is what makes the kind and tag checks
|
|
227
|
+
# below meaningful.
|
|
228
|
+
def authentic?(event)
|
|
229
|
+
event.valid_signature? && event.pubkey == @connection_string.wallet_pubkey
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# A relay is not trusted to honour the REQ filter it was sent, so the
|
|
233
|
+
# response is bound to the request here. Without this an older but genuine
|
|
234
|
+
# response (say, a past "payment succeeded") can be replayed against a new
|
|
235
|
+
# request.
|
|
236
|
+
def response_to?(event, request_id)
|
|
237
|
+
e_tag = event.tags.find { |t| t[0] == 'e' }
|
|
238
|
+
!e_tag.nil? && e_tag[1] == request_id
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Advisory only — the relay decides what it actually sends, so `fetch_info`
|
|
242
|
+
# re-checks the author and kind on whatever comes back.
|
|
243
|
+
def info_filter
|
|
244
|
+
{
|
|
245
|
+
'authors' => [@connection_string.wallet_pubkey],
|
|
246
|
+
'kinds' => [NIP47::Methods::KIND_INFO],
|
|
247
|
+
'limit' => 1
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
|
|
222
251
|
# rubocop:disable Metrics/MethodLength
|
|
223
252
|
def call(method, params)
|
|
224
253
|
ensure_supports!(method)
|
|
@@ -260,8 +289,9 @@ module NwcRuby
|
|
|
260
289
|
next unless parsed[0] == 'EVENT' && parsed[1] == sub_id
|
|
261
290
|
|
|
262
291
|
event = Event.from_hash(parsed[2])
|
|
263
|
-
next unless event
|
|
264
|
-
next unless event.
|
|
292
|
+
next unless authentic?(event)
|
|
293
|
+
next unless event.kind == NIP47::Methods::KIND_RESPONSE
|
|
294
|
+
next unless response_to?(event, request_event.id)
|
|
265
295
|
|
|
266
296
|
result = NIP47::Response.parse(event, @connection_string.secret, @connection_string.wallet_pubkey)
|
|
267
297
|
break
|
|
@@ -290,11 +320,7 @@ module NwcRuby
|
|
|
290
320
|
Async do
|
|
291
321
|
Async::WebSocket::Client.connect(endpoint) do |conn|
|
|
292
322
|
sub_id = "info-#{SecureRandom.hex(4)}"
|
|
293
|
-
conn.write(Protocol::WebSocket::TextMessage.generate(['REQ', sub_id,
|
|
294
|
-
'authors' => [@connection_string.wallet_pubkey],
|
|
295
|
-
'kinds' => [NIP47::Methods::KIND_INFO],
|
|
296
|
-
'limit' => 1
|
|
297
|
-
}]))
|
|
323
|
+
conn.write(Protocol::WebSocket::TextMessage.generate(['REQ', sub_id, info_filter]))
|
|
298
324
|
conn.flush
|
|
299
325
|
|
|
300
326
|
while (msg = conn.read)
|
|
@@ -307,8 +333,12 @@ module NwcRuby
|
|
|
307
333
|
end
|
|
308
334
|
|
|
309
335
|
if parsed[0] == 'EVENT' && parsed[1] == sub_id
|
|
310
|
-
event
|
|
311
|
-
|
|
336
|
+
event = Event.from_hash(parsed[2])
|
|
337
|
+
# A rejected event must not end the read: a genuine info event
|
|
338
|
+
# may still follow, and the deadline above bounds the wait.
|
|
339
|
+
next unless authentic?(event) && event.kind == NIP47::Methods::KIND_INFO
|
|
340
|
+
|
|
341
|
+
result = NIP47::Info.parse(event)
|
|
312
342
|
break
|
|
313
343
|
elsif parsed[0] == 'EOSE' && parsed[1] == sub_id
|
|
314
344
|
break
|
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/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.2.
|
|
4
|
+
version: 0.2.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- MegalithicBTC
|
|
@@ -204,9 +204,9 @@ licenses:
|
|
|
204
204
|
- MIT
|
|
205
205
|
metadata:
|
|
206
206
|
homepage_uri: https://github.com/MegalithicBTC/nwc-ruby
|
|
207
|
-
source_code_uri: https://github.com/MegalithicBTC/nwc-ruby/tree/
|
|
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.
|