tsubmarine_swaps.py - electrum - Electrum Bitcoin wallet
HTML git clone https://git.parazyd.org/electrum
DIR Log
DIR Files
DIR Refs
DIR Submodules
---
tsubmarine_swaps.py (19542B)
---
1 import asyncio
2 import json
3 import os
4 from typing import TYPE_CHECKING, Optional, Dict, Union
5
6 import attr
7
8 from .crypto import sha256, hash_160
9 from .ecc import ECPrivkey
10 from .bitcoin import (script_to_p2wsh, opcodes, p2wsh_nested_script, push_script,
11 is_segwit_address, construct_witness)
12 from .transaction import PartialTxInput, PartialTxOutput, PartialTransaction
13 from .transaction import script_GetOp, match_script_against_template, OPPushDataGeneric, OPPushDataPubkey
14 from .util import log_exceptions
15 from .lnutil import REDEEM_AFTER_DOUBLE_SPENT_DELAY, ln_dummy_address, LN_MAX_HTLC_VALUE_MSAT
16 from .bitcoin import dust_threshold
17 from .logging import Logger
18 from .lnutil import hex_to_bytes
19 from .json_db import StoredObject
20 from . import constants
21
22 if TYPE_CHECKING:
23 from .network import Network
24 from .wallet import Abstract_Wallet
25 from .lnwatcher import LNWalletWatcher
26 from .lnworker import LNWallet
27
28
29 API_URL_MAINNET = 'https://swaps.electrum.org/api'
30 API_URL_TESTNET = 'https://swaps.electrum.org/testnet'
31 API_URL_REGTEST = 'https://localhost/api'
32
33
34
35 WITNESS_TEMPLATE_SWAP = [
36 opcodes.OP_HASH160,
37 OPPushDataGeneric(lambda x: x == 20),
38 opcodes.OP_EQUAL,
39 opcodes.OP_IF,
40 OPPushDataPubkey,
41 opcodes.OP_ELSE,
42 OPPushDataGeneric(None),
43 opcodes.OP_CHECKLOCKTIMEVERIFY,
44 opcodes.OP_DROP,
45 OPPushDataPubkey,
46 opcodes.OP_ENDIF,
47 opcodes.OP_CHECKSIG
48 ]
49
50
51 # The script of the reverse swaps has one extra check in it to verify
52 # that the length of the preimage is 32. This is required because in
53 # the reverse swaps the preimage is generated by the user and to
54 # settle the hold invoice, you need a preimage with 32 bytes . If that
55 # check wasn't there the user could generate a preimage with a
56 # different length which would still allow for claiming the onchain
57 # coins but the invoice couldn't be settled
58
59 WITNESS_TEMPLATE_REVERSE_SWAP = [
60 opcodes.OP_SIZE,
61 OPPushDataGeneric(None),
62 opcodes.OP_EQUAL,
63 opcodes.OP_IF,
64 opcodes.OP_HASH160,
65 OPPushDataGeneric(lambda x: x == 20),
66 opcodes.OP_EQUALVERIFY,
67 OPPushDataPubkey,
68 opcodes.OP_ELSE,
69 opcodes.OP_DROP,
70 OPPushDataGeneric(None),
71 opcodes.OP_CHECKLOCKTIMEVERIFY,
72 opcodes.OP_DROP,
73 OPPushDataPubkey,
74 opcodes.OP_ENDIF,
75 opcodes.OP_CHECKSIG
76 ]
77
78
79 @attr.s
80 class SwapData(StoredObject):
81 is_reverse = attr.ib(type=bool)
82 locktime = attr.ib(type=int)
83 onchain_amount = attr.ib(type=int) # in sats
84 lightning_amount = attr.ib(type=int) # in sats
85 redeem_script = attr.ib(type=bytes, converter=hex_to_bytes)
86 preimage = attr.ib(type=bytes, converter=hex_to_bytes)
87 prepay_hash = attr.ib(type=Optional[bytes], converter=hex_to_bytes)
88 privkey = attr.ib(type=bytes, converter=hex_to_bytes)
89 lockup_address = attr.ib(type=str)
90 funding_txid = attr.ib(type=Optional[str])
91 spending_txid = attr.ib(type=Optional[str])
92 is_redeemed = attr.ib(type=bool)
93
94
95 def create_claim_tx(
96 *,
97 txin: PartialTxInput,
98 witness_script: bytes,
99 preimage: Union[bytes, int], # 0 if timing out forward-swap
100 privkey: bytes,
101 address: str,
102 amount_sat: int,
103 locktime: int,
104 ) -> PartialTransaction:
105 """Create tx to either claim successful reverse-swap,
106 or to get refunded for timed-out forward-swap.
107 """
108 if is_segwit_address(txin.address):
109 txin.script_type = 'p2wsh'
110 txin.script_sig = b''
111 else:
112 txin.script_type = 'p2wsh-p2sh'
113 txin.redeem_script = bytes.fromhex(p2wsh_nested_script(witness_script.hex()))
114 txin.script_sig = bytes.fromhex(push_script(txin.redeem_script.hex()))
115 txin.witness_script = witness_script
116 txout = PartialTxOutput.from_address_and_value(address, amount_sat)
117 tx = PartialTransaction.from_io([txin], [txout], version=2, locktime=locktime)
118 #tx.set_rbf(True)
119 sig = bytes.fromhex(tx.sign_txin(0, privkey))
120 witness = [sig, preimage, witness_script]
121 txin.witness = bytes.fromhex(construct_witness(witness))
122 return tx
123
124
125 class SwapManager(Logger):
126
127 network: Optional['Network'] = None
128 lnwatcher: Optional['LNWalletWatcher'] = None
129
130 def __init__(self, *, wallet: 'Abstract_Wallet', lnworker: 'LNWallet'):
131 Logger.__init__(self)
132 self.normal_fee = 0
133 self.lockup_fee = 0
134 self.percentage = 0
135 self.min_amount = 0
136 self._max_amount = 0
137 self.wallet = wallet
138 self.lnworker = lnworker
139 self.swaps = self.wallet.db.get_dict('submarine_swaps') # type: Dict[str, SwapData]
140 self.prepayments = {} # type: Dict[bytes, bytes] # fee_preimage -> preimage
141 for k, swap in self.swaps.items():
142 if swap.is_reverse and swap.prepay_hash is not None:
143 self.prepayments[swap.prepay_hash] = bytes.fromhex(k)
144 # api url
145 if constants.net == constants.BitcoinMainnet:
146 self.api_url = API_URL_MAINNET
147 elif constants.net == constants.BitcoinTestnet:
148 self.api_url = API_URL_TESTNET
149 else:
150 self.api_url = API_URL_REGTEST
151
152 def start_network(self, *, network: 'Network', lnwatcher: 'LNWalletWatcher'):
153 assert network
154 assert lnwatcher
155 self.network = network
156 self.lnwatcher = lnwatcher
157 for k, swap in self.swaps.items():
158 if swap.is_redeemed:
159 continue
160 self.add_lnwatcher_callback(swap)
161
162 @log_exceptions
163 async def _claim_swap(self, swap: SwapData) -> None:
164 assert self.network
165 assert self.lnwatcher
166 if not self.lnwatcher.is_up_to_date():
167 return
168 current_height = self.network.get_local_height()
169 delta = current_height - swap.locktime
170 if not swap.is_reverse and delta < 0:
171 # too early for refund
172 return
173 txos = self.lnwatcher.get_addr_outputs(swap.lockup_address)
174 for txin in txos.values():
175 if swap.is_reverse and txin.value_sats() < swap.onchain_amount:
176 self.logger.info('amount too low, we should not reveal the preimage')
177 continue
178 spent_height = txin.spent_height
179 if spent_height is not None:
180 if spent_height > 0 and current_height - spent_height > REDEEM_AFTER_DOUBLE_SPENT_DELAY:
181 self.logger.info(f'stop watching swap {swap.lockup_address}')
182 self.lnwatcher.remove_callback(swap.lockup_address)
183 swap.is_redeemed = True
184 continue
185 amount_sat = txin.value_sats() - self.get_claim_fee()
186 if amount_sat < dust_threshold():
187 self.logger.info('utxo value below dust threshold')
188 continue
189 address = self.wallet.get_receiving_address()
190 if swap.is_reverse: # successful reverse swap
191 preimage = swap.preimage
192 locktime = 0
193 else: # timing out forward swap
194 preimage = 0
195 locktime = swap.locktime
196 tx = create_claim_tx(
197 txin=txin,
198 witness_script=swap.redeem_script,
199 preimage=preimage,
200 privkey=swap.privkey,
201 address=address,
202 amount_sat=amount_sat,
203 locktime=locktime,
204 )
205 await self.network.broadcast_transaction(tx)
206 # save txid
207 if swap.is_reverse:
208 swap.spending_txid = tx.txid()
209 else:
210 self.wallet.set_label(tx.txid(), 'Swap refund')
211
212 def get_claim_fee(self):
213 return self.wallet.config.estimate_fee(136, allow_fallback_to_static_rates=True)
214
215 def get_swap(self, payment_hash: bytes) -> Optional[SwapData]:
216 # for history
217 swap = self.swaps.get(payment_hash.hex())
218 if swap:
219 return swap
220 payment_hash = self.prepayments.get(payment_hash)
221 if payment_hash:
222 return self.swaps.get(payment_hash.hex())
223
224 def add_lnwatcher_callback(self, swap: SwapData) -> None:
225 callback = lambda: self._claim_swap(swap)
226 self.lnwatcher.add_callback(swap.lockup_address, callback)
227
228 async def normal_swap(
229 self,
230 *,
231 lightning_amount_sat: int,
232 expected_onchain_amount_sat: int,
233 password,
234 tx: PartialTransaction = None,
235 ) -> str:
236 """send on-chain BTC, receive on Lightning
237
238 - User generates an LN invoice with RHASH, and knows preimage.
239 - User creates on-chain output locked to RHASH.
240 - Server pays LN invoice. User reveals preimage.
241 - Server spends the on-chain output using preimage.
242 """
243 assert self.network
244 assert self.lnwatcher
245 privkey = os.urandom(32)
246 pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True)
247 lnaddr, invoice = await self.lnworker.create_invoice(
248 amount_msat=lightning_amount_sat * 1000,
249 message='swap',
250 expiry=3600 * 24,
251 )
252 payment_hash = lnaddr.paymenthash
253 preimage = self.lnworker.get_preimage(payment_hash)
254 request_data = {
255 "type": "submarine",
256 "pairId": "BTC/BTC",
257 "orderSide": "sell",
258 "invoice": invoice,
259 "refundPublicKey": pubkey.hex()
260 }
261 response = await self.network._send_http_on_proxy(
262 'post',
263 self.api_url + '/createswap',
264 json=request_data,
265 timeout=30)
266 data = json.loads(response)
267 response_id = data["id"]
268 zeroconf = data["acceptZeroConf"]
269 onchain_amount = data["expectedAmount"]
270 locktime = data["timeoutBlockHeight"]
271 lockup_address = data["address"]
272 redeem_script = data["redeemScript"]
273 # verify redeem_script is built with our pubkey and preimage
274 redeem_script = bytes.fromhex(redeem_script)
275 parsed_script = [x for x in script_GetOp(redeem_script)]
276 if not match_script_against_template(redeem_script, WITNESS_TEMPLATE_SWAP):
277 raise Exception("fswap check failed: scriptcode does not match template")
278 if script_to_p2wsh(redeem_script.hex()) != lockup_address:
279 raise Exception("fswap check failed: inconsistent scriptcode and address")
280 if hash_160(preimage) != parsed_script[1][1]:
281 raise Exception("fswap check failed: our preimage not in script")
282 if pubkey != parsed_script[9][1]:
283 raise Exception("fswap check failed: our pubkey not in script")
284 if locktime != int.from_bytes(parsed_script[6][1], byteorder='little'):
285 raise Exception("fswap check failed: inconsistent locktime and script")
286 # check that onchain_amount is not more than what we estimated
287 if onchain_amount > expected_onchain_amount_sat:
288 raise Exception(f"fswap check failed: onchain_amount is more than what we estimated: "
289 f"{onchain_amount} > {expected_onchain_amount_sat}")
290 # verify that they are not locking up funds for more than a day
291 if locktime - self.network.get_local_height() >= 144:
292 raise Exception("fswap check failed: locktime too far in future")
293 # create funding tx
294 funding_output = PartialTxOutput.from_address_and_value(lockup_address, onchain_amount)
295 if tx is None:
296 tx = self.wallet.create_transaction(outputs=[funding_output], rbf=False, password=password)
297 else:
298 dummy_output = PartialTxOutput.from_address_and_value(ln_dummy_address(), expected_onchain_amount_sat)
299 tx.outputs().remove(dummy_output)
300 tx.add_outputs([funding_output])
301 tx.set_rbf(False)
302 self.wallet.sign_transaction(tx, password)
303 # save swap data in wallet in case we need a refund
304 swap = SwapData(
305 redeem_script = redeem_script,
306 locktime = locktime,
307 privkey = privkey,
308 preimage = preimage,
309 prepay_hash = None,
310 lockup_address = lockup_address,
311 onchain_amount = expected_onchain_amount_sat,
312 lightning_amount = lightning_amount_sat,
313 is_reverse = False,
314 is_redeemed = False,
315 funding_txid = tx.txid(),
316 spending_txid = None,
317 )
318 self.swaps[payment_hash.hex()] = swap
319 self.add_lnwatcher_callback(swap)
320 await self.network.broadcast_transaction(tx)
321 return tx.txid()
322
323 async def reverse_swap(
324 self,
325 *,
326 lightning_amount_sat: int,
327 expected_onchain_amount_sat: int,
328 ) -> bool:
329 """send on Lightning, receive on-chain
330
331 - User generates preimage, RHASH. Sends RHASH to server.
332 - Server creates an LN invoice for RHASH.
333 - User pays LN invoice - except server needs to hold the HTLC as preimage is unknown.
334 - Server creates on-chain output locked to RHASH.
335 - User spends on-chain output, revealing preimage.
336 - Server fulfills HTLC using preimage.
337 """
338 assert self.network
339 assert self.lnwatcher
340 privkey = os.urandom(32)
341 pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True)
342 preimage = os.urandom(32)
343 preimage_hash = sha256(preimage)
344 request_data = {
345 "type": "reversesubmarine",
346 "pairId": "BTC/BTC",
347 "orderSide": "buy",
348 "invoiceAmount": lightning_amount_sat,
349 "preimageHash": preimage_hash.hex(),
350 "claimPublicKey": pubkey.hex()
351 }
352 response = await self.network._send_http_on_proxy(
353 'post',
354 self.api_url + '/createswap',
355 json=request_data,
356 timeout=30)
357 data = json.loads(response)
358 invoice = data['invoice']
359 fee_invoice = data.get('minerFeeInvoice')
360 lockup_address = data['lockupAddress']
361 redeem_script = data['redeemScript']
362 locktime = data['timeoutBlockHeight']
363 onchain_amount = data["onchainAmount"]
364 response_id = data['id']
365 # verify redeem_script is built with our pubkey and preimage
366 redeem_script = bytes.fromhex(redeem_script)
367 parsed_script = [x for x in script_GetOp(redeem_script)]
368 if not match_script_against_template(redeem_script, WITNESS_TEMPLATE_REVERSE_SWAP):
369 raise Exception("rswap check failed: scriptcode does not match template")
370 if script_to_p2wsh(redeem_script.hex()) != lockup_address:
371 raise Exception("rswap check failed: inconsistent scriptcode and address")
372 if hash_160(preimage) != parsed_script[5][1]:
373 raise Exception("rswap check failed: our preimage not in script")
374 if pubkey != parsed_script[7][1]:
375 raise Exception("rswap check failed: our pubkey not in script")
376 if locktime != int.from_bytes(parsed_script[10][1], byteorder='little'):
377 raise Exception("rswap check failed: inconsistent locktime and script")
378 # check that the onchain amount is what we expected
379 if onchain_amount < expected_onchain_amount_sat:
380 raise Exception(f"rswap check failed: onchain_amount is less than what we expected: "
381 f"{onchain_amount} < {expected_onchain_amount_sat}")
382 # verify that we will have enough time to get our tx confirmed
383 if locktime - self.network.get_local_height() <= 60:
384 raise Exception("rswap check failed: locktime too close")
385 # verify invoice preimage_hash
386 lnaddr = self.lnworker._check_invoice(invoice)
387 invoice_amount = lnaddr.get_amount_sat()
388 if lnaddr.paymenthash != preimage_hash:
389 raise Exception("rswap check failed: inconsistent RHASH and invoice")
390 # check that the lightning amount is what we requested
391 if fee_invoice:
392 fee_lnaddr = self.lnworker._check_invoice(fee_invoice)
393 invoice_amount += fee_lnaddr.get_amount_sat()
394 prepay_hash = fee_lnaddr.paymenthash
395 else:
396 prepay_hash = None
397 if int(invoice_amount) != lightning_amount_sat:
398 raise Exception(f"rswap check failed: invoice_amount ({invoice_amount}) "
399 f"not what we requested ({lightning_amount_sat})")
400 # save swap data to wallet file
401 swap = SwapData(
402 redeem_script = redeem_script,
403 locktime = locktime,
404 privkey = privkey,
405 preimage = preimage,
406 prepay_hash = prepay_hash,
407 lockup_address = lockup_address,
408 onchain_amount = onchain_amount,
409 lightning_amount = lightning_amount_sat,
410 is_reverse = True,
411 is_redeemed = False,
412 funding_txid = None,
413 spending_txid = None,
414 )
415 self.swaps[preimage_hash.hex()] = swap
416 # add callback to lnwatcher
417 self.add_lnwatcher_callback(swap)
418 # initiate payment.
419 if fee_invoice:
420 self.prepayments[prepay_hash] = preimage_hash
421 asyncio.ensure_future(self.lnworker.pay_invoice(fee_invoice, attempts=10))
422 # initiate payment.
423 success, log = await self.lnworker.pay_invoice(invoice, attempts=10)
424 return success
425
426 async def get_pairs(self) -> None:
427 assert self.network
428 response = await self.network._send_http_on_proxy(
429 'get',
430 self.api_url + '/getpairs',
431 timeout=30)
432 pairs = json.loads(response)
433 fees = pairs['pairs']['BTC/BTC']['fees']
434 self.percentage = fees['percentage']
435 self.normal_fee = fees['minerFees']['baseAsset']['normal']
436 self.lockup_fee = fees['minerFees']['baseAsset']['reverse']['lockup']
437 limits = pairs['pairs']['BTC/BTC']['limits']
438 self.min_amount = limits['minimal']
439 self._max_amount = limits['maximal']
440
441 def get_max_amount(self):
442 return min(self._max_amount, LN_MAX_HTLC_VALUE_MSAT // 1000)
443
444 def check_invoice_amount(self, x):
445 return x >= self.min_amount and x <= self._max_amount
446
447 def get_recv_amount(self, send_amount: Optional[int], is_reverse: bool) -> Optional[int]:
448 if send_amount is None:
449 return
450 x = send_amount
451 if is_reverse:
452 if not self.check_invoice_amount(x):
453 return
454 x = int(x * (100 - self.percentage) / 100)
455 x -= self.lockup_fee
456 x -= self.get_claim_fee()
457 if x < dust_threshold():
458 return
459 else:
460 x -= self.normal_fee
461 x = int(x / ((100 + self.percentage) / 100))
462 if not self.check_invoice_amount(x):
463 return
464 return x
465
466 def get_send_amount(self, recv_amount: Optional[int], is_reverse: bool) -> Optional[int]:
467 if not recv_amount:
468 return
469 x = recv_amount
470 if is_reverse:
471 x += self.lockup_fee
472 x += self.get_claim_fee()
473 x = int(x * 100 / (100 - self.percentage)) + 1
474 if not self.check_invoice_amount(x):
475 return
476 else:
477 if not self.check_invoice_amount(x):
478 return
479 x = int(x * 100 / (100 + self.percentage)) + 1
480 x += self.normal_fee
481 return x
482