Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
407 changes: 347 additions & 60 deletions crates/attestation/src/lib.rs

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions crates/config/maximal-config-example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions crates/config/minimal-config-example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
232 changes: 230 additions & 2 deletions crates/config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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<Mnemonic>,
/// Multiple operator mnemonics for supporting allocations created
/// with different operator keys (e.g., after key rotation or migration).
#[serde(default)]
pub operator_mnemonics: Option<Vec<Mnemonic>>,
}

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<Mnemonic> {
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)]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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"));
}
}
30 changes: 22 additions & 8 deletions crates/monitor/src/attestation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,30 @@ use crate::{AllocationWatcher, DisputeManagerWatcher};
pub type AttestationWatcher = Receiver<HashMap<Address, AttestationSigner>>;

/// 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<Mnemonic>,
chain_id: ChainId,
dispute_manager_rx: DisputeManagerWatcher,
) -> AttestationWatcher {
let attestation_signers_map: &'static Mutex<HashMap<Address, AttestationSigner>> =
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::<Vec<_>>()
.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,
Expand All @@ -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<HashMap<Address, AttestationSigner>>,
allocations: &HashMap<Address, Allocation>,
Expand All @@ -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!(
Expand All @@ -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"
);
Expand Down Expand Up @@ -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,
);
Expand Down
2 changes: 1 addition & 1 deletion crates/service/src/middleware/attestation_signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
Loading
Loading