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