1from typing import TYPE_CHECKING 2 3from kivy.lang import Builder 4from kivy.factory import Factory 5 6from electrum.gui import messages 7from electrum.gui.kivy.i18n import _ 8from electrum.lnaddr import lndecode 9from electrum.util import bh2u 10from electrum.bitcoin import COIN 11import electrum.simple_config as config 12from electrum.logging import Logger 13from electrum.lnutil import ln_dummy_address, extract_nodeid, ConnStringFormatError 14 15from .label_dialog import LabelDialog 16from .confirm_tx_dialog import ConfirmTxDialog 17from .qr_dialog import QRDialog 18from .question import Question 19 20if TYPE_CHECKING: 21 from ...main_window import ElectrumWindow 22 23 24Builder.load_string(''' 25#:import KIVY_GUI_PATH electrum.gui.kivy.KIVY_GUI_PATH 26 27<LightningOpenChannelDialog@Popup> 28 use_gossip: False 29 id: s 30 name: 'lightning_open_channel' 31 title: _('Open Lightning Channel') 32 pubkey: '' 33 amount: '' 34 is_max: False 35 ipport: '' 36 BoxLayout 37 spacing: '12dp' 38 padding: '12dp' 39 orientation: 'vertical' 40 SendReceiveBlueBottom: 41 id: blue_bottom 42 size_hint: 1, None 43 height: self.minimum_height 44 BoxLayout: 45 size_hint: 1, None 46 height: blue_bottom.item_height 47 Image: 48 source: f'atlas://{KIVY_GUI_PATH}/theming/atlas/light/globe' 49 size_hint: None, None 50 size: '22dp', '22dp' 51 pos_hint: {'center_y': .5} 52 BlueButton: 53 text: s.pubkey if s.pubkey else (_('Node ID') if root.use_gossip else _('Trampoline node')) 54 shorten: True 55 on_release: s.suggest_node() 56 CardSeparator: 57 color: blue_bottom.foreground_color 58 BoxLayout: 59 size_hint: 1, None 60 height: blue_bottom.item_height 61 Image: 62 source: f'atlas://{KIVY_GUI_PATH}/theming/atlas/light/calculator' 63 size_hint: None, None 64 size: '22dp', '22dp' 65 pos_hint: {'center_y': .5} 66 BlueButton: 67 text: s.amount if s.amount else _('Amount') 68 on_release: app.amount_dialog(s, True) 69 TopLabel: 70 text: _('Paste or scan a node ID, a connection string or a lightning invoice.') if root.use_gossip else _('Choose a trampoline node and the amount') 71 BoxLayout: 72 size_hint: 1, None 73 height: '48dp' 74 IconButton: 75 icon: f'atlas://{KIVY_GUI_PATH}/theming/atlas/light/copy' 76 size_hint: 0.5, None 77 height: '48dp' 78 on_release: s.do_paste() 79 disabled: not app.use_gossip 80 IconButton: 81 icon: f'atlas://{KIVY_GUI_PATH}/theming/atlas/light/camera' 82 size_hint: 0.5, None 83 height: '48dp' 84 on_release: app.scan_qr(on_complete=s.on_qr) 85 disabled: not app.use_gossip 86 Button: 87 text: _('Suggest') 88 size_hint: 1, None 89 height: '48dp' 90 on_release: s.suggest_node() 91 Button: 92 text: _('Clear') 93 size_hint: 1, None 94 height: '48dp' 95 on_release: s.do_clear() 96 Widget: 97 size_hint: 1, 1 98 BoxLayout: 99 size_hint: 1, None 100 Widget: 101 size_hint: 2, None 102 Button: 103 text: _('Open') 104 size_hint: 1, None 105 height: '48dp' 106 on_release: s.open_channel() 107 disabled: not root.pubkey or not root.amount 108''') 109 110class LightningOpenChannelDialog(Factory.Popup, Logger): 111 def ipport_dialog(self): 112 def callback(text): 113 self.ipport = text 114 d = LabelDialog(_('IP/port in format:\n[host]:[port]'), self.ipport, callback) 115 d.open() 116 117 def suggest_node(self): 118 if self.use_gossip: 119 suggested = self.app.wallet.lnworker.suggest_peer() 120 if suggested: 121 self.pubkey = suggested.hex() 122 else: 123 _, _, percent = self.app.wallet.network.lngossip.get_sync_progress_estimate() 124 if percent is None: 125 percent = "??" 126 self.pubkey = f"Please wait, graph is updating ({percent}% / 30% done)." 127 else: 128 self.trampoline_index += 1 129 self.trampoline_index = self.trampoline_index % len(self.trampoline_names) 130 self.pubkey = self.trampoline_names[self.trampoline_index] 131 132 def __init__(self, app, lnaddr=None, msg=None): 133 Factory.Popup.__init__(self) 134 Logger.__init__(self) 135 self.app = app # type: ElectrumWindow 136 self.lnaddr = lnaddr 137 self.msg = msg 138 self.use_gossip = bool(self.app.network.channel_db) 139 if not self.use_gossip: 140 from electrum.lnworker import hardcoded_trampoline_nodes 141 self.trampolines = hardcoded_trampoline_nodes() 142 self.trampoline_names = list(self.trampolines.keys()) 143 self.trampoline_index = 0 144 self.pubkey = '' 145 146 def open(self, *args, **kwargs): 147 super(LightningOpenChannelDialog, self).open(*args, **kwargs) 148 if self.lnaddr: 149 fee = self.app.electrum_config.fee_per_kb() 150 if not fee: 151 fee = config.FEERATE_FALLBACK_STATIC_FEE 152 self.amount = self.app.format_amount_and_units(self.lnaddr.amount * COIN + fee * 2) # FIXME magic number?! 153 self.pubkey = bh2u(self.lnaddr.pubkey.serialize()) 154 if self.msg: 155 self.app.show_info(self.msg) 156 157 def do_clear(self): 158 self.pubkey = '' 159 self.amount = '' 160 161 def do_paste(self): 162 contents = self.app._clipboard.paste() 163 if not contents: 164 self.app.show_info(_("Clipboard is empty")) 165 return 166 self.pubkey = contents 167 168 def on_qr(self, conn_str): 169 self.pubkey = conn_str 170 171 # FIXME "max" button in amount_dialog should enforce LN_MAX_FUNDING_SAT 172 def open_channel(self): 173 if not self.pubkey or not self.amount: 174 self.app.show_info(_('All fields must be filled out')) 175 return 176 if self.use_gossip: 177 conn_str = self.pubkey 178 if self.ipport: 179 conn_str += '@' + self.ipport.strip() 180 else: 181 conn_str = str(self.trampolines[self.pubkey]) 182 amount = '!' if self.is_max else self.app.get_amount(self.amount) 183 self.dismiss() 184 lnworker = self.app.wallet.lnworker 185 try: 186 node_id, rest = extract_nodeid(conn_str) 187 except ConnStringFormatError as e: 188 self.app.show_error(_('Problem opening channel: ') + '\n' + str(e)) 189 return 190 if lnworker.has_conflicting_backup_with(node_id): 191 msg = messages.MGS_CONFLICTING_BACKUP_INSTANCE 192 d = Question(msg, lambda x: self._open_channel(x, conn_str, amount)) 193 d.open() 194 else: 195 self._open_channel(True, conn_str, amount) 196 197 def _open_channel(self, x, conn_str, amount): 198 if not x: 199 return 200 lnworker = self.app.wallet.lnworker 201 coins = self.app.wallet.get_spendable_coins(None, nonlocal_only=True) 202 node_id, rest = extract_nodeid(conn_str) 203 make_tx = lambda rbf: lnworker.mktx_for_open_channel( 204 coins=coins, 205 funding_sat=amount, 206 node_id=node_id, 207 fee_est=None) 208 on_pay = lambda tx: self.app.protected('Create a new channel?', self.do_open_channel, (tx, conn_str)) 209 d = ConfirmTxDialog( 210 self.app, 211 amount = amount, 212 make_tx=make_tx, 213 on_pay=on_pay, 214 show_final=False) 215 d.open() 216 217 def do_open_channel(self, funding_tx, conn_str, password): 218 # read funding_sat from tx; converts '!' to int value 219 funding_sat = funding_tx.output_value_for_address(ln_dummy_address()) 220 lnworker = self.app.wallet.lnworker 221 try: 222 chan, funding_tx = lnworker.open_channel( 223 connect_str=conn_str, 224 funding_tx=funding_tx, 225 funding_sat=funding_sat, 226 push_amt_sat=0, 227 password=password) 228 except Exception as e: 229 self.app.logger.exception("Problem opening channel") 230 self.app.show_error(_('Problem opening channel: ') + '\n' + repr(e)) 231 return 232 # TODO: it would be nice to show this before broadcasting 233 if chan.has_onchain_backup(): 234 self.maybe_show_funding_tx(chan, funding_tx) 235 else: 236 title = _('Save backup') 237 help_text = _(messages.MSG_CREATED_NON_RECOVERABLE_CHANNEL) 238 data = lnworker.export_channel_backup(chan.channel_id) 239 popup = QRDialog( 240 title, data, 241 show_text=False, 242 text_for_clipboard=data, 243 help_text=help_text, 244 close_button_text=_('OK'), 245 on_close=lambda: self.maybe_show_funding_tx(chan, funding_tx)) 246 popup.open() 247 248 def maybe_show_funding_tx(self, chan, funding_tx): 249 n = chan.constraints.funding_txn_minimum_depth 250 message = '\n'.join([ 251 _('Channel established.'), 252 _('Remote peer ID') + ':' + chan.node_id.hex(), 253 _('This channel will be usable after {} confirmations').format(n) 254 ]) 255 if not funding_tx.is_complete(): 256 message += '\n\n' + _('Please sign and broadcast the funding transaction') 257 self.app.show_info(message) 258 259 if not funding_tx.is_complete(): 260 self.app.tx_dialog(funding_tx) 261