package main import ( "git.smesh.lol/smesh/web/common/helpers" "git.smesh.lol/smesh/web/common/jsbridge/dom" "git.smesh.lol/smesh/web/common/jsbridge/signer" "git.smesh.lol/smesh/web/common/jsbridge/ws" ) // NWC (NIP-47) page-side client. // // Flow per call: // 1. Ask signer-bg for signed request event via signer.NwcBuildRequest. // 2. Open WebSocket to conn.RelayURL if not already open. // 3. Subscribe to kind 23195 tagged with our client pubkey. // 4. Publish EVENT; wait for matching response by e-tag. // 5. Decrypt response content via signer.NwcParseResponse. // 6. Invoke callback with plaintext JSON. // // The signer holds all secrets; this layer only ferries ciphertext. type NwcConn struct { ID string Alias string WalletPubkey string RelayURL string ClientPubkey string CreatedAt int64 } // nwcPending maps subscription ID → callback for the response event. type nwcPending struct { subID string requestID string conn *NwcConn encryption string callback func(string) // plaintext JSON or empty on error socket ws.Conn timeoutMs int32 } var ( nwcConns []NwcConn nwcSockets = map[string]ws.Conn{} // connID → ws handle nwcSubs = map[string]*nwcPending{} // subID → pending nwcConnected = map[string]bool{} // connID → open nwcPendingQ = map[string][]func(ws.Conn, bool){} // queued while connecting nwcNextSubN int32 ) // nwcRefreshList reloads connection metadata from the signer. func nwcRefreshList(done func()) { signer.NwcList(func(raw string) { nwcConns = parseNwcListJSON(raw) if done != nil { done() } }) } func parseNwcListJSON(raw string) (ns []NwcConn) { var out []NwcConn i := 0 for i < len(raw) && raw[i] != '[' { i++ } i++ for i < len(raw) { for i < len(raw) && raw[i] != '{' && raw[i] != ']' { i++ } if i >= len(raw) || raw[i] == ']' { break } end := i + 1 depth := 1 for end < len(raw) && depth > 0 { if raw[end] == '{' { depth++ } else if raw[end] == '}' { depth-- } else if raw[end] == '"' { end++ for end < len(raw) && raw[end] != '"' { if raw[end] == '\\' { end++ } end++ } } end++ } obj := raw[i:end] ca := helpers.JsonGetValue(obj, "createdAt") out = append(out, NwcConn{ ID: helpers.JsonGetString(obj, "id"), Alias: helpers.JsonGetString(obj, "alias"), WalletPubkey: helpers.JsonGetString(obj, "walletPubkey"), RelayURL: helpers.JsonGetString(obj, "relayURL"), ClientPubkey: helpers.JsonGetString(obj, "clientPubkey"), CreatedAt: parseI64(ca), }) i = end } return out } func findNwcConnClient(id string) (n *NwcConn) { for i := range nwcConns { if nwcConns[i].ID == id { return &nwcConns[i] } } return nil } // NwcCall invokes an NWC method on a connection. // method: "get_info", "get_balance", "pay_invoice", etc. // paramsJSON: JSON object string (e.g. `{"invoice":"lnbc..."}`) or "{}". // encryption: "nip04" or "nip44" - auto-selected if empty. // cb: receives plaintext JSON response or empty string on failure. func NwcCall(connID, method, paramsJSON, encryption string, cb func(string)) { dom.ConsoleLog("[nwc] NwcCall method=" | method | " enc=" | encryption | " id=" | connID) c := findNwcConnClient(connID) if c == nil { dom.ConsoleLog("[nwc] NwcCall: unknown connection " | connID) cb("") return } if encryption == "" { encryption = "nip44" } if paramsJSON == "" { paramsJSON = "{}" } now := dom.NowSeconds() signer.NwcBuildRequest(connID, method, paramsJSON, encryption, now, func(evJSON string) { if evJSON == "" { dom.ConsoleLog("[nwc] buildRequest returned empty (signer error)") cb("") return } reqID := helpers.JsonGetString(evJSON, "id") if reqID == "" { dom.ConsoleLog("[nwc] buildRequest: no id in " | evJSON) cb("") return } dom.ConsoleLog("[nwc] built req id=" | reqID | " → dial " | c.RelayURL) nwcEnsureConnected(c, func(sock ws.Conn, ok bool) { if !ok { dom.ConsoleLog("[nwc] relay connect failed") cb("") return } dom.ConsoleLog("[nwc] relay connected; REQ+EVENT publishing for req=" | reqID) // Build an ephemeral subscription for the response. nwcNextSubN++ subID := "nwc-" | helpers.Itoa(int64(nwcNextSubN)) p := &nwcPending{ subID: subID, requestID: reqID, conn: c, encryption: encryption, callback: cb, socket: sock, timeoutMs: 60000, } nwcSubs[subID] = p // REQ for kind 23195 e-tagged with requestID AND p-tagged with us. reqMsg := `["REQ",` | helpers.JsonString(subID) | `,{"kinds":[23195],"#e":[` | helpers.JsonString(reqID) | `],"#p":[` | helpers.JsonString(c.ClientPubkey) | `],"limit":1}]` ws.Send(sock, reqMsg) // EVENT to publish the request. evMsg := `["EVENT",` | evJSON | `]` ws.Send(sock, evMsg) dom.SetTimeout(func() { if _, ok := nwcSubs[subID]; ok { dom.ConsoleLog("[nwc] timeout waiting for response to req=" | reqID | " sub=" | subID) delete(nwcSubs, subID) ws.Send(sock, `["CLOSE",` | helpers.JsonString(subID) | `]`) p.callback("") } }, p.timeoutMs) }) }) } func nwcEnsureConnected(c *NwcConn, done func(ws.Conn, bool)) { if sock, ok := nwcSockets[c.ID]; ok && nwcConnected[c.ID] { done(sock, true) return } // If a dial is in progress, queue the callback. if _, ok := nwcSockets[c.ID]; ok { nwcPendingQ[c.ID] = append(nwcPendingQ[c.ID], done) return } connID := c.ID nwcPendingQ[connID] = append(nwcPendingQ[connID], done) sock := ws.Dial( c.RelayURL, func(_ int32, data string) { nwcOnMessage(connID, data) }, func(_ int32) { nwcConnected[connID] = true waiters := nwcPendingQ[connID] delete(nwcPendingQ, connID) s := nwcSockets[connID] for _, w := range waiters { w(s, true) } }, func(_ int32, code int32, reason string) { dom.ConsoleLog("[nwc] ws close code=" | helpers.Itoa(int64(code)) | " reason=" | reason | " url=" | c.RelayURL) delete(nwcSockets, connID) delete(nwcConnected, connID) waiters := nwcPendingQ[connID] delete(nwcPendingQ, connID) for _, w := range waiters { w(0, false) } }, func(_ int32) { dom.ConsoleLog("[nwc] ws error url=" | c.RelayURL) delete(nwcSockets, connID) delete(nwcConnected, connID) waiters := nwcPendingQ[connID] delete(nwcPendingQ, connID) for _, w := range waiters { w(0, false) } }, ) nwcSockets[connID] = sock } func nwcOnMessage(connID, msg string) { // Quick probe: "[\"EVENT\",..." or "[\"EOSE\",..." etc. if len(msg) < 10 || msg[0] != '[' { return } // Parse type. i := 1 if msg[i] != '"' { return } i++ tStart := i for i < len(msg) && msg[i] != '"' { i++ } if i >= len(msg) { return } tag := msg[tStart:i] i++ // skip " if msg[i] != ',' { return } i++ switch tag { case "EVENT": // ["EVENT", subID, event] subID, ev, ok := parseEventMsg(msg[i:]) if !ok { return } p := nwcSubs[subID] if p == nil { return } ct := helpers.JsonGetString(ev, "content") dom.ConsoleLog("[nwc] EVENT sub=" | subID | " enc=" | p.encryption | " ct.len=" | helpers.Itoa(int64(len(ct)))) signer.NwcParseResponse(p.conn.ID, ct, p.encryption, func(plain string) { dom.ConsoleLog("[nwc] parseResponse plain=" | plain) delete(nwcSubs, subID) if s, ok := nwcSockets[connID]; ok { ws.Send(s, `["CLOSE",` | helpers.JsonString(subID) | `]`) } p.callback(plain) }) case "CLOSED": dom.ConsoleLog("[nwc] CLOSED from relay: " | msg) case "NOTICE": dom.ConsoleLog("[nwc] NOTICE: " | msg) case "OK": dom.ConsoleLog("[nwc] OK: " | msg) case "EOSE", "AUTH": // ignore } } // parseEventMsg extracts subID and event JSON from the portion after ["EVENT",. // Input starts at the char after the comma following "EVENT". func parseEventMsg(s string) (string, string, bool) { // Expect: "",{...}] i := 0 for i < len(s) && (s[i] == ' ' || s[i] == '\t') { i++ } if i >= len(s) || s[i] != '"' { return "", "", false } i++ start := i for i < len(s) && s[i] != '"' { if s[i] == '\\' { i++ } i++ } if i >= len(s) { return "", "", false } subID := s[start:i] i++ // skip " for i < len(s) && (s[i] == ' ' || s[i] == ',' || s[i] == '\t') { i++ } if i >= len(s) || s[i] != '{' { return "", "", false } objStart := i depth := 1 i++ for i < len(s) && depth > 0 { switch s[i] { case '{': depth++ case '}': depth-- case '"': i++ for i < len(s) && s[i] != '"' { if s[i] == '\\' { i++ } i++ } } i++ } if depth != 0 { return "", "", false } ev := s[objStart:i] return subID, ev, true } // NwcAdd parses a URI, registers with signer, refreshes local list. // alias is a human label. done called with new id or empty on failure. func NwcAdd(uri, alias string, done func(string)) { signer.NwcAdd(uri, alias, dom.NowSeconds(), func(id string) { if id == "" { done("") return } nwcRefreshList(func() { done(id) }) }) } // NwcRemove removes a connection by id, closes its socket, refreshes list. func NwcRemove(id string, done func(bool)) { if sock, ok := nwcSockets[id]; ok { ws.Close(sock) delete(nwcSockets, id) delete(nwcConnected, id) } signer.NwcRemove(id, func(ok bool) { if !ok { done(false) return } nwcRefreshList(func() { done(true) }) }) }