nwc-ruby 0.3.1 → 0.4.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 +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +5 -0
- data/lib/nwc_ruby/client.rb +6 -3
- data/lib/nwc_ruby/connection_string.rb +7 -0
- data/lib/nwc_ruby/crypto/conversation.rb +32 -0
- data/lib/nwc_ruby/nip04/cipher.rb +18 -6
- data/lib/nwc_ruby/nip44/cipher.rb +20 -6
- data/lib/nwc_ruby/nip47/notification.rb +6 -3
- data/lib/nwc_ruby/nip47/request.rb +7 -3
- data/lib/nwc_ruby/nip47/response.rb +14 -5
- data/lib/nwc_ruby/version.rb +1 -1
- data/lib/nwc_ruby.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb3477afba784c35d4e2db68ea408f30efec1330516c24f368471b123dcc7ddc
|
|
4
|
+
data.tar.gz: aabf55cb7cbeb1263945e457aab8f2908f39e560f0297d7406adc31ba7e56a73
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9c52a015142aec41f912b9f3881f56e5ce8289be0106f000be2786677320426e250ac73b70a9905c691e082ac293286caf169aac453af92886380053b7e34461
|
|
7
|
+
data.tar.gz: 73035a32f7fe34602f527f44941703ebecf7c505319f5e4899e8a6168350b2ae5d108f3be1e8c661d2854b05c087e752f44d6960a9f401ac880be0776d6a6166
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] — 2026-10-20
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Each connection now derives its encryption key once, not on every
|
|
15
|
+
message.** NIP-04 and NIP-44 v2 both start from an ECDH that this gem runs
|
|
16
|
+
as pure-Ruby scalar multiplication: about 33 ms on an Apple-silicon laptop,
|
|
17
|
+
more on a typical server vCPU. Every request ran it twice (encrypting the
|
|
18
|
+
request, decrypting the response) and every notification once. The result
|
|
19
|
+
depends only on the client secret and the wallet pubkey, both fixed when the
|
|
20
|
+
connection string is parsed, so `ConnectionString#conversation` now computes
|
|
21
|
+
it on first use and keeps it. A `Client` reused across calls pays for ECDH
|
|
22
|
+
once in its lifetime. Nonces and IVs are still drawn fresh for every
|
|
23
|
+
message, and the wire format is unchanged.
|
|
24
|
+
- A response that fails NIP-44 decryption and falls back to NIP-04 reuses the
|
|
25
|
+
same ECDH instead of running it a second time.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- `NwcRuby::Crypto::Conversation`: the NIP-04 and NIP-44 v2 keys for one
|
|
30
|
+
(private key, public key) pair, derived lazily and cached. A bad key raises
|
|
31
|
+
at first use, as before, and a failure is not cached.
|
|
32
|
+
- `NIP44::Cipher.encrypt_with` / `decrypt_with` and
|
|
33
|
+
`NIP04::Cipher.encrypt_with` / `decrypt_with`, which take a `Conversation`
|
|
34
|
+
in place of the two hex keys. The existing `encrypt` / `decrypt` signatures
|
|
35
|
+
are unchanged.
|
|
36
|
+
- An optional `conversation:` keyword on `NIP47::Request.build`,
|
|
37
|
+
`NIP47::Response.parse` and `NIP47::Notification.parse`.
|
|
38
|
+
- Specs against paulmillr's `get_conversation_key` and `encrypt_decrypt`
|
|
39
|
+
NIP-44 vectors.
|
|
40
|
+
|
|
10
41
|
## [0.3.1] — 2026-10-05
|
|
11
42
|
|
|
12
43
|
### Fixed
|
data/README.md
CHANGED
|
@@ -528,6 +528,11 @@ the request is written to the relay are retried across every `relay` in the
|
|
|
528
528
|
connection string, with 0.25 s / 1 s backoff, inside `request_timeout`.
|
|
529
529
|
Nothing is ever retried after the request is written.
|
|
530
530
|
|
|
531
|
+
Build one `Client` per connection string and reuse it. It caches the wallet's
|
|
532
|
+
info event and the connection's encryption key, so only its first call pays
|
|
533
|
+
for the ECDH (tens of milliseconds in pure Ruby); a `Client` built per request
|
|
534
|
+
pays it on every call.
|
|
535
|
+
|
|
531
536
|
Introspection:
|
|
532
537
|
|
|
533
538
|
| Method | Returns |
|
data/lib/nwc_ruby/client.rb
CHANGED
|
@@ -207,7 +207,8 @@ module NwcRuby
|
|
|
207
207
|
last_seen_at = event.created_at if event.created_at && event.created_at > last_seen_at
|
|
208
208
|
|
|
209
209
|
begin
|
|
210
|
-
notification = NIP47::Notification.parse(event, @connection_string.secret, @connection_string.wallet_pubkey
|
|
210
|
+
notification = NIP47::Notification.parse(event, @connection_string.secret, @connection_string.wallet_pubkey,
|
|
211
|
+
conversation: @connection_string.conversation)
|
|
211
212
|
rescue EncryptionError => e
|
|
212
213
|
@logger.warn("[nwc] could not decrypt notification: #{e.message}")
|
|
213
214
|
next
|
|
@@ -282,7 +283,8 @@ module NwcRuby
|
|
|
282
283
|
params: params,
|
|
283
284
|
client_privkey: @connection_string.secret,
|
|
284
285
|
wallet_pubkey: @connection_string.wallet_pubkey,
|
|
285
|
-
encryption: @info.preferred_encryption
|
|
286
|
+
encryption: @info.preferred_encryption,
|
|
287
|
+
conversation: @connection_string.conversation
|
|
286
288
|
)
|
|
287
289
|
|
|
288
290
|
response = with_relays(deadline) { |url| send_request(url, deadline, method, request_event) }
|
|
@@ -328,7 +330,8 @@ module NwcRuby
|
|
|
328
330
|
event = relay_event(parsed, sub_id)
|
|
329
331
|
next unless event && event.kind == NIP47::Methods::KIND_RESPONSE && response_to?(event, request_id)
|
|
330
332
|
|
|
331
|
-
return NIP47::Response.parse(event, @connection_string.secret, @connection_string.wallet_pubkey
|
|
333
|
+
return NIP47::Response.parse(event, @connection_string.secret, @connection_string.wallet_pubkey,
|
|
334
|
+
conversation: @connection_string.conversation)
|
|
332
335
|
end
|
|
333
336
|
raise TransportError, 'relay closed the connection before the wallet responded'
|
|
334
337
|
end
|
|
@@ -53,6 +53,13 @@ module NwcRuby
|
|
|
53
53
|
@client_pubkey ||= Crypto::Keys.public_key_from_private(@secret)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
# Encryption keys for this client/wallet pair. Both inputs are fixed at
|
|
57
|
+
# parse time, so the ECDH behind them runs once per connection string
|
|
58
|
+
# rather than on every request, response and notification.
|
|
59
|
+
def conversation
|
|
60
|
+
@conversation ||= Crypto::Conversation.new(@secret, @wallet_pubkey)
|
|
61
|
+
end
|
|
62
|
+
|
|
56
63
|
def to_s
|
|
57
64
|
params = { 'relay' => @relays, 'secret' => [@secret] }
|
|
58
65
|
params['lud16'] = [@lud16] if @lud16
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NwcRuby
|
|
4
|
+
module Crypto
|
|
5
|
+
# The symmetric keys for one (our private key, their public key) pair.
|
|
6
|
+
#
|
|
7
|
+
# NIP-04 and NIP-44 v2 both start from the same ECDH, which is the slow
|
|
8
|
+
# step here (pure-Ruby scalar multiplication, tens of milliseconds) and
|
|
9
|
+
# depends only on the two keys. This object runs it once, on first use,
|
|
10
|
+
# and keeps the result. Per-message randomness is unaffected: NIP-44 still
|
|
11
|
+
# draws a fresh nonce and NIP-04 a fresh IV on every encrypt.
|
|
12
|
+
#
|
|
13
|
+
# Nothing is computed in `initialize`, so a bad key raises at first use,
|
|
14
|
+
# exactly where the uncached path raises it, and a failure is not cached.
|
|
15
|
+
class Conversation
|
|
16
|
+
def initialize(privkey_hex, pubkey_hex)
|
|
17
|
+
@privkey_hex = privkey_hex
|
|
18
|
+
@pubkey_hex = pubkey_hex
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# NIP-04's AES-256 key: the raw shared X coordinate.
|
|
22
|
+
def nip04_key
|
|
23
|
+
@nip04_key ||= ECDH.shared_x(@privkey_hex, @pubkey_hex)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# NIP-44 v2's conversation key: HKDF-extract over the same shared X.
|
|
27
|
+
def nip44_key
|
|
28
|
+
@nip44_key ||= NIP44::Cipher.hkdf_extract(NIP44::Cipher::SALT.b, nip04_key)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -14,11 +14,17 @@ module NwcRuby
|
|
|
14
14
|
module_function
|
|
15
15
|
|
|
16
16
|
def encrypt(plaintext, privkey_hex, pubkey_hex)
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
encrypt_with(plaintext, Crypto::Conversation.new(privkey_hex, pubkey_hex))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# `encrypt`, reusing the conversation's cached key instead of redoing ECDH.
|
|
21
|
+
#
|
|
22
|
+
# @param conversation [Crypto::Conversation]
|
|
23
|
+
def encrypt_with(plaintext, conversation)
|
|
24
|
+
iv = SecureRandom.bytes(16)
|
|
19
25
|
|
|
20
26
|
cipher = OpenSSL::Cipher.new('aes-256-cbc').encrypt
|
|
21
|
-
cipher.key =
|
|
27
|
+
cipher.key = conversation.nip04_key
|
|
22
28
|
cipher.iv = iv
|
|
23
29
|
ct = cipher.update(plaintext.encode('UTF-8')) + cipher.final
|
|
24
30
|
|
|
@@ -26,6 +32,14 @@ module NwcRuby
|
|
|
26
32
|
end
|
|
27
33
|
|
|
28
34
|
def decrypt(payload, privkey_hex, pubkey_hex)
|
|
35
|
+
decrypt_with(payload, Crypto::Conversation.new(privkey_hex, pubkey_hex))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# `decrypt`, reusing the conversation's cached key instead of redoing
|
|
39
|
+
# ECDH. The payload is checked before the key is touched.
|
|
40
|
+
#
|
|
41
|
+
# @param conversation [Crypto::Conversation]
|
|
42
|
+
def decrypt_with(payload, conversation)
|
|
29
43
|
raise EncryptionError, 'malformed NIP-04 payload' unless payload.include?('?iv=')
|
|
30
44
|
|
|
31
45
|
ct_b64, iv_b64 = payload.split('?iv=', 2)
|
|
@@ -33,10 +47,8 @@ module NwcRuby
|
|
|
33
47
|
iv = Base64.strict_decode64(iv_b64)
|
|
34
48
|
raise EncryptionError, 'invalid IV length' unless iv.bytesize == 16
|
|
35
49
|
|
|
36
|
-
key = Crypto::ECDH.shared_x(privkey_hex, pubkey_hex)
|
|
37
|
-
|
|
38
50
|
cipher = OpenSSL::Cipher.new('aes-256-cbc').decrypt
|
|
39
|
-
cipher.key =
|
|
51
|
+
cipher.key = conversation.nip04_key
|
|
40
52
|
cipher.iv = iv
|
|
41
53
|
(cipher.update(ct) + cipher.final).force_encoding('UTF-8')
|
|
42
54
|
rescue OpenSSL::Cipher::CipherError, ArgumentError => e
|
|
@@ -37,12 +37,19 @@ module NwcRuby
|
|
|
37
37
|
# @param nonce [String, nil] optional 32-byte nonce (for tests); random if nil
|
|
38
38
|
# @return [String] base64 payload
|
|
39
39
|
def encrypt(plaintext, privkey_hex, pubkey_hex, nonce: nil)
|
|
40
|
+
encrypt_with(plaintext, Crypto::Conversation.new(privkey_hex, pubkey_hex), nonce: nonce)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# `encrypt`, reusing the conversation's cached key instead of redoing ECDH.
|
|
44
|
+
#
|
|
45
|
+
# @param conversation [Crypto::Conversation]
|
|
46
|
+
def encrypt_with(plaintext, conversation, nonce: nil)
|
|
40
47
|
pt_bytes = plaintext.to_s.dup.force_encoding('UTF-8').b
|
|
41
48
|
if pt_bytes.bytesize < MIN_PLAINTEXT || pt_bytes.bytesize > MAX_PLAINTEXT
|
|
42
49
|
raise EncryptionError, 'plaintext length must be 1..65535 bytes'
|
|
43
50
|
end
|
|
44
51
|
|
|
45
|
-
conversation_key =
|
|
52
|
+
conversation_key = conversation.nip44_key
|
|
46
53
|
nonce ||= SecureRandom.bytes(32)
|
|
47
54
|
raise EncryptionError, 'nonce must be 32 bytes' unless nonce.bytesize == 32
|
|
48
55
|
|
|
@@ -56,7 +63,16 @@ module NwcRuby
|
|
|
56
63
|
|
|
57
64
|
# @param payload [String] base64 NIP-44 payload
|
|
58
65
|
# @return [String] UTF-8 plaintext
|
|
59
|
-
def decrypt(payload, privkey_hex, pubkey_hex)
|
|
66
|
+
def decrypt(payload, privkey_hex, pubkey_hex)
|
|
67
|
+
decrypt_with(payload, Crypto::Conversation.new(privkey_hex, pubkey_hex))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# `decrypt`, reusing the conversation's cached key instead of redoing
|
|
71
|
+
# ECDH. The payload is fully checked before the key is touched, so a
|
|
72
|
+
# malformed one is rejected without paying for ECDH.
|
|
73
|
+
#
|
|
74
|
+
# @param conversation [Crypto::Conversation]
|
|
75
|
+
def decrypt_with(payload, conversation) # rubocop:disable Metrics/AbcSize
|
|
60
76
|
raise EncryptionError, 'payload is nil or empty' if payload.nil? || payload.empty?
|
|
61
77
|
raise EncryptionError, "payload starts with '#' (not encrypted)" if payload.start_with?('#')
|
|
62
78
|
|
|
@@ -77,8 +93,7 @@ module NwcRuby
|
|
|
77
93
|
mac = raw.byteslice(raw.bytesize - 32, 32)
|
|
78
94
|
ciphertext = raw.byteslice(33, raw.bytesize - 33 - 32)
|
|
79
95
|
|
|
80
|
-
|
|
81
|
-
chacha_key, chacha_nonce, hmac_key = derive_message_keys(conversation_key, nonce)
|
|
96
|
+
chacha_key, chacha_nonce, hmac_key = derive_message_keys(conversation.nip44_key, nonce)
|
|
82
97
|
|
|
83
98
|
expected_mac = OpenSSL::HMAC.digest('SHA256', hmac_key, nonce + ciphertext)
|
|
84
99
|
raise EncryptionError, 'NIP-44 MAC verification failed' unless secure_compare(mac, expected_mac)
|
|
@@ -90,8 +105,7 @@ module NwcRuby
|
|
|
90
105
|
# --- Internals ------------------------------------------------------
|
|
91
106
|
|
|
92
107
|
def derive_conversation_key(privkey_hex, pubkey_hex)
|
|
93
|
-
|
|
94
|
-
hkdf_extract(SALT.b, shared)
|
|
108
|
+
Crypto::Conversation.new(privkey_hex, pubkey_hex).nip44_key
|
|
95
109
|
end
|
|
96
110
|
|
|
97
111
|
def derive_message_keys(conversation_key, nonce)
|
|
@@ -32,12 +32,15 @@ module NwcRuby
|
|
|
32
32
|
@type == 'payment_sent'
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
# @param conversation [Crypto::Conversation, nil] cached keys for this
|
|
36
|
+
# client/wallet pair; derived from the two keys when nil
|
|
37
|
+
def self.parse(event, client_privkey, wallet_pubkey, conversation: nil)
|
|
38
|
+
conversation ||= Crypto::Conversation.new(client_privkey, wallet_pubkey)
|
|
36
39
|
plaintext = case event.kind
|
|
37
40
|
when Methods::KIND_NOTIFICATION_NIP44
|
|
38
|
-
NIP44::Cipher.
|
|
41
|
+
NIP44::Cipher.decrypt_with(event.content, conversation)
|
|
39
42
|
when Methods::KIND_NOTIFICATION_NIP04
|
|
40
|
-
NIP04::Cipher.
|
|
43
|
+
NIP04::Cipher.decrypt_with(event.content, conversation)
|
|
41
44
|
else
|
|
42
45
|
raise ArgumentError, "not a notification kind: #{event.kind}"
|
|
43
46
|
end
|
|
@@ -13,12 +13,16 @@ module NwcRuby
|
|
|
13
13
|
# @param wallet_pubkey [String] hex
|
|
14
14
|
# @param encryption [Symbol] :nip44_v2 or :nip04
|
|
15
15
|
# @param expiration [Integer, nil] optional unix timestamp
|
|
16
|
+
# @param conversation [Crypto::Conversation, nil] cached keys for this
|
|
17
|
+
# client/wallet pair; derived from the two keys above when nil
|
|
16
18
|
# @return [Event]
|
|
17
|
-
def build(method:, params:, client_privkey:, wallet_pubkey:, encryption: :nip44_v2, expiration: nil
|
|
19
|
+
def build(method:, params:, client_privkey:, wallet_pubkey:, encryption: :nip44_v2, expiration: nil,
|
|
20
|
+
conversation: nil)
|
|
21
|
+
conversation ||= Crypto::Conversation.new(client_privkey, wallet_pubkey)
|
|
18
22
|
payload = JSON.generate({ 'method' => method, 'params' => params })
|
|
19
23
|
ciphertext = case encryption
|
|
20
|
-
when :nip44_v2 then NIP44::Cipher.
|
|
21
|
-
when :nip04 then NIP04::Cipher.
|
|
24
|
+
when :nip44_v2 then NIP44::Cipher.encrypt_with(payload, conversation)
|
|
25
|
+
when :nip04 then NIP04::Cipher.encrypt_with(payload, conversation)
|
|
22
26
|
else raise ArgumentError, "unknown encryption: #{encryption}"
|
|
23
27
|
end
|
|
24
28
|
|
|
@@ -29,13 +29,16 @@ module NwcRuby
|
|
|
29
29
|
# @param event [Event] the kind 23195 event (already signature-verified)
|
|
30
30
|
# @param client_privkey [String] hex
|
|
31
31
|
# @param wallet_pubkey [String] hex
|
|
32
|
+
# @param conversation [Crypto::Conversation, nil] cached keys for this
|
|
33
|
+
# client/wallet pair; derived from the two keys above when nil
|
|
32
34
|
# @return [Response]
|
|
33
|
-
def self.parse(event, client_privkey, wallet_pubkey)
|
|
35
|
+
def self.parse(event, client_privkey, wallet_pubkey, conversation: nil)
|
|
34
36
|
# Tags: ["p", client_pubkey], ["e", request_event_id]
|
|
35
37
|
e_tag = event.tags.find { |t| t[0] == 'e' }
|
|
36
38
|
request_id = e_tag && e_tag[1]
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
conversation ||= Crypto::Conversation.new(client_privkey, wallet_pubkey)
|
|
41
|
+
plaintext = decrypt_with(event.content, conversation)
|
|
39
42
|
data = JSON.parse(plaintext)
|
|
40
43
|
|
|
41
44
|
new(
|
|
@@ -47,11 +50,17 @@ module NwcRuby
|
|
|
47
50
|
)
|
|
48
51
|
end
|
|
49
52
|
|
|
50
|
-
# Try NIP-44 v2 first; fall back to NIP-04 if the version byte is wrong.
|
|
51
53
|
def self.decrypt(content, client_privkey, wallet_pubkey)
|
|
52
|
-
|
|
54
|
+
decrypt_with(content, Crypto::Conversation.new(client_privkey, wallet_pubkey))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Try NIP-44 v2 first; fall back to NIP-04 if the version byte is wrong.
|
|
58
|
+
# Both attempts share the conversation, so the fallback reuses any ECDH
|
|
59
|
+
# the NIP-44 attempt already ran.
|
|
60
|
+
def self.decrypt_with(content, conversation)
|
|
61
|
+
NIP44::Cipher.decrypt_with(content, conversation)
|
|
53
62
|
rescue EncryptionError
|
|
54
|
-
NIP04::Cipher.
|
|
63
|
+
NIP04::Cipher.decrypt_with(content, conversation)
|
|
55
64
|
end
|
|
56
65
|
end
|
|
57
66
|
end
|
data/lib/nwc_ruby/version.rb
CHANGED
data/lib/nwc_ruby.rb
CHANGED
|
@@ -12,6 +12,7 @@ require_relative 'nwc_ruby/errors'
|
|
|
12
12
|
require_relative 'nwc_ruby/crypto/keys'
|
|
13
13
|
require_relative 'nwc_ruby/crypto/schnorr'
|
|
14
14
|
require_relative 'nwc_ruby/crypto/ecdh'
|
|
15
|
+
require_relative 'nwc_ruby/crypto/conversation'
|
|
15
16
|
require_relative 'nwc_ruby/nip04/cipher'
|
|
16
17
|
require_relative 'nwc_ruby/nip44/cipher'
|
|
17
18
|
require_relative 'nwc_ruby/event'
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- MegalithicBTC
|
|
@@ -183,6 +183,7 @@ files:
|
|
|
183
183
|
- lib/nwc_ruby.rb
|
|
184
184
|
- lib/nwc_ruby/client.rb
|
|
185
185
|
- lib/nwc_ruby/connection_string.rb
|
|
186
|
+
- lib/nwc_ruby/crypto/conversation.rb
|
|
186
187
|
- lib/nwc_ruby/crypto/ecdh.rb
|
|
187
188
|
- lib/nwc_ruby/crypto/keys.rb
|
|
188
189
|
- lib/nwc_ruby/crypto/schnorr.rb
|
|
@@ -222,7 +223,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
222
223
|
- !ruby/object:Gem::Version
|
|
223
224
|
version: '0'
|
|
224
225
|
requirements: []
|
|
225
|
-
rubygems_version: 3.6.
|
|
226
|
+
rubygems_version: 3.6.9
|
|
226
227
|
specification_version: 4
|
|
227
228
|
summary: Ruby client for Nostr Wallet Connect (NIP-47) with safe long-running relay
|
|
228
229
|
connections.
|