Skip to content

Commit d22b7f3

Browse files
bitromortacecdsa
authored andcommitted
review: simulate closing
1 parent ae6ba67 commit d22b7f3

File tree

1 file changed

+201
-0
lines changed

1 file changed

+201
-0
lines changed

electrum/tests/test_modern.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import asyncio
2+
from asyncio import Queue
3+
from typing import Dict, NamedTuple, Optional
4+
from unittest import TestCase
5+
6+
7+
async def peer(
8+
is_initiator: bool,
9+
send_queue: Queue,
10+
receive_queue: Queue,
11+
fee_range: Optional[Dict],
12+
our_fee: int,
13+
):
14+
def print_named(text):
15+
print(f"{'A' if is_initiator else 'B'}: " + text)
16+
17+
cycles = 0
18+
their_fee = None
19+
20+
fee_range_sent = {}
21+
22+
async def send_closing_signed():
23+
MODERN_FEE = True
24+
if MODERN_FEE:
25+
nonlocal fee_range_sent # we change fee_range_sent in outer scope
26+
fee_range_sent = fee_range
27+
await send_queue.put({'fee_satoshis': our_fee, 'fee_range': fee_range_sent})
28+
else:
29+
await send_queue.put({'fee_satoshis': our_fee, 'fee_range': {}})
30+
31+
if is_initiator:
32+
await send_closing_signed()
33+
34+
# negotiate fee
35+
while True:
36+
cycles += 1
37+
cs_payload = await receive_queue.get()
38+
39+
their_previous_fee = their_fee
40+
their_fee = cs_payload['fee_satoshis']
41+
42+
# 0. integrity checks
43+
# skipped
44+
45+
# 1. check fees
46+
# if fee_satoshis is equal to its previously sent fee_satoshis:
47+
if our_fee == their_fee:
48+
# SHOULD sign and broadcast the final closing transaction.
49+
break # we publish
50+
51+
# 2. at start, adapt our fee range if we are not the channel initiator
52+
fee_range_received = cs_payload['fee_range']
53+
print_named(f"Received fee range: {fee_range_received} and fee: {their_fee}")
54+
# The sending node: if it is not the funder:
55+
if fee_range_received and not is_initiator and not fee_range_sent:
56+
# SHOULD set max_fee_satoshis to at least the max_fee_satoshis received
57+
fee_range['max_fee_satoshis'] = max(fee_range_received['max_fee_satoshis'], fee_range['max_fee_satoshis'])
58+
# SHOULD set min_fee_satoshis to a fairly low value
59+
# TODO: what's a fairly low value? allows the initiator to go to low values
60+
fee_range['min_fee_satoshis'] = min(fee_range_received['min_fee_satoshis'], fee_range['min_fee_satoshis']) # maximal collaboration
61+
# fee_range['min_fee_satoshis'] = fee_range['min_fee_satoshis'] // 2 # just lower our minimal fee a bit
62+
63+
# 3. if fee_satoshis matches its previously sent fee_range:
64+
if fee_range_sent and (fee_range_sent['min_fee_satoshis'] <= their_fee <= fee_range_sent['max_fee_satoshis']):
65+
# SHOULD reply with a closing_signed with the same fee_satoshis value if it is different from its previously sent fee_satoshis
66+
if our_fee != their_fee:
67+
our_fee = their_fee
68+
await send_closing_signed() # peer publishes
69+
break
70+
# SHOULD use `fee_satoshis` to sign and broadcast the final closing transaction
71+
else:
72+
our_fee = their_fee
73+
break # we publish
74+
75+
# 4. if the message contains a fee_range
76+
if fee_range_received:
77+
overlap_min = max(fee_range['min_fee_satoshis'], fee_range_received['min_fee_satoshis'])
78+
overlap_max = min(fee_range['max_fee_satoshis'], fee_range_received['max_fee_satoshis'])
79+
# if there is no overlap between that and its own fee_range
80+
if overlap_min > overlap_max:
81+
raise Exception("There is no overlap between between their and our fee range.")
82+
# TODO: MUST fail the channel if it doesn't receive a satisfying fee_range after a reasonable amount of time
83+
# otherwise:
84+
else:
85+
if is_initiator:
86+
# if fee_satoshis is not in the overlap between the sent and received fee_range:
87+
if not (overlap_min <= their_fee <= overlap_max):
88+
# MUST fail the channel
89+
raise Exception("Their fee is not in the overlap region, we force closed.")
90+
# otherwise:
91+
else:
92+
our_fee = their_fee
93+
# MUST reply with the same fee_satoshis.
94+
await send_closing_signed() # peer publishes
95+
break
96+
# otherwise (it is not the funder):
97+
else:
98+
# if it has already sent a closing_signed:
99+
if fee_range_sent:
100+
# if fee_satoshis is not the same as the value it sent:
101+
if their_fee != our_fee:
102+
# MUST fail the channel
103+
raise Exception("Expected the same fee as ours, we force closed.")
104+
# otherwise:
105+
else:
106+
# MUST propose a fee_satoshis in the overlap between received and (about-to-be) sent fee_range.
107+
our_fee = (overlap_min + overlap_max) // 2
108+
await send_closing_signed()
109+
continue
110+
# otherwise, if fee_satoshis is not strictly between its last-sent fee_satoshis
111+
# and its previously-received fee_satoshis, UNLESS it has since reconnected:
112+
elif their_previous_fee and not (min(our_fee, their_previous_fee) < their_fee < max(our_fee, their_previous_fee)):
113+
# SHOULD fail the connection.
114+
raise Exception('Their fee is not between our last sent and their last sent fee.')
115+
# otherwise, if the receiver agrees with the fee:
116+
elif abs(their_fee - our_fee) <= 1: # we cannot have another strictly in-between value
117+
# SHOULD reply with a closing_signed with the same fee_satoshis value.
118+
our_fee = their_fee
119+
await send_closing_signed() # peer publishes
120+
break
121+
# otherwise:
122+
else:
123+
# MUST propose a value "strictly between" the received fee_satoshis and its previously-sent fee_satoshis.
124+
our_fee = (our_fee + their_fee) // 2
125+
await send_closing_signed()
126+
127+
# reaching this part of the code means that we have reached agreement; to make
128+
# sure the peer doesn't force close, send a last closing_signed
129+
if not is_initiator:
130+
await send_closing_signed()
131+
132+
print_named(f"agree {our_fee} {their_fee}, I'm signing and broadcasting")
133+
return our_fee, cycles
134+
135+
136+
async def main(initiator_fee, initiator_fee_range, receiver_fee, receiver_fee_range):
137+
queue1 = Queue(maxsize=1)
138+
queue2 = Queue(maxsize=1)
139+
worker1 = peer(is_initiator=True, send_queue=queue1, receive_queue=queue2, fee_range=initiator_fee_range, our_fee=initiator_fee)
140+
worker2 = peer(is_initiator=False, send_queue=queue2, receive_queue=queue1, fee_range=receiver_fee_range, our_fee=receiver_fee)
141+
return await asyncio.gather(worker1, worker2)
142+
143+
144+
class TestNegotiation(TestCase):
145+
146+
def test_legacy_ini_low(self):
147+
"""legacy fee negotiation"""
148+
inititator, receiver = asyncio.run(main(initiator_fee=100, receiver_fee=150, initiator_fee_range={}, receiver_fee_range={}))
149+
self.assertTrue(inititator[0] == receiver[0] == 116)
150+
self.assertEqual(3, inititator[1])
151+
self.assertEqual(4, receiver[1])
152+
153+
def test_legacy_ini_high(self):
154+
"""legacy fee negotiation"""
155+
inititator, receiver = asyncio.run(main(initiator_fee=2000, receiver_fee=100, initiator_fee_range={}, receiver_fee_range={}))
156+
self.assertTrue(inititator[0] == receiver[0] == 1365)
157+
self.assertEqual(6, inititator[1])
158+
self.assertEqual(7, receiver[1])
159+
160+
def test_modern_ini_low_fee_range(self):
161+
inititator, receiver = asyncio.run(
162+
main(initiator_fee=1, receiver_fee=200,
163+
initiator_fee_range={'min_fee_satoshis': 1, 'max_fee_satoshis': 10},
164+
receiver_fee_range={'min_fee_satoshis': 10, 'max_fee_satoshis': 300}))
165+
self.assertTrue(inititator[0] == receiver[0] == 5)
166+
self.assertEqual(1, inititator[1])
167+
self.assertEqual(2, receiver[1])
168+
169+
def test_modern_no_initial_overlap(self):
170+
# fails, because non-initiator accepts low fee range bound
171+
# self.assertRaises(Exception, lambda: asyncio.run(
172+
# main(initiator_fee=1, receiver_fee=200,
173+
# initiator_fee_range={'min_fee_satoshis': 1, 'max_fee_satoshis': 10},
174+
# receiver_fee_range={'min_fee_satoshis': 50, 'max_fee_satoshis': 300})))
175+
176+
# succeeds, because non-initiator accepts low fee range bound
177+
inititator, receiver = asyncio.run(
178+
main(initiator_fee=1, receiver_fee=200,
179+
initiator_fee_range={'min_fee_satoshis': 1, 'max_fee_satoshis': 10},
180+
receiver_fee_range={'min_fee_satoshis': 50, 'max_fee_satoshis': 300}))
181+
self.assertTrue(inititator[0] == receiver[0] == 5)
182+
self.assertEqual(1, inititator[1])
183+
self.assertEqual(2, receiver[1])
184+
185+
def test_modern_fee_range_overlap(self):
186+
inititator, receiver = asyncio.run(main(
187+
initiator_fee=100, receiver_fee=200,
188+
initiator_fee_range={'min_fee_satoshis': 100, 'max_fee_satoshis': 300},
189+
receiver_fee_range={'min_fee_satoshis': 50, 'max_fee_satoshis': 200}))
190+
self.assertTrue(inititator[0] == receiver[0] == 200)
191+
self.assertEqual(1, inititator[1])
192+
self.assertEqual(2, receiver[1])
193+
194+
def test_modern_fee_range_overlap_swapped(self):
195+
inititator, receiver = asyncio.run(main(
196+
receiver_fee=100, initiator_fee=200,
197+
initiator_fee_range={'min_fee_satoshis': 50, 'max_fee_satoshis': 200},
198+
receiver_fee_range={'min_fee_satoshis': 100, 'max_fee_satoshis': 300}))
199+
self.assertTrue(inititator[0] == receiver[0] == 125)
200+
self.assertEqual(1, inititator[1])
201+
self.assertEqual(2, receiver[1])

0 commit comments

Comments
 (0)