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