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