wallet.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 )
8
9 // Wallet popover. NWC (NIP-47) wallet connection + send.
10 //
11 // State model:
12 // - NWC connections live in the signer vault (sovereignty: secrets never leave).
13 // - We hold a local cached NwcConn list (pubkey, relay, alias) for UI.
14 // - The active connection is the first one; multi-wallet management is a
15 // future concern.
16 //
17 // UI layout:
18 // 1. Active wallet row: alias, balance (sats), refresh, remove.
19 // 2. Add wallet form: URI + alias (hidden until "add wallet" clicked).
20 // 3. Send form: recipient (invoice | lightning address) + optional amount
21 // and comment. Pay button.
22
23 var (
24 walletBalanceSats int64 = -1 // -1 = not fetched, -2 = fetch failed
25 walletActiveID string
26 walletStatus string
27 walletEnc string // "nip04" | "nip44", auto-picked on first get_info
28 walletPayResultEl dom.Element
29 walletShowAddForm bool
30 walletLud16 string // cached from get_info
31 walletInfoLoaded bool
32 )
33
34 // buildWalletPanel creates the wallet popover (hidden).
35 func buildWalletPanel() {
36 walletPanel = dom.CreateElement("div")
37 dom.SetStyle(walletPanel, "marginTop", "24px")
38 dom.SetStyle(walletPanel, "padding", "12px 0 0 0")
39 dom.SetStyle(walletPanel, "borderTop", "1px solid var(--border)")
40 dom.SetStyle(walletPanel, "fontSize", "13px")
41
42 walletContent = dom.CreateElement("div")
43 dom.AppendChild(walletPanel, walletContent)
44 }
45
46 // toggleWalletPanel shows/hides the wallet popover.
47 func toggleWalletPanel() {
48 if int32(walletPanel) == 0 {
49 return
50 }
51 if walletPanelOpen {
52 hideWalletPanel()
53 return
54 }
55 // Close other popovers so they don't overlap.
56 if signerOpen {
57 hideSignerPanel()
58 }
59 if popoverOpen {
60 togglePopover()
61 }
62 closeFeedPopover()
63 walletPanelOpen = true
64 dom.SetStyle(walletPanel, "display", "block")
65 if int32(walletBtnEl) != 0 {
66 dom.SetStyle(walletBtnEl, "background", "var(--accent)")
67 dom.SetStyle(walletBtnEl, "color", "var(--bg)")
68 }
69 renderWalletUI()
70 }
71
72 func hideWalletPanel() {
73 if !walletPanelOpen {
74 return
75 }
76 walletPanelOpen = false
77 dom.SetStyle(walletPanel, "display", "none")
78 if int32(walletBtnEl) != 0 {
79 dom.SetStyle(walletBtnEl, "background", "transparent")
80 dom.SetStyle(walletBtnEl, "color", "var(--muted)")
81 }
82 }
83
84 // renderWalletUI rebuilds the panel based on current state.
85 func renderWalletUI() {
86 dom.SetTextContent(walletContent, "")
87
88 if !signer.HasSigner() {
89 p := dom.CreateElement("p")
90 dom.SetStyle(p, "color", "var(--muted)")
91 dom.SetTextContent(p, t("no_signer_backend"))
92 dom.AppendChild(walletContent, p)
93 return
94 }
95
96 // Refresh list every open to catch external changes.
97 signer.NwcList(func(raw string) {
98 nwcConns = parseNwcListJSON(raw)
99 dom.ConsoleLog("[wallet] NwcList raw=" | raw | " parsed=" | helpers.Itoa(int64(len(nwcConns))))
100 renderWalletBody()
101 })
102 }
103
104 func renderWalletBody() {
105 dom.SetTextContent(walletContent, "")
106
107 h := dom.CreateElement("h3")
108 dom.SetTextContent(h, t("wallet"))
109 dom.AppendChild(walletContent, h)
110
111 if len(nwcConns) == 0 {
112 p := dom.CreateElement("p")
113 dom.SetStyle(p, "color", "var(--muted)")
114 dom.SetTextContent(p, t("wallet_no_wallet"))
115 dom.AppendChild(walletContent, p)
116 renderAddWalletForm()
117 return
118 }
119
120 // Active wallet is the first one; multi-wallet switch TBD.
121 active := &nwcConns[0]
122 walletActiveID = active.ID
123
124 row := dom.CreateElement("div")
125 dom.SetAttribute(row, "class", "signer-identity")
126
127 label := dom.CreateElement("span")
128 name := active.Alias
129 if name == "" {
130 name = active.WalletPubkey[:8] | "..."
131 }
132 balStr := t("wallet_loading_balance")
133 if walletBalanceSats == -2 {
134 balStr = "-"
135 } else if walletBalanceSats >= 0 {
136 balStr = formatSats(walletBalanceSats) | " " | t("wallet_sats")
137 }
138 line := name
139 if walletLud16 != "" {
140 line = line | " · " | walletLud16
141 }
142 line = line | " - " | balStr
143 dom.SetTextContent(label, line)
144 dom.AppendChild(row, label)
145
146 btns := dom.CreateElement("span")
147
148 refreshBtn := dom.CreateElement("button")
149 dom.SetAttribute(refreshBtn, "class", "signer-btn-sm")
150 dom.SetTextContent(refreshBtn, t("wallet_refresh"))
151 dom.AddEventListener(refreshBtn, "click", dom.RegisterCallback(func() {
152 walletBalanceSats = -1
153 walletInfoLoaded = false
154 walletLud16 = ""
155 renderWalletBody()
156 fetchWalletInfo()
157 fetchWalletBalance()
158 }))
159 dom.AppendChild(btns, refreshBtn)
160
161 rmBtn := dom.CreateElement("button")
162 dom.SetAttribute(rmBtn, "class", "signer-btn-sm signer-btn-danger")
163 dom.SetTextContent(rmBtn, "\u00d7")
164 rmID := active.ID
165 dom.AddEventListener(rmBtn, "click", dom.RegisterCallback(func() {
166 if !dom.Confirm(t("wallet_remove_confirm")) {
167 return
168 }
169 NwcRemove(rmID, func(ok bool) {
170 walletBalanceSats = -1
171 walletActiveID = ""
172 walletEnc = ""
173 walletLud16 = ""
174 walletInfoLoaded = false
175 renderWalletUI()
176 })
177 }))
178 dom.AppendChild(btns, rmBtn)
179
180 dom.AppendChild(row, btns)
181 dom.AppendChild(walletContent, row)
182
183 if !walletInfoLoaded {
184 fetchWalletInfo()
185 }
186 if walletBalanceSats == -1 {
187 fetchWalletBalance()
188 }
189
190 renderSendForm()
191
192 // Add-another (hidden behind a small link).
193 renderAddWalletForm()
194 }
195
196 func renderAddWalletForm() {
197 if !walletShowAddForm && len(nwcConns) > 0 {
198 toggle := dom.CreateElement("button")
199 dom.SetAttribute(toggle, "class", "signer-btn signer-btn-secondary")
200 dom.SetTextContent(toggle, t("wallet_add_another"))
201 dom.AddEventListener(toggle, "click", dom.RegisterCallback(func() {
202 walletShowAddForm = true
203 renderWalletBody()
204 }))
205 dom.AppendChild(walletContent, toggle)
206 return
207 }
208
209 h3 := dom.CreateElement("h3")
210 dom.SetTextContent(h3, t("wallet_add"))
211 dom.AppendChild(walletContent, h3)
212
213 p := dom.CreateElement("p")
214 dom.SetStyle(p, "fontSize", "12px")
215 dom.SetStyle(p, "color", "var(--muted)")
216 dom.SetTextContent(p, t("wallet_add_hint"))
217 dom.AppendChild(walletContent, p)
218
219 uriInput := dom.CreateElement("textarea")
220 dom.SetAttribute(uriInput, "placeholder", "nostr+walletconnect://...")
221 dom.SetAttribute(uriInput, "class", "signer-input signer-textarea")
222 dom.SetAttribute(uriInput, "rows", "3")
223 dom.AppendChild(walletContent, uriInput)
224
225 aliasInput := dom.CreateElement("input")
226 dom.SetAttribute(aliasInput, "type", "text")
227 dom.SetAttribute(aliasInput, "placeholder", t("wallet_alias_placeholder"))
228 dom.SetAttribute(aliasInput, "class", "signer-input")
229 dom.AppendChild(walletContent, aliasInput)
230
231 status := dom.CreateElement("p")
232 dom.SetStyle(status, "fontSize", "12px")
233 dom.SetStyle(status, "marginTop", "4px")
234 dom.AppendChild(walletContent, status)
235
236 addBtn := dom.CreateElement("button")
237 dom.SetAttribute(addBtn, "class", "signer-btn")
238 dom.SetTextContent(addBtn, t("wallet_add"))
239 dom.AddEventListener(addBtn, "click", dom.RegisterCallback(func() {
240 uri := dom.GetProperty(uriInput, "value")
241 alias := dom.GetProperty(aliasInput, "value")
242 if uri == "" {
243 dom.ReadClipboard(func(clip string) {
244 if clip != "" {
245 dom.SetProperty(uriInput, "value", clip)
246 }
247 })
248 return
249 }
250 dom.SetTextContent(status, t("wallet_adding"))
251 dom.SetAttribute(addBtn, "disabled", "true")
252 NwcAdd(uri, alias, func(id string) {
253 if id == "" {
254 dom.SetTextContent(status, t("wallet_add_failed"))
255 dom.SetAttribute(addBtn, "disabled", "")
256 return
257 }
258 walletShowAddForm = false
259 walletBalanceSats = -1
260 walletEnc = ""
261 renderWalletUI()
262 })
263 }))
264 dom.AppendChild(walletContent, addBtn)
265 }
266
267 // renderSendForm draws the invoice/lightning-address send UI.
268 func renderSendForm() {
269 h3 := dom.CreateElement("h3")
270 dom.SetTextContent(h3, t("wallet_send"))
271 dom.AppendChild(walletContent, h3)
272
273 recipInput := dom.CreateElement("input")
274 dom.SetAttribute(recipInput, "type", "text")
275 dom.SetAttribute(recipInput, "placeholder", t("wallet_recipient_placeholder"))
276 dom.SetAttribute(recipInput, "class", "signer-input")
277 dom.AppendChild(walletContent, recipInput)
278
279 amountInput := dom.CreateElement("input")
280 dom.SetAttribute(amountInput, "type", "number")
281 dom.SetAttribute(amountInput, "placeholder", t("wallet_amount_placeholder"))
282 dom.SetAttribute(amountInput, "class", "signer-input")
283 dom.AppendChild(walletContent, amountInput)
284
285 commentInput := dom.CreateElement("input")
286 dom.SetAttribute(commentInput, "type", "text")
287 dom.SetAttribute(commentInput, "placeholder", t("wallet_comment_placeholder"))
288 dom.SetAttribute(commentInput, "class", "signer-input")
289 dom.AppendChild(walletContent, commentInput)
290
291 walletPayResultEl = dom.CreateElement("p")
292 dom.SetStyle(walletPayResultEl, "fontSize", "12px")
293 dom.SetStyle(walletPayResultEl, "marginTop", "4px")
294 dom.SetStyle(walletPayResultEl, "wordBreak", "break-all")
295 dom.AppendChild(walletContent, walletPayResultEl)
296
297 payBtn := dom.CreateElement("button")
298 dom.SetAttribute(payBtn, "class", "signer-btn")
299 dom.SetTextContent(payBtn, t("wallet_pay"))
300 dom.AddEventListener(payBtn, "click", dom.RegisterCallback(func() {
301 recip := trimText(dom.GetProperty(recipInput, "value"))
302 amtStr := dom.GetProperty(amountInput, "value")
303 comment := dom.GetProperty(commentInput, "value")
304 if recip == "" {
305 return
306 }
307 dom.SetAttribute(payBtn, "disabled", "true")
308 dom.SetTextContent(payBtn, t("wallet_paying"))
309 dom.SetTextContent(walletPayResultEl, "")
310 finish := func(plain string) {
311 dom.SetTextContent(payBtn, t("wallet_pay"))
312 dom.SetAttribute(payBtn, "disabled", "")
313 handlePayResult(plain)
314 }
315 if isLightningAddress(recip) {
316 msats := parseI64(amtStr) * 1000
317 if msats <= 0 {
318 dom.SetTextContent(walletPayResultEl, t("wallet_need_amount"))
319 dom.SetTextContent(payBtn, t("wallet_pay"))
320 dom.SetAttribute(payBtn, "disabled", "")
321 return
322 }
323 ensureEncAnd(func(enc string) {
324 PayLightningAddress(walletActiveID, recip, msats, comment, enc, finish)
325 })
326 return
327 }
328 if isBolt11(recip) {
329 inner := `{"invoice":` | helpers.JsonString(recip) | `}`
330 ensureEncAnd(func(enc string) {
331 NwcCall(walletActiveID, "pay_invoice", inner, enc, finish)
332 })
333 return
334 }
335 dom.SetTextContent(walletPayResultEl, t("wallet_unknown_recipient"))
336 dom.SetTextContent(payBtn, t("wallet_pay"))
337 dom.SetAttribute(payBtn, "disabled", "")
338 }))
339 dom.AppendChild(walletContent, payBtn)
340 }
341
342 // handlePayResult parses an NWC response and updates walletPayResultEl.
343 func handlePayResult(plain string) {
344 if plain == "" {
345 dom.SetTextContent(walletPayResultEl, t("wallet_pay_failed"))
346 dom.SetStyle(walletPayResultEl, "color", "var(--accent)")
347 return
348 }
349 errVal := helpers.JsonGetValue(plain, "error")
350 if errVal != "" && errVal != "null" {
351 reason := helpers.JsonGetString(plain, "message")
352 if reason == "" {
353 reason = helpers.JsonGetString(errVal, "message")
354 }
355 if reason == "" {
356 reason = errVal
357 }
358 dom.SetTextContent(walletPayResultEl, t("wallet_pay_failed") | ": " | reason)
359 dom.SetStyle(walletPayResultEl, "color", "var(--accent)")
360 return
361 }
362 result := helpers.JsonGetValue(plain, "result")
363 preimage := helpers.JsonGetString(result, "preimage")
364 if preimage == "" {
365 dom.SetTextContent(walletPayResultEl, t("wallet_pay_ok"))
366 } else {
367 pre := preimage
368 if len(pre) > 16 {
369 pre = pre[:16] | "..."
370 }
371 dom.SetTextContent(walletPayResultEl, t("wallet_pay_ok") | " - " | pre)
372 }
373 dom.SetStyle(walletPayResultEl, "color", "var(--accent)")
374 // Re-fetch balance after successful pay.
375 walletBalanceSats = -1
376 fetchWalletBalance()
377 }
378
379 // fetchWalletBalance calls get_balance and updates the UI.
380 func fetchWalletBalance() {
381 if walletActiveID == "" {
382 dom.ConsoleLog("[wallet] fetchWalletBalance: no active wallet")
383 return
384 }
385 dom.ConsoleLog("[wallet] fetchWalletBalance: start id=" | walletActiveID)
386 ensureEncAnd(func(enc string) {
387 dom.ConsoleLog("[wallet] fetchWalletBalance: enc=" | enc | " → get_balance")
388 NwcCall(walletActiveID, "get_balance", "{}", enc, func(plain string) {
389 if plain == "" {
390 dom.ConsoleLog("[wallet] get_balance: empty (timeout or decrypt failed)")
391 walletBalanceSats = -2
392 renderWalletBody()
393 return
394 }
395 errVal := helpers.JsonGetValue(plain, "error")
396 if errVal != "" && errVal != "null" {
397 dom.ConsoleLog("[wallet] get_balance error: " | errVal)
398 walletBalanceSats = -2
399 renderWalletBody()
400 return
401 }
402 result := helpers.JsonGetValue(plain, "result")
403 if result == "" {
404 dom.ConsoleLog("[wallet] get_balance: no result field in " | plain)
405 walletBalanceSats = -2
406 renderWalletBody()
407 return
408 }
409 msats := parseI64(helpers.JsonGetValue(result, "balance"))
410 walletBalanceSats = msats / 1000
411 dom.ConsoleLog("[wallet] get_balance: ok sats=" | helpers.Itoa(walletBalanceSats))
412 renderWalletBody()
413 })
414 })
415 }
416
417 // fetchWalletInfo calls get_info to retrieve lud16 + alias and cache them.
418 func fetchWalletInfo() {
419 if walletActiveID == "" {
420 return
421 }
422 dom.ConsoleLog("[wallet] fetchWalletInfo: start")
423 ensureEncAnd(func(enc string) {
424 NwcCall(walletActiveID, "get_info", "{}", enc, func(plain string) {
425 walletInfoLoaded = true
426 if plain == "" {
427 dom.ConsoleLog("[wallet] get_info: empty")
428 return
429 }
430 result := helpers.JsonGetValue(plain, "result")
431 if result == "" {
432 dom.ConsoleLog("[wallet] get_info: no result in " | plain)
433 return
434 }
435 walletLud16 = helpers.JsonGetString(result, "lud16")
436 dom.ConsoleLog("[wallet] get_info: lud16=" | walletLud16)
437 renderWalletBody()
438 })
439 })
440 }
441
442 func ensureEncAnd(fn func(string)) {
443 if walletEnc != "" {
444 fn(walletEnc)
445 return
446 }
447 NwcCall(walletActiveID, "get_info", "{}", "nip44", func(plain string) {
448 if plain != "" && helpers.JsonGetValue(plain, "result") != "" {
449 walletEnc = "nip44"
450 fn("nip44")
451 return
452 }
453 NwcCall(walletActiveID, "get_info", "{}", "nip04", func(plain2 string) {
454 if plain2 != "" && helpers.JsonGetValue(plain2, "result") != "" {
455 walletEnc = "nip04"
456 } else {
457 walletEnc = "nip04"
458 }
459 fn(walletEnc)
460 })
461 })
462 }
463
464 // trimText strips leading/trailing ASCII whitespace.
465 func trimText(s string) (sv string) {
466 a := 0
467 b := len(s)
468 for a < b && (s[a] == ' ' || s[a] == '\t' || s[a] == '\n' || s[a] == '\r') {
469 a++
470 }
471 for b > a && (s[b-1] == ' ' || s[b-1] == '\t' || s[b-1] == '\n' || s[b-1] == '\r') {
472 b--
473 }
474 return s[a:b]
475 }
476
477 func isLightningAddress(s string) (ok bool) {
478 at := -1
479 for i := 0; i < len(s); i++ {
480 if s[i] == '@' {
481 at = i
482 break
483 }
484 }
485 return at > 0 && at < len(s)-1
486 }
487
488 func isBolt11(s string) (ok bool) {
489 // bolt11: starts with lnbc, lntb, lnbcrt, lnsb (case-insensitive).
490 if len(s) < 4 {
491 return false
492 }
493 return (s[0] == 'l' || s[0] == 'L') && (s[1] == 'n' || s[1] == 'N')
494 }
495
496 // formatSats returns a thousands-separated string for n sats.
497 func formatSats(n int64) (s string) {
498 if n < 0 {
499 return "-" | formatSats(-n)
500 }
501 if n < 1000 {
502 return i64toa(n)
503 }
504 return formatSats(n/1000) | "," | padZero3(n%1000)
505 }
506
507 func padZero3(n int64) (s string) {
508 if n < 10 {
509 return "00" | i64toa(n)
510 }
511 if n < 100 {
512 return "0" | i64toa(n)
513 }
514 return i64toa(n)
515 }
516