Skip to content

Commit fe5cced

Browse files
committed
Looks for more potential issues with change outputs
1 parent 36b7e6f commit fe5cced

1 file changed

Lines changed: 77 additions & 0 deletions

File tree

shared/psbt.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,83 @@ def consider_outputs(self):
11141114
self.warnings.append(('Big Fee', 'Network fee is more than '
11151115
'5%% of total value (%.1f%%).' % per_fee))
11161116

1117+
# Enforce policy related to change outputs
1118+
self.consider_dangerous_change(self.my_xfp)
1119+
1120+
def consider_dangerous_change(self, my_xfp):
1121+
# Enforce some policy on change outputs:
1122+
# - need to "look like" they are going to same wallet as inputs came from
1123+
# - range limit last two path components (numerically)
1124+
# - same pattern of hard/not hardened components
1125+
# - MAX_PATH_DEPTH already enforced before this point
1126+
#
1127+
in_paths = []
1128+
for inp in self.inputs:
1129+
if inp.fully_signed: continue
1130+
if not inp.required_key: continue
1131+
if not inp.subpaths: continue # not expected if we're signing it
1132+
for path in inp.subpaths.values():
1133+
if path[0] == my_xfp:
1134+
in_paths.append(path[1:])
1135+
1136+
if not in_paths:
1137+
# We aren't adding any signatures? Can happen but we're going to be
1138+
# showing a warning about that elsewhere.
1139+
return
1140+
1141+
shortest = min(len(i) for i in in_paths)
1142+
longest = max(len(i) for i in in_paths)
1143+
if shortest != longest or shortest <= 2:
1144+
# We aren't seeing common input path lengths.
1145+
# They are probbably doing weird stuff, so leave them alone.
1146+
# (Remember: we are trusting the input side of things here)
1147+
return
1148+
1149+
# Assumption: hard/not hardened depths will match for all address in wallet
1150+
def hard_bits(p):
1151+
return [bool(i & 0x80000000) for i in p]
1152+
1153+
# Assumption: common wallets modulate the last two components only
1154+
# of the path. Typically m/.../change/index where change is {0, 1}
1155+
# and index changes slowly over lifetime of wallet (increasing)
1156+
path_len = shortest
1157+
path_prefix = in_paths[0][0:-2]
1158+
idx_max = max(i[-1]&0x7fffffff for i in in_paths) + 200
1159+
hard_pattern = hard_bits(in_paths[0])
1160+
1161+
probs = []
1162+
for nout, out in enumerate(self.outputs):
1163+
if not out.is_change: continue
1164+
# it's a change output, okay if a p2sh change; we're looking at paths
1165+
for path in inp.subpaths.values():
1166+
if path[0] != my_xfp: continue # possible in p2sh case
1167+
1168+
path = path[1:]
1169+
if len(path) != path_len:
1170+
iss = "has wrong path length (%d not %d)" % (len(path), path_len)
1171+
elif hard_bits(path) != hard_pattern:
1172+
iss = "has different hard/not hardened pattern"
1173+
elif path[0:len(path_prefix)] != path_prefix:
1174+
iss = "goes to diff path prefix"
1175+
elif (path[-2]&0x7fffffff) not in {0, 1}:
1176+
iss = "2nd last component not 0 or 1"
1177+
elif (path[-1]&0x7fffffff) > idx_max:
1178+
iss = "last component beyond idx+200 of inputs"
1179+
else:
1180+
# looks ok
1181+
continue
1182+
1183+
probs.append("Output#%d: %s: %s not %s/{0~1}%s/{0~%d}%s expected"
1184+
% (nout, iss, path_to_str(path, skip=0),
1185+
path_to_str(path_prefix, skip=0),
1186+
"'" if hard_pattern[-2] else "",
1187+
idx_max, "'" if hard_pattern[-1] else "",
1188+
))
1189+
break
1190+
1191+
for p in probs:
1192+
self.warnings.append(('Troublesome Change Outs', p))
1193+
11171194
def consider_inputs(self):
11181195
# Look an the UTXO's that we are spending. Do we have them? Do the
11191196
# hashes match, and what values are we getting?

0 commit comments

Comments
 (0)