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