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