nwc-ruby 0.2.2 → 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 +66 -0
- data/README.md +69 -0
- data/lib/nwc_ruby/client.rb +41 -11
- data/lib/nwc_ruby/event.rb +12 -0
- data/lib/nwc_ruby/transport/relay_connection.rb +39 -3
- 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,72 @@ 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
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- Silenced the noisy `Async::Task` warn log ("Task may have ended with
|
|
66
|
+
unhandled exception") that was emitted every `recycle_interval` (default
|
|
67
|
+
5 min) by the heartbeat task. The forced recycle used to be signaled by
|
|
68
|
+
raising `TransportError` from inside the heartbeat's child Async task; the
|
|
69
|
+
parent's reconnect loop caught it correctly, but Async's console logger
|
|
70
|
+
logged the unhandled-in-child exception with a full backtrace first.
|
|
71
|
+
The recycle is now signaled by setting an `@recycle_requested` flag and
|
|
72
|
+
closing the websocket, which unblocks the read loop and lets
|
|
73
|
+
`run_one_connection` return normally without any exception crossing a task
|
|
74
|
+
boundary.
|
|
75
|
+
|
|
10
76
|
## [0.2.2] — 2026-04-21
|
|
11
77
|
|
|
12
78
|
### Fixed
|
data/README.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
A production-grade Ruby client for [Nostr Wallet Connect (NIP-47)][nip47].
|
|
4
4
|
|
|
5
|
+
Accept and track Bitcoin Lightning payments in your Rails app without running
|
|
6
|
+
any Lightning infrastructure yourself. Generate Lightning invoices for your
|
|
7
|
+
users to pay, then get notified the instant each one settles — all by talking
|
|
8
|
+
to a Nostr Wallet Connect (NWC) service that someone else operates. The gem
|
|
9
|
+
works with any NWC-compatible backend (Rizful, Alby Hub, Alby Cloud, CoinOs,
|
|
10
|
+
any NIP-47 wallet service), so you're not locked into a single provider and
|
|
11
|
+
can swap vendors by changing one environment variable.
|
|
12
|
+
|
|
5
13
|
```ruby
|
|
6
14
|
require "nwc_ruby"
|
|
7
15
|
|
|
@@ -216,6 +224,67 @@ to keep middleboxes from idle-closing the socket, reconnects with capped
|
|
|
216
224
|
exponential backoff on failure, and force-recycles the connection every 5 minutes
|
|
217
225
|
as a belt-and-suspenders check against silently dead TCP streams.
|
|
218
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
|
+
|
|
219
288
|
#### Resuming after a restart
|
|
220
289
|
|
|
221
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)
|
|
@@ -146,6 +146,7 @@ module NwcRuby
|
|
|
146
146
|
def run_one_connection(top)
|
|
147
147
|
endpoint = Async::HTTP::Endpoint.parse(@url, alpn_protocols: ['http/1.1'])
|
|
148
148
|
opened_at = Async::Clock.now
|
|
149
|
+
@recycle_requested = false
|
|
149
150
|
@logger.info("[nwc] connecting to #{@url}")
|
|
150
151
|
|
|
151
152
|
Async::WebSocket::Client.connect(endpoint) do |conn|
|
|
@@ -155,6 +156,7 @@ module NwcRuby
|
|
|
155
156
|
|
|
156
157
|
@open_cb&.call(self)
|
|
157
158
|
read_loop(conn)
|
|
159
|
+
@logger.info("[nwc] recycling connection (#{@recycle_interval}s)") if @recycle_requested
|
|
158
160
|
ensure
|
|
159
161
|
heartbeat&.stop
|
|
160
162
|
poll&.stop
|
|
@@ -167,6 +169,13 @@ module NwcRuby
|
|
|
167
169
|
end
|
|
168
170
|
end
|
|
169
171
|
|
|
172
|
+
# Heartbeat task: sends RFC 6455 ping every @ping_interval seconds and
|
|
173
|
+
# requests a recycle once the connection has been open longer than
|
|
174
|
+
# @recycle_interval. The recycle is signaled via a flag + close rather
|
|
175
|
+
# than by raising across task boundaries, because an exception raised
|
|
176
|
+
# inside a child Async task is logged at warn level by Async's console
|
|
177
|
+
# logger ("Task may have ended with unhandled exception") before the
|
|
178
|
+
# parent's rescue runs — producing a noisy backtrace on every recycle.
|
|
170
179
|
def start_heartbeat(top, conn, opened_at)
|
|
171
180
|
top.async do
|
|
172
181
|
loop do
|
|
@@ -176,12 +185,25 @@ module NwcRuby
|
|
|
176
185
|
|
|
177
186
|
sleep @ping_interval
|
|
178
187
|
break if @stop
|
|
179
|
-
|
|
180
|
-
raise TransportError, "recycle (#{@recycle_interval}s)" if Async::Clock.now - opened_at > @recycle_interval
|
|
188
|
+
break if recycle_due?(opened_at) && request_recycle(conn)
|
|
181
189
|
end
|
|
182
190
|
end
|
|
183
191
|
end
|
|
184
192
|
|
|
193
|
+
def recycle_due?(opened_at)
|
|
194
|
+
Async::Clock.now - opened_at > @recycle_interval
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def request_recycle(conn)
|
|
198
|
+
@recycle_requested = true
|
|
199
|
+
begin
|
|
200
|
+
conn.close
|
|
201
|
+
rescue StandardError
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
true
|
|
205
|
+
end
|
|
206
|
+
|
|
185
207
|
def start_poll(top)
|
|
186
208
|
return unless @poll_interval && @poll_cb
|
|
187
209
|
|
|
@@ -200,7 +222,21 @@ module NwcRuby
|
|
|
200
222
|
end
|
|
201
223
|
|
|
202
224
|
def read_loop(conn)
|
|
203
|
-
|
|
225
|
+
loop do
|
|
226
|
+
message =
|
|
227
|
+
begin
|
|
228
|
+
conn.read
|
|
229
|
+
rescue StandardError => e
|
|
230
|
+
# If we asked for the recycle/close ourselves, treat the
|
|
231
|
+
# resulting read error as a clean EOF rather than propagating
|
|
232
|
+
# it up into the reconnect-on-error path (which would log a
|
|
233
|
+
# spurious warning).
|
|
234
|
+
raise unless @recycle_requested || @stop
|
|
235
|
+
|
|
236
|
+
@logger.debug("[nwc] read interrupted by close: #{e.class}")
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
break if message.nil?
|
|
204
240
|
break if @stop
|
|
205
241
|
|
|
206
242
|
begin
|
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.
|