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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 97f270975d59347524e31943926149e2d361833a5ab9dd7c580cf00281b5fec9
|
|
4
|
+
data.tar.gz: 99b7a6b3a40c8c0f7ca30408f34afb653fab4c5afb8c69327e79be3a00afa663
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 96bc19c5c7ee1ac4794c76be6aa9e64b9aeddf7d89d5a4df88134104f07e65a40c1466efa54ffea9df546d55a92bb06c27e2ee1e8397d3c9459272bca6065526
|
|
7
|
+
data.tar.gz: 20f46cf66670993db5c8fa351e2bc95b611c7ad994dd7d64495927ffac5347c8b9a1a8767cb19dd16a2deb2dea869086ae8ae606a13211f55f3546156526afd8
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0] — 2026-04-20
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Breaking:** Split `NwcRuby.test` into two methods: `NwcRuby.test` (info +
|
|
15
|
+
read tests + write test if applicable) and `NwcRuby.test_notifications`
|
|
16
|
+
(subscribe and block forever, printing each notification). Notifications are
|
|
17
|
+
now tested in a separate process instead of a background thread, which
|
|
18
|
+
fixes the issue where notifications were never received during the
|
|
19
|
+
integrated test.
|
|
20
|
+
- Removed `test_send_and_receive` and `test_readonly` — a single `test` method
|
|
21
|
+
handles both read-only and read+write codes. If the code is read-only but
|
|
22
|
+
a Lightning address is provided, it prints a helpful warning instead of failing.
|
|
23
|
+
- `bin/nwc_test` now accepts an optional `notifications` subcommand:
|
|
24
|
+
`bin/nwc_test` (default test), `bin/nwc_test notifications` (listen forever).
|
|
25
|
+
|
|
26
|
+
## [0.1.1] — 2026-04-20
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- `subscribe_to_notifications` crashed on startup — `RelayConnection#initialize` was missing
|
|
31
|
+
the `poll_interval:` keyword argument passed by the client.
|
|
32
|
+
- Ctrl+C / SIGINT / SIGTERM now exits cleanly — signal trap closes the WebSocket to unblock
|
|
33
|
+
the read loop instead of only setting a flag.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- `poll_interval` default changed from `5` to `nil` (disabled). The relay pushes events to
|
|
38
|
+
active subscriptions, so periodic re-subscribe polling is unnecessary. Pass
|
|
39
|
+
`poll_interval: 5` explicitly if your relay requires it.
|
|
40
|
+
|
|
41
|
+
## [0.1.0] — 2026-04-20
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Initial release.
|
|
46
|
+
- NIP-01 event construction, serialization, SHA-256 id, BIP-340 Schnorr signing & verification.
|
|
47
|
+
- NIP-04 (AES-256-CBC) encryption, for wallets that still only support legacy DMs.
|
|
48
|
+
- NIP-44 v2 encryption (ChaCha20 + HMAC-SHA256 + power-of-two padding), validated against
|
|
49
|
+
[paulmillr/nip44](https://github.com/paulmillr/nip44/blob/main/nip44.vectors.json).
|
|
50
|
+
- NIP-47 `nostr+walletconnect://` URI parser.
|
|
51
|
+
- Full NIP-47 method coverage: `pay_invoice`, `multi_pay_invoice`, `pay_keysend`,
|
|
52
|
+
`multi_pay_keysend`, `make_invoice`, `lookup_invoice`, `list_transactions`,
|
|
53
|
+
`get_balance`, `get_info`, `sign_message`.
|
|
54
|
+
- Notification listener (kinds 23196 and 23197) with dedupe by `payment_hash`.
|
|
55
|
+
- Reliable long-running `Transport::RelayConnection`: RFC 6455 ping (30 s),
|
|
56
|
+
pong deadline (45 s), forced recycle (5 min), capped exponential backoff,
|
|
57
|
+
SIGTERM/SIGINT handling.
|
|
58
|
+
- `NwcRuby.test` and `NwcRuby.test_notifications` diagnostic methods, backed
|
|
59
|
+
by `NwcRuby::TestRunner`. Announces read-only vs read+write, exercises every
|
|
60
|
+
advertised method, pays a Lightning address if the code is read+write.
|
|
61
|
+
Callable from IRB, Rails console, RSpec, or a rake task in the host app.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MegalithicBTC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
# nwc-ruby
|
|
2
|
+
|
|
3
|
+
A production-grade Ruby client for [Nostr Wallet Connect (NIP-47)][nip47].
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
require "nwc_ruby"
|
|
7
|
+
|
|
8
|
+
client = NwcRuby::Client.from_uri(ENV["NWC_URL"])
|
|
9
|
+
|
|
10
|
+
# Create an invoice
|
|
11
|
+
invoice = client.make_invoice(amount: 1_000, description: "tip")
|
|
12
|
+
puts invoice["invoice"]
|
|
13
|
+
|
|
14
|
+
# Listen for payments, forever, reliably
|
|
15
|
+
client.subscribe_to_notifications do |n|
|
|
16
|
+
puts "Got paid: #{n.amount_msats} msats for #{n.payment_hash}"
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That's it. The gem handles the Nostr protocol, encryption, WebSocket lifecycle,
|
|
21
|
+
heartbeats, zombie-TCP detection, reconnects, and backoff. You call methods.
|
|
22
|
+
|
|
23
|
+
[nip47]: https://github.com/nostr-protocol/nips/blob/master/47.md
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **Full NIP-47 coverage** — `pay_invoice`, `multi_pay_invoice`, `pay_keysend`,
|
|
30
|
+
`multi_pay_keysend`, `make_invoice`, `lookup_invoice`, `list_transactions`,
|
|
31
|
+
`get_balance`, `get_info`, `sign_message`.
|
|
32
|
+
- **Notifications** — `payment_received` and `payment_sent` via kinds 23196
|
|
33
|
+
(NIP-04) and 23197 (NIP-44 v2), deduplicated by `payment_hash`.
|
|
34
|
+
- **Both encryption schemes** — NIP-44 v2 when the wallet advertises it
|
|
35
|
+
(validated against [paulmillr's test vectors][vectors]), NIP-04 fallback for
|
|
36
|
+
wallets that haven't migrated.
|
|
37
|
+
- **Bulletproof long-running transport** — 30 s ping, 45 s pong deadline, 5-min
|
|
38
|
+
forced recycle, capped exponential backoff, clean SIGTERM handling. Built on
|
|
39
|
+
[async-websocket][aw] (no dead EventMachine dependency).
|
|
40
|
+
- **Two diagnostic methods** — `NwcRuby.test` (info, read tests, write test
|
|
41
|
+
if applicable) and `NwcRuby.test_notifications` (listen forever in a separate
|
|
42
|
+
process). Each tells you whether your NWC code works, exercises the methods
|
|
43
|
+
the service advertises, and flags non-conforming responses with actionable
|
|
44
|
+
errors. Callable from IRB, a Rails console, a spec, or a rake task.
|
|
45
|
+
|
|
46
|
+
[vectors]: https://github.com/paulmillr/nip44/blob/main/nip44.vectors.json
|
|
47
|
+
[aw]: https://github.com/socketry/async-websocket
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Get a free NWC connection string
|
|
52
|
+
|
|
53
|
+
You need a `nostr+walletconnect://...` URI. Two free, reliable options:
|
|
54
|
+
|
|
55
|
+
- **[rizful.com][rizful]** — Lightning vaults and cloud-based Lightning nodes designed for reliability and NWC support.
|
|
56
|
+
built by the Megalith Node team. Dedicated NWC relay.
|
|
57
|
+
- **[getalby.com][alby]** — Alby Hub (self-hosted) or Alby Cloud. The
|
|
58
|
+
most widely used NWC implementation.
|
|
59
|
+
|
|
60
|
+
[rizful]: https://rizful.com
|
|
61
|
+
[alby]: https://getalby.com
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Read-only vs read+write — understand this before you build
|
|
66
|
+
|
|
67
|
+
This is the single most important concept to internalize before using NWC. A
|
|
68
|
+
connection string's capabilities are set **when the wallet issues it** and
|
|
69
|
+
cannot be widened by the client.
|
|
70
|
+
|
|
71
|
+
### Read-only code
|
|
72
|
+
|
|
73
|
+
A read-only NWC code supports `get_info`, `get_balance`, `make_invoice`,
|
|
74
|
+
`lookup_invoice`, `list_transactions`, and typically notifications — but **not**
|
|
75
|
+
`pay_invoice` or `pay_keysend`. It cannot move funds out of the wallet.
|
|
76
|
+
|
|
77
|
+
**Use read-only for:** e-commerce checkouts (Shopify plugins, donation pages,
|
|
78
|
+
paywall integrations). Your server generates invoices, watches for
|
|
79
|
+
`payment_received` notifications, and credits the purchase. Even if your server
|
|
80
|
+
is fully compromised, the attacker cannot drain your wallet.
|
|
81
|
+
|
|
82
|
+
### Read+write code
|
|
83
|
+
|
|
84
|
+
A read+write code adds `pay_invoice`, `multi_pay_invoice`, `pay_keysend`, and
|
|
85
|
+
`multi_pay_keysend`. Anyone holding it can spend from your wallet up to the
|
|
86
|
+
budget / rate limits the wallet enforces.
|
|
87
|
+
|
|
88
|
+
**Use read+write for:** tipping bots, treasury automation, nostr zap clients,
|
|
89
|
+
any app that legitimately needs to send Lightning payments. **Treat the
|
|
90
|
+
connection string like a private key.** Don't commit it. Rotate it if leaked.
|
|
91
|
+
Use per-app codes with per-app budgets — never reuse your main wallet's code.
|
|
92
|
+
|
|
93
|
+
The gem tells you which mode you have:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
client = NwcRuby::Client.from_uri(ENV["NWC_URL"])
|
|
97
|
+
puts client.read_only? # => true or false
|
|
98
|
+
puts client.capabilities # => ["get_info", "get_balance", "make_invoice", ...]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Or from IRB / a Rails console:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
NwcRuby.test_readonly(nwc_url: ENV["NWC_URL"])
|
|
105
|
+
# ...
|
|
106
|
+
# ℹ This is a READ-ONLY code. It cannot move funds.
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Installation
|
|
112
|
+
|
|
113
|
+
Add to your Gemfile:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
gem "nwc-ruby"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Or install directly:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
gem install nwc-ruby
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The `rbsecp256k1` dependency is a C extension that bundles and compiles
|
|
126
|
+
`libsecp256k1` from source during `gem install`. You need a C toolchain and
|
|
127
|
+
a few libraries available **before** running `bundle install`.
|
|
128
|
+
|
|
129
|
+
**macOS:**
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
brew install automake openssl libtool pkg-config gmp libffi
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Ubuntu / Debian:**
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
sudo apt-get update
|
|
139
|
+
sudo apt-get install -y build-essential automake pkg-config libtool \
|
|
140
|
+
libffi-dev libgmp-dev
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Alpine:**
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
apk add build-base automake autoconf libtool pkgconfig gmp-dev libffi-dev
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Docker (Kamal / production):**
|
|
150
|
+
|
|
151
|
+
```dockerfile
|
|
152
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
153
|
+
build-essential automake pkg-config libtool libffi-dev libgmp-dev \
|
|
154
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
If you see `LoadError: cannot load such file -- secp256k1` at runtime, the
|
|
158
|
+
native extension wasn't compiled. Install the build dependencies above and
|
|
159
|
+
run `gem pristine rbsecp256k1` (or re-run `bundle install`) to rebuild it.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Usage
|
|
164
|
+
|
|
165
|
+
### One-shot requests
|
|
166
|
+
|
|
167
|
+
Each call transparently opens a WebSocket, sends the request, waits for the
|
|
168
|
+
response, and closes the connection.
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
client = NwcRuby::Client.from_uri(ENV["NWC_URL"])
|
|
172
|
+
|
|
173
|
+
info = client.get_info
|
|
174
|
+
# => {"alias"=>"my-node", "color"=>"#3399FF", "pubkey"=>"...",
|
|
175
|
+
# "network"=>"mainnet", "block_height"=>820_000, "block_hash"=>"...",
|
|
176
|
+
# "methods"=>[...], "notifications"=>[...]}
|
|
177
|
+
|
|
178
|
+
balance = client.get_balance["balance"] # msats
|
|
179
|
+
|
|
180
|
+
invoice = client.make_invoice(amount: 10_000, description: "coffee")
|
|
181
|
+
# => {"type"=>"incoming", "state"=>"pending",
|
|
182
|
+
# "invoice"=>"lnbc100n1p...", "payment_hash"=>"...",
|
|
183
|
+
# "amount"=>10_000, "created_at"=>1_730_000_000, ...}
|
|
184
|
+
|
|
185
|
+
status = client.lookup_invoice(payment_hash: invoice["payment_hash"])
|
|
186
|
+
# state: "pending" -> "settled"
|
|
187
|
+
|
|
188
|
+
# Only if read+write:
|
|
189
|
+
client.pay_invoice(invoice: "lnbc...")
|
|
190
|
+
# => {"preimage"=>"<64 hex chars>", "fees_paid"=>1234}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Listening for notifications
|
|
194
|
+
|
|
195
|
+
This is the scenario the gem is most carefully engineered for: a long-running
|
|
196
|
+
process that needs to credit invoices the instant they're paid.
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
client = NwcRuby::Client.from_uri(ENV["NWC_URL"])
|
|
200
|
+
|
|
201
|
+
client.subscribe_to_notifications do |notification|
|
|
202
|
+
case notification.type
|
|
203
|
+
when "payment_received"
|
|
204
|
+
Invoice.find_by(payment_hash: notification.payment_hash)
|
|
205
|
+
&.mark_paid!(amount_msats: notification.amount_msats)
|
|
206
|
+
when "payment_sent"
|
|
207
|
+
Payout.find_by(payment_hash: notification.payment_hash)&.mark_settled!
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
# Blocks forever. SIGTERM / SIGINT cause a clean exit.
|
|
211
|
+
```
|
|
212
|
+
|
|
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 30 seconds,
|
|
215
|
+
reconnects with exponential backoff if the pong deadline is missed, and
|
|
216
|
+
force-recycles the connection every 5 minutes as a belt-and-suspenders check
|
|
217
|
+
against middleboxes that silently drop stale TCP streams.
|
|
218
|
+
|
|
219
|
+
#### Resuming after a restart
|
|
220
|
+
|
|
221
|
+
Persist the `created_at` of the last notification you processed and pass it as
|
|
222
|
+
`since:` on restart to avoid replaying history:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
since = AppState.get("nwc_last_seen") || Time.now.to_i
|
|
226
|
+
|
|
227
|
+
client.subscribe_to_notifications(since: since) do |n|
|
|
228
|
+
process(n)
|
|
229
|
+
AppState.set("nwc_last_seen", n.event.created_at)
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Using in a Rails app (Kamal deployment)
|
|
234
|
+
|
|
235
|
+
The Ruby NWC listener is best deployed as a [Kamal role][kamal-roles] — the
|
|
236
|
+
same app image as your web container, but with a different `cmd`. This is the
|
|
237
|
+
canonical 37signals pattern for a Sidekiq/Solid Queue/cron/listener process.
|
|
238
|
+
**Don't use a Kamal "accessory" for this** — accessories are for third-party
|
|
239
|
+
services (Postgres, Redis) and are not redeployed on `kamal deploy`.
|
|
240
|
+
|
|
241
|
+
```yaml
|
|
242
|
+
# config/deploy.yml
|
|
243
|
+
service: myapp
|
|
244
|
+
image: ghcr.io/myorg/myapp
|
|
245
|
+
|
|
246
|
+
servers:
|
|
247
|
+
web:
|
|
248
|
+
hosts: [10.0.0.10]
|
|
249
|
+
nwc_listener:
|
|
250
|
+
hosts: [10.0.0.10]
|
|
251
|
+
cmd: "bundle exec rake nwc:listen_in_app"
|
|
252
|
+
options:
|
|
253
|
+
memory: 512m
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Important: make your notification handler idempotent.** During deploys,
|
|
257
|
+
reconnects, or if you scale `nwc_listener` to multiple hosts, the same
|
|
258
|
+
`payment_received` notification can be delivered more than once. The gem
|
|
259
|
+
deduplicates within a single process lifetime, but across restarts or multiple
|
|
260
|
+
instances you must handle duplicates at the database level. Use a unique
|
|
261
|
+
constraint on `payment_hash` (or an `UPDATE ... WHERE state != 'paid'` guard)
|
|
262
|
+
so that processing the same notification twice is a harmless no-op — never
|
|
263
|
+
double-credit a payment.
|
|
264
|
+
|
|
265
|
+
Define the listener rake task in your app. Use Postgres `LISTEN/NOTIFY` or
|
|
266
|
+
GoodJob to communicate with the web container:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
# lib/tasks/nwc.rake (in your Rails app)
|
|
270
|
+
namespace :nwc do
|
|
271
|
+
task listen_in_app: :environment do
|
|
272
|
+
client = NwcRuby::Client.from_uri(ENV["NWC_URL"])
|
|
273
|
+
since = AppState.find_or_create_by(key: "nwc_last_seen").value.to_i
|
|
274
|
+
since = Time.now.to_i if since.zero?
|
|
275
|
+
|
|
276
|
+
client.subscribe_to_notifications(since: since) do |n|
|
|
277
|
+
Invoice.transaction do
|
|
278
|
+
# Idempotent: only transitions pending → paid, ignores already-paid rows.
|
|
279
|
+
rows = Invoice.where(payment_hash: n.payment_hash, state: "pending")
|
|
280
|
+
.update_all(
|
|
281
|
+
state: "paid",
|
|
282
|
+
paid_amount_msats: n.amount_msats,
|
|
283
|
+
paid_at: Time.at(n.event.created_at)
|
|
284
|
+
)
|
|
285
|
+
AppState.where(key: "nwc_last_seen").update_all(value: n.event.created_at)
|
|
286
|
+
ActiveRecord::Base.connection.execute("NOTIFY nwc_invoice_paid") if rows > 0
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Docker's `--restart unless-stopped` (Kamal's default) plus the gem's internal
|
|
294
|
+
reconnect loop plus a SIGTERM trap gives you crash-only reliability without
|
|
295
|
+
systemd, foreman, or any process supervisor.
|
|
296
|
+
|
|
297
|
+
If you run the listener on multiple hosts, each instance receives the same
|
|
298
|
+
notifications independently. This is fine as long as the handler is idempotent
|
|
299
|
+
(as shown above). Running multiple instances gives you redundancy — if one
|
|
300
|
+
host goes down, the others keep listening — but they do not partition work.
|
|
301
|
+
|
|
302
|
+
[kamal-roles]: https://kamal-deploy.org/docs/configuration/roles/
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
## Testing against a real wallet
|
|
307
|
+
|
|
308
|
+
The gem ships two diagnostic methods. Call them from anywhere — IRB, a Rails
|
|
309
|
+
console, an RSpec test, or a rake task in your own app.
|
|
310
|
+
|
|
311
|
+
### `NwcRuby.test` — info, read tests, write test
|
|
312
|
+
|
|
313
|
+
Parses the connection string, fetches info, runs all read tests (`get_info`,
|
|
314
|
+
`get_balance`, `list_transactions`, `make_invoice`, `lookup_invoice`). If the
|
|
315
|
+
code is read+write **and** you provide a Lightning address, it also sends a
|
|
316
|
+
real payment via `pay_invoice`. If you provide a Lightning address but the code
|
|
317
|
+
is read-only, it prints a helpful warning instead of failing.
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
NwcRuby.test(
|
|
321
|
+
nwc_url: ENV["NWC_URL"],
|
|
322
|
+
pay_to_lightning_address: "you@rizful.com", # optional — only used if code is read+write
|
|
323
|
+
pay_to_satoshis_amount: 10 # default: 100
|
|
324
|
+
)
|
|
325
|
+
# => true if all checks passed, false otherwise
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### `NwcRuby.test_notifications` — listen for notifications
|
|
329
|
+
|
|
330
|
+
Subscribes to notifications and **blocks forever**, printing each one as it
|
|
331
|
+
arrives. Run this in a **separate terminal / process**. Ctrl-C to stop.
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
NwcRuby.test_notifications(nwc_url: ENV["NWC_URL"])
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Using `bin/nwc_test`
|
|
338
|
+
|
|
339
|
+
```sh
|
|
340
|
+
# Test (read tests + write test if applicable)
|
|
341
|
+
NWC_URL="..." PAY_TO_LIGHTNING_ADDRESS=you@example.com bin/nwc_test
|
|
342
|
+
|
|
343
|
+
# Listen for notifications (blocks forever — run in a separate terminal)
|
|
344
|
+
NWC_URL="..." bin/nwc_test notifications
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Calling from a Rails console
|
|
348
|
+
|
|
349
|
+
```sh
|
|
350
|
+
bin/rails c
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
NwcRuby.test(nwc_url: ENV["NWC_URL"])
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Wrapping in your own rake tasks
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
# lib/tasks/nwc.rake (in your Rails app)
|
|
361
|
+
namespace :nwc do
|
|
362
|
+
desc "Run NWC diagnostic (read tests + write test if applicable)."
|
|
363
|
+
task test: :environment do
|
|
364
|
+
ok = NwcRuby.test(
|
|
365
|
+
nwc_url: ENV.fetch("NWC_URL"),
|
|
366
|
+
pay_to_lightning_address: ENV["PAY_TO_LIGHTNING_ADDRESS"],
|
|
367
|
+
pay_to_satoshis_amount: Integer(ENV.fetch("PAY_TO_SATOSHIS_AMOUNT", 100))
|
|
368
|
+
)
|
|
369
|
+
exit(ok ? 0 : 1)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
desc "Listen for NWC notifications (blocks forever)."
|
|
373
|
+
task notifications: :environment do
|
|
374
|
+
NwcRuby.test_notifications(nwc_url: ENV.fetch("NWC_URL"))
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### Sample output (`NwcRuby.test`)
|
|
380
|
+
|
|
381
|
+
```
|
|
382
|
+
NWC Ruby diagnostic
|
|
383
|
+
|
|
384
|
+
✓ Connection string parsed
|
|
385
|
+
✓ Fetched info event (kind 13194)
|
|
386
|
+
|
|
387
|
+
⚠ This code is READ+WRITE and can allow payments. Be careful with it.
|
|
388
|
+
|
|
389
|
+
Supported methods:
|
|
390
|
+
✓ pay_invoice (mutating)
|
|
391
|
+
✓ multi_pay_invoice (mutating)
|
|
392
|
+
— pay_keysend
|
|
393
|
+
— multi_pay_keysend
|
|
394
|
+
✓ make_invoice
|
|
395
|
+
✓ lookup_invoice
|
|
396
|
+
✓ list_transactions
|
|
397
|
+
✓ get_balance
|
|
398
|
+
✓ get_info
|
|
399
|
+
— sign_message
|
|
400
|
+
|
|
401
|
+
Notifications: payment_received, payment_sent
|
|
402
|
+
|
|
403
|
+
✓ Encryption: nip44_v2, nip04 — will use NIP-44 v2
|
|
404
|
+
|
|
405
|
+
Read tests
|
|
406
|
+
✓ get_info (214ms)
|
|
407
|
+
✓ get_balance (188ms)
|
|
408
|
+
✓ list_transactions (312ms)
|
|
409
|
+
✓ make_invoice (1000 msats) (267ms)
|
|
410
|
+
✓ lookup_invoice (payment_hash from previous step) (241ms)
|
|
411
|
+
|
|
412
|
+
Write tests (read+write code detected, Lightning address provided)
|
|
413
|
+
✓ pay_invoice (10 sats to you@rizful.com) (1843ms)
|
|
414
|
+
|
|
415
|
+
All tests passed.
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Sample output (`NwcRuby.test_notifications`)
|
|
419
|
+
|
|
420
|
+
```
|
|
421
|
+
NWC Ruby diagnostic
|
|
422
|
+
|
|
423
|
+
✓ Connection string parsed
|
|
424
|
+
✓ Fetched info event (kind 13194)
|
|
425
|
+
|
|
426
|
+
Listening for notifications...
|
|
427
|
+
Press Ctrl-C to stop.
|
|
428
|
+
|
|
429
|
+
✓ 2026-04-20 21:19:05 — payment_received
|
|
430
|
+
payment_hash=58e45fee1b5ebe83807944896ff99e9252594ef3be4e3404c3ba2ab4536a9988
|
|
431
|
+
amount=11000 msats
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
When the wallet service misbehaves, the runner flags it:
|
|
435
|
+
|
|
436
|
+
```
|
|
437
|
+
✗ make_invoice: `type` should be 'incoming'
|
|
438
|
+
✗ get_info: `network` is "unknown", expected one of mainnet/testnet/signet/regtest
|
|
439
|
+
✗ lookup_invoice: wallet returned INTERNAL:
|
|
440
|
+
→ The wallet service accepted the request but never responded.
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
## API reference
|
|
446
|
+
|
|
447
|
+
### `NwcRuby::Client`
|
|
448
|
+
|
|
449
|
+
Constructor:
|
|
450
|
+
|
|
451
|
+
| Method | Returns |
|
|
452
|
+
| ------------------------------- | --------------------------------------------------- |
|
|
453
|
+
| `Client.from_uri(uri)` | `Client` — parses the `nostr+walletconnect://` URI |
|
|
454
|
+
| `Client.new(connection_string)` | `Client` — if you already have a `ConnectionString` |
|
|
455
|
+
|
|
456
|
+
Introspection:
|
|
457
|
+
|
|
458
|
+
| Method | Returns |
|
|
459
|
+
| ----------------- | ---------------------------------------- |
|
|
460
|
+
| `#info(refresh:)` | `NIP47::Info` — cached on first call |
|
|
461
|
+
| `#capabilities` | `Array<String>` — supported method names |
|
|
462
|
+
| `#read_only?` | `Boolean` |
|
|
463
|
+
| `#read_write?` | `Boolean` |
|
|
464
|
+
|
|
465
|
+
Methods (all raise `WalletServiceError` on wallet-side errors and
|
|
466
|
+
`TimeoutError` after 30 s of silence):
|
|
467
|
+
|
|
468
|
+
| Method | Params | Returns (hash keys) |
|
|
469
|
+
| -------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
470
|
+
| `#pay_invoice` | `invoice:`, `amount:` (msats, optional partial) | `preimage`, `fees_paid` |
|
|
471
|
+
| `#multi_pay_invoice` | `invoices: [{id:, invoice:, amount:}]` | array of results |
|
|
472
|
+
| `#pay_keysend` | `amount:`, `pubkey:`, `preimage:`, `tlv_records:` | `preimage`, `fees_paid` |
|
|
473
|
+
| `#multi_pay_keysend` | `keysends: [...]` | array of results |
|
|
474
|
+
| `#make_invoice` | `amount:`, `description:`, `description_hash:`, `expiry:`, `metadata:` | `type`, `state`, `invoice`, `payment_hash`, `amount`, `created_at`, `expires_at` |
|
|
475
|
+
| `#lookup_invoice` | `payment_hash:` or `invoice:` | same as `make_invoice`, plus `settled_at`, `preimage` |
|
|
476
|
+
| `#list_transactions` | `from:`, `until_ts:`, `limit:`, `offset:`, `unpaid:`, `type:` | `transactions: [...]` |
|
|
477
|
+
| `#get_balance` | — | `balance` (msats) |
|
|
478
|
+
| `#get_info` | — | `alias`, `color`, `pubkey`, `network`, `block_height`, `block_hash`, `methods`, `notifications` |
|
|
479
|
+
| `#sign_message` | `message:` | `message`, `signature` |
|
|
480
|
+
|
|
481
|
+
Listener:
|
|
482
|
+
|
|
483
|
+
| Method | Description |
|
|
484
|
+
| ----------------------------------------------- | --------------------------------------------- |
|
|
485
|
+
| `#subscribe_to_notifications(since:) { \|n\| }` | Blocks forever. Yields `NIP47::Notification`. |
|
|
486
|
+
|
|
487
|
+
### `NwcRuby::NIP47::Notification`
|
|
488
|
+
|
|
489
|
+
| Field | |
|
|
490
|
+
| --------------- | ---------------------------------------- |
|
|
491
|
+
| `#type` | `"payment_received"` or `"payment_sent"` |
|
|
492
|
+
| `#payment_hash` | hex |
|
|
493
|
+
| `#amount_msats` | integer |
|
|
494
|
+
| `#data` | full notification hash |
|
|
495
|
+
| `#event` | the underlying `Event` |
|
|
496
|
+
|
|
497
|
+
### Errors
|
|
498
|
+
|
|
499
|
+
All gem errors inherit from `NwcRuby::Error`:
|
|
500
|
+
|
|
501
|
+
- `InvalidConnectionStringError` — the URI couldn't be parsed.
|
|
502
|
+
- `EncryptionError` — bad MAC / bad padding / unknown version byte / bad key.
|
|
503
|
+
- `InvalidSignatureError` — an event's signature did not verify.
|
|
504
|
+
- `TransportError` — the WebSocket couldn't connect or died unrecoverably.
|
|
505
|
+
- `TimeoutError` — no response within the timeout window.
|
|
506
|
+
- `UnsupportedMethodError` — wallet service doesn't advertise this method.
|
|
507
|
+
- `WalletServiceError` — the wallet returned an error envelope. Check `#code`
|
|
508
|
+
for `RATE_LIMITED`, `NOT_IMPLEMENTED`, `INSUFFICIENT_BALANCE`,
|
|
509
|
+
`QUOTA_EXCEEDED`, `RESTRICTED`, `UNAUTHORIZED`, `INTERNAL`,
|
|
510
|
+
`UNSUPPORTED_ENCRYPTION`, `PAYMENT_FAILED`, `NOT_FOUND`, or `OTHER`.
|
|
511
|
+
|
|
512
|
+
---
|
|
513
|
+
|
|
514
|
+
## Security notes
|
|
515
|
+
|
|
516
|
+
- **NIP-44 v2 correctness is a gated invariant.** The gem's test suite verifies
|
|
517
|
+
against [paulmillr's canonical vectors][vectors]. Report any discrepancy as
|
|
518
|
+
a security issue.
|
|
519
|
+
- **Never log the `secret` portion of a connection string.** It is a private
|
|
520
|
+
key. The gem's logger never emits it.
|
|
521
|
+
- **Prefer read-only codes** for any server that doesn't strictly need to
|
|
522
|
+
spend. The extra operational overhead of a read+write code (rotation, budget
|
|
523
|
+
limits, audit logging) is usually not worth it for checkout flows.
|
|
524
|
+
- **MAC verification runs in constant time before decryption returns.** The
|
|
525
|
+
`NIP44::Cipher.decrypt` path rejects unknown version bytes and fails closed
|
|
526
|
+
on bad padding.
|
|
527
|
+
|
|
528
|
+
---
|
|
529
|
+
|
|
530
|
+
## Development
|
|
531
|
+
|
|
532
|
+
```sh
|
|
533
|
+
git clone https://github.com/MegalithicBTC/nwc-ruby
|
|
534
|
+
cd nwc-ruby
|
|
535
|
+
bundle install
|
|
536
|
+
bundle exec rspec
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
To run the diagnostics against a real wallet while developing the gem itself:
|
|
540
|
+
|
|
541
|
+
```sh
|
|
542
|
+
# Test (read + write if applicable)
|
|
543
|
+
bundle exec ruby -Ilib -rnwc_ruby -e '
|
|
544
|
+
NwcRuby.test(
|
|
545
|
+
nwc_url: ENV["NWC_URL"],
|
|
546
|
+
pay_to_lightning_address: ENV["LN_ADDR"],
|
|
547
|
+
pay_to_satoshis_amount: 10
|
|
548
|
+
)
|
|
549
|
+
'
|
|
550
|
+
|
|
551
|
+
# Listen for notifications (separate terminal)
|
|
552
|
+
bundle exec ruby -Ilib -rnwc_ruby -e '
|
|
553
|
+
NwcRuby.test_notifications(nwc_url: ENV["NWC_URL"])
|
|
554
|
+
'
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Or drop into IRB:
|
|
558
|
+
|
|
559
|
+
```sh
|
|
560
|
+
bundle exec irb -Ilib -rnwc_ruby
|
|
561
|
+
> NwcRuby.test(nwc_url: ENV["NWC_URL"])
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
---
|
|
565
|
+
|
|
566
|
+
## Contributing
|
|
567
|
+
|
|
568
|
+
PRs welcome. Please include RSpec coverage. For crypto changes, include or
|
|
569
|
+
update the vectors in `spec/fixtures/`.
|
|
570
|
+
|
|
571
|
+
1. Fork it
|
|
572
|
+
2. Create your feature branch (`git checkout -b feature/my-change`)
|
|
573
|
+
3. Commit your changes
|
|
574
|
+
4. Push to the branch
|
|
575
|
+
5. Create a Pull Request
|
|
576
|
+
|
|
577
|
+
## License
|
|
578
|
+
|
|
579
|
+
MIT. See [LICENSE](LICENSE).
|
|
580
|
+
|
|
581
|
+
## Prior art and thanks
|
|
582
|
+
|
|
583
|
+
- [NIP-47 spec][nip47]
|
|
584
|
+
- [`@getalby/sdk`][alby-sdk] — the reference JavaScript implementation.
|
|
585
|
+
- [BLFS][blfs] — Bitcoin Lightning For Shopify
|
|
586
|
+
|
|
587
|
+
[alby-sdk]: https://github.com/getAlby/js-sdk/tree/master/src/nwc
|
|
588
|
+
[blfs]: https://docs.megalithic.me/BLFS/
|
data/lib/nwc-ruby.rb
ADDED