1
0

Merge remote-tracking branch 'spesmilo/pr/9993': lnurl-withdraw

ref https://github.com/spesmilo/electrum/pull/9993
This commit is contained in:
SomberNight
2025-10-02 23:34:40 +00:00
13 changed files with 942 additions and 122 deletions

View File

@@ -20,3 +20,85 @@ class TestLnurl(TestCase):
def test_lightning_address_to_url(self):
url = lnurl.lightning_address_to_url("mempool@jhoenicke.de")
self.assertEqual("https://jhoenicke.de/.well-known/lnurlp/mempool", url)
def test_parse_lnurl3_response(self):
# Test successful parsing with all fields
sample_response = {
'callback': 'https://service.io/withdraw?sessionid=123',
'k1': 'abcdef1234567890',
'defaultDescription': 'Withdraw from service',
'minWithdrawable': 10_000_000,
'maxWithdrawable': 100_000_000,
}
result = lnurl._parse_lnurl3_response(sample_response)
self.assertEqual('https://service.io/withdraw?sessionid=123', result.callback_url)
self.assertEqual('abcdef1234567890', result.k1)
self.assertEqual('Withdraw from service', result.default_description)
self.assertEqual(10_000, result.min_withdrawable_sat)
self.assertEqual(100_000, result.max_withdrawable_sat)
# Test with .onion URL
onion_response = {
'callback': 'http://robosatsy56bwqn56qyadmcxkx767hnabg4mihxlmgyt6if5gnuxvzad.onion/withdraw?sessionid=123',
'k1': 'abcdef1234567890',
'minWithdrawable': 10_000_000,
'maxWithdrawable': 100_000_000
}
result = lnurl._parse_lnurl3_response(onion_response)
self.assertEqual('http://robosatsy56bwqn56qyadmcxkx767hnabg4mihxlmgyt6if5gnuxvzad.onion/withdraw?sessionid=123',
result.callback_url)
self.assertEqual('', result.default_description) # Missing defaultDescription uses empty string
# Test missing callback (should raise error)
no_callback_response = {
'k1': 'abcdef1234567890',
'minWithdrawable': 10_000_000,
'maxWithdrawable': 100_000_000
}
with self.assertRaises(lnurl.LNURLError):
lnurl._parse_lnurl3_response(no_callback_response)
# Test unsafe callback URL
unsafe_response = {
'callback': 'http://service.io/withdraw?sessionid=123', # HTTP URL
'k1': 'abcdef1234567890',
'minWithdrawable': 10_000_000,
'maxWithdrawable': 100_000_000
}
with self.assertRaises(lnurl.LNURLError):
lnurl._parse_lnurl3_response(unsafe_response)
# Test missing k1 (should raise error)
no_k1_response = {
'callback': 'https://service.io/withdraw?sessionid=123',
'minWithdrawable': 10_000_000,
'maxWithdrawable': 100_000_000
}
with self.assertRaises(lnurl.LNURLError):
lnurl._parse_lnurl3_response(no_k1_response)
# Test missing withdrawable amounts (should raise error)
no_amounts_response = {
'callback': 'https://service.io/withdraw?sessionid=123',
'k1': 'abcdef1234567890',
}
with self.assertRaises(lnurl.LNURLError):
lnurl._parse_lnurl3_response(no_amounts_response)
# Test malformed withdrawable amounts (should raise error)
bad_amounts_response = {
'callback': 'https://service.io/withdraw?sessionid=123',
'k1': 'abcdef1234567890',
'minWithdrawable': 'this is not a number',
'maxWithdrawable': 100_000_000
}
with self.assertRaises(lnurl.LNURLError):
lnurl._parse_lnurl3_response(bad_amounts_response)

View File

@@ -1,9 +1,13 @@
import os
import asyncio
from unittest.mock import patch
from electrum import SimpleConfig
from electrum.invoices import Invoice
from electrum.payment_identifier import (maybe_extract_lightning_payment_identifier, PaymentIdentifier,
PaymentIdentifierType, invoice_from_payment_identifier)
PaymentIdentifierType, PaymentIdentifierState,
invoice_from_payment_identifier)
from electrum.lnurl import LNURL6Data, LNURL3Data, LNURLError
from electrum.transaction import PartialTxOutput
from . import ElectrumTestCase
@@ -143,14 +147,112 @@ class TestPaymentIdentifier(ElectrumTestCase):
pi = PaymentIdentifier(None, bip21)
self.assertFalse(pi.is_valid())
def test_lnurl(self):
lnurl = 'lnurl1dp68gurn8ghj7um9wfmxjcm99e5k7telwy7nxenrxvmrgdtzxsenjcm98pjnwxq96s9'
pi = PaymentIdentifier(None, lnurl)
def test_lnurl_basic(self):
"""Test basic LNURL parsing without resolve"""
valid_lnurl = 'lnurl1dp68gurn8ghj7um9wfmxjcm99e5k7telwy7nxenrxvmrgdtzxsenjcm98pjnwxq96s9'
pi = PaymentIdentifier(None, valid_lnurl)
self.assertTrue(pi.is_valid())
self.assertEqual(PaymentIdentifierType.LNURL, pi.type)
self.assertFalse(pi.is_available())
self.assertTrue(pi.need_resolve())
self.assertEqual(PaymentIdentifierState.NEED_RESOLVE, pi.state)
# TODO: resolve mock
# Test with lightning: prefix
lightning_lnurl = f'lightning:{valid_lnurl}'
pi = PaymentIdentifier(None, lightning_lnurl)
self.assertTrue(pi.is_valid())
self.assertEqual(PaymentIdentifierType.LNURL, pi.type)
self.assertTrue(pi.need_resolve())
@patch('electrum.payment_identifier.request_lnurl')
def test_lnurl_pay_resolve(self, mock_request_lnurl):
"""Test LNURL-pay (LNURL6) with mocked resolve"""
valid_lnurl = 'LNURL1DP68GURN8GHJ7MRWVF5HGUEWD3HXZERYWFJHXUEWVDHK6TMVDE6HYMRS9ANRV46DXETQPJQCS4'
# Mock lnurl-p response
mock_lnurl6_data = LNURL6Data(
callback_url='https://example.com/lnurl-pay',
max_sendable_sat=1_000_000,
min_sendable_sat=1_000,
metadata_plaintext='Test payment',
comment_allowed=100,
)
mock_request_lnurl.return_value = mock_lnurl6_data
pi = PaymentIdentifier(None, valid_lnurl)
self.assertTrue(pi.need_resolve())
self.assertEqual(PaymentIdentifierType.LNURL, pi.type)
async def run_resolve():
await pi._do_resolve()
asyncio.run(run_resolve())
self.assertEqual(PaymentIdentifierType.LNURLP, pi.type)
self.assertEqual(PaymentIdentifierState.LNURLP_FINALIZE, pi.state)
self.assertTrue(pi.need_finalize())
self.assertIsNotNone(pi.lnurl_data)
self.assertTrue(isinstance(pi.lnurl_data, LNURL6Data))
self.assertEqual(1_000, pi.lnurl_data.min_sendable_sat)
self.assertEqual(1_000_000, pi.lnurl_data.max_sendable_sat)
self.assertEqual('Test payment', pi.lnurl_data.metadata_plaintext)
self.assertEqual(100, pi.lnurl_data.comment_allowed)
@patch('electrum.payment_identifier.request_lnurl')
def test_lnurl_withdraw_resolve(self, mock_request_lnurl):
"""Test LNURL-withdraw (LNURL3) with mocked resolve"""
valid_lnurl = 'LNURL1DP68GURN8GHJ7MRWVF5HGUEWD3HXZERYWFJHXUEWVDHK6TM4WPNHYCTYV4EJ7DFCVGENSDPH8QCRZETXVGCXGCMPVFJR' \
'WENP8P3NJEP3XE3NQWRPXFJR2VRRVSCX2V33V5UNVC3SXP3RXCFSVFSKVWPCV3SKZWTP8YUZ7AMFW35XGUNPWUHKZURF9AMRZT' \
'MVDE6HYMP0FETHVUNZDAMHQ7JSF4RX73TZ2VU9Z3J3GVMSLCJ57F'
# Mock lnurl-w response
mock_lnurl3_data = LNURL3Data(
callback_url='https://example.com/lnurl-withdraw',
k1='test-k1-value',
default_description='Test withdrawal',
min_withdrawable_sat=1_000,
max_withdrawable_sat=500_000,
)
mock_request_lnurl.return_value = mock_lnurl3_data
pi = PaymentIdentifier(None, valid_lnurl)
self.assertTrue(pi.need_resolve())
self.assertEqual(PaymentIdentifierType.LNURL, pi.type)
async def run_resolve():
await pi._do_resolve()
asyncio.run(run_resolve())
self.assertEqual(PaymentIdentifierType.LNURLW, pi.type)
self.assertEqual(PaymentIdentifierState.LNURLW_FINALIZE, pi.state)
self.assertIsNotNone(pi.lnurl_data)
self.assertEqual('test-k1-value', pi.lnurl_data.k1)
self.assertEqual('Test withdrawal', pi.lnurl_data.default_description)
self.assertEqual(1000, pi.lnurl_data.min_withdrawable_sat)
self.assertEqual(500000, pi.lnurl_data.max_withdrawable_sat)
@patch('electrum.payment_identifier.request_lnurl')
def test_lnurl_resolve_error(self, mock_request_lnurl):
"""Test LNURL resolve error handling"""
lnurl = 'LNURL1DP68GURN8GHJ7MRWVF5HGUEWD3HXZERYWFJHXUEWVDHK6TM4WPNHYCTYV4EJ7DFCVGENSDPH8QCRZETXVGCXGCMPVFJR' \
'WENP8P3NJEP3XE3NQWRPXFJR2VRRVSCX2V33V5UNVC3SXP3RXCFSVFSKVWPCV3SKZWTP8YUZ7AMFW35XGUNPWUHKZURF9AMRZT' \
'MVDE6HYMP0FETHVUNZDAMHQ7JSF4RX73TZ2VU9Z3J3GVMSLCJ57F'
# Mock LNURL error
mock_request_lnurl.side_effect = LNURLError("Server error")
pi = PaymentIdentifier(None, lnurl)
self.assertTrue(pi.need_resolve())
async def run_resolve():
await pi._do_resolve()
asyncio.run(run_resolve())
self.assertEqual(PaymentIdentifierState.ERROR, pi.state)
self.assertTrue(pi.is_error())
self.assertIn("Server error", pi.get_error())
def test_multiline(self):
pi_str = '\n'.join([