@@ -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 ) ) ]
291317pub 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}
0 commit comments