Skip to content

Commit e2a9499

Browse files
committed
feat: support multiple operator mnemonics for attestation signing
Add support for configuring multiple operator mnemonics to fix 'no attestation' errors for allocations created with different operator keys (e.g., after key rotation or migration). Changes: - Add config field alongside existing - Update attestation signer to try all configured mnemonics when deriving wallet for an allocation - Maintain full backward compatibility - existing configs work unchanged Configuration example:
1 parent 7a20446 commit e2a9499

File tree

8 files changed

+641
-79
lines changed

8 files changed

+641
-79
lines changed

crates/attestation/src/lib.rs

Lines changed: 347 additions & 60 deletions
Large diffs are not rendered by default.

crates/config/maximal-config-example.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,21 @@
2222

2323
[indexer]
2424
indexer_address = "0x1111111111111111111111111111111111111111"
25+
# Single operator mnemonic (for most setups)
2526
operator_mnemonic = "celery smart tip orange scare van steel radio dragon joy alarm crane"
27+
# For key rotation or migration, you can specify multiple mnemonics.
28+
# All configured mnemonics are tried when creating attestation signers,
29+
# allowing allocations created with different operator keys to work.
30+
#
31+
# Notes:
32+
# - The first mnemonic (from operator_mnemonic, or first in operator_mnemonics)
33+
# is used as the "primary" identity shown on the /info endpoint.
34+
# - Both fields can be used together; duplicates are automatically ignored.
35+
# - Order matters: put your current/primary mnemonic first.
36+
#
37+
# operator_mnemonics = [
38+
# "previous mnemonic phrase here if you rotated keys"
39+
# ]
2640

2741
[metrics]
2842
# Port to serve metrics. This one should stay private.

crates/config/minimal-config-example.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212

1313
[indexer]
1414
indexer_address = "0x1111111111111111111111111111111111111111"
15+
# Single operator mnemonic (for most setups)
1516
operator_mnemonic = "celery smart tip orange scare van steel radio dragon joy alarm crane"
17+
# For key rotation or migration, you can specify multiple mnemonics:
18+
# operator_mnemonics = [
19+
# "celery smart tip orange scare van steel radio dragon joy alarm crane",
20+
# "previous mnemonic phrase here if you rotated keys"
21+
# ]
1622

1723
[database]
1824
# The URL of the Postgres database used for the indexer components. The same database

crates/config/src/config.rs

Lines changed: 230 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,32 @@ impl Config {
155155

156156
// custom validation of the values
157157
fn validate(&self) -> Result<(), String> {
158+
// Validate that at least one operator mnemonic is configured
159+
if self.indexer.operator_mnemonic.is_none()
160+
&& self
161+
.indexer
162+
.operator_mnemonics
163+
.as_ref()
164+
.is_none_or(|v| v.is_empty())
165+
{
166+
return Err("No operator mnemonic configured. \
167+
Set either `indexer.operator_mnemonic` or `indexer.operator_mnemonics`."
168+
.to_string());
169+
}
170+
171+
// Warn if the same mnemonic appears in both fields (will be deduplicated)
172+
if let (Some(singular), Some(plural)) = (
173+
&self.indexer.operator_mnemonic,
174+
&self.indexer.operator_mnemonics,
175+
) {
176+
if plural.contains(singular) {
177+
tracing::warn!(
178+
"The same mnemonic appears in both `operator_mnemonic` and \
179+
`operator_mnemonics`. The duplicate will be ignored."
180+
);
181+
}
182+
}
183+
158184
match &self.tap.rav_request.trigger_value_divisor {
159185
x if *x <= 1.into() => {
160186
return Err("trigger_value_divisor must be greater than 1".to_string())
@@ -290,7 +316,44 @@ impl Config {
290316
#[cfg_attr(test, derive(PartialEq))]
291317
pub struct IndexerConfig {
292318
pub indexer_address: Address,
293-
pub operator_mnemonic: Mnemonic,
319+
/// Single operator mnemonic (for backward compatibility).
320+
/// Use `operator_mnemonics` for multiple mnemonics.
321+
#[serde(default)]
322+
pub operator_mnemonic: Option<Mnemonic>,
323+
/// Multiple operator mnemonics for supporting allocations created
324+
/// with different operator keys (e.g., after key rotation or migration).
325+
#[serde(default)]
326+
pub operator_mnemonics: Option<Vec<Mnemonic>>,
327+
}
328+
329+
impl IndexerConfig {
330+
/// Get all configured operator mnemonics.
331+
///
332+
/// Returns mnemonics from both `operator_mnemonic` (singular) and
333+
/// `operator_mnemonics` (plural) fields combined. This allows for
334+
/// backward compatibility while supporting multiple mnemonics.
335+
///
336+
/// Note: Config validation ensures at least one mnemonic is configured
337+
/// before this method is called. Returns an empty Vec only if called
338+
/// on an unvalidated config.
339+
pub fn get_operator_mnemonics(&self) -> Vec<Mnemonic> {
340+
let mut mnemonics = Vec::new();
341+
342+
if let Some(ref mnemonic) = self.operator_mnemonic {
343+
mnemonics.push(mnemonic.clone());
344+
}
345+
346+
if let Some(ref additional) = self.operator_mnemonics {
347+
for m in additional {
348+
// Avoid duplicates if the same mnemonic is in both fields
349+
if !mnemonics.iter().any(|existing| existing == m) {
350+
mnemonics.push(m.clone());
351+
}
352+
}
353+
}
354+
355+
mnemonics
356+
}
294357
}
295358

296359
#[derive(Debug, Deserialize, Clone)]
@@ -752,12 +815,13 @@ mod tests {
752815
str::FromStr,
753816
};
754817

818+
use bip39::Mnemonic;
755819
use figment::value::Uncased;
756820
use sealed_test::prelude::*;
757821
use thegraph_core::alloy::primitives::{address, Address, FixedBytes, U256};
758822
use tracing_test::traced_test;
759823

760-
use super::{DatabaseConfig, SHARED_PREFIX};
824+
use super::{DatabaseConfig, IndexerConfig, SHARED_PREFIX};
761825
use crate::{Config, ConfigPrefix};
762826

763827
#[test]
@@ -1128,4 +1192,168 @@ mod tests {
11281192
test_value
11291193
);
11301194
}
1195+
1196+
const MNEMONIC_1: &str =
1197+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1198+
const MNEMONIC_2: &str = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong";
1199+
const MNEMONIC_3: &str =
1200+
"legal winner thank year wave sausage worth useful legal winner thank yellow";
1201+
1202+
/// Test that duplicate mnemonics in both operator_mnemonic and operator_mnemonics
1203+
/// are deduplicated.
1204+
#[test]
1205+
fn test_get_operator_mnemonics_deduplication() {
1206+
let mnemonic = Mnemonic::from_str(MNEMONIC_1).unwrap();
1207+
1208+
// Same mnemonic in both fields
1209+
let config = IndexerConfig {
1210+
indexer_address: Address::ZERO,
1211+
operator_mnemonic: Some(mnemonic.clone()),
1212+
operator_mnemonics: Some(vec![mnemonic.clone()]),
1213+
};
1214+
1215+
let result = config.get_operator_mnemonics();
1216+
1217+
assert_eq!(
1218+
result.len(),
1219+
1,
1220+
"Duplicate mnemonics should be deduplicated"
1221+
);
1222+
assert_eq!(result[0], mnemonic);
1223+
}
1224+
1225+
/// Test that order is preserved: singular field first, then plural field entries.
1226+
#[test]
1227+
fn test_get_operator_mnemonics_order_preserved() {
1228+
let mnemonic_1 = Mnemonic::from_str(MNEMONIC_1).unwrap();
1229+
let mnemonic_2 = Mnemonic::from_str(MNEMONIC_2).unwrap();
1230+
let mnemonic_3 = Mnemonic::from_str(MNEMONIC_3).unwrap();
1231+
1232+
let config = IndexerConfig {
1233+
indexer_address: Address::ZERO,
1234+
operator_mnemonic: Some(mnemonic_1.clone()),
1235+
operator_mnemonics: Some(vec![mnemonic_2.clone(), mnemonic_3.clone()]),
1236+
};
1237+
1238+
let result = config.get_operator_mnemonics();
1239+
1240+
assert_eq!(result.len(), 3, "Should have 3 distinct mnemonics");
1241+
assert_eq!(
1242+
result[0], mnemonic_1,
1243+
"First should be from operator_mnemonic"
1244+
);
1245+
assert_eq!(
1246+
result[1], mnemonic_2,
1247+
"Second should be first from operator_mnemonics"
1248+
);
1249+
assert_eq!(
1250+
result[2], mnemonic_3,
1251+
"Third should be second from operator_mnemonics"
1252+
);
1253+
}
1254+
1255+
/// Test combining both fields with partial overlap produces correct merged result.
1256+
#[test]
1257+
fn test_get_operator_mnemonics_combined_with_overlap() {
1258+
let mnemonic_1 = Mnemonic::from_str(MNEMONIC_1).unwrap();
1259+
let mnemonic_2 = Mnemonic::from_str(MNEMONIC_2).unwrap();
1260+
let mnemonic_3 = Mnemonic::from_str(MNEMONIC_3).unwrap();
1261+
1262+
// mnemonic_1 is in both fields (should be deduplicated)
1263+
// mnemonic_2 and mnemonic_3 are only in operator_mnemonics
1264+
let config = IndexerConfig {
1265+
indexer_address: Address::ZERO,
1266+
operator_mnemonic: Some(mnemonic_1.clone()),
1267+
operator_mnemonics: Some(vec![
1268+
mnemonic_1.clone(), // duplicate
1269+
mnemonic_2.clone(),
1270+
mnemonic_3.clone(),
1271+
]),
1272+
};
1273+
1274+
let result = config.get_operator_mnemonics();
1275+
1276+
assert_eq!(
1277+
result.len(),
1278+
3,
1279+
"Should have 3 mnemonics after deduplication"
1280+
);
1281+
assert_eq!(result[0], mnemonic_1, "First should be mnemonic_1");
1282+
assert_eq!(result[1], mnemonic_2, "Second should be mnemonic_2");
1283+
assert_eq!(result[2], mnemonic_3, "Third should be mnemonic_3");
1284+
}
1285+
1286+
/// Test that only operator_mnemonic (singular) works correctly.
1287+
#[test]
1288+
fn test_get_operator_mnemonics_singular_only() {
1289+
let mnemonic = Mnemonic::from_str(MNEMONIC_1).unwrap();
1290+
1291+
let config = IndexerConfig {
1292+
indexer_address: Address::ZERO,
1293+
operator_mnemonic: Some(mnemonic.clone()),
1294+
operator_mnemonics: None,
1295+
};
1296+
1297+
let result = config.get_operator_mnemonics();
1298+
1299+
assert_eq!(result.len(), 1);
1300+
assert_eq!(result[0], mnemonic);
1301+
}
1302+
1303+
/// Test that only operator_mnemonics (plural) works correctly.
1304+
#[test]
1305+
fn test_get_operator_mnemonics_plural_only() {
1306+
let mnemonic_1 = Mnemonic::from_str(MNEMONIC_1).unwrap();
1307+
let mnemonic_2 = Mnemonic::from_str(MNEMONIC_2).unwrap();
1308+
1309+
let config = IndexerConfig {
1310+
indexer_address: Address::ZERO,
1311+
operator_mnemonic: None,
1312+
operator_mnemonics: Some(vec![mnemonic_1.clone(), mnemonic_2.clone()]),
1313+
};
1314+
1315+
let result = config.get_operator_mnemonics();
1316+
1317+
assert_eq!(result.len(), 2);
1318+
assert_eq!(result[0], mnemonic_1);
1319+
assert_eq!(result[1], mnemonic_2);
1320+
}
1321+
1322+
/// Test that config validation rejects when no operator mnemonics are configured.
1323+
#[sealed_test(files = ["minimal-config-example.toml"])]
1324+
fn test_validation_rejects_missing_operator_mnemonics() {
1325+
let mut minimal_config: toml::Value = toml::from_str(
1326+
fs::read_to_string("minimal-config-example.toml")
1327+
.unwrap()
1328+
.as_str(),
1329+
)
1330+
.unwrap();
1331+
1332+
// Remove operator_mnemonic from the config
1333+
minimal_config
1334+
.get_mut("indexer")
1335+
.unwrap()
1336+
.as_table_mut()
1337+
.unwrap()
1338+
.remove("operator_mnemonic");
1339+
1340+
// Save to temp file
1341+
let temp_config_path = tempfile::NamedTempFile::new().unwrap();
1342+
fs::write(
1343+
temp_config_path.path(),
1344+
toml::to_string(&minimal_config).unwrap(),
1345+
)
1346+
.unwrap();
1347+
1348+
// Parse should fail due to validation
1349+
let result = Config::parse(
1350+
ConfigPrefix::Service,
1351+
Some(PathBuf::from(temp_config_path.path())).as_ref(),
1352+
);
1353+
1354+
assert!(result.is_err());
1355+
assert!(result
1356+
.unwrap_err()
1357+
.contains("No operator mnemonic configured"));
1358+
}
11311359
}

crates/monitor/src/attestation.rs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,30 @@ use crate::{AllocationWatcher, DisputeManagerWatcher};
1919
pub type AttestationWatcher = Receiver<HashMap<Address, AttestationSigner>>;
2020

2121
/// An always up-to-date list of attestation signers, one for each of the indexer's allocations.
22+
///
23+
/// This function accepts multiple mnemonics to support allocations created with different
24+
/// operator keys (e.g., after key rotation or migration).
2225
pub fn attestation_signers(
2326
indexer_allocations_rx: AllocationWatcher,
24-
indexer_mnemonic: Mnemonic,
27+
indexer_mnemonics: Vec<Mnemonic>,
2528
chain_id: ChainId,
2629
dispute_manager_rx: DisputeManagerWatcher,
2730
) -> AttestationWatcher {
2831
let attestation_signers_map: &'static Mutex<HashMap<Address, AttestationSigner>> =
2932
Box::leak(Box::new(Mutex::new(HashMap::new())));
30-
let indexer_mnemonic = Arc::new(indexer_mnemonic.to_string());
33+
let indexer_mnemonics: Arc<[String]> = indexer_mnemonics
34+
.iter()
35+
.map(|m| m.to_string())
36+
.collect::<Vec<_>>()
37+
.into();
3138

3239
join_and_map_watcher(
3340
indexer_allocations_rx,
3441
dispute_manager_rx,
3542
move |(allocation, dispute)| {
36-
let indexer_mnemonic = indexer_mnemonic.clone();
43+
let indexer_mnemonics = indexer_mnemonics.clone();
3744
modify_signers(
38-
&indexer_mnemonic,
45+
&indexer_mnemonics,
3946
chain_id,
4047
attestation_signers_map,
4148
&allocation,
@@ -44,8 +51,9 @@ pub fn attestation_signers(
4451
},
4552
)
4653
}
54+
4755
fn modify_signers(
48-
indexer_mnemonic: &str,
56+
indexer_mnemonics: &[String],
4957
chain_id: ChainId,
5058
attestation_signers_map: &'static Mutex<HashMap<Address, AttestationSigner>>,
5159
allocations: &HashMap<Address, Allocation>,
@@ -62,11 +70,16 @@ fn modify_signers(
6270
allocation_id = ?allocation.id,
6371
deployment_id = ?allocation.subgraph_deployment.id,
6472
created_at_epoch = allocation.created_at_epoch,
73+
mnemonic_count = indexer_mnemonics.len(),
6574
"Attempting to create attestation signer for allocation"
6675
);
6776

68-
let signer =
69-
AttestationSigner::new(indexer_mnemonic, allocation, chain_id, *dispute_manager);
77+
let signer = AttestationSigner::new_with_mnemonics(
78+
indexer_mnemonics,
79+
allocation,
80+
chain_id,
81+
*dispute_manager,
82+
);
7083
match signer {
7184
Ok(signer) => {
7285
tracing::debug!(
@@ -80,6 +93,7 @@ fn modify_signers(
8093
allocation_id = ?allocation.id,
8194
deployment_id = ?allocation.subgraph_deployment.id,
8295
created_at_epoch = allocation.created_at_epoch,
96+
mnemonic_count = indexer_mnemonics.len(),
8397
error = %e,
8498
"Failed to establish signer for allocation"
8599
);
@@ -111,7 +125,7 @@ mod tests {
111125
let (_, dispute_manager_rx) = watch::channel(DISPUTE_MANAGER_ADDRESS);
112126
let mut signers = attestation_signers(
113127
allocations_rx,
114-
INDEXER_MNEMONIC.clone(),
128+
vec![INDEXER_MNEMONIC.clone()],
115129
1,
116130
dispute_manager_rx,
117131
);

crates/service/src/middleware/attestation_signer.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ mod tests {
6161
let (_, dispute_manager_rx) = watch::channel(DISPUTE_MANAGER_ADDRESS);
6262
let attestation_signers = attestation_signers(
6363
allocations_rx,
64-
INDEXER_MNEMONIC.clone(),
64+
vec![INDEXER_MNEMONIC.clone()],
6565
1,
6666
dispute_manager_rx,
6767
);

0 commit comments

Comments
 (0)