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