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,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+ require 'async/http/endpoint'
5
+ require 'async/websocket/client'
6
+
7
+ module NwcRuby
8
+ # The main public API.
9
+ #
10
+ # client = NwcRuby::Client.from_uri(ENV["NWC_URL"])
11
+ #
12
+ # # One-shot request/response (transparently opens a WS, sends, waits, closes):
13
+ # info = client.get_info
14
+ # balance = client.get_balance
15
+ # invoice = client.make_invoice(amount: 1_000, description: "tip")
16
+ #
17
+ # # Long-running listener (transparently heartbeats, reconnects, resumes):
18
+ # client.subscribe_to_notifications do |notification|
19
+ # puts "Got #{notification.amount_msats} msats"
20
+ # end
21
+ class Client
22
+ DEFAULT_TIMEOUT = 30
23
+
24
+ attr_reader :connection_string, :logger
25
+
26
+ def self.from_uri(uri_string, **)
27
+ new(ConnectionString.parse(uri_string), **)
28
+ end
29
+
30
+ def initialize(connection_string, logger: nil, request_timeout: DEFAULT_TIMEOUT)
31
+ @connection_string = connection_string
32
+ @logger = logger || default_logger
33
+ @request_timeout = request_timeout
34
+ @info = nil
35
+ end
36
+
37
+ # -- Introspection --------------------------------------------------------
38
+
39
+ # Fetch and cache the kind 13194 info event. This tells us which methods
40
+ # the wallet service supports and which encryption schemes it accepts.
41
+ def info(refresh: false)
42
+ return @info if @info && !refresh
43
+
44
+ @info = fetch_info
45
+ end
46
+
47
+ def capabilities
48
+ info.methods
49
+ end
50
+
51
+ def read_only?
52
+ info.read_only?
53
+ end
54
+
55
+ def read_write?
56
+ info.read_write?
57
+ end
58
+
59
+ # -- NIP-47 methods -------------------------------------------------------
60
+
61
+ def pay_invoice(invoice:, amount: nil)
62
+ params = { 'invoice' => invoice }
63
+ params['amount'] = amount if amount
64
+ call(NIP47::Methods::PAY_INVOICE, params)
65
+ end
66
+
67
+ def multi_pay_invoice(invoices:)
68
+ call(NIP47::Methods::MULTI_PAY_INVOICE, { 'invoices' => invoices })
69
+ end
70
+
71
+ def pay_keysend(amount:, pubkey:, preimage: nil, tlv_records: nil)
72
+ params = { 'amount' => amount, 'pubkey' => pubkey }
73
+ params['preimage'] = preimage if preimage
74
+ params['tlv_records'] = tlv_records if tlv_records
75
+ call(NIP47::Methods::PAY_KEYSEND, params)
76
+ end
77
+
78
+ def multi_pay_keysend(keysends:)
79
+ call(NIP47::Methods::MULTI_PAY_KEYSEND, { 'keysends' => keysends })
80
+ end
81
+
82
+ def make_invoice(amount:, description: nil, description_hash: nil, expiry: nil, metadata: nil)
83
+ params = { 'amount' => amount }
84
+ params['description'] = description if description
85
+ params['description_hash'] = description_hash if description_hash
86
+ params['expiry'] = expiry if expiry
87
+ params['metadata'] = metadata if metadata
88
+ call(NIP47::Methods::MAKE_INVOICE, params)
89
+ end
90
+
91
+ def lookup_invoice(payment_hash: nil, invoice: nil)
92
+ raise ArgumentError, 'lookup_invoice requires payment_hash or invoice' if payment_hash.nil? && invoice.nil?
93
+
94
+ params = {}
95
+ params['payment_hash'] = payment_hash if payment_hash
96
+ params['invoice'] = invoice if invoice
97
+ call(NIP47::Methods::LOOKUP_INVOICE, params)
98
+ end
99
+
100
+ def list_transactions(from: nil, until_ts: nil, limit: nil, offset: nil, unpaid: nil, type: nil)
101
+ params = {}
102
+ params['from'] = from if from
103
+ params['until'] = until_ts if until_ts
104
+ params['limit'] = limit if limit
105
+ params['offset'] = offset if offset
106
+ params['unpaid'] = unpaid unless unpaid.nil?
107
+ params['type'] = type if type
108
+ call(NIP47::Methods::LIST_TRANSACTIONS, params)
109
+ end
110
+
111
+ def get_balance
112
+ call(NIP47::Methods::GET_BALANCE, {})
113
+ end
114
+
115
+ def get_info
116
+ call(NIP47::Methods::GET_INFO, {})
117
+ end
118
+
119
+ def sign_message(message:)
120
+ call(NIP47::Methods::SIGN_MESSAGE, { 'message' => message })
121
+ end
122
+
123
+ # Stop a running subscribe_to_notifications listener.
124
+ def stop_notifications!
125
+ @notification_connection&.stop!
126
+ end
127
+
128
+ # -- Notification listener -----------------------------------------------
129
+
130
+ # Subscribe to payment_received / payment_sent notifications from the
131
+ # wallet service. Blocks forever, handling heartbeat and reconnect.
132
+ #
133
+ # client.subscribe_to_notifications do |notification|
134
+ # case notification.type
135
+ # when "payment_received" then credit_invoice(notification.payment_hash, notification.amount_msats)
136
+ # when "payment_sent" then mark_outbound_settled(notification.payment_hash)
137
+ # end
138
+ # end
139
+ #
140
+ # @param since [Integer] unix timestamp for the `since:` filter on the
141
+ # subscription; defaults to now. Pass the last-seen `created_at` on
142
+ # reconnect to avoid replaying history.
143
+ # @param kinds [Array<Integer>] notification kinds to listen for. Defaults
144
+ # to both NIP-04 (23196) and NIP-44 v2 (23197). The listener dedupes by
145
+ # `payment_hash`, so receiving both is safe.
146
+ def subscribe_to_notifications(since: Time.now.to_i,
147
+ kinds: [NIP47::Methods::KIND_NOTIFICATION_NIP04,
148
+ NIP47::Methods::KIND_NOTIFICATION_NIP44],
149
+ sub_id: "nwc-#{SecureRandom.hex(4)}",
150
+ poll_interval: nil,
151
+ &block)
152
+ raise ArgumentError, 'block required' unless block
153
+
154
+ seen = {}
155
+ last_seen_at = since
156
+ filters = [{
157
+ 'authors' => [@connection_string.wallet_pubkey],
158
+ '#p' => [@connection_string.client_pubkey],
159
+ 'kinds' => kinds,
160
+ 'since' => since
161
+ }]
162
+
163
+ conn = Transport::RelayConnection.new(
164
+ url: @connection_string.relays.first,
165
+ logger: @logger,
166
+ poll_interval: poll_interval
167
+ )
168
+ @notification_connection = conn
169
+
170
+ conn.on_open do |c|
171
+ @logger.info("[nwc] subscribing sub_id=#{sub_id} client_pubkey=#{@connection_string.client_pubkey} wallet_pubkey=#{@connection_string.wallet_pubkey} kinds=#{kinds.inspect} since=#{since}")
172
+ c.send_req(sub_id: sub_id, filters: filters)
173
+ end
174
+
175
+ # Periodic re-subscribe: some relays (e.g. strfry) store ephemeral
176
+ # events temporarily but never push them to existing subscriptions.
177
+ # Re-sending the REQ with the same sub_id forces the relay to return
178
+ # any newly-stored events.
179
+ conn.on_poll do |c|
180
+ filters[0]['since'] = last_seen_at
181
+ @logger.debug("[nwc] re-subscribing sub_id=#{sub_id} since=#{last_seen_at}")
182
+ c.send_req(sub_id: sub_id, filters: filters)
183
+ end
184
+
185
+ conn.on_event do |_sub, event_hash|
186
+ event = Event.from_hash(event_hash)
187
+ next unless event.valid_signature?
188
+ next unless event.pubkey == @connection_string.wallet_pubkey
189
+
190
+ # Advance the poll watermark so we don't re-fetch old events.
191
+ last_seen_at = event.created_at if event.created_at && event.created_at > last_seen_at
192
+
193
+ begin
194
+ notification = NIP47::Notification.parse(event, @connection_string.secret, @connection_string.wallet_pubkey)
195
+ rescue EncryptionError => e
196
+ @logger.warn("[nwc] could not decrypt notification: #{e.message}")
197
+ next
198
+ end
199
+
200
+ # Dedupe: wallets that support both encryption schemes publish both
201
+ # 23196 and 23197 for the same event.
202
+ key = notification.payment_hash || event.id
203
+ next if seen[key]
204
+
205
+ seen[key] = true
206
+ # Primitive GC to keep the hash bounded.
207
+ seen.shift while seen.size > 10_000
208
+
209
+ block.call(notification)
210
+ end
211
+
212
+ conn.run!
213
+ end
214
+
215
+ # -- Internals ------------------------------------------------------------
216
+
217
+ private
218
+
219
+ # rubocop:disable Metrics/MethodLength
220
+ def call(method, params)
221
+ ensure_supports!(method)
222
+ encryption = info.preferred_encryption
223
+
224
+ deadline = Time.now + @request_timeout
225
+ result = nil
226
+
227
+ Async do
228
+ endpoint = Async::HTTP::Endpoint.parse(@connection_string.relays.first, alpn_protocols: ['http/1.1'])
229
+ Async::WebSocket::Client.connect(endpoint) do |conn|
230
+ request_event = NIP47::Request.build(
231
+ method: method,
232
+ params: params,
233
+ client_privkey: @connection_string.secret,
234
+ wallet_pubkey: @connection_string.wallet_pubkey,
235
+ encryption: encryption
236
+ )
237
+
238
+ sub_id = "rsp-#{SecureRandom.hex(4)}"
239
+ conn.write(Protocol::WebSocket::TextMessage.generate(['REQ', sub_id, {
240
+ 'authors' => [@connection_string.wallet_pubkey],
241
+ 'kinds' => [NIP47::Methods::KIND_RESPONSE],
242
+ '#e' => [request_event.id],
243
+ '#p' => [@connection_string.client_pubkey]
244
+ }]))
245
+ conn.write(Protocol::WebSocket::TextMessage.generate(['EVENT', request_event.to_h]))
246
+ conn.flush
247
+
248
+ while (msg = conn.read)
249
+ break if Time.now > deadline
250
+
251
+ parsed = begin
252
+ JSON.parse(msg.buffer)
253
+ rescue JSON::ParserError
254
+ next
255
+ end
256
+
257
+ next unless parsed[0] == 'EVENT' && parsed[1] == sub_id
258
+
259
+ event = Event.from_hash(parsed[2])
260
+ next unless event.valid_signature?
261
+ next unless event.pubkey == @connection_string.wallet_pubkey
262
+
263
+ result = NIP47::Response.parse(event, @connection_string.secret, @connection_string.wallet_pubkey)
264
+ break
265
+ end
266
+ ensure
267
+ begin
268
+ conn&.close
269
+ rescue StandardError
270
+ nil
271
+ end
272
+ end
273
+ end.wait
274
+
275
+ raise TimeoutError, "no response to #{method} within #{@request_timeout}s" if result.nil?
276
+ raise WalletServiceError.new(result.error_code || 'UNKNOWN', result.error_message || '') unless result.success?
277
+
278
+ result.result
279
+ end
280
+ # rubocop:enable Metrics/MethodLength
281
+
282
+ def fetch_info
283
+ endpoint = Async::HTTP::Endpoint.parse(@connection_string.relays.first, alpn_protocols: ['http/1.1'])
284
+ deadline = Time.now + @request_timeout
285
+ result = nil
286
+
287
+ Async do
288
+ Async::WebSocket::Client.connect(endpoint) do |conn|
289
+ sub_id = "info-#{SecureRandom.hex(4)}"
290
+ conn.write(Protocol::WebSocket::TextMessage.generate(['REQ', sub_id, {
291
+ 'authors' => [@connection_string.wallet_pubkey],
292
+ 'kinds' => [NIP47::Methods::KIND_INFO],
293
+ 'limit' => 1
294
+ }]))
295
+ conn.flush
296
+
297
+ while (msg = conn.read)
298
+ break if Time.now > deadline
299
+
300
+ parsed = begin
301
+ JSON.parse(msg.buffer)
302
+ rescue JSON::ParserError
303
+ next
304
+ end
305
+
306
+ if parsed[0] == 'EVENT' && parsed[1] == sub_id
307
+ event = Event.from_hash(parsed[2])
308
+ result = NIP47::Info.parse(event) if event.valid_signature?
309
+ break
310
+ elsif parsed[0] == 'EOSE' && parsed[1] == sub_id
311
+ break
312
+ end
313
+ end
314
+ ensure
315
+ begin
316
+ conn&.close
317
+ rescue StandardError
318
+ nil
319
+ end
320
+ end
321
+ end.wait
322
+
323
+ if result.nil?
324
+ raise TransportError,
325
+ "wallet service published no info event (kind 13194) on #{@connection_string.relays.first}"
326
+ end
327
+
328
+ result
329
+ end
330
+
331
+ def ensure_supports!(method)
332
+ return if info.supports?(method)
333
+
334
+ raise UnsupportedMethodError,
335
+ "wallet service does not advertise `#{method}`. Supported: #{info.methods.join(', ')}"
336
+ end
337
+
338
+ def default_logger
339
+ logger = Logger.new($stdout)
340
+ logger.level = ENV['NWC_LOG_LEVEL'] ? Logger.const_get(ENV['NWC_LOG_LEVEL'].upcase) : Logger::INFO
341
+ logger
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NwcRuby
4
+ # Parses a `nostr+walletconnect://` URI.
5
+ #
6
+ # Canonical form:
7
+ # nostr+walletconnect://<wallet_service_pubkey_hex>?
8
+ # relay=wss://...
9
+ # &secret=<32_byte_hex>
10
+ # &lud16=<optional>
11
+ #
12
+ # `relay` may repeat. `secret` is the CLIENT private key (32 bytes hex); the
13
+ # derived client pubkey is what the wallet encrypts responses to.
14
+ class ConnectionString
15
+ attr_reader :wallet_pubkey, :relays, :secret, :lud16
16
+
17
+ def self.parse(uri_string)
18
+ new(uri_string)
19
+ end
20
+
21
+ def initialize(uri_string)
22
+ raise InvalidConnectionStringError, 'connection string is empty' if uri_string.nil? || uri_string.empty?
23
+
24
+ # The built-in URI parser chokes on `nostr+walletconnect://` because of
25
+ # the `+`, so normalize the scheme before parsing.
26
+ normalized = uri_string.sub(%r{\Anostr\+walletconnect://}, 'https://')
27
+ uri = URI.parse(normalized)
28
+
29
+ unless uri_string.start_with?('nostr+walletconnect://') ||
30
+ uri_string.start_with?('nostrwalletconnect://')
31
+ raise InvalidConnectionStringError,
32
+ 'expected nostr+walletconnect:// scheme'
33
+ end
34
+
35
+ @wallet_pubkey = uri.host&.downcase
36
+ Crypto::Keys.validate_hex32!(@wallet_pubkey, 'wallet service pubkey (host)')
37
+
38
+ params = decode_query(uri.query || '')
39
+
40
+ @relays = Array(params['relay'])
41
+ raise InvalidConnectionStringError, 'at least one `relay` parameter is required' if @relays.empty?
42
+
43
+ @secret = params['secret']&.first&.downcase
44
+ Crypto::Keys.validate_hex32!(@secret, 'secret')
45
+
46
+ @lud16 = params['lud16']&.first
47
+ rescue URI::InvalidURIError => e
48
+ raise InvalidConnectionStringError, "malformed URI: #{e.message}"
49
+ end
50
+
51
+ # The client's own x-only pubkey, derived from `secret`.
52
+ def client_pubkey
53
+ @client_pubkey ||= Crypto::Keys.public_key_from_private(@secret)
54
+ end
55
+
56
+ def to_s
57
+ params = { 'relay' => @relays, 'secret' => [@secret] }
58
+ params['lud16'] = [@lud16] if @lud16
59
+ "nostr+walletconnect://#{@wallet_pubkey}?#{encode_query(params)}"
60
+ end
61
+
62
+ private
63
+
64
+ def decode_query(query)
65
+ result = Hash.new { |h, k| h[k] = [] }
66
+ query.split('&').each do |pair|
67
+ next if pair.empty?
68
+
69
+ k, v = pair.split('=', 2)
70
+ result[URI.decode_www_form_component(k)] << URI.decode_www_form_component(v.to_s)
71
+ end
72
+ result
73
+ end
74
+
75
+ def encode_query(params)
76
+ params.flat_map do |k, values|
77
+ Array(values).map { |v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }
78
+ end.join('&')
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbsecp256k1'
4
+ require 'ecdsa'
5
+
6
+ module NwcRuby
7
+ module Crypto
8
+ # ECDH for Nostr: compute the shared secret between our private key and
9
+ # their x-only public key.
10
+ #
11
+ # CRITICAL: Nostr ECDH returns the **X coordinate of the shared point only**
12
+ # (32 bytes). This differs from libsecp256k1's default `ecdh()` function,
13
+ # which returns SHA256(compressed_point). We have to do the multiplication
14
+ # ourselves using the `ecdsa` gem.
15
+ #
16
+ # This is used by both NIP-04 (as the AES key directly) and NIP-44 v2 (as
17
+ # the IKM for HKDF).
18
+ module ECDH
19
+ module_function
20
+
21
+ GROUP = ::ECDSA::Group::Secp256k1
22
+
23
+ # Returns the raw 32-byte X coordinate of the shared point.
24
+ #
25
+ # @param privkey_hex [String] our 32-byte private key (hex)
26
+ # @param xonly_pubkey_hex [String] their 32-byte x-only public key (hex)
27
+ # @return [String] 32 raw bytes (binary-encoded)
28
+ def shared_x(privkey_hex, xonly_pubkey_hex)
29
+ Keys.validate_hex32!(privkey_hex, 'private key')
30
+ Keys.validate_hex32!(xonly_pubkey_hex, 'public key')
31
+
32
+ # BIP-340 x-only pubkeys always correspond to the even-Y lifted point.
33
+ pubkey_point = lift_x(Keys.hex_to_bytes(xonly_pubkey_hex).unpack1('H*').to_i(16))
34
+ priv_int = privkey_hex.to_i(16)
35
+
36
+ shared_point = pubkey_point.multiply_by_scalar(priv_int)
37
+ [shared_point.x.to_s(16).rjust(64, '0')].pack('H*')
38
+ end
39
+
40
+ # BIP-340 "lift_x": given an x coordinate, return the point with even Y.
41
+ def lift_x(x)
42
+ raise EncryptionError, 'x out of range' if x.zero? || x >= GROUP.field.prime
43
+
44
+ p = GROUP.field.prime
45
+ c = (x.pow(3, p) + 7) % p
46
+ y = c.pow((p + 1) / 4, p)
47
+ raise EncryptionError, 'x is not on the curve' unless y.pow(2, p) == c
48
+
49
+ y = p - y if y.odd?
50
+ GROUP.new_point([x, y])
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbsecp256k1'
4
+
5
+ module NwcRuby
6
+ module Crypto
7
+ # Key helpers: hex <-> bytes, private key -> x-only pubkey, validation.
8
+ #
9
+ # Nostr uses BIP-340 Schnorr signatures over secp256k1. Public keys are
10
+ # "x-only" — just the 32-byte X coordinate. We use rbsecp256k1 (which wraps
11
+ # libsecp256k1) to derive them correctly.
12
+ module Keys
13
+ module_function
14
+
15
+ # Generate a new 32-byte private key as a lowercase hex string.
16
+ def generate_private_key
17
+ SecureRandom.bytes(32).unpack1('H*')
18
+ end
19
+
20
+ # Derive the 32-byte x-only public key (hex) from a private key (hex).
21
+ def public_key_from_private(privkey_hex)
22
+ validate_hex32!(privkey_hex, 'private key')
23
+ ctx = ::Secp256k1::Context.create
24
+ kp = ctx.key_pair_from_private_key(hex_to_bytes(privkey_hex))
25
+ # rbsecp256k1 gives us a compressed public key (33 bytes, 02/03 prefix).
26
+ # X-only pubkey is just bytes 1..32.
27
+ compressed = kp.public_key.compressed
28
+ bytes_to_hex(compressed[1, 32])
29
+ end
30
+
31
+ def hex_to_bytes(hex)
32
+ [hex].pack('H*')
33
+ end
34
+
35
+ def bytes_to_hex(bytes)
36
+ bytes.unpack1('H*')
37
+ end
38
+
39
+ def validate_hex32!(hex, label = 'value')
40
+ return if hex.is_a?(String) && hex.match?(/\A[0-9a-fA-F]{64}\z/)
41
+
42
+ raise InvalidConnectionStringError, "#{label} must be 64 hex characters (32 bytes)"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbsecp256k1'
4
+
5
+ module NwcRuby
6
+ module Crypto
7
+ # BIP-340 Schnorr signatures for Nostr events.
8
+ #
9
+ # Nostr event IDs are 32-byte SHA-256 hashes. The event signature is a
10
+ # 64-byte BIP-340 Schnorr signature over that hash, verifiable against the
11
+ # event's 32-byte x-only public key.
12
+ module Schnorr
13
+ module_function
14
+
15
+ # Sign a 32-byte digest with a private key. Returns 64-byte signature
16
+ # as a lowercase hex string (128 chars).
17
+ def sign(digest_bytes, privkey_hex)
18
+ raise ArgumentError, 'digest must be 32 bytes' unless digest_bytes.bytesize == 32
19
+
20
+ ctx = ::Secp256k1::Context.create
21
+ kp = ctx.key_pair_from_private_key(Keys.hex_to_bytes(privkey_hex))
22
+ sig = ctx.sign_schnorr(kp, digest_bytes)
23
+ Keys.bytes_to_hex(sig.serialized)
24
+ end
25
+
26
+ # Verify a 64-byte Schnorr signature. Returns true / false.
27
+ def verify(digest_bytes, sig_hex, xonly_pubkey_hex)
28
+ return false unless sig_hex.is_a?(String) && sig_hex.length == 128
29
+
30
+ xonly_pub = ::Secp256k1::XOnlyPublicKey.from_data(Keys.hex_to_bytes(xonly_pubkey_hex))
31
+ sig_obj = ::Secp256k1::SchnorrSignature.from_data(Keys.hex_to_bytes(sig_hex))
32
+ sig_obj.verify(digest_bytes, xonly_pub)
33
+ rescue ::Secp256k1::Error, ::Secp256k1::DeserializationError, ArgumentError
34
+ false
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NwcRuby
4
+ # Base class for all gem errors. Rescue this to catch anything the gem raises.
5
+ class Error < StandardError; end
6
+
7
+ # The connection string is missing or malformed.
8
+ class InvalidConnectionStringError < Error; end
9
+
10
+ # Encryption / decryption failed (bad MAC, bad padding, unknown version byte).
11
+ class EncryptionError < Error; end
12
+
13
+ # Signature verification failed on an inbound event.
14
+ class InvalidSignatureError < Error; end
15
+
16
+ # The relay could not be reached, or the connection died and did not recover
17
+ # within the configured timeout.
18
+ class TransportError < Error; end
19
+
20
+ # The wallet service returned an error envelope. `#code` is the NIP-47 error
21
+ # code (one of RATE_LIMITED, NOT_IMPLEMENTED, INSUFFICIENT_BALANCE,
22
+ # QUOTA_EXCEEDED, RESTRICTED, UNAUTHORIZED, INTERNAL, UNSUPPORTED_ENCRYPTION,
23
+ # PAYMENT_FAILED, NOT_FOUND, or OTHER).
24
+ class WalletServiceError < Error
25
+ attr_reader :code
26
+
27
+ def initialize(code, message)
28
+ @code = code
29
+ super("#{code}: #{message}")
30
+ end
31
+ end
32
+
33
+ # A request was sent but no response arrived within the timeout window.
34
+ class TimeoutError < Error; end
35
+
36
+ # The wallet service does not support the method we tried to call. Check
37
+ # `Client#capabilities` first, or use a read+write NWC string.
38
+ class UnsupportedMethodError < Error; end
39
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NwcRuby
4
+ # A Nostr event (NIP-01).
5
+ #
6
+ # The canonical serialization for ID computation is a JSON array with no
7
+ # whitespace between elements:
8
+ # [0, pubkey, created_at, kind, tags, content]
9
+ # SHA-256 of that JSON is the event id; the event id is signed with BIP-340
10
+ # Schnorr against the author's private key.
11
+ class Event
12
+ attr_accessor :id, :pubkey, :created_at, :kind, :tags, :content, :sig
13
+
14
+ def initialize(pubkey:, kind:, content:, tags: [], created_at: Time.now.to_i)
15
+ @pubkey = pubkey
16
+ @kind = kind
17
+ @content = content
18
+ @tags = tags
19
+ @created_at = created_at
20
+ end
21
+
22
+ # Build from a hash as received from the relay.
23
+ def self.from_hash(h)
24
+ e = allocate
25
+ e.id = h['id']
26
+ e.pubkey = h['pubkey']
27
+ e.created_at = h['created_at']
28
+ e.kind = h['kind']
29
+ e.tags = h['tags'] || []
30
+ e.content = h['content'] || ''
31
+ e.sig = h['sig']
32
+ e
33
+ end
34
+
35
+ # JSON used for ID computation. MUST NOT contain whitespace between tokens
36
+ # and MUST use the array form below.
37
+ def serialize_for_id
38
+ JSON.generate([0, @pubkey, @created_at, @kind, @tags, @content])
39
+ end
40
+
41
+ def compute_id!
42
+ @id = OpenSSL::Digest::SHA256.hexdigest(serialize_for_id)
43
+ self
44
+ end
45
+
46
+ def sign!(privkey_hex)
47
+ compute_id!
48
+ digest_bytes = Crypto::Keys.hex_to_bytes(@id)
49
+ @sig = Crypto::Schnorr.sign(digest_bytes, privkey_hex)
50
+ self
51
+ end
52
+
53
+ def valid_signature?
54
+ return false unless @id && @sig && @pubkey
55
+
56
+ digest_bytes = Crypto::Keys.hex_to_bytes(@id)
57
+ Crypto::Schnorr.verify(digest_bytes, @sig, @pubkey)
58
+ end
59
+
60
+ def to_h
61
+ {
62
+ 'id' => @id,
63
+ 'pubkey' => @pubkey,
64
+ 'created_at' => @created_at,
65
+ 'kind' => @kind,
66
+ 'tags' => @tags,
67
+ 'content' => @content,
68
+ 'sig' => @sig
69
+ }
70
+ end
71
+ end
72
+ end