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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NwcRuby
4
+ VERSION = '0.2.0'
5
+ 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