nwc_client.mx raw
1 package main
2
3 import (
4 "git.smesh.lol/musiquay/web/common/helpers"
5 "git.smesh.lol/musiquay/web/common/jsbridge/dom"
6 "git.smesh.lol/musiquay/web/common/jsbridge/signer"
7 "git.smesh.lol/musiquay/web/common/jsbridge/ws"
8 )
9
10 // NWC (NIP-47) page-side client.
11 //
12 // Flow per call:
13 // 1. Ask signer-bg for signed request event via signer.NwcBuildRequest.
14 // 2. Open WebSocket to conn.RelayURL if not already open.
15 // 3. Subscribe to kind 23195 tagged with our client pubkey.
16 // 4. Publish EVENT; wait for matching response by e-tag.
17 // 5. Decrypt response content via signer.NwcParseResponse.
18 // 6. Invoke callback with plaintext JSON.
19 //
20 // The signer holds all secrets; this layer only ferries ciphertext.
21
22 type NwcConn struct {
23 ID string
24 Alias string
25 WalletPubkey string
26 RelayURL string
27 ClientPubkey string
28 CreatedAt int64
29 }
30
31 // nwcPending maps subscription ID → callback for the response event.
32 type nwcPending struct {
33 subID string
34 requestID string
35 conn *NwcConn
36 encryption string
37 callback func(string) // plaintext JSON or empty on error
38 socket ws.Conn
39 timeoutMs int32
40 }
41
42 var (
43 )
44
45 // nwcRefreshList reloads connection metadata from the signer.
46 func nwcRefreshList(done func()) {
47 signer.NwcList(func(raw string) {
48 appSt.nwcConns = parseNwcListJSON(raw)
49 if done != nil {
50 done()
51 }
52 })
53 }
54
55 func parseNwcListJSON(raw string) (ns []NwcConn) {
56 var out []NwcConn
57 i := 0
58 for i < len(raw) && raw[i] != '[' {
59 i++
60 }
61 i++
62 for i < len(raw) {
63 for i < len(raw) && raw[i] != '{' && raw[i] != ']' {
64 i++
65 }
66 if i >= len(raw) || raw[i] == ']' {
67 break
68 }
69 end := i + 1
70 depth := 1
71 for end < len(raw) && depth > 0 {
72 if raw[end] == '{' {
73 depth++
74 } else if raw[end] == '}' {
75 depth--
76 } else if raw[end] == '"' {
77 end++
78 for end < len(raw) && raw[end] != '"' {
79 if raw[end] == '\\' {
80 end++
81 }
82 end++
83 }
84 }
85 end++
86 }
87 obj := raw[i:end]
88 ca := helpers.JsonGetValue(obj, "createdAt")
89 out = push(out, NwcConn{
90 ID: helpers.JsonGetString(obj, "id"),
91 Alias: helpers.JsonGetString(obj, "alias"),
92 WalletPubkey: helpers.JsonGetString(obj, "walletPubkey"),
93 RelayURL: helpers.JsonGetString(obj, "relayURL"),
94 ClientPubkey: helpers.JsonGetString(obj, "clientPubkey"),
95 CreatedAt: parseI64(ca),
96 })
97 i = end
98 }
99 return out
100 }
101
102 func findNwcConnClient(id string) (n *NwcConn) {
103 for i := range appSt.nwcConns {
104 if appSt.nwcConns[i].ID == id {
105 return &appSt.nwcConns[i]
106 }
107 }
108 return nil
109 }
110
111 // NwcCall invokes an NWC method on a connection.
112 // method: "get_info", "get_balance", "pay_invoice", etc.
113 // paramsJSON: JSON object string (e.g. `{"invoice":"lnbc..."}`) or "{}".
114 // encryption: "nip04" or "nip44" - auto-selected if empty.
115 // cb: receives plaintext JSON response or empty string on failure.
116 func NwcCall(connID, method, paramsJSON, encryption string, cb func(string)) {
117 dom.ConsoleLog("[nwc] NwcCall method=" | method | " enc=" | encryption | " id=" | connID)
118 c := findNwcConnClient(connID)
119 if c == nil {
120 dom.ConsoleLog("[nwc] NwcCall: unknown connection " | connID)
121 cb("")
122 return
123 }
124 if encryption == "" {
125 encryption = "nip44"
126 }
127 if paramsJSON == "" {
128 paramsJSON = "{}"
129 }
130 now := dom.NowSeconds()
131
132 signer.NwcBuildRequest(connID, method, paramsJSON, encryption, now, func(evJSON string) {
133 if evJSON == "" {
134 dom.ConsoleLog("[nwc] buildRequest returned empty (signer error)")
135 cb("")
136 return
137 }
138 reqID := helpers.JsonGetString(evJSON, "id")
139 if reqID == "" {
140 dom.ConsoleLog("[nwc] buildRequest: no id in " | evJSON)
141 cb("")
142 return
143 }
144 dom.ConsoleLog("[nwc] built req id=" | reqID | " → dial " | c.RelayURL)
145 nwcEnsureConnected(c, func(sock ws.Conn, ok bool) {
146 if !ok {
147 dom.ConsoleLog("[nwc] relay connect failed")
148 cb("")
149 return
150 }
151 dom.ConsoleLog("[nwc] relay connected; REQ+EVENT publishing for req=" | reqID)
152 // Build an ephemeral subscription for the response.
153 appSt.nwcNextSubN++
154 subID := "nwc-" | helpers.Itoa(int64(appSt.nwcNextSubN))
155 p := &nwcPending{
156 subID: subID,
157 requestID: reqID,
158 conn: c,
159 encryption: encryption,
160 callback: cb,
161 socket: sock,
162 timeoutMs: 60000,
163 }
164 appSt.nwcSubs[subID] = p
165 // REQ for kind 23195 e-tagged with requestID AND p-tagged with us.
166 reqMsg := `["REQ",` | helpers.JsonString(subID) |
167 `,{"kinds":[23195],"#e":[` | helpers.JsonString(reqID) |
168 `],"#p":[` | helpers.JsonString(c.ClientPubkey) | `],"limit":1}]`
169 ws.Send(sock, reqMsg)
170 // EVENT to publish the request.
171 evMsg := `["EVENT",` | evJSON | `]`
172 ws.Send(sock, evMsg)
173 dom.SetTimeout(func() {
174 if _, ok2 := appSt.nwcSubs[subID]; ok2 {
175 dom.ConsoleLog("[nwc] timeout waiting for response to req=" | reqID | " sub=" | subID)
176 delete(appSt.nwcSubs, subID)
177 ws.Send(sock, `["CLOSE",` | helpers.JsonString(subID) | `]`)
178 // cb is the pending record's own callback; reaching
179 // through p here made the closure capture a pointer to a
180 // local record just to read one field.
181 cb("")
182 }
183 }, p.timeoutMs)
184 })
185 })
186 }
187
188 func nwcEnsureConnected(c *NwcConn, done func(ws.Conn, bool)) {
189 if sock, ok := appSt.nwcSockets[c.ID]; ok && appSt.nwcConnected[c.ID] {
190 done(sock, true)
191 return
192 }
193 // If a dial is in progress, queue the callback.
194 if _, ok2 := appSt.nwcSockets[c.ID]; ok2 {
195 appSt.nwcPendingQ[c.ID] = push(appSt.nwcPendingQ[c.ID], done)
196 return
197 }
198 connID := c.ID
199 appSt.nwcPendingQ[connID] = push(appSt.nwcPendingQ[connID], done)
200 sck := ws.Dial(
201 c.RelayURL,
202 func(_ int32, data string) { nwcOnMessage(connID, data) },
203 func(_ int32) {
204 appSt.nwcConnected[connID] = true
205 waiters := appSt.nwcPendingQ[connID]
206 delete(appSt.nwcPendingQ, connID)
207 s := appSt.nwcSockets[connID]
208 for _, w := range waiters {
209 w(s, true)
210 }
211 },
212 func(_ int32, code int32, reason string) {
213 dom.ConsoleLog("[nwc] ws close code=" | helpers.Itoa(int64(code)) | " reason=" | reason | " url=" | c.RelayURL)
214 delete(appSt.nwcSockets, connID)
215 delete(appSt.nwcConnected, connID)
216 waiters := appSt.nwcPendingQ[connID]
217 delete(appSt.nwcPendingQ, connID)
218 for _, w := range waiters {
219 w(0, false)
220 }
221 },
222 func(_ int32) {
223 dom.ConsoleLog("[nwc] ws error url=" | c.RelayURL)
224 delete(appSt.nwcSockets, connID)
225 delete(appSt.nwcConnected, connID)
226 waiters := appSt.nwcPendingQ[connID]
227 delete(appSt.nwcPendingQ, connID)
228 for _, w := range waiters {
229 w(0, false)
230 }
231 },
232 )
233 appSt.nwcSockets[connID] = sck
234 }
235
236 func nwcOnMessage(connID, msg string) {
237 // Quick probe: "[\"EVENT\",..." or "[\"EOSE\",..." etc.
238 if len(msg) < 10 || msg[0] != '[' {
239 return
240 }
241 // Parse type.
242 i := 1
243 if msg[i] != '"' {
244 return
245 }
246 i++
247 tStart := i
248 for i < len(msg) && msg[i] != '"' {
249 i++
250 }
251 if i >= len(msg) {
252 return
253 }
254 tag := msg[tStart:i]
255 i++ // skip "
256 if msg[i] != ',' {
257 return
258 }
259 i++
260 switch tag {
261 case "EVENT":
262 // ["EVENT", subID, event]
263 subID, ev, ok := parseEventMsg(msg[i:])
264 if !ok {
265 return
266 }
267 p := appSt.nwcSubs[subID]
268 if p == nil {
269 return
270 }
271 ct := helpers.JsonGetString(ev, "content")
272 dom.ConsoleLog("[nwc] EVENT sub=" | subID | " enc=" | p.encryption | " ct.len=" | helpers.Itoa(int64(len(ct))))
273 // Read the callback before the closure: a field access on a captured
274 // local does not resolve inside it, and this closure only needs the
275 // function value.
276 pCB := p.callback
277 signer.NwcParseResponse(p.conn.ID, ct, p.encryption, func(plain string) {
278 dom.ConsoleLog("[nwc] parseResponse plain=" | plain)
279 delete(appSt.nwcSubs, subID)
280 if s, ok2 := appSt.nwcSockets[connID]; ok2 {
281 ws.Send(s, `["CLOSE",` | helpers.JsonString(subID) | `]`)
282 }
283 pCB(plain)
284 })
285 case "CLOSED":
286 dom.ConsoleLog("[nwc] CLOSED from relay: " | msg)
287 case "NOTICE":
288 dom.ConsoleLog("[nwc] NOTICE: " | msg)
289 case "OK":
290 dom.ConsoleLog("[nwc] OK: " | msg)
291 case "EOSE", "AUTH":
292 // ignore
293 }
294 }
295
296 // parseEventMsg extracts subID and event JSON from the portion after ["EVENT",.
297 // Input starts at the char after the comma following "EVENT".
298 func parseEventMsg(s string) (s2 string, s3 string, ok3 bool) {
299 // Expect: "<subID>",{...}]
300 i := 0
301 for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
302 i++
303 }
304 if i >= len(s) || s[i] != '"' {
305 return "", "", false
306 }
307 i++
308 start := i
309 for i < len(s) && s[i] != '"' {
310 if s[i] == '\\' {
311 i++
312 }
313 i++
314 }
315 if i >= len(s) {
316 return "", "", false
317 }
318 subID := s[start:i]
319 i++ // skip "
320 for i < len(s) && (s[i] == ' ' || s[i] == ',' || s[i] == '\t') {
321 i++
322 }
323 if i >= len(s) || s[i] != '{' {
324 return "", "", false
325 }
326 objStart := i
327 depth := 1
328 i++
329 for i < len(s) && depth > 0 {
330 switch s[i] {
331 case '{':
332 depth++
333 case '}':
334 depth--
335 case '"':
336 i++
337 for i < len(s) && s[i] != '"' {
338 if s[i] == '\\' {
339 i++
340 }
341 i++
342 }
343 }
344 i++
345 }
346 if depth != 0 {
347 return "", "", false
348 }
349 ev := s[objStart:i]
350 return subID, ev, true
351 }
352
353 // NwcAdd parses a URI, registers with signer, refreshes local list.
354 // alias is a human label. done called with new id or empty on failure.
355 func NwcAdd(uri, alias string, done func(string)) {
356 signer.NwcAdd(uri, alias, dom.NowSeconds(), func(id string) {
357 if id == "" {
358 done("")
359 return
360 }
361 nwcRefreshList(func() {
362 done(id)
363 })
364 })
365 }
366
367 // NwcRemove removes a connection by id, closes its socket, refreshes list.
368 func NwcRemove(id string, done func(bool)) {
369 if sock, ok2 := appSt.nwcSockets[id]; ok2 {
370 ws.Close(sock)
371 delete(appSt.nwcSockets, id)
372 delete(appSt.nwcConnected, id)
373 }
374 signer.NwcRemove(id, func(ok bool) {
375 if !ok {
376 done(false)
377 return
378 }
379 nwcRefreshList(func() {
380 done(true)
381 })
382 })
383 }
384