Skip to content

Commit cb41874

Browse files
refactor(nns): move random subaccount generation into NeuronStore (#9185)
### Why Random subaccount generation in `create_neuron`, `split_neuron`, and `spawn_neuron` was duplicated inline with a single-shot collision check that would fail on collision rather than retry. This is inconsistent with `new_neuron_id()` which retries. ### What - Add `NeuronStore::new_neuron_subaccount()` which generates a unique random subaccount with retry-on-collision, mirroring `new_neuron_id()` - Update `create_neuron`, `split_neuron`, and `spawn_neuron` to use it for the random subaccount path - Deterministic subaccount paths (from user-supplied memo/nonce) are unchanged ### PR Chain - ➡️ Next: #9186 ### Testing - Added unit tests for `new_neuron_subaccount` (no-collision and retry-on-collision cases)
1 parent b26144a commit cb41874

4 files changed

Lines changed: 146 additions & 46 deletions

File tree

rs/nns/governance/src/governance.rs

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2203,20 +2203,24 @@ impl Governance {
22032203

22042204
let from_subaccount = parent_neuron.subaccount();
22052205

2206-
let to_subaccount_bytes = if let Some(memo) = memo {
2207-
ledger::compute_neuron_split_subaccount_bytes(parent_neuron.controller(), memo)
2206+
let to_subaccount = if let Some(memo) = memo {
2207+
let to_subaccount = Subaccount(ledger::compute_neuron_split_subaccount_bytes(
2208+
parent_neuron.controller(),
2209+
memo,
2210+
));
2211+
// Deterministic subaccount: fail immediately on collision since
2212+
// retrying would produce the same result.
2213+
if self.neuron_store.has_neuron_with_subaccount(to_subaccount) {
2214+
return Err(GovernanceError::new_with_message(
2215+
ErrorType::PreconditionFailed,
2216+
"There is already a neuron with the same subaccount.",
2217+
));
2218+
}
2219+
to_subaccount
22082220
} else {
2209-
self.randomness.random_byte_array()?
2221+
self.neuron_store
2222+
.new_neuron_subaccount(&mut *self.randomness)?
22102223
};
2211-
let to_subaccount = Subaccount(to_subaccount_bytes);
2212-
2213-
// Make sure there isn't already a neuron with the same sub-account.
2214-
if self.neuron_store.has_neuron_with_subaccount(to_subaccount) {
2215-
return Err(GovernanceError::new_with_message(
2216-
ErrorType::PreconditionFailed,
2217-
"There is already a neuron with the same subaccount.",
2218-
));
2219-
}
22202224

22212225
let in_flight_command = NeuronInFlightCommand {
22222226
timestamp: created_timestamp_seconds,
@@ -2671,22 +2675,26 @@ impl Governance {
26712675

26722676
let child_nid = self.neuron_store.new_neuron_id(&mut *self.randomness)?;
26732677

2674-
// use provided sub-account if any, otherwise generate a random one.
2678+
// Use provided sub-account if any, otherwise generate a random one.
26752679
let to_subaccount = match spawn.nonce {
2676-
None => Subaccount(self.randomness.random_byte_array()?),
2680+
None => self
2681+
.neuron_store
2682+
.new_neuron_subaccount(&mut *self.randomness)?,
26772683
Some(nonce_val) => {
2678-
ledger::compute_neuron_staking_subaccount(child_controller, nonce_val)
2684+
let to_subaccount =
2685+
ledger::compute_neuron_staking_subaccount(child_controller, nonce_val);
2686+
// Deterministic subaccount: fail immediately on collision since
2687+
// retrying would produce the same result.
2688+
if self.neuron_store.has_neuron_with_subaccount(to_subaccount) {
2689+
return Err(GovernanceError::new_with_message(
2690+
ErrorType::PreconditionFailed,
2691+
"There is already a neuron with the same subaccount.",
2692+
));
2693+
}
2694+
to_subaccount
26792695
}
26802696
};
26812697

2682-
// Make sure there isn't already a neuron with the same sub-account.
2683-
if self.neuron_store.has_neuron_with_subaccount(to_subaccount) {
2684-
return Err(GovernanceError::new_with_message(
2685-
ErrorType::PreconditionFailed,
2686-
"There is already a neuron with the same subaccount.",
2687-
));
2688-
}
2689-
26902698
let created_timestamp_seconds = self.env.now();
26912699
let dissolve_and_spawn_at_timestamp_seconds =
26922700
created_timestamp_seconds + economics.neuron_spawn_dissolve_delay_seconds;

rs/nns/governance/src/governance/create_neuron.rs

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ use ic_cdk::println;
1414
use ic_nns_constants::GOVERNANCE_CANISTER_ID;
1515
use ic_nns_governance_api::{CreateNeuronRequest, CreatedNeuron};
1616
use ic_types::PrincipalId;
17-
use icp_ledger::Subaccount;
1817
use icrc_ledger_types::icrc1::account::Account as Icrc1Account;
1918
use std::{cell::RefCell, thread::LocalKey};
2019

@@ -96,28 +95,9 @@ impl Governance {
9695
NEURON_RATE_LIMITER_KEY.to_string(),
9796
1,
9897
)?;
99-
let neuron_subaccount =
100-
governance.randomness.random_byte_array().map_err(|_| {
101-
GovernanceError::new_with_message(
102-
ErrorType::Unavailable,
103-
"Failed to generate neuron subaccount",
104-
)
105-
})?;
106-
let neuron_subaccount = Subaccount(neuron_subaccount);
107-
if governance
98+
let neuron_subaccount = governance
10899
.neuron_store
109-
.has_neuron_with_subaccount(neuron_subaccount)
110-
{
111-
println!(
112-
"{LOG_PREFIX}Warning: An improbable event has occurred: a neuron \
113-
subaccount was generated randomly but there is already a neuron with the same \
114-
subaccount."
115-
);
116-
return Err(GovernanceError::new_with_message(
117-
ErrorType::Unavailable,
118-
"There is already a neuron with the same subaccount.",
119-
));
120-
}
100+
.new_neuron_subaccount(&mut *governance.randomness)?;
121101
let neuron_id = governance
122102
.neuron_store
123103
.new_neuron_id(&mut *governance.randomness)?;

rs/nns/governance/src/neuron_store.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ pub enum NeuronStoreError {
6868
neuron_id: NeuronId,
6969
},
7070
NeuronIdGenerationUnavailable,
71+
NeuronSubaccountGenerationUnavailable,
7172
InvalidOperation {
7273
reason: String,
7374
},
@@ -172,6 +173,13 @@ impl Display for NeuronStoreError {
172173
Likely due to uninitialized RNG."
173174
)
174175
}
176+
NeuronStoreError::NeuronSubaccountGenerationUnavailable => {
177+
write!(
178+
f,
179+
"Neuron subaccount generation is not available currently. \
180+
Likely due to uninitialized RNG."
181+
)
182+
}
175183
NeuronStoreError::InvalidOperation { reason } => {
176184
write!(f, "Invalid operation: {reason}")
177185
}
@@ -198,6 +206,7 @@ impl From<NeuronStoreError> for GovernanceError {
198206
NeuronStoreError::InvalidData { .. } => ErrorType::PreconditionFailed,
199207
NeuronStoreError::NotAuthorizedToGetFullNeuron { .. } => ErrorType::NotAuthorized,
200208
NeuronStoreError::NeuronIdGenerationUnavailable => ErrorType::Unavailable,
209+
NeuronStoreError::NeuronSubaccountGenerationUnavailable => ErrorType::Unavailable,
201210
NeuronStoreError::InvalidOperation { .. } => ErrorType::PreconditionFailed,
202211
NeuronStoreError::TotalPotentialVotingPowerOverflow => ErrorType::PreconditionFailed,
203212
NeuronStoreError::TotalDecidingVotingPowerOverflow => ErrorType::PreconditionFailed,
@@ -304,6 +313,32 @@ impl NeuronStore {
304313
}
305314
}
306315

316+
/// Generates a unique random neuron subaccount, retrying on collision.
317+
pub fn new_neuron_subaccount(
318+
&self,
319+
random: &mut dyn RandomnessGenerator,
320+
) -> Result<Subaccount, NeuronStoreError> {
321+
loop {
322+
let subaccount = Subaccount(
323+
random
324+
.random_byte_array()
325+
.map_err(|_| NeuronStoreError::NeuronSubaccountGenerationUnavailable)?,
326+
);
327+
328+
if !self.has_neuron_with_subaccount(subaccount) {
329+
return Ok(subaccount);
330+
}
331+
332+
ic_cdk::println!(
333+
"{}WARNING: A suspiciously near-impossible event has just occurred: \
334+
we randomly picked a neuron subaccount, but it's already used: \
335+
{:?}. Trying again...",
336+
LOG_PREFIX,
337+
subaccount,
338+
);
339+
}
340+
}
341+
307342
/// Returns if store contains a Neuron by id
308343
pub fn contains(&self, neuron_id: NeuronId) -> bool {
309344
with_stable_neuron_store(|stable_neuron_store| stable_neuron_store.contains(neuron_id))

rs/nns/governance/src/neuron_store/neuron_store_tests.rs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use super::*;
22
use crate::{
3-
governance::max_dissolve_delay_seconds,
3+
governance::{RandomnessGenerator, RngError, max_dissolve_delay_seconds},
44
neuron::{DissolveStateAndAge, NeuronBuilder},
55
pb::v1::{
66
BallotInfo, Followees, KnownNeuronData, MaturityDisbursement, NeuronDissolveStateSnapshot,
@@ -1194,3 +1194,80 @@ fn test_clamp_dissolve_delay_for_all_neurons_multiple_neurons() {
11941194
})
11951195
.unwrap();
11961196
}
1197+
1198+
/// A mock RNG that returns predefined byte arrays in sequence.
1199+
struct SequentialMockRng {
1200+
byte_arrays: Vec<[u8; 32]>,
1201+
index: usize,
1202+
}
1203+
1204+
impl SequentialMockRng {
1205+
fn new(byte_arrays: Vec<[u8; 32]>) -> Self {
1206+
Self {
1207+
byte_arrays,
1208+
index: 0,
1209+
}
1210+
}
1211+
}
1212+
1213+
impl RandomnessGenerator for SequentialMockRng {
1214+
fn random_u64(&mut self) -> Result<u64, RngError> {
1215+
unimplemented!("not needed for subaccount tests")
1216+
}
1217+
1218+
fn random_byte_array(&mut self) -> Result<[u8; 32], RngError> {
1219+
let result = *self
1220+
.byte_arrays
1221+
.get(self.index)
1222+
.expect("SequentialMockRng exhausted predefined byte arrays");
1223+
self.index += 1;
1224+
Ok(result)
1225+
}
1226+
1227+
fn seed_rng(&mut self, _seed: [u8; 32]) {}
1228+
1229+
fn get_rng_seed(&self) -> Option<[u8; 32]> {
1230+
None
1231+
}
1232+
}
1233+
1234+
#[test]
1235+
fn test_new_neuron_subaccount_succeeds_without_collision() {
1236+
let neuron_store = NeuronStore::new(BTreeMap::new());
1237+
1238+
let expected_bytes = [42_u8; 32];
1239+
let mut rng = SequentialMockRng::new(vec![expected_bytes]);
1240+
1241+
let observed = neuron_store.new_neuron_subaccount(&mut rng);
1242+
1243+
assert_eq!(observed, Ok(Subaccount(expected_bytes)));
1244+
}
1245+
1246+
#[test]
1247+
fn test_new_neuron_subaccount_retries_on_collision() {
1248+
let colliding_bytes = [1_u8; 32];
1249+
let unique_bytes = [2_u8; 32];
1250+
1251+
// Create a neuron store with a neuron whose subaccount matches colliding_bytes.
1252+
let neuron = NeuronBuilder::new(
1253+
NeuronId { id: 1 },
1254+
Subaccount(colliding_bytes),
1255+
PrincipalId::new_user_test_id(1),
1256+
DissolveStateAndAge::NotDissolving {
1257+
dissolve_delay_seconds: 1,
1258+
aging_since_timestamp_seconds: 0,
1259+
},
1260+
CREATED_TIMESTAMP_SECONDS,
1261+
)
1262+
.build();
1263+
let neuron_store = NeuronStore::new(btreemap! { 1 => neuron });
1264+
1265+
// The first call returns the colliding subaccount; the second returns a unique one.
1266+
let mut rng = SequentialMockRng::new(vec![colliding_bytes, unique_bytes]);
1267+
1268+
let observed = neuron_store.new_neuron_subaccount(&mut rng);
1269+
1270+
assert_eq!(observed, Ok(Subaccount(unique_bytes)));
1271+
// Verify that both byte arrays were consumed (i.e., a retry happened).
1272+
assert_eq!(rng.index, 2);
1273+
}

0 commit comments

Comments
 (0)