nwc-ruby 0.2.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 +7 -0
- data/CHANGELOG.md +61 -0
- data/LICENSE +21 -0
- data/README.md +588 -0
- data/lib/nwc-ruby.rb +3 -0
- data/lib/nwc_ruby/client.rb +344 -0
- data/lib/nwc_ruby/connection_string.rb +81 -0
- data/lib/nwc_ruby/crypto/ecdh.rb +54 -0
- data/lib/nwc_ruby/crypto/keys.rb +46 -0
- data/lib/nwc_ruby/crypto/schnorr.rb +38 -0
- data/lib/nwc_ruby/errors.rb +39 -0
- data/lib/nwc_ruby/event.rb +72 -0
- data/lib/nwc_ruby/nip04/cipher.rb +47 -0
- data/lib/nwc_ruby/nip44/cipher.rb +176 -0
- data/lib/nwc_ruby/nip47/info.rb +67 -0
- data/lib/nwc_ruby/nip47/methods.rb +43 -0
- data/lib/nwc_ruby/nip47/notification.rb +50 -0
- data/lib/nwc_ruby/nip47/request.rb +35 -0
- data/lib/nwc_ruby/nip47/response.rb +58 -0
- data/lib/nwc_ruby/test_runner.rb +331 -0
- data/lib/nwc_ruby/transport/relay_connection.rb +240 -0
- data/lib/nwc_ruby/version.rb +5 -0
- data/lib/nwc_ruby.rb +82 -0
- data/nwc-ruby.gemspec +52 -0
- metadata +229 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module NwcRuby
|
|
7
|
+
# A diagnostic test runner for a live NWC connection string. Surfaces
|
|
8
|
+
# misbehavior with actionable error messages rather than cryptic failures.
|
|
9
|
+
#
|
|
10
|
+
# Exposed both as a class and through the convenience method
|
|
11
|
+
# `NwcRuby.test(...)`. Call it from IRB, a Rails console, a
|
|
12
|
+
# custom rake task in your own app, a spec — anywhere.
|
|
13
|
+
#
|
|
14
|
+
# Example:
|
|
15
|
+
#
|
|
16
|
+
# NwcRuby.test(
|
|
17
|
+
# nwc_url: "nostr+walletconnect://...",
|
|
18
|
+
# pay_to_lightning_address: "you@getalby.com",
|
|
19
|
+
# pay_to_satoshis_amount: 10
|
|
20
|
+
# )
|
|
21
|
+
class TestRunner
|
|
22
|
+
PASS = "\e[32m✓\e[0m"
|
|
23
|
+
FAIL = "\e[31m✗\e[0m"
|
|
24
|
+
WARN = "\e[33m!\e[0m"
|
|
25
|
+
SKIP = "\e[90m—\e[0m"
|
|
26
|
+
BOLD = "\e[1m"
|
|
27
|
+
DIM = "\e[90m"
|
|
28
|
+
CLR = "\e[0m"
|
|
29
|
+
|
|
30
|
+
DEFAULT_SATOSHIS = 100
|
|
31
|
+
|
|
32
|
+
# @param nwc_url [String] the nostr+walletconnect:// connection string
|
|
33
|
+
# @param pay_to_lightning_address [String, nil] Lightning address to send
|
|
34
|
+
# the write test payment to. Only used if the NWC code is read+write.
|
|
35
|
+
# @param pay_to_satoshis_amount [Integer] amount in sats used for both
|
|
36
|
+
# the outbound write test (if applicable) and the inbound make_invoice
|
|
37
|
+
# generated for the notification test. Defaults to 100.
|
|
38
|
+
# @param output [IO] where to print diagnostic output.
|
|
39
|
+
def initialize(nwc_url:,
|
|
40
|
+
pay_to_lightning_address: nil,
|
|
41
|
+
pay_to_satoshis_amount: DEFAULT_SATOSHIS,
|
|
42
|
+
output: $stdout)
|
|
43
|
+
@nwc_url = nwc_url
|
|
44
|
+
@pay_to_lightning_address = pay_to_lightning_address
|
|
45
|
+
@pay_to_satoshis_amount = Integer(pay_to_satoshis_amount)
|
|
46
|
+
@out = output
|
|
47
|
+
@failures = []
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Runs info + read tests + write tests (if applicable).
|
|
51
|
+
# Does NOT test notifications — run run_notifications in a separate process.
|
|
52
|
+
def run
|
|
53
|
+
return false unless preamble
|
|
54
|
+
|
|
55
|
+
run_read_tests
|
|
56
|
+
|
|
57
|
+
if @pay_to_lightning_address
|
|
58
|
+
if @info.read_only?
|
|
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
|
+
@out.puts
|
|
62
|
+
else
|
|
63
|
+
run_write_tests
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
summary
|
|
68
|
+
@failures.empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Subscribes to notifications and blocks forever, printing each one.
|
|
72
|
+
# Ctrl-C / SIGTERM cause a clean exit. Run in a separate process.
|
|
73
|
+
def run_notifications
|
|
74
|
+
return false unless preamble
|
|
75
|
+
|
|
76
|
+
@out.puts "#{BOLD}Listening for notifications...#{CLR}"
|
|
77
|
+
@out.puts "#{DIM}Press Ctrl-C to stop.#{CLR}"
|
|
78
|
+
@out.puts
|
|
79
|
+
|
|
80
|
+
@client.subscribe_to_notifications do |n|
|
|
81
|
+
@out.puts " #{PASS} #{Time.now.strftime('%Y-%m-%d %H:%M:%S')} — #{n.type}"
|
|
82
|
+
@out.puts " #{DIM}payment_hash=#{n.payment_hash}#{CLR}"
|
|
83
|
+
@out.puts " #{DIM}amount=#{n.amount_msats} msats#{CLR}"
|
|
84
|
+
@out.puts
|
|
85
|
+
end
|
|
86
|
+
# subscribe_to_notifications blocks until SIGTERM/SIGINT
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Shared setup: header, parse, info, mode, encryption.
|
|
93
|
+
def preamble
|
|
94
|
+
header
|
|
95
|
+
return false unless validate_connection_string
|
|
96
|
+
return false unless fetch_info
|
|
97
|
+
|
|
98
|
+
announce_mode
|
|
99
|
+
check_encryption_support
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def header
|
|
104
|
+
@out.puts "#{BOLD}NWC Ruby diagnostic#{CLR}"
|
|
105
|
+
@out.puts "#{DIM}gem version #{NwcRuby::VERSION}#{CLR}"
|
|
106
|
+
@out.puts
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_connection_string
|
|
110
|
+
if @nwc_url.to_s.empty?
|
|
111
|
+
fail!('No NWC URL provided. Pass nwc_url: to TestRunner.new or NwcRuby.test.')
|
|
112
|
+
return false
|
|
113
|
+
end
|
|
114
|
+
@conn_str = ConnectionString.parse(@nwc_url)
|
|
115
|
+
pass('Connection string parsed')
|
|
116
|
+
@out.puts " #{DIM}wallet_pubkey: #{@conn_str.wallet_pubkey}#{CLR}"
|
|
117
|
+
@out.puts " #{DIM}client_pubkey: #{@conn_str.client_pubkey}#{CLR}"
|
|
118
|
+
@out.puts " #{DIM}relay: #{@conn_str.relays.first}#{CLR}"
|
|
119
|
+
@out.puts
|
|
120
|
+
true
|
|
121
|
+
rescue InvalidConnectionStringError => e
|
|
122
|
+
fail!("Invalid NWC URL: #{e.message}")
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def fetch_info
|
|
127
|
+
@client = Client.from_uri(@nwc_url)
|
|
128
|
+
@info = @client.info
|
|
129
|
+
pass('Fetched info event (kind 13194)')
|
|
130
|
+
true
|
|
131
|
+
rescue TransportError => e
|
|
132
|
+
fail!("Could not fetch info event: #{e.message}")
|
|
133
|
+
fail!(' → Confirm the relay URL is reachable and that the wallet service has published its kind 13194 event.')
|
|
134
|
+
false
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
fail!("Unexpected error fetching info: #{e.class}: #{e.message}")
|
|
137
|
+
false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def announce_mode
|
|
141
|
+
@out.puts
|
|
142
|
+
if @info.read_write?
|
|
143
|
+
@out.puts " #{BOLD}\e[33m⚠ This code is READ+WRITE and can allow payments. Be careful with it.#{CLR}"
|
|
144
|
+
else
|
|
145
|
+
@out.puts " #{BOLD}\e[36mℹ This is a READ-ONLY code. It cannot move funds.#{CLR}"
|
|
146
|
+
end
|
|
147
|
+
@out.puts
|
|
148
|
+
@out.puts ' Supported methods:'
|
|
149
|
+
NIP47::Methods::ALL.each do |method|
|
|
150
|
+
marker = @info.supports?(method) ? PASS : SKIP
|
|
151
|
+
mutates = NIP47::Methods::MUTATING.include?(method) ? "#{DIM}(mutating)#{CLR}" : ''
|
|
152
|
+
@out.puts " #{marker} #{method} #{mutates}"
|
|
153
|
+
end
|
|
154
|
+
@out.puts
|
|
155
|
+
@out.puts " Notifications: #{@info.notification_types.empty? ? '(none advertised)' : @info.notification_types.join(', ')}"
|
|
156
|
+
@out.puts
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def check_encryption_support
|
|
160
|
+
schemes = @info.encryption_schemes.join(', ')
|
|
161
|
+
if @info.supports_nip44?
|
|
162
|
+
pass("Encryption: #{schemes} — will use NIP-44 v2")
|
|
163
|
+
else
|
|
164
|
+
warn!("Encryption: #{schemes} — wallet service does not advertise nip44_v2. Will fall back to NIP-04.")
|
|
165
|
+
warn!(' → NIP-04 is deprecated. Consider a wallet service that supports NIP-44 v2.')
|
|
166
|
+
end
|
|
167
|
+
@out.puts
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def run_read_tests
|
|
171
|
+
@out.puts "#{BOLD}Read tests#{CLR}"
|
|
172
|
+
|
|
173
|
+
try('get_info', NIP47::Methods::GET_INFO) do
|
|
174
|
+
result = @client.get_info
|
|
175
|
+
@get_info_result = result
|
|
176
|
+
@out.puts " #{DIM}alias=#{result['alias']} network=#{result['network']} pubkey=#{result['pubkey']}#{CLR}"
|
|
177
|
+
@out.puts " #{DIM}lud16=#{result['lud16']}#{CLR}" if result['lud16']
|
|
178
|
+
sanity_check_get_info(result)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
try('get_balance', NIP47::Methods::GET_BALANCE) do
|
|
182
|
+
result = @client.get_balance
|
|
183
|
+
@out.puts " #{DIM}balance=#{result['balance']} msats#{CLR}"
|
|
184
|
+
fail!('get_balance: `balance` field missing or not an integer') unless result['balance'].is_a?(Integer)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
try('list_transactions', NIP47::Methods::LIST_TRANSACTIONS) do
|
|
188
|
+
result = @client.list_transactions(limit: 5)
|
|
189
|
+
txs = result['transactions'] || []
|
|
190
|
+
@out.puts " #{DIM}returned #{txs.size} transactions#{CLR}"
|
|
191
|
+
unless result['transactions'].is_a?(Array)
|
|
192
|
+
fail!('list_transactions: `transactions` field missing or not an array')
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
try('make_invoice (1000 msats)', NIP47::Methods::MAKE_INVOICE) do
|
|
197
|
+
result = @client.make_invoice(amount: 1_000, description: 'nwc_ruby gem test')
|
|
198
|
+
@out.puts " #{DIM}invoice=#{truncate(result['invoice'], 40)}#{CLR}"
|
|
199
|
+
@out.puts " #{DIM}payment_hash=#{result['payment_hash']}#{CLR}"
|
|
200
|
+
sanity_check_make_invoice(result)
|
|
201
|
+
@test_payment_hash = result['payment_hash']
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
if @test_payment_hash
|
|
205
|
+
try('lookup_invoice (payment_hash from previous step)', NIP47::Methods::LOOKUP_INVOICE) do
|
|
206
|
+
result = @client.lookup_invoice(payment_hash: @test_payment_hash)
|
|
207
|
+
@out.puts " #{DIM}state=#{result['state']} amount=#{result['amount']} msats#{CLR}"
|
|
208
|
+
warn!("lookup_invoice: `state` is #{result['state'].inspect}, expected 'pending' for a fresh invoice (optional per NIP-47)") unless result['state'] == 'pending'
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
@out.puts
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def run_write_tests
|
|
216
|
+
@out.puts "#{BOLD}Write tests#{CLR} #{DIM}(read+write code detected, Lightning address provided)#{CLR}"
|
|
217
|
+
|
|
218
|
+
amount_msats = @pay_to_satoshis_amount * 1_000
|
|
219
|
+
invoice_from_lnaddr = fetch_invoice_from_lightning_address(@pay_to_lightning_address, amount_msats)
|
|
220
|
+
|
|
221
|
+
if invoice_from_lnaddr.nil?
|
|
222
|
+
fail!("Could not resolve Lightning address #{@pay_to_lightning_address} — skipping pay_invoice test")
|
|
223
|
+
@out.puts
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
try("pay_invoice (#{@pay_to_satoshis_amount} sats to #{@pay_to_lightning_address})",
|
|
228
|
+
NIP47::Methods::PAY_INVOICE) do
|
|
229
|
+
result = @client.pay_invoice(invoice: invoice_from_lnaddr)
|
|
230
|
+
@out.puts " #{DIM}preimage=#{result['preimage']}#{CLR}"
|
|
231
|
+
unless result['preimage']&.match?(/\A[0-9a-f]{64}\z/)
|
|
232
|
+
fail!("pay_invoice: `preimage` should be 64 hex chars, got #{result['preimage'].inspect}")
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
@out.puts
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# --- Sanity checks ------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def sanity_check_get_info(result)
|
|
241
|
+
%w[alias pubkey network].each do |field|
|
|
242
|
+
fail!("get_info: `#{field}` field is missing") if result[field].nil?
|
|
243
|
+
end
|
|
244
|
+
return if %w[mainnet testnet signet regtest].include?(result['network'])
|
|
245
|
+
|
|
246
|
+
warn!("get_info: `network` is #{result['network'].inspect}, expected one of mainnet/testnet/signet/regtest")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def sanity_check_make_invoice(result)
|
|
250
|
+
fail!('make_invoice: `invoice` is missing or not a BOLT11 string') unless result['invoice'].to_s.start_with?('ln')
|
|
251
|
+
unless result['payment_hash'].to_s.match?(/\A[0-9a-f]{64}\z/)
|
|
252
|
+
fail!('make_invoice: `payment_hash` is not a 64-char hex')
|
|
253
|
+
end
|
|
254
|
+
fail!('make_invoice: `amount` should echo 1000') unless result['amount'] == 1_000
|
|
255
|
+
warn!("make_invoice: `type` is #{result['type'].inspect}, expected 'incoming' (optional per NIP-47)") unless result['type'] == 'incoming'
|
|
256
|
+
warn!("make_invoice: `state` is #{result['state'].inspect}, expected 'pending' (optional per NIP-47)") unless result['state'] == 'pending'
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# --- Lightning address resolver ----------------------------------------
|
|
260
|
+
|
|
261
|
+
def fetch_invoice_from_lightning_address(address, amount_msats)
|
|
262
|
+
user, domain = address.split('@', 2)
|
|
263
|
+
return nil unless user && domain
|
|
264
|
+
|
|
265
|
+
lnurlp = URI.parse("https://#{domain}/.well-known/lnurlp/#{user}")
|
|
266
|
+
metadata = Net::HTTP.get_response(lnurlp)
|
|
267
|
+
return nil unless metadata.is_a?(Net::HTTPSuccess)
|
|
268
|
+
|
|
269
|
+
meta_json = JSON.parse(metadata.body)
|
|
270
|
+
callback = URI.parse(meta_json['callback'])
|
|
271
|
+
callback.query = URI.encode_www_form(amount: amount_msats)
|
|
272
|
+
resp = Net::HTTP.get_response(callback)
|
|
273
|
+
return nil unless resp.is_a?(Net::HTTPSuccess)
|
|
274
|
+
|
|
275
|
+
JSON.parse(resp.body)['pr']
|
|
276
|
+
rescue StandardError => e
|
|
277
|
+
@out.puts " #{WARN} LNURL-pay resolution failed: #{e.class}: #{e.message}"
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# --- Output helpers -----------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def try(label, method_name)
|
|
284
|
+
unless @info.supports?(method_name)
|
|
285
|
+
@out.puts " #{SKIP} #{label} #{DIM}(wallet service does not support this)#{CLR}"
|
|
286
|
+
return
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
start = Time.now
|
|
290
|
+
begin
|
|
291
|
+
yield
|
|
292
|
+
elapsed = ((Time.now - start) * 1000).round
|
|
293
|
+
@out.puts " #{PASS} #{label} #{DIM}(#{elapsed}ms)#{CLR}"
|
|
294
|
+
rescue WalletServiceError => e
|
|
295
|
+
fail!("#{label}: wallet returned #{e.code}: #{e.message}")
|
|
296
|
+
rescue TimeoutError => e
|
|
297
|
+
fail!("#{label}: #{e.message}")
|
|
298
|
+
fail!(' → The wallet service accepted the request but never responded. The service may be down or overloaded.')
|
|
299
|
+
rescue UnsupportedMethodError => e
|
|
300
|
+
fail!("#{label}: #{e.message}")
|
|
301
|
+
rescue EncryptionError => e
|
|
302
|
+
fail!("#{label}: decryption failed — #{e.message}")
|
|
303
|
+
fail!(' → This usually means the wallet service encrypted with an unexpected scheme, or the shared secret is wrong.')
|
|
304
|
+
rescue StandardError => e
|
|
305
|
+
fail!("#{label}: unexpected error #{e.class}: #{e.message}")
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def pass(msg) = @out.puts(" #{PASS} #{msg}")
|
|
310
|
+
def warn!(msg) = @out.puts(" #{WARN} #{msg}")
|
|
311
|
+
|
|
312
|
+
def fail!(msg)
|
|
313
|
+
@out.puts " #{FAIL} #{msg}"
|
|
314
|
+
@failures << msg
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def truncate(str, n)
|
|
318
|
+
s = str.to_s
|
|
319
|
+
s.length > n ? "#{s[0, n]}..." : s
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def summary
|
|
323
|
+
@out.puts
|
|
324
|
+
if @failures.empty?
|
|
325
|
+
@out.puts "#{BOLD}\e[32mAll tests passed.#{CLR}"
|
|
326
|
+
else
|
|
327
|
+
@out.puts "#{BOLD}\e[31m#{@failures.size} failure(s).#{CLR}"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async'
|
|
4
|
+
require 'async/clock'
|
|
5
|
+
require 'async/http/endpoint'
|
|
6
|
+
require 'async/websocket/client'
|
|
7
|
+
|
|
8
|
+
module NwcRuby
|
|
9
|
+
module Transport
|
|
10
|
+
# A reliable long-running connection to a Nostr relay.
|
|
11
|
+
#
|
|
12
|
+
# This is the reliability layer: everything the developer shouldn't have to
|
|
13
|
+
# think about. It handles:
|
|
14
|
+
#
|
|
15
|
+
# - RFC 6455 ping every `ping_interval` seconds (keeps middleboxes from
|
|
16
|
+
# idle-closing the socket; the relay's pong reply is handled by the
|
|
17
|
+
# protocol layer automatically)
|
|
18
|
+
# - forced recycle every `recycle_interval` (belt-and-suspenders against
|
|
19
|
+
# relay bugs or silent connection death)
|
|
20
|
+
# - capped exponential backoff on reconnect (1s → 2 → 4 → ... → 60s)
|
|
21
|
+
# - SIGTERM / SIGINT handling for clean Kamal deploys
|
|
22
|
+
#
|
|
23
|
+
# Usage:
|
|
24
|
+
#
|
|
25
|
+
# conn = RelayConnection.new(url: "wss://relay.rizful.com")
|
|
26
|
+
# conn.on_event { |event_hash| ... }
|
|
27
|
+
# conn.on_open { |c| c.send_req(sub_id: "foo", filters: [...]) }
|
|
28
|
+
# conn.run! # blocks until stop! or signal
|
|
29
|
+
class RelayConnection
|
|
30
|
+
DEFAULT_PING_INTERVAL = 15
|
|
31
|
+
DEFAULT_RECYCLE_INTERVAL = 300
|
|
32
|
+
DEFAULT_MAX_BACKOFF = 60
|
|
33
|
+
|
|
34
|
+
attr_reader :url, :logger
|
|
35
|
+
|
|
36
|
+
def initialize(url:,
|
|
37
|
+
ping_interval: DEFAULT_PING_INTERVAL,
|
|
38
|
+
recycle_interval: DEFAULT_RECYCLE_INTERVAL,
|
|
39
|
+
max_backoff: DEFAULT_MAX_BACKOFF,
|
|
40
|
+
poll_interval: nil,
|
|
41
|
+
logger: default_logger,
|
|
42
|
+
install_signal_traps: true)
|
|
43
|
+
@url = url
|
|
44
|
+
@ping_interval = ping_interval
|
|
45
|
+
@recycle_interval = recycle_interval
|
|
46
|
+
@max_backoff = max_backoff
|
|
47
|
+
@poll_interval = poll_interval
|
|
48
|
+
@logger = logger
|
|
49
|
+
|
|
50
|
+
@event_cb = nil
|
|
51
|
+
@open_cb = nil
|
|
52
|
+
@error_cb = nil
|
|
53
|
+
@poll_cb = nil
|
|
54
|
+
@stop = false
|
|
55
|
+
@signal_traps = install_signal_traps
|
|
56
|
+
@top_task = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def on_event(&block) = @event_cb = block
|
|
60
|
+
def on_open(&block) = @open_cb = block
|
|
61
|
+
def on_error(&block) = @error_cb = block
|
|
62
|
+
def on_poll(&block) = @poll_cb = block
|
|
63
|
+
|
|
64
|
+
def stop!
|
|
65
|
+
@stop = true
|
|
66
|
+
# Close the websocket to unblock the read loop. The @stop flag
|
|
67
|
+
# will cause the run loop to exit on its own. We avoid calling
|
|
68
|
+
# @top_task.stop here because stop! may be called from a
|
|
69
|
+
# different thread than the Async reactor.
|
|
70
|
+
begin
|
|
71
|
+
@conn&.close
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Blocks forever, reconnecting as needed, until #stop! is called
|
|
78
|
+
# or SIGTERM / SIGINT is received.
|
|
79
|
+
def run!
|
|
80
|
+
install_traps if @signal_traps
|
|
81
|
+
backoff = 1
|
|
82
|
+
|
|
83
|
+
Async do |top|
|
|
84
|
+
@top_task = top
|
|
85
|
+
until @stop
|
|
86
|
+
begin
|
|
87
|
+
run_one_connection(top)
|
|
88
|
+
backoff = 1
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
break if @stop
|
|
91
|
+
|
|
92
|
+
@logger.warn("[nwc] connection failed: #{e.class}: #{e.message}")
|
|
93
|
+
@error_cb&.call(e)
|
|
94
|
+
sleep_seconds = [backoff, @max_backoff].min
|
|
95
|
+
@logger.info("[nwc] reconnecting in #{sleep_seconds}s")
|
|
96
|
+
sleep sleep_seconds
|
|
97
|
+
backoff *= 2
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
ensure
|
|
101
|
+
@top_task = nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Send raw client->relay message (e.g. REQ, EVENT, CLOSE). Safe to call
|
|
106
|
+
# from within on_open / on_event callbacks.
|
|
107
|
+
def send_message(message)
|
|
108
|
+
raise TransportError, 'not connected' unless @conn
|
|
109
|
+
|
|
110
|
+
@conn.write(Protocol::WebSocket::TextMessage.generate(message))
|
|
111
|
+
@conn.flush
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Helper: send ["REQ", sub_id, filter1, filter2, ...]
|
|
115
|
+
def send_req(sub_id:, filters:)
|
|
116
|
+
send_message(['REQ', sub_id, *Array(filters)])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Helper: send ["EVENT", event_hash]
|
|
120
|
+
def send_event(event_hash)
|
|
121
|
+
send_message(['EVENT', event_hash])
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Helper: send ["CLOSE", sub_id]
|
|
125
|
+
def send_close(sub_id)
|
|
126
|
+
send_message(['CLOSE', sub_id])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def run_one_connection(top)
|
|
132
|
+
endpoint = Async::HTTP::Endpoint.parse(@url, alpn_protocols: ['http/1.1'])
|
|
133
|
+
opened_at = Async::Clock.now
|
|
134
|
+
@logger.info("[nwc] connecting to #{@url}")
|
|
135
|
+
|
|
136
|
+
Async::WebSocket::Client.connect(endpoint) do |conn|
|
|
137
|
+
@conn = conn
|
|
138
|
+
|
|
139
|
+
heartbeat = top.async do
|
|
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
|
|
171
|
+
|
|
172
|
+
@open_cb&.call(self)
|
|
173
|
+
read_loop(conn)
|
|
174
|
+
ensure
|
|
175
|
+
heartbeat&.stop
|
|
176
|
+
poll&.stop
|
|
177
|
+
@conn = nil
|
|
178
|
+
begin
|
|
179
|
+
conn&.close
|
|
180
|
+
rescue StandardError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def read_loop(conn)
|
|
187
|
+
while (message = conn.read)
|
|
188
|
+
break if @stop
|
|
189
|
+
|
|
190
|
+
begin
|
|
191
|
+
parsed = JSON.parse(message.buffer)
|
|
192
|
+
rescue JSON::ParserError => e
|
|
193
|
+
@logger.warn("[nwc] malformed JSON from relay: #{e.message}")
|
|
194
|
+
next
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
dispatch(parsed)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Relay -> client messages: ["EVENT", sub_id, event], ["EOSE", sub_id],
|
|
202
|
+
# ["OK", event_id, accepted_bool, message], ["NOTICE", message], ["CLOSED", sub_id, reason].
|
|
203
|
+
def dispatch(message)
|
|
204
|
+
case message[0]
|
|
205
|
+
when 'EVENT'
|
|
206
|
+
@event_cb&.call(message[1], message[2])
|
|
207
|
+
when 'OK'
|
|
208
|
+
@logger.debug("[nwc] OK #{message[1]} accepted=#{message[2]} msg=#{message[3]}")
|
|
209
|
+
when 'EOSE'
|
|
210
|
+
@logger.debug("[nwc] EOSE #{message[1]}")
|
|
211
|
+
when 'NOTICE'
|
|
212
|
+
@logger.info("[nwc] NOTICE #{message[1]}")
|
|
213
|
+
when 'CLOSED'
|
|
214
|
+
@logger.info("[nwc] CLOSED #{message[1]} #{message[2]}")
|
|
215
|
+
else
|
|
216
|
+
@logger.debug("[nwc] unknown message type: #{message[0]}")
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def install_traps
|
|
221
|
+
%w[TERM INT].each do |sig|
|
|
222
|
+
trap(sig) do
|
|
223
|
+
@stop = true
|
|
224
|
+
# Close the socket to unblock the read loop.
|
|
225
|
+
begin
|
|
226
|
+
@conn&.close
|
|
227
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def default_logger
|
|
234
|
+
logger = Logger.new($stdout)
|
|
235
|
+
logger.level = ENV['NWC_LOG_LEVEL'] ? Logger.const_get(ENV['NWC_LOG_LEVEL'].upcase) : Logger::INFO
|
|
236
|
+
logger
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
data/lib/nwc_ruby.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
require 'uri'
|
|
8
|
+
require 'logger'
|
|
9
|
+
|
|
10
|
+
require_relative 'nwc_ruby/version'
|
|
11
|
+
require_relative 'nwc_ruby/errors'
|
|
12
|
+
require_relative 'nwc_ruby/crypto/keys'
|
|
13
|
+
require_relative 'nwc_ruby/crypto/schnorr'
|
|
14
|
+
require_relative 'nwc_ruby/crypto/ecdh'
|
|
15
|
+
require_relative 'nwc_ruby/nip04/cipher'
|
|
16
|
+
require_relative 'nwc_ruby/nip44/cipher'
|
|
17
|
+
require_relative 'nwc_ruby/event'
|
|
18
|
+
require_relative 'nwc_ruby/connection_string'
|
|
19
|
+
require_relative 'nwc_ruby/nip47/methods'
|
|
20
|
+
require_relative 'nwc_ruby/nip47/request'
|
|
21
|
+
require_relative 'nwc_ruby/nip47/response'
|
|
22
|
+
require_relative 'nwc_ruby/nip47/notification'
|
|
23
|
+
require_relative 'nwc_ruby/nip47/info'
|
|
24
|
+
require_relative 'nwc_ruby/transport/relay_connection'
|
|
25
|
+
require_relative 'nwc_ruby/client'
|
|
26
|
+
require_relative 'nwc_ruby/test_runner'
|
|
27
|
+
|
|
28
|
+
# NwcRuby is a Ruby client for NIP-47 (Nostr Wallet Connect).
|
|
29
|
+
#
|
|
30
|
+
# Quick start:
|
|
31
|
+
#
|
|
32
|
+
# client = NwcRuby::Client.from_uri(ENV["NWC_URL"])
|
|
33
|
+
# invoice = client.make_invoice(amount: 1_000) # msats
|
|
34
|
+
# puts invoice["invoice"]
|
|
35
|
+
#
|
|
36
|
+
# client.subscribe_to_notifications do |n|
|
|
37
|
+
# puts "Got paid: #{n['amount']} msats for #{n['payment_hash']}"
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# To exercise a connection string end-to-end (from IRB, a Rails console, a
|
|
41
|
+
# spec, or a rake task in your own app), use the top-level test method:
|
|
42
|
+
#
|
|
43
|
+
# NwcRuby.test(
|
|
44
|
+
# nwc_url: ENV["NWC_URL"],
|
|
45
|
+
# pay_to_lightning_address: "you@getalby.com", # optional — only used for write tests
|
|
46
|
+
# pay_to_satoshis_amount: 10 # used for both outbound and inbound tests
|
|
47
|
+
# )
|
|
48
|
+
#
|
|
49
|
+
# Returns true if every check passed, false otherwise. Output is streamed to
|
|
50
|
+
# $stdout by default; pass `output: some_io` to redirect.
|
|
51
|
+
module NwcRuby
|
|
52
|
+
# Run the diagnostic: info, read tests, and (if the code is read+write and
|
|
53
|
+
# a Lightning address is provided) a write test. Does NOT test notifications
|
|
54
|
+
# — run test_notifications in a separate process for that.
|
|
55
|
+
#
|
|
56
|
+
# If pay_to_lightning_address is provided but the code is read-only, a
|
|
57
|
+
# helpful warning is printed instead of attempting the payment.
|
|
58
|
+
#
|
|
59
|
+
# @return [Boolean] true if all checks passed.
|
|
60
|
+
def self.test(nwc_url:,
|
|
61
|
+
pay_to_lightning_address: nil,
|
|
62
|
+
pay_to_satoshis_amount: TestRunner::DEFAULT_SATOSHIS,
|
|
63
|
+
output: $stdout)
|
|
64
|
+
TestRunner.new(
|
|
65
|
+
nwc_url: nwc_url,
|
|
66
|
+
pay_to_lightning_address: pay_to_lightning_address,
|
|
67
|
+
pay_to_satoshis_amount: pay_to_satoshis_amount,
|
|
68
|
+
output: output
|
|
69
|
+
).run
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Subscribe to notifications and block forever, printing each one.
|
|
73
|
+
# Run this in a separate process / terminal. Ctrl-C to stop.
|
|
74
|
+
#
|
|
75
|
+
# @return [Boolean] true (only returns on clean shutdown).
|
|
76
|
+
def self.test_notifications(nwc_url:, output: $stdout)
|
|
77
|
+
TestRunner.new(
|
|
78
|
+
nwc_url: nwc_url,
|
|
79
|
+
output: output
|
|
80
|
+
).run_notifications
|
|
81
|
+
end
|
|
82
|
+
end
|