nwc-ruby 0.2.0 → 0.2.2
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 +36 -3
- data/README.md +6 -6
- data/lib/nwc_ruby/client.rb +5 -2
- data/lib/nwc_ruby/test_runner.rb +12 -5
- data/lib/nwc_ruby/transport/relay_connection.rb +93 -44
- data/lib/nwc_ruby/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b847b05f148d03753548528e0f4483129cf59887e40329f28e7bbe31f0eda1cd
|
|
4
|
+
data.tar.gz: 9c99704df81a6e7cfa9c1bfede014b49270b15bf11a3bba8663b5d6f1f3500fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 11e62747c2dfa88459d900a2e1f89e7a42df5420b88221508bf62df39b65ebe7f9937db357557b2ab3ee8b95be0b50b5062fa3667cf21002dbf607366f49675d
|
|
7
|
+
data.tar.gz: c0ff4c40f5d8fa68d3b12e6b7c745ff1ff5b19da5719c0998c8cc5db6098ee77917c2c914b0ca96dc9e9301ac4240595f7e472019e6001394c996e75ffdcff6a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.2] — 2026-04-21
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Ctrl+C / SIGINT / SIGTERM now reliably exits the notification listener. The
|
|
15
|
+
previous trap handler called `@conn.close` directly, which deadlocks because
|
|
16
|
+
closing an SSL socket from inside a Ruby signal handler is unsafe
|
|
17
|
+
(`docker stop` was the only way out).
|
|
18
|
+
- Replaced the trap-based close with a self-pipe: the trap only flips `@stop`
|
|
19
|
+
and writes one byte to a pipe; an `Async` task reads from the pipe inside the
|
|
20
|
+
reactor and calls `top.stop` from the correct fiber context. This avoids two
|
|
21
|
+
further async-specific pitfalls discovered along the way — `Async::Task#stop`
|
|
22
|
+
raises `ThreadError: can't be called from trap context` (uses a Mutex), and
|
|
23
|
+
offloading to `Thread.new { task.stop }` fails with `NoMethodError` on
|
|
24
|
+
`Fiber.scheduler` because the reactor lives on a different thread.
|
|
25
|
+
- Added `rescue Interrupt, Async::Stop` at the top of the reconnect-loop rescue
|
|
26
|
+
chain in `RelayConnection#run!` so the stop signal is not swallowed by the
|
|
27
|
+
generic reconnect-on-error path.
|
|
28
|
+
|
|
29
|
+
## [0.2.1] — 2026-04-20
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Fixed all RuboCop offenses (CI now passes clean).
|
|
34
|
+
- Extracted `start_heartbeat` / `start_poll` from `run_one_connection` to reduce
|
|
35
|
+
method length and perceived complexity.
|
|
36
|
+
- Corrected README and CHANGELOG: transport uses 15 s ping keepalive (not 30 s
|
|
37
|
+
ping + 45 s pong deadline — the pong deadline was removed in 0.1.1 due to
|
|
38
|
+
async-websocket 0.30 API changes).
|
|
39
|
+
- Fixed string quoting, hash alignment, guard clause, and line length offenses
|
|
40
|
+
across Rakefile, `bin/nwc_test`, `client.rb`, `test_runner.rb`, and
|
|
41
|
+
`relay_connection.rb`.
|
|
42
|
+
- Excluded `lib/nwc-ruby.rb` from `Naming/FileName` (compatibility shim).
|
|
43
|
+
|
|
10
44
|
## [0.2.0] — 2026-04-20
|
|
11
45
|
|
|
12
46
|
### Changed
|
|
@@ -52,9 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
52
86
|
`multi_pay_keysend`, `make_invoice`, `lookup_invoice`, `list_transactions`,
|
|
53
87
|
`get_balance`, `get_info`, `sign_message`.
|
|
54
88
|
- Notification listener (kinds 23196 and 23197) with dedupe by `payment_hash`.
|
|
55
|
-
- Reliable long-running `Transport::RelayConnection`: RFC 6455 ping (
|
|
56
|
-
|
|
57
|
-
SIGTERM/SIGINT handling.
|
|
89
|
+
- Reliable long-running `Transport::RelayConnection`: RFC 6455 ping (15 s),
|
|
90
|
+
forced recycle (5 min), capped exponential backoff, SIGTERM/SIGINT handling.
|
|
58
91
|
- `NwcRuby.test` and `NwcRuby.test_notifications` diagnostic methods, backed
|
|
59
92
|
by `NwcRuby::TestRunner`. Announces read-only vs read+write, exercises every
|
|
60
93
|
advertised method, pays a Lightning address if the code is read+write.
|
data/README.md
CHANGED
|
@@ -34,8 +34,8 @@ heartbeats, zombie-TCP detection, reconnects, and backoff. You call methods.
|
|
|
34
34
|
- **Both encryption schemes** — NIP-44 v2 when the wallet advertises it
|
|
35
35
|
(validated against [paulmillr's test vectors][vectors]), NIP-04 fallback for
|
|
36
36
|
wallets that haven't migrated.
|
|
37
|
-
- **Bulletproof long-running transport** —
|
|
38
|
-
|
|
37
|
+
- **Bulletproof long-running transport** — 15 s ping keepalive, 5-min forced
|
|
38
|
+
recycle, capped exponential backoff, clean SIGTERM handling. Built on
|
|
39
39
|
[async-websocket][aw] (no dead EventMachine dependency).
|
|
40
40
|
- **Two diagnostic methods** — `NwcRuby.test` (info, read tests, write test
|
|
41
41
|
if applicable) and `NwcRuby.test_notifications` (listen forever in a separate
|
|
@@ -211,10 +211,10 @@ end
|
|
|
211
211
|
```
|
|
212
212
|
|
|
213
213
|
Under the hood, this subscribes to both kind 23196 (NIP-04) and kind 23197
|
|
214
|
-
(NIP-44 v2), dedupes by `payment_hash`, sends a WebSocket ping every
|
|
215
|
-
|
|
216
|
-
force-recycles the connection every 5 minutes
|
|
217
|
-
|
|
214
|
+
(NIP-44 v2), dedupes by `payment_hash`, sends a WebSocket ping every 15 seconds
|
|
215
|
+
to keep middleboxes from idle-closing the socket, reconnects with capped
|
|
216
|
+
exponential backoff on failure, and force-recycles the connection every 5 minutes
|
|
217
|
+
as a belt-and-suspenders check against silently dead TCP streams.
|
|
218
218
|
|
|
219
219
|
#### Resuming after a restart
|
|
220
220
|
|
data/lib/nwc_ruby/client.rb
CHANGED
|
@@ -143,7 +143,7 @@ module NwcRuby
|
|
|
143
143
|
# @param kinds [Array<Integer>] notification kinds to listen for. Defaults
|
|
144
144
|
# to both NIP-04 (23196) and NIP-44 v2 (23197). The listener dedupes by
|
|
145
145
|
# `payment_hash`, so receiving both is safe.
|
|
146
|
-
def subscribe_to_notifications(since: Time.now.to_i,
|
|
146
|
+
def subscribe_to_notifications(since: Time.now.to_i, # rubocop:disable Metrics/MethodLength
|
|
147
147
|
kinds: [NIP47::Methods::KIND_NOTIFICATION_NIP04,
|
|
148
148
|
NIP47::Methods::KIND_NOTIFICATION_NIP44],
|
|
149
149
|
sub_id: "nwc-#{SecureRandom.hex(4)}",
|
|
@@ -168,7 +168,10 @@ module NwcRuby
|
|
|
168
168
|
@notification_connection = conn
|
|
169
169
|
|
|
170
170
|
conn.on_open do |c|
|
|
171
|
-
@logger.info(
|
|
171
|
+
@logger.info(
|
|
172
|
+
"[nwc] subscribing sub_id=#{sub_id} client_pubkey=#{@connection_string.client_pubkey} " \
|
|
173
|
+
"wallet_pubkey=#{@connection_string.wallet_pubkey} kinds=#{kinds.inspect} since=#{since}"
|
|
174
|
+
)
|
|
172
175
|
c.send_req(sub_id: sub_id, filters: filters)
|
|
173
176
|
end
|
|
174
177
|
|
data/lib/nwc_ruby/test_runner.rb
CHANGED
|
@@ -56,8 +56,8 @@ module NwcRuby
|
|
|
56
56
|
|
|
57
57
|
if @pay_to_lightning_address
|
|
58
58
|
if @info.read_only?
|
|
59
|
-
warn!(
|
|
60
|
-
warn!(
|
|
59
|
+
warn!('pay_to_lightning_address was provided but this is a READ-ONLY code — it does not support pay_invoice.')
|
|
60
|
+
warn!(' → Generate a read+write NWC code if you need to test outbound payments.')
|
|
61
61
|
@out.puts
|
|
62
62
|
else
|
|
63
63
|
run_write_tests
|
|
@@ -205,7 +205,10 @@ module NwcRuby
|
|
|
205
205
|
try('lookup_invoice (payment_hash from previous step)', NIP47::Methods::LOOKUP_INVOICE) do
|
|
206
206
|
result = @client.lookup_invoice(payment_hash: @test_payment_hash)
|
|
207
207
|
@out.puts " #{DIM}state=#{result['state']} amount=#{result['amount']} msats#{CLR}"
|
|
208
|
-
|
|
208
|
+
unless result['state'] == 'pending'
|
|
209
|
+
warn!("lookup_invoice: `state` is #{result['state'].inspect}, " \
|
|
210
|
+
"expected 'pending' for a fresh invoice (optional per NIP-47)")
|
|
211
|
+
end
|
|
209
212
|
end
|
|
210
213
|
end
|
|
211
214
|
|
|
@@ -252,8 +255,12 @@ module NwcRuby
|
|
|
252
255
|
fail!('make_invoice: `payment_hash` is not a 64-char hex')
|
|
253
256
|
end
|
|
254
257
|
fail!('make_invoice: `amount` should echo 1000') unless result['amount'] == 1_000
|
|
255
|
-
|
|
256
|
-
|
|
258
|
+
unless result['type'] == 'incoming'
|
|
259
|
+
warn!("make_invoice: `type` is #{result['type'].inspect}, expected 'incoming' (optional per NIP-47)")
|
|
260
|
+
end
|
|
261
|
+
return if result['state'] == 'pending'
|
|
262
|
+
|
|
263
|
+
warn!("make_invoice: `state` is #{result['state'].inspect}, expected 'pending' (optional per NIP-47)")
|
|
257
264
|
end
|
|
258
265
|
|
|
259
266
|
# --- Lightning address resolver ----------------------------------------
|
|
@@ -63,14 +63,19 @@ module NwcRuby
|
|
|
63
63
|
|
|
64
64
|
def stop!
|
|
65
65
|
@stop = true
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
66
|
+
# Poke the signal pipe if available (works from any thread); the
|
|
67
|
+
# watcher task will call @top_task.stop from inside the reactor.
|
|
68
|
+
# If we're already inside the reactor thread/fiber, we can stop
|
|
69
|
+
# the top task directly.
|
|
70
|
+
if @signal_pipe_w
|
|
71
|
+
begin
|
|
72
|
+
@signal_pipe_w.write_nonblock('.')
|
|
73
|
+
rescue IO::WaitWritable, Errno::EPIPE, IOError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
else
|
|
77
|
+
task = @top_task
|
|
78
|
+
task&.stop
|
|
74
79
|
end
|
|
75
80
|
end
|
|
76
81
|
|
|
@@ -82,10 +87,15 @@ module NwcRuby
|
|
|
82
87
|
|
|
83
88
|
Async do |top|
|
|
84
89
|
@top_task = top
|
|
90
|
+
signal_watcher = start_signal_watcher(top)
|
|
85
91
|
until @stop
|
|
86
92
|
begin
|
|
87
93
|
run_one_connection(top)
|
|
88
94
|
backoff = 1
|
|
95
|
+
rescue Interrupt, Async::Stop
|
|
96
|
+
# Ctrl-C / SIGTERM / task.stop: exit cleanly.
|
|
97
|
+
@stop = true
|
|
98
|
+
break
|
|
89
99
|
rescue StandardError => e
|
|
90
100
|
break if @stop
|
|
91
101
|
|
|
@@ -97,7 +107,12 @@ module NwcRuby
|
|
|
97
107
|
backoff *= 2
|
|
98
108
|
end
|
|
99
109
|
end
|
|
110
|
+
rescue Interrupt
|
|
111
|
+
# Signal arrived while not inside a connection; just exit.
|
|
112
|
+
@stop = true
|
|
100
113
|
ensure
|
|
114
|
+
signal_watcher&.stop
|
|
115
|
+
close_signal_pipe
|
|
101
116
|
@top_task = nil
|
|
102
117
|
end
|
|
103
118
|
end
|
|
@@ -135,39 +150,8 @@ module NwcRuby
|
|
|
135
150
|
|
|
136
151
|
Async::WebSocket::Client.connect(endpoint) do |conn|
|
|
137
152
|
@conn = conn
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
loop do
|
|
141
|
-
conn.send_ping
|
|
142
|
-
conn.flush
|
|
143
|
-
@logger.debug("[nwc] ping sent")
|
|
144
|
-
|
|
145
|
-
sleep @ping_interval
|
|
146
|
-
break if @stop
|
|
147
|
-
|
|
148
|
-
if Async::Clock.now - opened_at > @recycle_interval
|
|
149
|
-
raise TransportError, "recycle (#{@recycle_interval}s)"
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# Some relays (e.g. strfry) store ephemeral events temporarily but
|
|
155
|
-
# do not push them to active subscriptions. Periodic re-subscribe
|
|
156
|
-
# forces the relay to return any newly-stored events.
|
|
157
|
-
poll = if @poll_interval && @poll_cb
|
|
158
|
-
top.async do
|
|
159
|
-
loop do
|
|
160
|
-
sleep @poll_interval
|
|
161
|
-
break if @stop
|
|
162
|
-
|
|
163
|
-
begin
|
|
164
|
-
@poll_cb.call(self)
|
|
165
|
-
rescue StandardError => e
|
|
166
|
-
@logger.warn("[nwc] poll error: #{e.message}")
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
end
|
|
153
|
+
heartbeat = start_heartbeat(top, conn, opened_at)
|
|
154
|
+
poll = start_poll(top)
|
|
171
155
|
|
|
172
156
|
@open_cb&.call(self)
|
|
173
157
|
read_loop(conn)
|
|
@@ -183,6 +167,38 @@ module NwcRuby
|
|
|
183
167
|
end
|
|
184
168
|
end
|
|
185
169
|
|
|
170
|
+
def start_heartbeat(top, conn, opened_at)
|
|
171
|
+
top.async do
|
|
172
|
+
loop do
|
|
173
|
+
conn.send_ping
|
|
174
|
+
conn.flush
|
|
175
|
+
@logger.debug('[nwc] ping sent')
|
|
176
|
+
|
|
177
|
+
sleep @ping_interval
|
|
178
|
+
break if @stop
|
|
179
|
+
|
|
180
|
+
raise TransportError, "recycle (#{@recycle_interval}s)" if Async::Clock.now - opened_at > @recycle_interval
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def start_poll(top)
|
|
186
|
+
return unless @poll_interval && @poll_cb
|
|
187
|
+
|
|
188
|
+
top.async do
|
|
189
|
+
loop do
|
|
190
|
+
sleep @poll_interval
|
|
191
|
+
break if @stop
|
|
192
|
+
|
|
193
|
+
begin
|
|
194
|
+
@poll_cb.call(self)
|
|
195
|
+
rescue StandardError => e
|
|
196
|
+
@logger.warn("[nwc] poll error: #{e.message}")
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
186
202
|
def read_loop(conn)
|
|
187
203
|
while (message = conn.read)
|
|
188
204
|
break if @stop
|
|
@@ -218,18 +234,51 @@ module NwcRuby
|
|
|
218
234
|
end
|
|
219
235
|
|
|
220
236
|
def install_traps
|
|
237
|
+
# Keep signal handlers tiny. We can't do much from inside a Ruby
|
|
238
|
+
# signal handler:
|
|
239
|
+
# - SSL I/O / socket close can deadlock.
|
|
240
|
+
# - Async::Task#stop takes a Mutex, which raises
|
|
241
|
+
# "can't be called from trap context (ThreadError)".
|
|
242
|
+
# - Thread.new { task.stop } runs outside the reactor and
|
|
243
|
+
# Fiber.scheduler is nil there.
|
|
244
|
+
# So: flip a flag and poke a self-pipe. An Async task watches the
|
|
245
|
+
# read end of the pipe and calls @top_task.stop from inside the
|
|
246
|
+
# reactor, which is the only safe place to do it.
|
|
247
|
+
@signal_pipe_r, @signal_pipe_w = IO.pipe
|
|
221
248
|
%w[TERM INT].each do |sig|
|
|
222
249
|
trap(sig) do
|
|
223
250
|
@stop = true
|
|
224
|
-
# Close the socket to unblock the read loop.
|
|
225
251
|
begin
|
|
226
|
-
@
|
|
227
|
-
rescue
|
|
252
|
+
@signal_pipe_w.write_nonblock('.')
|
|
253
|
+
rescue IO::WaitWritable, Errno::EPIPE, IOError
|
|
254
|
+
nil
|
|
228
255
|
end
|
|
229
256
|
end
|
|
230
257
|
end
|
|
231
258
|
end
|
|
232
259
|
|
|
260
|
+
def start_signal_watcher(top)
|
|
261
|
+
return unless @signal_pipe_r
|
|
262
|
+
|
|
263
|
+
top.async do
|
|
264
|
+
@signal_pipe_r.read(1)
|
|
265
|
+
@logger.debug('[nwc] signal received, stopping')
|
|
266
|
+
top.stop
|
|
267
|
+
rescue IOError, Errno::EBADF
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def close_signal_pipe
|
|
273
|
+
[@signal_pipe_r, @signal_pipe_w].each do |io|
|
|
274
|
+
io&.close
|
|
275
|
+
rescue IOError
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
@signal_pipe_r = nil
|
|
279
|
+
@signal_pipe_w = nil
|
|
280
|
+
end
|
|
281
|
+
|
|
233
282
|
def default_logger
|
|
234
283
|
logger = Logger.new($stdout)
|
|
235
284
|
logger.level = ENV['NWC_LOG_LEVEL'] ? Logger.const_get(ENV['NWC_LOG_LEVEL'].upcase) : Logger::INFO
|
data/lib/nwc_ruby/version.rb
CHANGED
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.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- MegalithicBTC
|
|
@@ -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.8
|
|
226
226
|
specification_version: 4
|
|
227
227
|
summary: Ruby client for Nostr Wallet Connect (NIP-47) with safe long-running relay
|
|
228
228
|
connections.
|