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