From e2a94999a74f0fb950c126c761eedfb227e0cf37 Mon Sep 17 00:00:00 2001 From: Joseph Livesey Date: Thu, 4 Dec 2025 12:48:22 -0500 Subject: [PATCH] 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: --- crates/attestation/src/lib.rs | 407 +++++++++++++++--- crates/config/maximal-config-example.toml | 14 + crates/config/minimal-config-example.toml | 6 + crates/config/src/config.rs | 232 +++++++++- crates/monitor/src/attestation.rs | 30 +- .../src/middleware/attestation_signer.rs | 2 +- crates/service/src/service/router.rs | 26 +- crates/service/tests/router_test.rs | 3 +- 8 files changed, 641 insertions(+), 79 deletions(-) diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 82ff0d310..78534ded3 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -125,14 +125,43 @@ pub struct AttestationSigner { } impl AttestationSigner { + /// Create an attestation signer for an allocation using a single mnemonic. + /// + /// For supporting multiple operator mnemonics (e.g., after key rotation), + /// use [`AttestationSigner::new_with_mnemonics`] instead. pub fn new( indexer_mnemonic: &str, allocation: &Allocation, chain_id: ChainId, dispute_manager: Address, + ) -> Result { + Self::new_with_mnemonics(&[indexer_mnemonic], allocation, chain_id, dispute_manager) + } + + /// Create an attestation signer for an allocation using multiple mnemonics. + /// + /// This function tries each mnemonic in order until one produces a wallet + /// that matches the allocation ID. This is useful when an indexer has + /// rotated operator keys or has allocations created with different keys. + /// + /// # Arguments + /// + /// * `indexer_mnemonics` - A slice of mnemonic phrases to try + /// * `allocation` - The allocation to create a signer for + /// * `chain_id` - The chain ID for EIP-712 domain + /// * `dispute_manager` - The dispute manager address for EIP-712 domain + /// + /// # Errors + /// + /// Returns an error if no mnemonic produces a wallet matching the allocation. + pub fn new_with_mnemonics( + indexer_mnemonics: &[impl AsRef], + allocation: &Allocation, + chain_id: ChainId, + dispute_manager: Address, ) -> Result { // Recreate a wallet that has the same address as the allocation - let wallet = wallet_for_allocation(indexer_mnemonic, allocation)?; + let wallet = wallet_for_allocation_multi(indexer_mnemonics, allocation)?; Ok(Self { deployment: allocation.subgraph_deployment.id, @@ -163,98 +192,117 @@ impl AttestationSigner { } } -fn wallet_for_allocation( - indexer_mnemonic: &str, +/// Try to find a wallet matching the allocation using multiple mnemonics. +/// +/// For each mnemonic, tries 200 key combinations (100 indices × 2 epochs). +/// Returns the first wallet that matches the allocation ID. +fn wallet_for_allocation_multi( + indexer_mnemonics: &[impl AsRef], allocation: &Allocation, ) -> Result { tracing::debug!( - "Starting wallet derivation for allocation {}, deployment {}, epoch {}", + "Starting wallet derivation for allocation {}, deployment {}, epoch {}, trying {} mnemonic(s)", allocation.id, allocation.subgraph_deployment.id, - allocation.created_at_epoch + allocation.created_at_epoch, + indexer_mnemonics.len() ); - // Guess the allocation index by enumerating all indexes in the - // range [0, 100] and checking for a match - for i in 0..100 { - // The allocation was either created at the epoch it intended to or one - // epoch later. So try both. - for created_at_epoch in [allocation.created_at_epoch, allocation.created_at_epoch - 1] { - // The allocation ID is the address of a unique key pair, we just - // need to find the right one by enumerating them all - let wallet = derive_key_pair( - indexer_mnemonic, - created_at_epoch, - &allocation.subgraph_deployment.id, - i, - )?; + for (mnemonic_idx, indexer_mnemonic) in indexer_mnemonics.iter().enumerate() { + let mnemonic_str = indexer_mnemonic.as_ref(); + + // Guess the allocation index by enumerating all indexes in the + // range [0, 100] and checking for a match + for i in 0..100 { + // The allocation was either created at the epoch it intended to or one + // epoch later. So try both. + for created_at_epoch in [allocation.created_at_epoch, allocation.created_at_epoch - 1] { + // The allocation ID is the address of a unique key pair, we just + // need to find the right one by enumerating them all + let wallet = derive_key_pair( + mnemonic_str, + created_at_epoch, + &allocation.subgraph_deployment.id, + i, + )?; - let wallet_address = wallet.address(); + let wallet_address = wallet.address(); - if i < 5 || (i % 20 == 0) { - // Log first 5 attempts and every 20th attempt - tracing::debug!( - "Derivation attempt: epoch={}, index={}, derived_address={}, target_allocation={}", - created_at_epoch, i, wallet_address, allocation.id - ); - } + if i < 5 || (i % 20 == 0) { + // Log first 5 attempts and every 20th attempt + tracing::debug!( + "Derivation attempt: mnemonic={}, epoch={}, index={}, derived_address={}, target_allocation={}", + mnemonic_idx, created_at_epoch, i, wallet_address, allocation.id + ); + } - // Check if wallet address matches allocation ID - // This works for both Legacy (V1) and Horizon (V2) as both use 20-byte allocation IDs - if wallet_address == allocation.id { - tracing::debug!( - "Found matching wallet! epoch={}, index={}, address={}", - created_at_epoch, - i, - wallet_address - ); - return Ok(wallet); + // Check if wallet address matches allocation ID + // This works for both Legacy (V1) and Horizon (V2) as both use 20-byte allocation IDs + if wallet_address == allocation.id { + tracing::debug!( + "Found matching wallet! mnemonic={}, epoch={}, index={}, address={}", + mnemonic_idx, + created_at_epoch, + i, + wallet_address + ); + return Ok(wallet); + } } } } // Enhanced error reporting for troubleshooting + let mnemonic_count = indexer_mnemonics.len(); + let combinations_tried = mnemonic_count * 200; tracing::warn!( - "Cannot derive attestation signer for allocation {} after trying 200 key combinations. \ - The reason is unclear - this could be a configuration issue, but we're not certain.", - allocation.id + "Cannot derive attestation signer for allocation {} after trying {} key combinations \ + across {} mnemonic(s). This allocation may have been created with a different operator key.", + allocation.id, + combinations_tried, + mnemonic_count ); tracing::debug!( "What we tried: allocation_id={}, deployment={}, created_at_epoch={}, \ - tested epochs {} and {}, tested indices 0-99 for each epoch", + tested epochs {} and {}, tested indices 0-99 for each epoch across {} mnemonic(s)", allocation.id, allocation.subgraph_deployment.id, allocation.created_at_epoch, allocation.created_at_epoch, - allocation.created_at_epoch - 1 + allocation.created_at_epoch - 1, + mnemonic_count ); - // Show what we actually derived to help with troubleshooting - tracing::debug!("Here's what our key derivation produced instead:"); - for &epoch in [allocation.created_at_epoch, allocation.created_at_epoch - 1].iter() { - for i in 0..3 { - if let Ok(wallet) = derive_key_pair( - indexer_mnemonic, - epoch, - &allocation.subgraph_deployment.id, - i, - ) { - tracing::debug!( - " epoch={}, index={}, we_derived={}", + // Show what we actually derived to help with troubleshooting (using first mnemonic) + if let Some(first_mnemonic) = indexer_mnemonics.first() { + tracing::debug!("Here's what our key derivation produced (first mnemonic):"); + for &epoch in [allocation.created_at_epoch, allocation.created_at_epoch - 1].iter() { + for i in 0..3 { + if let Ok(wallet) = derive_key_pair( + first_mnemonic.as_ref(), epoch, + &allocation.subgraph_deployment.id, i, - wallet.address() - ); + ) { + tracing::debug!( + " epoch={}, index={}, we_derived={}", + epoch, + i, + wallet.address() + ); + } } } } Err(anyhow::anyhow!( - "No key combination we tried matched allocation {}. \ - We tested 200 different combinations but none produced this allocation ID. \ - Check the debug logs above to see what we actually derived.", - allocation.id + "No key combination matched allocation {} after trying {} combinations across {} mnemonic(s). \ + If this allocation was created with a different operator key, add that mnemonic to \ + `indexer.operator_mnemonics` in your configuration.", + allocation.id, + combinations_tried, + mnemonic_count )) } @@ -643,4 +691,243 @@ mod tests { ) .is_err()); } + + // Second mnemonic for multi-mnemonic fallback tests + const SECOND_MNEMONIC: &str = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"; + + /// Test that new_with_mnemonics correctly falls back to the second mnemonic + /// when the first one doesn't match the allocation. + #[test] + fn test_multi_mnemonic_fallback_to_second() { + // This allocation was created with INDEXER_OPERATOR_MNEMONIC at epoch 940, index 2 + let allocation = Allocation { + id: address!("a171cd12c3dde7eb8fe7717a0bcd06f3ffa65658"), + status: AllocationStatus::Null, + subgraph_deployment: SubgraphDeployment { + id: DeploymentId::from_str( + "0xbbde25a2c85f55b53b7698b9476610c3d1202d88870e66502ab0076b7218f98a", + ) + .unwrap(), + denied_at: None, + }, + indexer: Address::ZERO, + allocated_tokens: U256::ZERO, + created_at_epoch: 940, + created_at_block_hash: "".to_string(), + closed_at_epoch: None, + closed_at_epoch_start_block_hash: None, + previous_epoch_start_block_hash: None, + poi: None, + query_fee_rebates: None, + query_fees_collected: None, + is_legacy: false, + }; + + // Use SECOND_MNEMONIC first (won't match), then INDEXER_OPERATOR_MNEMONIC (will match) + let mnemonics = [SECOND_MNEMONIC, INDEXER_OPERATOR_MNEMONIC]; + + let signer = AttestationSigner::new_with_mnemonics( + &mnemonics, + &allocation, + 1, + DISPUTE_MANAGER_ADDRESS, + ); + + assert!( + signer.is_ok(), + "Should successfully create signer using the second mnemonic" + ); + + // Verify the signer produces the same key as when using the correct mnemonic directly + let expected_wallet = derive_key_pair( + INDEXER_OPERATOR_MNEMONIC, + 940, + &allocation.subgraph_deployment.id, + 2, + ) + .unwrap(); + + let signer = signer.unwrap(); + assert_eq!( + PrivateKeySigner::from_signing_key(signer.signer), + expected_wallet, + "Signer should use the matching mnemonic" + ); + } + + /// Test that new_with_mnemonics correctly falls back through multiple mnemonics + /// until finding one that matches (tests with 3 mnemonics where only the last matches). + #[test] + fn test_multi_mnemonic_fallback_to_third() { + // Third mnemonic that also won't match + const THIRD_MNEMONIC: &str = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + + // This allocation was created with INDEXER_OPERATOR_MNEMONIC at epoch 940, index 2 + let allocation = Allocation { + id: address!("a171cd12c3dde7eb8fe7717a0bcd06f3ffa65658"), + status: AllocationStatus::Null, + subgraph_deployment: SubgraphDeployment { + id: DeploymentId::from_str( + "0xbbde25a2c85f55b53b7698b9476610c3d1202d88870e66502ab0076b7218f98a", + ) + .unwrap(), + denied_at: None, + }, + indexer: Address::ZERO, + allocated_tokens: U256::ZERO, + created_at_epoch: 940, + created_at_block_hash: "".to_string(), + closed_at_epoch: None, + closed_at_epoch_start_block_hash: None, + previous_epoch_start_block_hash: None, + poi: None, + query_fee_rebates: None, + query_fees_collected: None, + is_legacy: false, + }; + + // Use two non-matching mnemonics first, then the correct one last + let mnemonics = [SECOND_MNEMONIC, THIRD_MNEMONIC, INDEXER_OPERATOR_MNEMONIC]; + + let signer = AttestationSigner::new_with_mnemonics( + &mnemonics, + &allocation, + 1, + DISPUTE_MANAGER_ADDRESS, + ); + + assert!( + signer.is_ok(), + "Should successfully create signer using the third mnemonic" + ); + + // Verify the signer produces the same key as when using the correct mnemonic directly + let expected_wallet = derive_key_pair( + INDEXER_OPERATOR_MNEMONIC, + 940, + &allocation.subgraph_deployment.id, + 2, + ) + .unwrap(); + + let signer = signer.unwrap(); + assert_eq!( + PrivateKeySigner::from_signing_key(signer.signer), + expected_wallet, + "Signer should use the matching mnemonic (third one)" + ); + } + + /// Test that new_with_mnemonics uses the first mnemonic when it matches, + /// not unnecessarily falling back. + #[test] + fn test_multi_mnemonic_uses_first_when_matches() { + // This allocation was created with INDEXER_OPERATOR_MNEMONIC at epoch 940, index 2 + let allocation = Allocation { + id: address!("a171cd12c3dde7eb8fe7717a0bcd06f3ffa65658"), + status: AllocationStatus::Null, + subgraph_deployment: SubgraphDeployment { + id: DeploymentId::from_str( + "0xbbde25a2c85f55b53b7698b9476610c3d1202d88870e66502ab0076b7218f98a", + ) + .unwrap(), + denied_at: None, + }, + indexer: Address::ZERO, + allocated_tokens: U256::ZERO, + created_at_epoch: 940, + created_at_block_hash: "".to_string(), + closed_at_epoch: None, + closed_at_epoch_start_block_hash: None, + previous_epoch_start_block_hash: None, + poi: None, + query_fee_rebates: None, + query_fees_collected: None, + is_legacy: false, + }; + + // Put the matching mnemonic first + let mnemonics = [INDEXER_OPERATOR_MNEMONIC, SECOND_MNEMONIC]; + + let signer = AttestationSigner::new_with_mnemonics( + &mnemonics, + &allocation, + 1, + DISPUTE_MANAGER_ADDRESS, + ); + + assert!( + signer.is_ok(), + "Should successfully create signer using the first mnemonic" + ); + + // Verify the signer produces the same key + let expected_wallet = derive_key_pair( + INDEXER_OPERATOR_MNEMONIC, + 940, + &allocation.subgraph_deployment.id, + 2, + ) + .unwrap(); + + let signer = signer.unwrap(); + assert_eq!( + PrivateKeySigner::from_signing_key(signer.signer), + expected_wallet, + "Signer should use the first (matching) mnemonic" + ); + } + + /// Test that new_with_mnemonics returns an error when no mnemonics match. + #[test] + fn test_multi_mnemonic_error_when_none_match() { + const THIRD_MNEMONIC: &str = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + + let allocation = Allocation { + // This address won't match any of our test mnemonics + id: address!("deadbeefcafebabedeadbeefcafebabedeadbeef"), + status: AllocationStatus::Null, + subgraph_deployment: SubgraphDeployment { + id: DeploymentId::from_str( + "0xbbde25a2c85f55b53b7698b9476610c3d1202d88870e66502ab0076b7218f98a", + ) + .unwrap(), + denied_at: None, + }, + indexer: Address::ZERO, + allocated_tokens: U256::ZERO, + created_at_epoch: 940, + created_at_block_hash: "".to_string(), + closed_at_epoch: None, + closed_at_epoch_start_block_hash: None, + previous_epoch_start_block_hash: None, + poi: None, + query_fee_rebates: None, + query_fees_collected: None, + is_legacy: false, + }; + + // None of these will match the allocation + let mnemonics = [SECOND_MNEMONIC, THIRD_MNEMONIC]; + + let result = AttestationSigner::new_with_mnemonics( + &mnemonics, + &allocation, + 1, + DISPUTE_MANAGER_ADDRESS, + ); + + assert!( + result.is_err(), + "Should return error when no mnemonic matches the allocation" + ); + + let error_message = result.unwrap_err().to_string(); + assert!( + error_message.contains("2 mnemonic(s)"), + "Error message should indicate number of mnemonics tried: {error_message}" + ); + } } diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index 89134fbf1..212bf8074 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -22,7 +22,21 @@ [indexer] indexer_address = "0x1111111111111111111111111111111111111111" +# Single operator mnemonic (for most setups) operator_mnemonic = "celery smart tip orange scare van steel radio dragon joy alarm crane" +# For key rotation or migration, you can specify multiple mnemonics. +# All configured mnemonics are tried when creating attestation signers, +# allowing allocations created with different operator keys to work. +# +# Notes: +# - The first mnemonic (from operator_mnemonic, or first in operator_mnemonics) +# is used as the "primary" identity shown on the /info endpoint. +# - Both fields can be used together; duplicates are automatically ignored. +# - Order matters: put your current/primary mnemonic first. +# +# operator_mnemonics = [ +# "previous mnemonic phrase here if you rotated keys" +# ] [metrics] # Port to serve metrics. This one should stay private. diff --git a/crates/config/minimal-config-example.toml b/crates/config/minimal-config-example.toml index af5f4d5f7..c6e48a2eb 100644 --- a/crates/config/minimal-config-example.toml +++ b/crates/config/minimal-config-example.toml @@ -12,7 +12,13 @@ [indexer] indexer_address = "0x1111111111111111111111111111111111111111" +# Single operator mnemonic (for most setups) operator_mnemonic = "celery smart tip orange scare van steel radio dragon joy alarm crane" +# For key rotation or migration, you can specify multiple mnemonics: +# operator_mnemonics = [ +# "celery smart tip orange scare van steel radio dragon joy alarm crane", +# "previous mnemonic phrase here if you rotated keys" +# ] [database] # The URL of the Postgres database used for the indexer components. The same database diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index f42f29471..6d55be084 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -155,6 +155,32 @@ impl Config { // custom validation of the values fn validate(&self) -> Result<(), String> { + // Validate that at least one operator mnemonic is configured + if self.indexer.operator_mnemonic.is_none() + && self + .indexer + .operator_mnemonics + .as_ref() + .is_none_or(|v| v.is_empty()) + { + return Err("No operator mnemonic configured. \ + Set either `indexer.operator_mnemonic` or `indexer.operator_mnemonics`." + .to_string()); + } + + // Warn if the same mnemonic appears in both fields (will be deduplicated) + if let (Some(singular), Some(plural)) = ( + &self.indexer.operator_mnemonic, + &self.indexer.operator_mnemonics, + ) { + if plural.contains(singular) { + tracing::warn!( + "The same mnemonic appears in both `operator_mnemonic` and \ + `operator_mnemonics`. The duplicate will be ignored." + ); + } + } + match &self.tap.rav_request.trigger_value_divisor { x if *x <= 1.into() => { return Err("trigger_value_divisor must be greater than 1".to_string()) @@ -290,7 +316,44 @@ impl Config { #[cfg_attr(test, derive(PartialEq))] pub struct IndexerConfig { pub indexer_address: Address, - pub operator_mnemonic: Mnemonic, + /// Single operator mnemonic (for backward compatibility). + /// Use `operator_mnemonics` for multiple mnemonics. + #[serde(default)] + pub operator_mnemonic: Option, + /// Multiple operator mnemonics for supporting allocations created + /// with different operator keys (e.g., after key rotation or migration). + #[serde(default)] + pub operator_mnemonics: Option>, +} + +impl IndexerConfig { + /// Get all configured operator mnemonics. + /// + /// Returns mnemonics from both `operator_mnemonic` (singular) and + /// `operator_mnemonics` (plural) fields combined. This allows for + /// backward compatibility while supporting multiple mnemonics. + /// + /// Note: Config validation ensures at least one mnemonic is configured + /// before this method is called. Returns an empty Vec only if called + /// on an unvalidated config. + pub fn get_operator_mnemonics(&self) -> Vec { + let mut mnemonics = Vec::new(); + + if let Some(ref mnemonic) = self.operator_mnemonic { + mnemonics.push(mnemonic.clone()); + } + + if let Some(ref additional) = self.operator_mnemonics { + for m in additional { + // Avoid duplicates if the same mnemonic is in both fields + if !mnemonics.iter().any(|existing| existing == m) { + mnemonics.push(m.clone()); + } + } + } + + mnemonics + } } #[derive(Debug, Deserialize, Clone)] @@ -752,12 +815,13 @@ mod tests { str::FromStr, }; + use bip39::Mnemonic; use figment::value::Uncased; use sealed_test::prelude::*; use thegraph_core::alloy::primitives::{address, Address, FixedBytes, U256}; use tracing_test::traced_test; - use super::{DatabaseConfig, SHARED_PREFIX}; + use super::{DatabaseConfig, IndexerConfig, SHARED_PREFIX}; use crate::{Config, ConfigPrefix}; #[test] @@ -1128,4 +1192,168 @@ mod tests { test_value ); } + + const MNEMONIC_1: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const MNEMONIC_2: &str = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"; + const MNEMONIC_3: &str = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + + /// Test that duplicate mnemonics in both operator_mnemonic and operator_mnemonics + /// are deduplicated. + #[test] + fn test_get_operator_mnemonics_deduplication() { + let mnemonic = Mnemonic::from_str(MNEMONIC_1).unwrap(); + + // Same mnemonic in both fields + let config = IndexerConfig { + indexer_address: Address::ZERO, + operator_mnemonic: Some(mnemonic.clone()), + operator_mnemonics: Some(vec![mnemonic.clone()]), + }; + + let result = config.get_operator_mnemonics(); + + assert_eq!( + result.len(), + 1, + "Duplicate mnemonics should be deduplicated" + ); + assert_eq!(result[0], mnemonic); + } + + /// Test that order is preserved: singular field first, then plural field entries. + #[test] + fn test_get_operator_mnemonics_order_preserved() { + let mnemonic_1 = Mnemonic::from_str(MNEMONIC_1).unwrap(); + let mnemonic_2 = Mnemonic::from_str(MNEMONIC_2).unwrap(); + let mnemonic_3 = Mnemonic::from_str(MNEMONIC_3).unwrap(); + + let config = IndexerConfig { + indexer_address: Address::ZERO, + operator_mnemonic: Some(mnemonic_1.clone()), + operator_mnemonics: Some(vec![mnemonic_2.clone(), mnemonic_3.clone()]), + }; + + let result = config.get_operator_mnemonics(); + + assert_eq!(result.len(), 3, "Should have 3 distinct mnemonics"); + assert_eq!( + result[0], mnemonic_1, + "First should be from operator_mnemonic" + ); + assert_eq!( + result[1], mnemonic_2, + "Second should be first from operator_mnemonics" + ); + assert_eq!( + result[2], mnemonic_3, + "Third should be second from operator_mnemonics" + ); + } + + /// Test combining both fields with partial overlap produces correct merged result. + #[test] + fn test_get_operator_mnemonics_combined_with_overlap() { + let mnemonic_1 = Mnemonic::from_str(MNEMONIC_1).unwrap(); + let mnemonic_2 = Mnemonic::from_str(MNEMONIC_2).unwrap(); + let mnemonic_3 = Mnemonic::from_str(MNEMONIC_3).unwrap(); + + // mnemonic_1 is in both fields (should be deduplicated) + // mnemonic_2 and mnemonic_3 are only in operator_mnemonics + let config = IndexerConfig { + indexer_address: Address::ZERO, + operator_mnemonic: Some(mnemonic_1.clone()), + operator_mnemonics: Some(vec![ + mnemonic_1.clone(), // duplicate + mnemonic_2.clone(), + mnemonic_3.clone(), + ]), + }; + + let result = config.get_operator_mnemonics(); + + assert_eq!( + result.len(), + 3, + "Should have 3 mnemonics after deduplication" + ); + assert_eq!(result[0], mnemonic_1, "First should be mnemonic_1"); + assert_eq!(result[1], mnemonic_2, "Second should be mnemonic_2"); + assert_eq!(result[2], mnemonic_3, "Third should be mnemonic_3"); + } + + /// Test that only operator_mnemonic (singular) works correctly. + #[test] + fn test_get_operator_mnemonics_singular_only() { + let mnemonic = Mnemonic::from_str(MNEMONIC_1).unwrap(); + + let config = IndexerConfig { + indexer_address: Address::ZERO, + operator_mnemonic: Some(mnemonic.clone()), + operator_mnemonics: None, + }; + + let result = config.get_operator_mnemonics(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0], mnemonic); + } + + /// Test that only operator_mnemonics (plural) works correctly. + #[test] + fn test_get_operator_mnemonics_plural_only() { + let mnemonic_1 = Mnemonic::from_str(MNEMONIC_1).unwrap(); + let mnemonic_2 = Mnemonic::from_str(MNEMONIC_2).unwrap(); + + let config = IndexerConfig { + indexer_address: Address::ZERO, + operator_mnemonic: None, + operator_mnemonics: Some(vec![mnemonic_1.clone(), mnemonic_2.clone()]), + }; + + let result = config.get_operator_mnemonics(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], mnemonic_1); + assert_eq!(result[1], mnemonic_2); + } + + /// Test that config validation rejects when no operator mnemonics are configured. + #[sealed_test(files = ["minimal-config-example.toml"])] + fn test_validation_rejects_missing_operator_mnemonics() { + let mut minimal_config: toml::Value = toml::from_str( + fs::read_to_string("minimal-config-example.toml") + .unwrap() + .as_str(), + ) + .unwrap(); + + // Remove operator_mnemonic from the config + minimal_config + .get_mut("indexer") + .unwrap() + .as_table_mut() + .unwrap() + .remove("operator_mnemonic"); + + // Save to temp file + let temp_config_path = tempfile::NamedTempFile::new().unwrap(); + fs::write( + temp_config_path.path(), + toml::to_string(&minimal_config).unwrap(), + ) + .unwrap(); + + // Parse should fail due to validation + let result = Config::parse( + ConfigPrefix::Service, + Some(PathBuf::from(temp_config_path.path())).as_ref(), + ); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("No operator mnemonic configured")); + } } diff --git a/crates/monitor/src/attestation.rs b/crates/monitor/src/attestation.rs index eeda01a29..6fe704b6d 100644 --- a/crates/monitor/src/attestation.rs +++ b/crates/monitor/src/attestation.rs @@ -19,23 +19,30 @@ use crate::{AllocationWatcher, DisputeManagerWatcher}; pub type AttestationWatcher = Receiver>; /// An always up-to-date list of attestation signers, one for each of the indexer's allocations. +/// +/// This function accepts multiple mnemonics to support allocations created with different +/// operator keys (e.g., after key rotation or migration). pub fn attestation_signers( indexer_allocations_rx: AllocationWatcher, - indexer_mnemonic: Mnemonic, + indexer_mnemonics: Vec, chain_id: ChainId, dispute_manager_rx: DisputeManagerWatcher, ) -> AttestationWatcher { let attestation_signers_map: &'static Mutex> = Box::leak(Box::new(Mutex::new(HashMap::new()))); - let indexer_mnemonic = Arc::new(indexer_mnemonic.to_string()); + let indexer_mnemonics: Arc<[String]> = indexer_mnemonics + .iter() + .map(|m| m.to_string()) + .collect::>() + .into(); join_and_map_watcher( indexer_allocations_rx, dispute_manager_rx, move |(allocation, dispute)| { - let indexer_mnemonic = indexer_mnemonic.clone(); + let indexer_mnemonics = indexer_mnemonics.clone(); modify_signers( - &indexer_mnemonic, + &indexer_mnemonics, chain_id, attestation_signers_map, &allocation, @@ -44,8 +51,9 @@ pub fn attestation_signers( }, ) } + fn modify_signers( - indexer_mnemonic: &str, + indexer_mnemonics: &[String], chain_id: ChainId, attestation_signers_map: &'static Mutex>, allocations: &HashMap, @@ -62,11 +70,16 @@ fn modify_signers( allocation_id = ?allocation.id, deployment_id = ?allocation.subgraph_deployment.id, created_at_epoch = allocation.created_at_epoch, + mnemonic_count = indexer_mnemonics.len(), "Attempting to create attestation signer for allocation" ); - let signer = - AttestationSigner::new(indexer_mnemonic, allocation, chain_id, *dispute_manager); + let signer = AttestationSigner::new_with_mnemonics( + indexer_mnemonics, + allocation, + chain_id, + *dispute_manager, + ); match signer { Ok(signer) => { tracing::debug!( @@ -80,6 +93,7 @@ fn modify_signers( allocation_id = ?allocation.id, deployment_id = ?allocation.subgraph_deployment.id, created_at_epoch = allocation.created_at_epoch, + mnemonic_count = indexer_mnemonics.len(), error = %e, "Failed to establish signer for allocation" ); @@ -111,7 +125,7 @@ mod tests { let (_, dispute_manager_rx) = watch::channel(DISPUTE_MANAGER_ADDRESS); let mut signers = attestation_signers( allocations_rx, - INDEXER_MNEMONIC.clone(), + vec![INDEXER_MNEMONIC.clone()], 1, dispute_manager_rx, ); diff --git a/crates/service/src/middleware/attestation_signer.rs b/crates/service/src/middleware/attestation_signer.rs index 1a71d3d57..7d0e225ae 100644 --- a/crates/service/src/middleware/attestation_signer.rs +++ b/crates/service/src/middleware/attestation_signer.rs @@ -61,7 +61,7 @@ mod tests { let (_, dispute_manager_rx) = watch::channel(DISPUTE_MANAGER_ADDRESS); let attestation_signers = attestation_signers( allocations_rx, - INDEXER_MNEMONIC.clone(), + vec![INDEXER_MNEMONIC.clone()], 1, dispute_manager_rx, ); diff --git a/crates/service/src/service/router.rs b/crates/service/src/service/router.rs index 6fab06224..957c82548 100644 --- a/crates/service/src/service/router.rs +++ b/crates/service/src/service/router.rs @@ -102,10 +102,12 @@ const DEFAULT_ROUTE: &str = "/"; impl ServiceRouter { pub async fn create_router(self) -> anyhow::Result { - let IndexerConfig { - indexer_address, - operator_mnemonic, - } = self.indexer; + let indexer_address = self.indexer.indexer_address; + let operator_mnemonics = self.indexer.get_operator_mnemonics(); + tracing::info!( + mnemonic_count = operator_mnemonics.len(), + "Loaded operator mnemonics for attestation signing" + ); let ServiceConfig { serve_network_subgraph, serve_escrow_subgraph, @@ -192,10 +194,11 @@ impl ServiceRouter { }; // Maintain an up-to-date set of attestation signers, one for each - // allocation + // allocation. Multiple mnemonics are tried to support allocations + // created with different operator keys. let attestation_signers = attestation_signers( allocations.clone(), - operator_mnemonic.clone(), + operator_mnemonics.clone(), self.blockchain.chain_id as u64, dispute_manager, ); @@ -407,8 +410,17 @@ impl ServiceRouter { None => Router::new(), }; + // The /info endpoint displays the operator's public key. When multiple operator + // mnemonics are configured (for supporting allocations created with different keys), + // only the first mnemonic's public key is displayed. This represents the "primary" + // operator identity. All mnemonics are still used internally for attestation signing. + let primary_mnemonic = operator_mnemonics.first().ok_or_else(|| { + anyhow::anyhow!( + "No operator mnemonic configured. This should have been caught during config validation." + ) + })?; let operator_address = - Json(serde_json::json!({ "publicKey": public_key(&operator_mnemonic)?})); + Json(serde_json::json!({ "publicKey": public_key(primary_mnemonic)?})); // Graph node state let graphnode_state = GraphNodeState { diff --git a/crates/service/tests/router_test.rs b/crates/service/tests/router_test.rs index 3fa8ade83..517d564db 100644 --- a/crates/service/tests/router_test.rs +++ b/crates/service/tests/router_test.rs @@ -74,7 +74,8 @@ async fn full_integration_test() { }) .indexer(IndexerConfig { indexer_address: test_assets::INDEXER_ADDRESS, - operator_mnemonic: test_assets::INDEXER_MNEMONIC.clone(), + operator_mnemonic: Some(test_assets::INDEXER_MNEMONIC.clone()), + operator_mnemonics: None, }) .service(indexer_config::ServiceConfig { serve_network_subgraph: false,