forked from spesmilo/electrum
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmpp_split.py
More file actions
259 lines (213 loc) · 11.1 KB
/
mpp_split.py
File metadata and controls
259 lines (213 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
import random
import math
from typing import List, Tuple, Optional, Sequence, Dict, TYPE_CHECKING
from collections import defaultdict
from .util import profiler
from .lnutil import NoPathFound
PART_PENALTY = 1.0 # 1.0 results in avoiding splits
MIN_PART_MSAT = 10_000_000 # we don't want to split indefinitely
EXHAUST_DECAY_FRACTION = 10 # fraction of the local balance that should be reserved if possible
# these parameters determine the granularity of the newly suggested configurations
REDISTRIBUTION_FRACTION = 50
SPLIT_FRACTION = 50
# these parameters affect the computational work in the probabilistic algorithm
STARTING_CONFIGS = 50
CANDIDATES_PER_LEVEL = 10
REDISTRIBUTE = 20
# maximum number of parts for splitting
MAX_PARTS = 5
def unique_hierarchy(hierarchy: Dict[int, List[Dict[Tuple[bytes, bytes], int]]]) -> Dict[int, List[Dict[Tuple[bytes, bytes], int]]]:
new_hierarchy = defaultdict(list)
for number_parts, configs in hierarchy.items():
unique_configs = set()
for config in configs:
# config dict can be out of order, so sort, otherwise not unique
unique_configs.add(tuple((c, config[c]) for c in sorted(config.keys())))
for unique_config in sorted(unique_configs):
new_hierarchy[number_parts].append(
{t[0]: t[1] for t in unique_config})
return new_hierarchy
def single_node_hierarchy(hierarchy: Dict[int, List[Dict[Tuple[bytes, bytes], int]]]) -> Dict[int, List[Dict[Tuple[bytes, bytes], int]]]:
new_hierarchy = defaultdict(list)
for number_parts, configs in hierarchy.items():
for config in configs:
# determine number of nodes in configuration
if number_nonzero_nodes(config) > 1:
continue
new_hierarchy[number_parts].append(config)
return new_hierarchy
def number_nonzero_parts(configuration: Dict[Tuple[bytes, bytes], int]) -> int:
return len([v for v in configuration.values() if v])
def number_nonzero_nodes(configuration: Dict[Tuple[bytes, bytes], int]) -> int:
return len({nodeid for (_, nodeid), amount in configuration.items() if amount > 0})
def create_starting_split_hierarchy(amount_msat: int, channels_with_funds: Dict[Tuple[bytes, bytes], int]):
"""Distributes the amount to send to a single or more channels in several
ways (randomly)."""
# TODO: find all possible starting configurations deterministically
# could try all permutations
split_hierarchy = defaultdict(list)
channels_order = list(channels_with_funds.keys())
for _ in range(STARTING_CONFIGS):
# shuffle to have different starting points
random.shuffle(channels_order)
configuration = {}
amount_added = 0
for c in channels_order:
s = channels_with_funds[c]
if amount_added == amount_msat:
configuration[c] = 0
else:
amount_to_add = amount_msat - amount_added
amt = min(s, amount_to_add)
configuration[c] = amt
amount_added += amt
if amount_added != amount_msat:
raise NoPathFound("Channels don't have enough sending capacity.")
split_hierarchy[number_nonzero_parts(configuration)].append(configuration)
return unique_hierarchy(split_hierarchy)
def balances_are_not_ok(proposed_balance_from, channel_from, proposed_balance_to, channel_to, channels_with_funds):
check = (
proposed_balance_to < MIN_PART_MSAT or
proposed_balance_to > channels_with_funds[channel_to] or
proposed_balance_from < MIN_PART_MSAT or
proposed_balance_from > channels_with_funds[channel_from]
)
return check
def propose_new_configuration(channels_with_funds: Dict[Tuple[bytes, bytes], int], configuration: Dict[Tuple[bytes, bytes], int],
amount_msat: int, preserve_number_parts=True) -> Dict[Tuple[bytes, bytes], int]:
"""Randomly alters a split configuration. If preserve_number_parts, the
configuration stays within the same class of number of splits."""
# there are three basic operations to reach different split configurations:
# redistribute, split, swap
def redistribute(config: dict):
# we redistribute the amount from a nonzero channel to a nonzero channel
redistribution_amount = amount_msat // REDISTRIBUTION_FRACTION
nonzero = [ck for ck, cv in config.items() if
cv >= redistribution_amount]
if len(nonzero) == 1: # we only have a single channel, so we can't redistribute
return config
channel_from = random.choice(nonzero)
channel_to = random.choice(nonzero)
if channel_from == channel_to:
return config
proposed_balance_from = config[channel_from] - redistribution_amount
proposed_balance_to = config[channel_to] + redistribution_amount
if balances_are_not_ok(proposed_balance_from, channel_from, proposed_balance_to, channel_to, channels_with_funds):
return config
else:
config[channel_from] = proposed_balance_from
config[channel_to] = proposed_balance_to
assert sum([cv for cv in config.values()]) == amount_msat
return config
def split(config: dict):
# we split off a certain amount from a nonzero channel and put it into a
# zero channel
nonzero = [ck for ck, cv in config.items() if cv != 0]
zero = [ck for ck, cv in config.items() if cv == 0]
try:
channel_from = random.choice(nonzero)
channel_to = random.choice(zero)
except IndexError:
return config
delta = config[channel_from] // SPLIT_FRACTION
proposed_balance_from = config[channel_from] - delta
proposed_balance_to = config[channel_to] + delta
if balances_are_not_ok(proposed_balance_from, channel_from, proposed_balance_to, channel_to, channels_with_funds):
return config
else:
config[channel_from] = proposed_balance_from
config[channel_to] = proposed_balance_to
assert sum([cv for cv in config.values()]) == amount_msat
return config
def swap(config: dict):
# we swap the amounts from a single channel with another channel
nonzero = [ck for ck, cv in config.items() if cv != 0]
all = list(config.keys())
channel_from = random.choice(nonzero)
channel_to = random.choice(all)
proposed_balance_to = config[channel_from]
proposed_balance_from = config[channel_to]
if balances_are_not_ok(proposed_balance_from, channel_from, proposed_balance_to, channel_to, channels_with_funds):
return config
else:
config[channel_to] = proposed_balance_to
config[channel_from] = proposed_balance_from
return config
initial_number_parts = number_nonzero_parts(configuration)
for _ in range(REDISTRIBUTE):
configuration = redistribute(configuration)
if not preserve_number_parts and number_nonzero_parts(
configuration) == initial_number_parts:
configuration = split(configuration)
configuration = swap(configuration)
return configuration
@profiler
def suggest_splits(amount_msat: int, channels_with_funds: Dict[Tuple[bytes, bytes], int],
exclude_single_parts=True, single_node=False) \
-> Sequence[Tuple[Dict[Tuple[bytes, bytes], int], float]]:
"""Creates split configurations for a payment over channels. Single channel
payments are excluded by default. channels_with_funds is keyed by
(channelid, nodeid)."""
def rate_configuration(config: dict) -> float:
"""Defines an objective function to rate a split configuration.
We calculate the normalized L2 norm for a split configuration and
add a part penalty for each nonzero amount. The consequence is that
amounts that are equally distributed and have less parts are rated
lowest."""
F = 0
total_amount = sum([v for v in config.values()])
for channel, amount in config.items():
funds = channels_with_funds[channel]
if amount:
F += amount * amount / (total_amount * total_amount) # a penalty to favor distribution of amounts
F += PART_PENALTY * PART_PENALTY # a penalty for each part
decay = funds / EXHAUST_DECAY_FRACTION
F += math.exp((amount - funds) / decay) # a penalty for channel saturation
return F
def rated_sorted_configurations(hierarchy: dict) -> Sequence[Tuple[Dict[Tuple[bytes, bytes], int], float]]:
"""Cleans up duplicate splittings, rates and sorts them according to
the rating. A lower rating is a better configuration."""
hierarchy = unique_hierarchy(hierarchy)
rated_configs = []
for level, configs in hierarchy.items():
for config in configs:
rated_configs.append((config, rate_configuration(config)))
sorted_rated_configs = sorted(rated_configs, key=lambda c: c[1], reverse=False)
return sorted_rated_configs
# create initial guesses
split_hierarchy = create_starting_split_hierarchy(amount_msat, channels_with_funds)
# randomize initial guesses and generate splittings of different split
# levels up to number of channels
for level in range(2, min(MAX_PARTS, len(channels_with_funds) + 1)):
# generate a set of random configurations for each level
for _ in range(CANDIDATES_PER_LEVEL):
configurations = unique_hierarchy(split_hierarchy).get(level, None)
if configurations: # we have a splitting of the desired number of parts
configuration = random.choice(configurations)
# generate new splittings preserving the number of parts
configuration = propose_new_configuration(
channels_with_funds, configuration, amount_msat,
preserve_number_parts=True)
else:
# go one level lower and look for valid splittings,
# try to go one level higher by splitting a single outgoing amount
configurations = unique_hierarchy(split_hierarchy).get(level - 1, None)
if not configurations:
continue
configuration = random.choice(configurations)
# generate new splittings going one level higher in the number of parts
configuration = propose_new_configuration(
channels_with_funds, configuration, amount_msat,
preserve_number_parts=False)
# add the newly found configuration (doesn't matter if nothing changed)
split_hierarchy[number_nonzero_parts(configuration)].append(configuration)
if exclude_single_parts:
# we only want to return configurations that have at least two parts
try:
del split_hierarchy[1]
except:
pass
if single_node:
# we only take configurations that send to a single node
split_hierarchy = single_node_hierarchy(split_hierarchy)
return rated_sorted_configurations(split_hierarchy)