Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions electrum/lnworker.py
Original file line number Diff line number Diff line change
Expand Up @@ -1865,6 +1865,7 @@ async def pay_invoice(
full_path: LNPaymentPath = None,
channels: Optional[Sequence[Channel]] = None, # my own direct channels
budget: Optional[PaymentFeeBudget] = None, # to limit max fee
probe_only: bool = False, # checks if a route for the payment exists, without actually sending the payment
) -> Tuple[bool, List[HtlcLog]]:
"""Attempt to pay a Lightning invoice (find routes, do MPP, send HTLCs).

Expand Down Expand Up @@ -1909,7 +1910,7 @@ async def pay_invoice(
attempts = 30
success = False
try:
await self.pay_to_node(
probe_only_result = await self.pay_to_node(
node_pubkey=invoice_pubkey,
payment_hash=payment_hash,
payment_secret=payment_secret,
Expand All @@ -1921,7 +1922,12 @@ async def pay_invoice(
full_path=full_path,
channels=channels,
budget=budget,
probe_only=probe_only,
)

if probe_only:
return probe_only_result, []

success = True
except PaymentFailure as e:
self.logger.info(f'payment failure: {e!r}')
Expand Down Expand Up @@ -1956,10 +1962,13 @@ async def pay_to_node(
budget: PaymentFeeBudget,
channels: Optional[Sequence[Channel]] = None,
fw_payment_key: str = None, # for forwarding
) -> None:
probe_only: bool = False, # checks if a route for the payment exists, without actually sending the payment
) -> bool:
"""
Can raise PaymentFailure, ChannelDBNotLoaded,
or OnionRoutingFailure (if forwarding trampoline).

returns bool: In case of probe_only, true/false denoting if a route to the destination exists.
"""

assert budget
Expand Down Expand Up @@ -2018,15 +2027,25 @@ async def pay_to_node(
channels=channels,
budget=budget._replace(fee_msat=remaining_fee_budget_msat),
)

can_route = False

# 2. send htlcs
async for sent_htlc_info, cltv_delta, trampoline_onion in routes:
if probe_only:
can_route = True
continue

await self.pay_to_route(
paysession=paysession,
sent_htlc_info=sent_htlc_info,
min_final_cltv_delta=cltv_delta,
trampoline_onion=trampoline_onion,
fw_payment_key=fw_payment_key,
)

if probe_only:
return can_route
Comment on lines +2030 to +2048
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you are not actually probing? HTLCs are not sent, this is just testing if the offline path finder can create some routes at all.

E.g. if Alice is trying to pay Bob, this is just testing that Alice has enough outbound capacity on the direct channel, and that some path can be found in the graph from Alice to Bob based on the gossip and the invoice r hints only. If Alice is using trampoline, it is checking even less, as Alice does not even have the graph.

Consider that Bob has a channel with the ACINQ node, and Alice has a channel with the "Electrum Trampoline" node. Bob likely only generates a bolt11 invoice if the Bob<->ACINQ channel has liquidity. Similarly, Alice knows if the Alice<->"Electrum Trampoline" channel has liquidity. Whether there is liquidity on any path from "Electrum Trampoline" to ACINQ is not checked here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you are not actually probing? HTLCs are not sent, this is just testing if the offline path finder can create some routes at all.

Yes, the check is very basic. It was somewhat intentional to get some feedback.

E.g. if Alice is trying to pay Bob, this is just testing that Alice has enough outbound capacity on the direct channel, and that some path can be found in the graph from Alice to Bob based on the gossip and the invoice r hints only. If Alice is using trampoline, it is checking even less, as Alice does not even have the graph.

Consider that Bob has a channel with the ACINQ node, and Alice has a channel with the "Electrum Trampoline" node. Bob likely only generates a bolt11 invoice if the Bob<->ACINQ channel has liquidity. Similarly, Alice knows if the Alice<->"Electrum Trampoline" channel has liquidity. Whether there is liquidity on any path from "Electrum Trampoline" to ACINQ is not checked here.

Thank you for the elaboration. Please help me understand if you find this to be a hard requirement for this PR, or not. I'm not sure how to add this behavior at the moment (perhaps @dejfcz does). If you do know how to add it, any pointer would be very helpful and much appreciated. It would save us time. :)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some path can be found in the graph from Alice to Bob based on the gossip

Yes. The suggestion is to start with something trivial, which can later be improved by someone with deeper knowledge of Electrum code base. As it is now, it does prevent accepting swap in some scenarios, but of course full probing would be much better. So it is more about setting up the interface (probe_only parameter) for now.

# invoice_status is triggered in self.set_invoice_status when it actually changes.
# It is also triggered here to update progress for a lightning payment in the GUI
# (e.g. attempt counter)
Expand All @@ -2050,11 +2069,18 @@ async def pay_to_node(
# max attempts or timeout
if (attempts is not None and len(log) >= attempts) or (attempts is None and time.time() - paysession.start_time > self.PAYMENT_TIMEOUT):
raise PaymentFailure('Giving up after %d attempts'%len(log))

return False
except PaymentSuccess:
pass
except Exception:
if probe_only:
return False

raise
finally:
paysession.is_active = False
if paysession.can_be_deleted():
if probe_only or paysession.can_be_deleted():
self._paysessions.pop(payment_key)
paysession.logger.info(f"pay_to_node ending session for RHASH={payment_hash.hex()}")

Expand Down
182 changes: 174 additions & 8 deletions electrum/submarine_swaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
run_sync_function_on_asyncio_thread, trigger_callback, NoDynamicFeeEstimates, UserFacingException,
)
from . import lnutil
from .lnutil import hex_to_bytes, REDEEM_AFTER_DOUBLE_SPENT_DELAY, Keypair
from .lnutil import hex_to_bytes, REDEEM_AFTER_DOUBLE_SPENT_DELAY, Keypair, NoPathFound
from .bolt11 import decode_bolt11_invoice
from .stored_dict import StoredObject, stored_at
from . import constants
Expand Down Expand Up @@ -79,7 +79,7 @@
# different length which would still allow for claiming the onchain
# coins but the invoice couldn't be settled

# Unified witness-script for all swaps. Historically with Boltz-backend, this was the reverse-swap script.
# Witness-script for all reverse swaps and the new normal swap following the new flow. Historically with Boltz-backend, this was the reverse-swap script.
WITNESS_TEMPLATE_SWAP = [
opcodes.OP_SIZE,
OPPushDataGeneric(None), # idx 1. length of preimage
Expand All @@ -99,6 +99,24 @@
opcodes.OP_CHECKSIG
]

# Witness-script for old forward swap flow.
WITNESS_TEMPLATE_SWAP_V1 = [
opcodes.OP_HASH160,
OPPushDataGeneric(lambda x: x == 20), # idx 1. payment_hash
opcodes.OP_EQUAL,
opcodes.OP_IF,
OPPushDataPubkey, # idx 4. server_pubkey
opcodes.OP_ELSE,
OPPushDataGeneric(None), # idx 6. locktime
opcodes.OP_CHECKLOCKTIMEVERIFY,
opcodes.OP_DROP,
OPPushDataPubkey, # idx 9. client_pubkey
opcodes.OP_ENDIF,
opcodes.OP_CHECKSIG
]

CAP_FORWARD_V1 = "forwardv1" # supports forward swaps with v1 flow


def _check_swap_scriptcode(
*,
Expand Down Expand Up @@ -150,6 +168,21 @@ def _construct_swap_scriptcode(
values={1: 32, 5: ripemd(payment_hash), 7: claim_pubkey, 10: locktime, 13: refund_pubkey}
)

def _construct_swap_scriptcode_v1(
payment_hash: bytes,
locktime: int,
server_pubkey: bytes,
client_pubkey: bytes,
) -> bytes:
assert isinstance(payment_hash, bytes) and len(payment_hash) == 32
assert isinstance(locktime, int) and (0 <= locktime <= bitcoin.NLOCKTIME_BLOCKHEIGHT_MAX)
assert isinstance(server_pubkey, bytes) and len(server_pubkey) == 33
assert isinstance(client_pubkey, bytes) and len(client_pubkey) == 33
return construct_script(
WITNESS_TEMPLATE_SWAP_V1,
values={1: ripemd(payment_hash), 4: server_pubkey, 6: locktime, 9: client_pubkey}
)


class SwapServerError(Exception):
def __init__(self, message=None):
Expand Down Expand Up @@ -781,6 +814,55 @@ def create_reverse_swap(self, *, lightning_amount_sat: int, their_pubkey: bytes)
lightning_amount_sat=lightning_amount_sat)
return swap

async def create_reverse_swap_v1(self, *, invoice: str, refund_pubkey: bytes) -> SwapData:
""" server method for v1 workflow:

- User generates an LN invoice with RHASH, and knows preimage.
- User creates on-chain output locked to RHASH.
- Server pays LN invoice. By completing payment, user reveals preimage.
- Server spends the on-chain output using preimage.
"""

height = self.network.get_local_height()
locktime = height + LOCKTIME_DELTA_REFUND
if self.network.blockchain().is_tip_stale():
raise Exception("our blockchain tip is stale")
lnaddr = lndecode(invoice)
payment_hash = lnaddr.paymenthash
lightning_amount_sat = int(lnaddr.get_amount_sat()) # should return int

privkey = os.urandom(32)
our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True)
onchain_amount_sat = self._get_send_amount(lightning_amount_sat, is_reverse=False)

if not onchain_amount_sat:
raise Exception("no onchain amount")

success, log = await self.lnworker.pay_invoice(
Invoice.from_bech32(invoice),
probe_only=True
)

if not success:
raise NoPathFound("no LN route to pay the invoice found")

self.logger.info(f'client requested forward swap; lightning_amount_sat={lightning_amount_sat}, '
f'onchain_amount_sat={onchain_amount_sat}, height={height}, locktime={locktime}')
redeem_script = _construct_swap_scriptcode_v1(
payment_hash=payment_hash,
locktime=locktime,
server_pubkey=our_pubkey,
client_pubkey=refund_pubkey,
)
swap = self.add_reverse_swap_v1(
redeem_script=redeem_script,
locktime=locktime,
privkey=privkey,
payment_hash=payment_hash,
onchain_amount_sat=onchain_amount_sat,
lightning_amount_sat=lightning_amount_sat)
return swap

def add_reverse_swap(
self,
*,
Expand Down Expand Up @@ -827,6 +909,40 @@ def add_reverse_swap(
self.add_lnwatcher_callback(swap)
return swap

def add_reverse_swap_v1(
self,
*,
redeem_script: bytes,
locktime: int, # onchain
privkey: bytes,
lightning_amount_sat: int,
onchain_amount_sat: int,
payment_hash: bytes
) -> SwapData:
if payment_hash.hex() in self._swaps:
raise Exception("payment_hash already in use")

lockup_address = script_to_p2wsh(redeem_script)
swap = SwapData(
redeem_script=redeem_script,
locktime=locktime,
privkey=privkey,
preimage=None,
prepay_hash=None,
lockup_address=lockup_address,
onchain_amount=onchain_amount_sat,
claim_to_output=None,
lightning_amount=lightning_amount_sat,
is_reverse=True,
is_redeemed=False,
funding_txid=None,
spending_txid=None,
)
swap._payment_hash = payment_hash
self._add_or_reindex_swap(swap, is_new=True)
self.add_lnwatcher_callback(swap)
return swap

def server_add_swap_invoice(self, request: dict) -> dict:
""" server method.
(client-forward-swap phase2)
Expand Down Expand Up @@ -856,6 +972,37 @@ def server_add_swap_invoice(self, request: dict) -> dict:
self.wallet.save_invoice(invoice)
return {}

def server_add_swap_invoice_v1(self, invoice: str, refundPublicKey: str) -> dict:
""" server method.
(client-forward-swap v1)
"""
invoice = Invoice.from_bech32(invoice)
key = invoice.rhash
payment_hash = bytes.fromhex(key)
their_pubkey = bytes.fromhex(refundPublicKey)
with self.swaps_lock:
assert key in self._swaps
swap = self._swaps[key]
self.logger.info(f'server_add_swap_invoice: found swap is: {swap}')

assert swap.lightning_amount == int(invoice.get_amount_sat())
assert swap.is_reverse is True
assert swap.spending_txid is None
# check their_pubkey by recalculating redeem_script
our_pubkey = ECPrivkey(swap.privkey).get_public_key_bytes(compressed=True)
redeem_script = _construct_swap_scriptcode_v1(
payment_hash=payment_hash, locktime=swap.locktime, server_pubkey=our_pubkey, client_pubkey=their_pubkey,
)
self.logger.info(f'server_add_swap_invoice: redeem scripts:')
self.logger.info(f'server_add_swap_invoice: - {redeem_script}')
self.logger.info(f'server_add_swap_invoice: - {swap.redeem_script}')
assert swap.redeem_script == redeem_script
assert key not in self.invoices_to_pay

assert self.wallet.get_invoice(invoice.get_id()) is None
self.wallet.save_invoice(invoice)
return {}

async def normal_swap(
self,
*,
Expand Down Expand Up @@ -1438,13 +1585,12 @@ def server_create_normal_swap(self, request):
}
return response

def server_create_swap(self, request):
# reverse for client, forward for server
# requesting a normal swap (old protocol) will raise an exception
async def server_create_swap(self, request):
#request = await r.json()
req_type = request['type']
assert request['pairId'] == 'BTC/BTC'
if req_type == 'reversesubmarine':
# reverse for client, forward for server
lightning_amount_sat=request['invoiceAmount']
payment_hash=bytes.fromhex(request['preimageHash'])
their_pubkey=bytes.fromhex(request['claimPublicKey'])
Expand All @@ -1465,7 +1611,26 @@ def server_create_swap(self, request):
"onchainAmount": swap.onchain_amount,
}
elif req_type == 'submarine':
raise Exception('Deprecated API. Please upgrade your version of Electrum')
# client is doing a normal swap (old protocol)
their_invoice = request['invoice']
refund_pubkey = bytes.fromhex(request['refundPublicKey'])
assert len(refund_pubkey) == 33

swap = await self.create_reverse_swap_v1(
invoice=their_invoice,
refund_pubkey=refund_pubkey
)

self.server_add_swap_invoice_v1(request['invoice'], request['refundPublicKey'])

response = {
"id": swap.payment_hash.hex(),
"acceptZeroConf": False,
"expectedAmount": swap.onchain_amount,
"timeoutBlockHeight": swap.locktime,
"address": swap.lockup_address,
"redeemScript": swap.redeem_script.hex()
}
else:
raise Exception('unsupported request type:' + req_type)
return response
Expand Down Expand Up @@ -1785,6 +1950,7 @@ async def publish_offer(self, sm: 'SwapManager') -> None:
'max_reverse_amount': sm._max_reverse,
'relays': sm.config.NOSTR_RELAYS,
'pow_nonce': hex(sm.config.SWAPSERVER_ANN_POW_NONCE),
'capabilities': [ CAP_FORWARD_V1 ], # announce support for the old forward swap protocol flow
}
# the first value of a single letter tag is indexed and can be filtered for
tags = [['d', f'electrum-swapserver-{self.NOSTR_EVENT_VERSION}'],
Expand Down Expand Up @@ -1985,8 +2151,8 @@ async def _handle_requests(self) -> None:
self.logger.info(f'handle_request: id={event_id} {method} {request}')
if method == 'addswapinvoice': # client-forward-swap phase2
r = self.sm.server_add_swap_invoice(request)
elif method == 'createswap': # client-reverse-swap
r = self.sm.server_create_swap(request)
elif method == 'createswap': # v1: client-forward-swap & client-reverse-swap
r = await self.sm.server_create_swap(request)
elif method == 'createnormalswap': # client-forward-swap phase1
r = self.sm.server_create_normal_swap(request)
else:
Expand Down