Skip to content

Commit cb2839a

Browse files
authored
fix(catalyst-toolbox): Count missing voting power | NPG-0000 (#541)
# Description Catalyst toolbox `snapshot` and `sve-snapshot` commands were both (with different code paths) excluding individual registrations with < threshold ada voting power. This meant that multiple registrations to the same voting key would have missing voting power if any of those individual registrations were < the threshold. This change does that check only on the cumulative total of all voting power. ## Type of change Please delete options that are not relevant. - [ x ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? The `utilities/snapshot-check/snapshot-check.py` was sued to validate the voting power is properly being calculated. The `utilities/snapshot-check/compar_snapshot.py` was used to compare a full processed snapshot with an SVE one. Old snapshots fail to validate, new ones after these changes validate correctly. ## Checklist - [ x ] My code follows the style guidelines of this project - [ x ] I have performed a self-review of my code - [ x ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ x ] My changes generate no new warnings - [ x ] I have added tests that prove my fix is effective or that my feature works - [ x ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
1 parent b211081 commit cb2839a

File tree

3 files changed

+208
-23
lines changed

3 files changed

+208
-23
lines changed

src/catalyst-toolbox/snapshot-lib/src/lib.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ pub struct Snapshot {
103103
}
104104

105105
impl Snapshot {
106+
#[allow(clippy::missing_errors_doc)]
106107
pub fn from_raw_snapshot(
107108
raw_snapshot: RawSnapshot,
108109
stake_threshold: Value,
@@ -114,8 +115,8 @@ impl Snapshot {
114115
.0
115116
.into_iter()
116117
// Discard registrations with 0 voting power since they don't influence
117-
// snapshot anyway
118-
.filter(|reg| reg.voting_power >= std::cmp::max(stake_threshold, 1.into()))
118+
// snapshot anyway. But can not throw any others away, even if less than the stake threshold.
119+
.filter(|reg| reg.voting_power >= 1.into())
119120
// TODO: add capability to select voting purpose for a snapshot.
120121
// At the moment Catalyst is the only one in use
121122
.filter(|reg| {
@@ -185,7 +186,12 @@ impl Snapshot {
185186
},
186187
contributions,
187188
})
189+
// Because of multiple registrations to the same voting key, we can only
190+
// filter once all registrations for the same key are known.
191+
// `stake_threshold` is the minimum stake for all registrations COMBINED.
192+
.filter(|entry| entry.hir.voting_power >= stake_threshold)
188193
.collect();
194+
189195
Ok(Self {
190196
inner: Self::apply_voting_power_cap(entries, cap)?
191197
.into_iter()
@@ -204,17 +210,20 @@ impl Snapshot {
204210
.collect())
205211
}
206212

213+
#[must_use]
207214
pub fn stake_threshold(&self) -> Value {
208215
self.stake_threshold
209216
}
210217

218+
#[must_use]
211219
pub fn to_voter_hir(&self) -> Vec<VoterHIR> {
212220
self.inner
213221
.values()
214222
.map(|entry| entry.hir.clone())
215223
.collect::<Vec<_>>()
216224
}
217225

226+
#[must_use]
218227
pub fn to_full_snapshot_info(&self) -> Vec<SnapshotInfo> {
219228
self.inner.values().cloned().collect()
220229
}
@@ -223,6 +232,7 @@ impl Snapshot {
223232
self.inner.keys()
224233
}
225234

235+
#[must_use]
226236
pub fn contributions_for_voting_key<I: Borrow<Identifier>>(
227237
&self,
228238
voting_public_key: I,
@@ -252,6 +262,7 @@ pub mod tests {
252262
}
253263

254264
impl Snapshot {
265+
#[must_use]
255266
pub fn to_block0_initials(&self, discrimination: Discrimination) -> Vec<InitialUTxO> {
256267
self.inner
257268
.iter()

src/catalyst-toolbox/snapshot-lib/src/sve.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ pub struct Snapshot {
1616
}
1717

1818
impl Snapshot {
19+
#[must_use]
20+
#[allow(clippy::missing_panics_doc)] // The one possible panic shouldn't happen in reality.
1921
pub fn new(raw_snapshot: RawSnapshot, min_stake_threshold: Value) -> (Self, usize) {
2022
let mut total_rejected_registrations: usize = 0;
2123

22-
let inner = raw_snapshot
24+
let mut inner = raw_snapshot
2325
.0
2426
.into_iter()
2527
.filter(|r| {
@@ -37,7 +39,6 @@ impl Snapshot {
3739

3840
true
3941
})
40-
.filter(|r| r.voting_power >= min_stake_threshold)
4142
.fold(HashMap::<Identifier, Vec<_>>::new(), |mut acc, r| {
4243
let k = match &r.delegations {
4344
Delegations::New(ds) => ds.first().unwrap().0.clone(),
@@ -48,9 +49,28 @@ impl Snapshot {
4849
acc
4950
});
5051

52+
// Because of multiple registrations to the same voting key, we can only
53+
// filter once all registrations for the same key are known.
54+
// `min_stake_threshold` is the minimum stake for all registrations COMBINED.
55+
inner.retain(|_, regs| {
56+
let value: Value = regs
57+
.iter()
58+
.map(|reg| u64::from(reg.voting_power))
59+
.sum::<u64>()
60+
.into();
61+
62+
// If the total stake across all registrations is < threshold, then they are all rejects.
63+
if value < min_stake_threshold {
64+
total_rejected_registrations += regs.len();
65+
}
66+
67+
value >= min_stake_threshold
68+
});
69+
5170
(Self { inner }, total_rejected_registrations)
5271
}
5372

73+
#[must_use]
5474
pub fn to_block0_initials(
5575
&self,
5676
discrimination: Discrimination,

utilities/snapshot-check/snapshot-check.py

Lines changed: 173 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
import json
1616

1717
from pathlib import Path
18-
from typing import Any
18+
from typing import Any, Iterable, List, Optional, Tuple, Union
19+
1920

2021
def is_dir(dirpath: str | Path):
2122
"""Check if the directory is a directory."""
@@ -43,6 +44,21 @@ def compare_reg_error(reg:dict, error:dict) -> bool:
4344
except:
4445
return False
4546

47+
def index_processed_snapshot(snapshot) -> dict:
48+
#
49+
indexed={}
50+
51+
if isinstance(snapshot, list):
52+
for rec in snapshot:
53+
indexed["0x" + rec["hir"]["voting_key"]] = rec
54+
else:
55+
# legacy snapshot
56+
print("Legacy Snapshot not supported. Use the 'compare_snapshot.py' tool to compare a legacy with a full processed snapshot.")
57+
exit(1)
58+
59+
return indexed
60+
61+
4662
def analyze_snapshot(args: argparse.Namespace):
4763
"""Convert a snapshot into a format supported by SVE1."""
4864

@@ -61,25 +77,82 @@ def analyze_snapshot(args: argparse.Namespace):
6177
cip_36_single: list[dict[str, Any]] = []
6278
cip_36_multi: list[dict[str, Any]] = []
6379

80+
vkey_power: dict[str, list[int]] = {}
81+
6482
total_rejects = 0
6583
total_registered_value = 0
6684

85+
rewards_payable = 0
86+
rewards_pointer = 0
87+
rewards_unpayable = 0
88+
rewards_invalid = 0
89+
rewards_types = {}
90+
unique_rewards = {}
91+
92+
6793
for registration in snapshot:
6894
# Index the registrations
6995
stake_pub_key = registration["stake_public_key"]
7096
snapshot_index[stake_pub_key] = registration
7197

72-
total_registered_value += registration["voting_power"]
98+
v_power = registration["voting_power"]
99+
100+
total_registered_value += v_power
101+
102+
rewards_addr = registration["rewards_address"]
103+
104+
long_addr_length = 116
105+
short_addr_length = 60
106+
107+
if len(rewards_addr) > 4 and rewards_addr[0:2] == "0x" and rewards_addr[2] in "01234567ef" and rewards_addr[3] == "1":
108+
rewards_type = rewards_addr[2]
109+
110+
if rewards_type in "0123":
111+
if len(rewards_addr) == long_addr_length:
112+
rewards_payable += 1
113+
unique_rewards[rewards_addr] = unique_rewards.get(rewards_addr, 0) + 1
114+
else:
115+
rewards_invalid += 1
116+
elif rewards_type in "45":
117+
if len(rewards_addr) == long_addr_length:
118+
rewards_pointer += 1
119+
unique_rewards[rewards_addr] = unique_rewards.get(rewards_addr, 0) + 1
120+
else:
121+
rewards_invalid += 1
122+
elif rewards_type in "67":
123+
if len(rewards_addr) == short_addr_length:
124+
rewards_payable += 1
125+
unique_rewards[rewards_addr] = unique_rewards.get(rewards_addr, 0) + 1
126+
else:
127+
rewards_invalid += 1
128+
elif rewards_type in "ef":
129+
if len(rewards_addr) == short_addr_length:
130+
rewards_unpayable += 1
131+
else:
132+
rewards_invalid += 1
133+
134+
rewards_types[rewards_type] = rewards_types.get(rewards_type,0) + 1
135+
else:
136+
rewards_invalid += 1
73137

74138
# Check if the delegation is a simple string.
75139
# If so, assume its a CIP-15 registration.
76140
delegation = registration["delegations"]
77141

78142
if isinstance(delegation, str):
79143
cip_15_snapshot.append(registration)
144+
145+
if delegation not in vkey_power:
146+
vkey_power[delegation] =[]
147+
vkey_power[delegation].append(v_power)
148+
80149
elif isinstance(delegation, list):
81150
if len(delegation) == 1:
82151
cip_36_single.append(registration)
152+
153+
if delegation[0][0] not in vkey_power:
154+
vkey_power[delegation[0][0]] =[]
155+
vkey_power[delegation[0][0]].append(v_power)
83156
else:
84157
cip_36_multi.append(registration)
85158
else:
@@ -89,6 +162,30 @@ def analyze_snapshot(args: argparse.Namespace):
89162
)
90163
total_rejects += 1
91164

165+
# Read the processed snapshot.
166+
total_processed_vpower = None
167+
processed_snapshot = None
168+
if args.processed is not None:
169+
processed_snapshot = index_processed_snapshot(json.loads(args.processed.read_text()))
170+
171+
for rec in processed_snapshot.items():
172+
rec_vpower = 0
173+
for contribution in rec[1]["contributions"]:
174+
175+
if contribution["stake_public_key"] in snapshot_index:
176+
snap = snapshot_index[contribution["stake_public_key"]]
177+
if snap["voting_power"] != contribution["value"]:
178+
print(f"Mismatched Contribution Value for {contribution['stake_public_key']}")
179+
else:
180+
rec_vpower += contribution["value"]
181+
if rec_vpower != rec[1]["hir"]["voting_power"]:
182+
print(f"Mismatched Voting Power for {rec}")
183+
else:
184+
if total_processed_vpower is None:
185+
total_processed_vpower = rec_vpower
186+
else:
187+
total_processed_vpower += rec_vpower
188+
92189
# Index Errors
93190
registration_obsolete: dict[str, Any] = {}
94191
decode_errors: list[Any] = []
@@ -121,8 +218,11 @@ def analyze_snapshot(args: argparse.Namespace):
121218
mismatched: dict[str, Any] = {}
122219
equal_snapshots = 0
123220

221+
222+
124223
if args.compare is not None:
125224
raw_compare = json.loads(args.compare.read_text())
225+
126226
for comp in raw_compare:
127227
# Index all records being compared.
128228
stake_pub_key = comp["stake_public_key"]
@@ -150,14 +250,21 @@ def analyze_snapshot(args: argparse.Namespace):
150250
missing_registrations.append(registration)
151251

152252
print("Snapshot Analysis:")
153-
print(f" Total Registrations : {len(snapshot)}")
154-
print(f" Total CIP-15 : {len(cip_15_snapshot)}")
155-
print(f" Total CIP-36 Single : {len(cip_36_single)}")
156-
print(f" Total CIP-36 Multi : {len(cip_36_multi)}")
157-
print(f" Total Rejects : {total_rejects}")
253+
print(f" Total Registrations : {len(snapshot):10}")
254+
print(f" Total CIP-15 : {len(cip_15_snapshot):10}")
255+
print(f" Total CIP-36 Single : {len(cip_36_single):10}")
256+
print(f" Total CIP-36 Multi : {len(cip_36_multi):10}")
257+
print(f" Total Rejects : {total_rejects:10}")
158258

159259
print()
160-
print("Stake Address Types:")
260+
print("Reward Address Types:")
261+
print(f" Total Payable : {rewards_payable:10}")
262+
print(f" Total Pointer : {rewards_pointer:10}")
263+
print(f" Total Unpayable : {rewards_unpayable:10}")
264+
print(f" Total Invalid : {rewards_invalid:10}")
265+
print(f" Total Types : {len(rewards_types):10}")
266+
print(f" Types = {','.join(rewards_types.keys())}")
267+
print(f" Total Unique Rewards : {len(unique_rewards):10}")
161268

162269
#if len(registration_errors) > 0:
163270
# print()
@@ -198,22 +305,62 @@ def analyze_snapshot(args: argparse.Namespace):
198305
for reg in missing_registrations:
199306
print(f" {reg}")
200307

201-
total_unregistered = len(snapshot_unregistered)
202-
value_unregistered = 0
203-
for value in snapshot_unregistered.values():
204-
value_unregistered += value
308+
total_unregistered = len(snapshot_unregistered)
309+
value_unregistered = 0
310+
for value in snapshot_unregistered.values():
311+
value_unregistered += value
312+
313+
total_threshold_voting_power = 0
314+
total_threshold_registrations = 0
315+
multi_reg_voting_keys = 0
316+
317+
print()
318+
print("Multiple Registrations to same voting key")
319+
for key in vkey_power:
320+
this_power = 0
321+
for v in vkey_power[key]:
322+
this_power += v
323+
if this_power >= 450000000:
324+
total_threshold_registrations += 1
325+
total_threshold_voting_power += this_power
326+
327+
if processed_snapshot is not None:
328+
if key not in processed_snapshot:
329+
print(f" Key {key} not in processed snapshot.")
330+
elif this_power != processed_snapshot[key]["hir"]["voting_power"]:
331+
print(f" Key {key} voting power mismatch. Processed = {processed_snapshot[key]['hir']['voting_power']} Actual = {this_power}")
332+
205333

206-
print(f" Total Registrations = Total Voting Power : {len(snapshot):>10} = {total_registered_value/1000000:>25} ADA")
207-
print(f" Total Unregistered = Total Voting Power : {total_unregistered:>10} = {value_unregistered/1000000:>25} ADA")
334+
elif key in processed_snapshot:
335+
print(f" Key {key} is in processed snapshot?")
208336

209-
staked_total = len(snapshot) + total_unregistered
210-
staked_total_value = total_registered_value + value_unregistered
337+
if len(vkey_power[key]) > 1:
338+
multi_reg_voting_keys += 1
339+
print(f" {multi_reg_voting_keys:3} {key} = {this_power/1000000:>25.6f} ADA")
340+
powers = ", ".join([f"{x/1000000:0.6f}".rstrip("0").rstrip(".") for x in sorted(vkey_power[key])])
341+
print(f" {len(vkey_power[key])} Stake Addresses : ADA = {powers} ")
211342

212-
reg_pct = 100.0 / staked_total * len(snapshot)
213-
val_pct = 100.0 / staked_total_value * total_registered_value
214343

215-
print(f" Registered% = VotingPower % : {reg_pct:>10.4}% = {val_pct:>23.4}%")
344+
print("")
216345

346+
if total_processed_vpower is not None:
347+
print(f" Total Processed Registrations = Total Voting Power : {len(processed_snapshot.keys()):>10} = {total_processed_vpower/1000000:>25.6f} ADA - Validates : {total_processed_vpower == total_threshold_voting_power}")
348+
print(f" Total Threshold Registrations = Total Voting Power : {total_threshold_registrations:>10} = {total_threshold_voting_power/1000000:>25.6f} ADA")
349+
print(f" Total Registrations = Total Voting Power : {len(snapshot):>10} = {total_registered_value/1000000:>25.6f} ADA")
350+
print(f" Total Unregistered = Total Voting Power : {total_unregistered:>10} = {value_unregistered/1000000:>25.6f} ADA")
351+
352+
staked_total = len(snapshot) + total_unregistered
353+
staked_total_value = total_registered_value + value_unregistered
354+
355+
reg_pct = 100.0 / staked_total * len(snapshot)
356+
val_pct = 100.0 / staked_total_value * total_registered_value
357+
358+
print(f" Registered% = VotingPower % : {reg_pct:>10.4f}% = {val_pct:>23.4f} %")
359+
360+
thresh_reg_pct = 100.0 / staked_total * total_threshold_registrations
361+
thresh_val_pct = 100.0 / staked_total_value * total_threshold_voting_power
362+
363+
print(f" Threshold Registered% (450 A) = VotingPower % : {thresh_reg_pct:>10.4f}% = {thresh_val_pct:>23.4f} %")
217364

218365

219366
def main() -> int:
@@ -235,6 +382,13 @@ def main() -> int:
235382
type=is_file,
236383
)
237384

385+
parser.add_argument(
386+
"--processed",
387+
help="Processed Snapshot file to compare with.",
388+
required=False,
389+
type=is_file,
390+
)
391+
238392
args = parser.parse_args()
239393
analyze_snapshot(args)
240394
return 0

0 commit comments

Comments
 (0)