Skip to content

Commit f42fcbc

Browse files
authored
feat: support multiple operator mnemonics for attestation signing (#884)
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 f42fcbc

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)