From 935ace218903fe0887fc68566836efc6606313d7 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Fri, 5 Jun 2026 23:44:06 +1200 Subject: [PATCH 1/2] feat(dips): separate accept and reject in the proposal response The proposal response used one shape for accept and reject, so an accept had to carry a dormant reject reason. Split it into accept and reject variants; a reject carries a reason and optional detail, store failures map to a retryable reason, and malformed ones to a catch-all. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/dips/proto/indexer.proto | 53 +++-- .../src/proto/graphprotocol.indexer.dips.rs | 127 +++++++----- crates/dips/src/server.rs | 191 ++++++++++++++++-- 3 files changed, 280 insertions(+), 91 deletions(-) diff --git a/crates/dips/proto/indexer.proto b/crates/dips/proto/indexer.proto index 6199036f3..7d40d9789 100644 --- a/crates/dips/proto/indexer.proto +++ b/crates/dips/proto/indexer.proto @@ -24,34 +24,51 @@ message SubmitAgreementProposalRequest { /** * A response to a request to propose a new _indexing agreement_ to an _indexer_. * + * The outcome is either `accepted` or `rejected` -- exactly one is set. A + * rejection carries a `RejectReason` plus an optional free-text detail. + * * See the `DipsService.SubmitAgreementProposal` method. */ message SubmitAgreementProposalResponse { - ProposalResponse response = 1; /// The response to the agreement proposal. - RejectReason reject_reason = 2; /// Only set when response = REJECT. + oneof outcome { + Accepted accepted = 1; /// Set when the proposal was accepted. + Rejected rejected = 2; /// Set when the proposal was rejected. + } } /** - * The response to an _indexing agreement_ proposal. + * An accepted _indexing agreement_ proposal. Empty for now; accept-only fields + * can be added later without disturbing the reject path. + */ +message Accepted {} + +/** + * A rejected _indexing agreement_ proposal. */ -enum ProposalResponse { - ACCEPT = 0; /// The agreement proposal was accepted. - REJECT = 1; /// The agreement proposal was rejected. +message Rejected { + RejectReason reason = 1; /// Why the proposal was rejected. + string detail = 2; /// Optional human-readable detail; may be empty. } /** - * The reason for rejecting an _indexing agreement_ proposal. - * Only meaningful when ProposalResponse = REJECT. + * The reason an _indexer_ rejected an _indexing agreement_ proposal. Values may + * be added over time; an older reader should treat an unrecognised reason as + * UNSPECIFIED (the catch-all). */ enum RejectReason { - REJECT_REASON_UNSPECIFIED = 0; /// Default / not set (used for ACCEPT responses). - REJECT_REASON_PRICE_TOO_LOW = 1; /// The offered price is below the indexer's minimum. - REJECT_REASON_OTHER = 2; /// Any other reason (bad signature, etc.). - REJECT_REASON_SIGNER_NOT_AUTHORISED = 3; /// The proposal signer is not authorised on the escrow contract. - REJECT_REASON_DEADLINE_EXPIRED = 4; /// The proposal deadline has already passed. - REJECT_REASON_UNSUPPORTED_NETWORK = 5; /// The subgraph's network is not supported by this indexer. - REJECT_REASON_SUBGRAPH_MANIFEST_UNAVAILABLE = 6; /// The subgraph manifest could not be fetched from IPFS. - REJECT_REASON_UNEXPECTED_SERVICE_PROVIDER = 7; /// The RCA service provider does not match this indexer. - REJECT_REASON_AGREEMENT_EXPIRED = 8; /// The agreement end time has already passed. - REJECT_REASON_UNSUPPORTED_METADATA_VERSION = 9; /// The metadata version is not supported. + REJECT_REASON_UNSPECIFIED = 0; /// Rejected for a reason not covered below (the catch-all). + REJECT_REASON_PRICE_TOO_LOW = 1; /// The offered price is below the indexer's minimum. + REJECT_REASON_DEADLINE_EXPIRED = 2; /// The proposal deadline has already passed. + REJECT_REASON_UNSUPPORTED_NETWORK = 3; /// The subgraph's network is not supported by this indexer. + REJECT_REASON_SUBGRAPH_MANIFEST_UNAVAILABLE = 4; /// The subgraph manifest could not be fetched from IPFS. + REJECT_REASON_UNEXPECTED_SERVICE_PROVIDER = 5; /// The RCA names a different indexer as service provider. + REJECT_REASON_AGREEMENT_EXPIRED = 6; /// The agreement end time has already passed. + REJECT_REASON_UNSUPPORTED_METADATA_VERSION = 7; /// The agreement metadata version is not supported. + REJECT_REASON_INVALID_SIGNATURE = 8; /// The proposal's signature failed to verify. + REJECT_REASON_SENDER_NOT_TRUSTED = 9; /// The signer is not an authorised agreement manager. + REJECT_REASON_CAPACITY_EXCEEDED = 10; /// The indexer is at its DIPs capacity; may resolve later. + REJECT_REASON_MANIFEST_TOO_LARGE = 11; /// The subgraph manifest exceeds the indexer's size cap. + REJECT_REASON_REPLAY_DETECTED = 12; /// A different proposal reuses an already-seen agreement id. + REJECT_REASON_INSUFFICIENT_ESCROW = 13; /// The payer has insufficient escrow to back the agreement. + REJECT_REASON_INDEXER_UNAVAILABLE = 14; /// A transient internal error; the proposal may be resent. } diff --git a/crates/dips/src/proto/graphprotocol.indexer.dips.rs b/crates/dips/src/proto/graphprotocol.indexer.dips.rs index 7cce8f90b..cd058c462 100644 --- a/crates/dips/src/proto/graphprotocol.indexer.dips.rs +++ b/crates/dips/src/proto/graphprotocol.indexer.dips.rs @@ -16,74 +16,83 @@ pub struct SubmitAgreementProposalRequest { /// /// A response to a request to propose a new *indexing agreement* to an *indexer*. /// +/// The outcome is either `accepted` or `rejected` -- exactly one is set. A +/// rejection carries a `RejectReason` plus an optional free-text detail. +/// /// See the `DipsService.SubmitAgreementProposal` method. -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct SubmitAgreementProposalResponse { - /// / The response to the agreement proposal. - #[prost(enumeration = "ProposalResponse", tag = "1")] - pub response: i32, - /// / Only set when response = REJECT. - #[prost(enumeration = "RejectReason", tag = "2")] - pub reject_reason: i32, + #[prost(oneof = "submit_agreement_proposal_response::Outcome", tags = "1, 2")] + pub outcome: ::core::option::Option, +} +/// Nested message and enum types in `SubmitAgreementProposalResponse`. +pub mod submit_agreement_proposal_response { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Outcome { + /// / Set when the proposal was accepted. + #[prost(message, tag = "1")] + Accepted(super::Accepted), + /// / Set when the proposal was rejected. + #[prost(message, tag = "2")] + Rejected(super::Rejected), + } } /// * /// -/// The response to an *indexing agreement* proposal. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum ProposalResponse { - /// / The agreement proposal was accepted. - Accept = 0, - /// / The agreement proposal was rejected. - Reject = 1, -} -impl ProposalResponse { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Accept => "ACCEPT", - Self::Reject => "REJECT", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "ACCEPT" => Some(Self::Accept), - "REJECT" => Some(Self::Reject), - _ => None, - } - } +/// An accepted *indexing agreement* proposal. Empty for now; accept-only fields +/// can be added later without disturbing the reject path. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Accepted {} +/// * +/// +/// A rejected *indexing agreement* proposal. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Rejected { + /// / Why the proposal was rejected. + #[prost(enumeration = "RejectReason", tag = "1")] + pub reason: i32, + /// / Optional human-readable detail; may be empty. + #[prost(string, tag = "2")] + pub detail: ::prost::alloc::string::String, } /// * /// -/// The reason for rejecting an *indexing agreement* proposal. -/// Only meaningful when ProposalResponse = REJECT. +/// The reason an *indexer* rejected an *indexing agreement* proposal. Values may +/// be added over time; an older reader should treat an unrecognised reason as +/// UNSPECIFIED (the catch-all). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum RejectReason { - /// / Default / not set (used for ACCEPT responses). + /// / Rejected for a reason not covered below (the catch-all). Unspecified = 0, /// / The offered price is below the indexer's minimum. PriceTooLow = 1, - /// / Any other reason (bad signature, etc.). - Other = 2, - /// / The proposal signer is not authorised on the escrow contract. - SignerNotAuthorised = 3, /// / The proposal deadline has already passed. - DeadlineExpired = 4, + DeadlineExpired = 2, /// / The subgraph's network is not supported by this indexer. - UnsupportedNetwork = 5, + UnsupportedNetwork = 3, /// / The subgraph manifest could not be fetched from IPFS. - SubgraphManifestUnavailable = 6, - /// / The RCA service provider does not match this indexer. - UnexpectedServiceProvider = 7, + SubgraphManifestUnavailable = 4, + /// / The RCA names a different indexer as service provider. + UnexpectedServiceProvider = 5, /// / The agreement end time has already passed. - AgreementExpired = 8, - /// / The metadata version is not supported. - UnsupportedMetadataVersion = 9, + AgreementExpired = 6, + /// / The agreement metadata version is not supported. + UnsupportedMetadataVersion = 7, + /// / The proposal's signature failed to verify. + InvalidSignature = 8, + /// / The signer is not an authorised agreement manager. + SenderNotTrusted = 9, + /// / The indexer is at its DIPs capacity; may resolve later. + CapacityExceeded = 10, + /// / The subgraph manifest exceeds the indexer's size cap. + ManifestTooLarge = 11, + /// / A different proposal reuses an already-seen agreement id. + ReplayDetected = 12, + /// / The payer has insufficient escrow to back the agreement. + InsufficientEscrow = 13, + /// / A transient internal error; the proposal may be resent. + IndexerUnavailable = 14, } impl RejectReason { /// String value of the enum field names used in the ProtoBuf definition. @@ -94,8 +103,6 @@ impl RejectReason { match self { Self::Unspecified => "REJECT_REASON_UNSPECIFIED", Self::PriceTooLow => "REJECT_REASON_PRICE_TOO_LOW", - Self::Other => "REJECT_REASON_OTHER", - Self::SignerNotAuthorised => "REJECT_REASON_SIGNER_NOT_AUTHORISED", Self::DeadlineExpired => "REJECT_REASON_DEADLINE_EXPIRED", Self::UnsupportedNetwork => "REJECT_REASON_UNSUPPORTED_NETWORK", Self::SubgraphManifestUnavailable => { @@ -108,6 +115,13 @@ impl RejectReason { Self::UnsupportedMetadataVersion => { "REJECT_REASON_UNSUPPORTED_METADATA_VERSION" } + Self::InvalidSignature => "REJECT_REASON_INVALID_SIGNATURE", + Self::SenderNotTrusted => "REJECT_REASON_SENDER_NOT_TRUSTED", + Self::CapacityExceeded => "REJECT_REASON_CAPACITY_EXCEEDED", + Self::ManifestTooLarge => "REJECT_REASON_MANIFEST_TOO_LARGE", + Self::ReplayDetected => "REJECT_REASON_REPLAY_DETECTED", + Self::InsufficientEscrow => "REJECT_REASON_INSUFFICIENT_ESCROW", + Self::IndexerUnavailable => "REJECT_REASON_INDEXER_UNAVAILABLE", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -115,8 +129,6 @@ impl RejectReason { match value { "REJECT_REASON_UNSPECIFIED" => Some(Self::Unspecified), "REJECT_REASON_PRICE_TOO_LOW" => Some(Self::PriceTooLow), - "REJECT_REASON_OTHER" => Some(Self::Other), - "REJECT_REASON_SIGNER_NOT_AUTHORISED" => Some(Self::SignerNotAuthorised), "REJECT_REASON_DEADLINE_EXPIRED" => Some(Self::DeadlineExpired), "REJECT_REASON_UNSUPPORTED_NETWORK" => Some(Self::UnsupportedNetwork), "REJECT_REASON_SUBGRAPH_MANIFEST_UNAVAILABLE" => { @@ -129,6 +141,13 @@ impl RejectReason { "REJECT_REASON_UNSUPPORTED_METADATA_VERSION" => { Some(Self::UnsupportedMetadataVersion) } + "REJECT_REASON_INVALID_SIGNATURE" => Some(Self::InvalidSignature), + "REJECT_REASON_SENDER_NOT_TRUSTED" => Some(Self::SenderNotTrusted), + "REJECT_REASON_CAPACITY_EXCEEDED" => Some(Self::CapacityExceeded), + "REJECT_REASON_MANIFEST_TOO_LARGE" => Some(Self::ManifestTooLarge), + "REJECT_REASON_REPLAY_DETECTED" => Some(Self::ReplayDetected), + "REJECT_REASON_INSUFFICIENT_ESCROW" => Some(Self::InsufficientEscrow), + "REJECT_REASON_INDEXER_UNAVAILABLE" => Some(Self::IndexerUnavailable), _ => None, } } diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index 935e6e90d..ccb8d7165 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -53,7 +53,8 @@ use crate::{ ipfs::IpfsFetcher, price::PriceCalculator, proto::indexer::graphprotocol::indexer::dips::{ - indexer_dips_service_server::IndexerDipsService, ProposalResponse, RejectReason, + indexer_dips_service_server::IndexerDipsService, + submit_agreement_proposal_response::Outcome, Accepted, RejectReason, Rejected, SubmitAgreementProposalRequest, SubmitAgreementProposalResponse, }, store::RcaStore, @@ -98,7 +99,8 @@ pub struct DipsServer { pub inflight: InflightCounter, } -/// Map a DipsError to the appropriate RejectReason for the gRPC response. +/// Classify a DipsError as the reason for the rejection. The match is exhaustive +/// so any future error variant must be classified here. fn reject_reason_from_error(err: &DipsError) -> RejectReason { match err { DipsError::TokensPerSecondTooLow { .. } @@ -109,7 +111,23 @@ fn reject_reason_from_error(err: &DipsError) -> RejectReason { DipsError::SubgraphManifestUnavailable(_) => RejectReason::SubgraphManifestUnavailable, DipsError::UnexpectedServiceProvider { .. } => RejectReason::UnexpectedServiceProvider, DipsError::UnsupportedMetadataVersion(_) => RejectReason::UnsupportedMetadataVersion, - _ => RejectReason::Other, + // Malformed proposals with no dedicated reason map to the catch-all; the + // detail carries the specifics. + DipsError::AbiDecoding(_) + | DipsError::InvalidSubgraphManifest(_) + | DipsError::InvalidRca(_) => RejectReason::Unspecified, + // A store failure means the proposal was valid but the indexer couldn't persist + // it -- tell dipper this is transient so it retries rather than giving up. + DipsError::UnknownError(_) => RejectReason::IndexerUnavailable, + } +} + +/// Human-readable detail sent to the caller. A store failure wraps a raw database +/// error that could leak schema internals, so it gets a generic message instead. +fn reject_detail_from_error(err: &DipsError) -> String { + match err { + DipsError::UnknownError(_) => "internal error while storing the proposal".to_string(), + other => other.to_string(), } } @@ -165,8 +183,7 @@ impl IndexerDipsService for DipsServer { Ok(agreement_id) => { tracing::info!(%agreement_id, "RCA accepted"); Ok(Response::new(SubmitAgreementProposalResponse { - response: ProposalResponse::Accept.into(), - reject_reason: RejectReason::Unspecified.into(), + outcome: Some(Outcome::Accepted(Accepted {})), })) } Err(e) => { @@ -178,8 +195,10 @@ impl IndexerDipsService for DipsServer { "RCA proposal rejected" ); Ok(Response::new(SubmitAgreementProposalResponse { - response: ProposalResponse::Reject.into(), - reject_reason: reject_reason.into(), + outcome: Some(Outcome::Rejected(Rejected { + reason: reject_reason.into(), + detail: reject_detail_from_error(&e), + })), })) } } @@ -348,18 +367,6 @@ mod tests { assert_eq!(reason, RejectReason::DeadlineExpired); } - #[test] - fn test_reject_reason_abi_decoding() { - // Arrange - let err = DipsError::AbiDecoding("invalid bytes".to_string()); - - // Act - let reason = super::reject_reason_from_error(&err); - - // Assert - assert_eq!(reason, RejectReason::Other); - } - #[test] fn test_reject_reason_agreement_expired() { // Arrange @@ -413,4 +420,150 @@ mod tests { // Assert assert_eq!(reason, RejectReason::UnsupportedMetadataVersion); } + + #[test] + fn test_reject_reason_malformed_proposals_map_to_unspecified() { + // Arrange: malformed inputs share the UNSPECIFIED catch-all; detail carries the specifics. + let abi = DipsError::AbiDecoding("invalid bytes".to_string()); + let manifest = DipsError::InvalidSubgraphManifest("QmTest".to_string()); + let rca = DipsError::InvalidRca("bad rca".to_string()); + + // Act + Assert + assert_eq!( + super::reject_reason_from_error(&abi), + RejectReason::Unspecified + ); + assert_eq!( + super::reject_reason_from_error(&manifest), + RejectReason::Unspecified + ); + assert_eq!( + super::reject_reason_from_error(&rca), + RejectReason::Unspecified + ); + } + + #[test] + fn test_reject_reason_unknown_error_is_transient() { + // Arrange: a store/database failure surfaces as UnknownError. + let err = DipsError::UnknownError(anyhow::anyhow!("connection pool timed out")); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert: a valid proposal the indexer can't persist is transient, so the + // sender should retry rather than treat it as a permanent failure. + assert_eq!(reason, RejectReason::IndexerUnavailable); + } + + #[test] + fn test_reject_detail_sanitizes_store_errors() { + // Arrange: the raw database error mentions schema internals. + let store_err = + DipsError::UnknownError(anyhow::anyhow!("relation \"users\" column secret")); + let decode_err = DipsError::AbiDecoding("offset 7".to_string()); + + // Act + let store_detail = super::reject_detail_from_error(&store_err); + let decode_detail = super::reject_detail_from_error(&decode_err); + + // Assert: the store error becomes generic while a well-formed validation + // error keeps its descriptive detail. + assert_eq!(store_detail, "internal error while storing the proposal"); + assert!(!store_detail.contains("users")); + assert!(decode_detail.contains("offset 7")); + } + + #[tokio::test] + async fn test_reject_response_carries_reason_and_detail() { + // Arrange: a non-decodable payload reaches validation and fails ABI decode. + let ctx = DipsServerContext::for_testing(); + let server = DipsServer { + ctx, + expected_payee: Address::ZERO, + inflight: empty_counter(), + }; + let request = Request::new(SubmitAgreementProposalRequest { + version: 2, + signed_rca: vec![1, 2, 3], + }); + + // Act + let response = server + .submit_agreement_proposal(request) + .await + .unwrap() + .into_inner(); + + // Assert + let Some(Outcome::Rejected(rejected)) = response.outcome else { + panic!("expected a rejected outcome"); + }; + assert_eq!(rejected.reason, RejectReason::Unspecified as i32); + assert!(rejected.detail.contains("ABI decoding")); + } + + #[test] + fn test_reject_reason_wire_names_round_trip() { + let cases = [ + (RejectReason::Unspecified, "REJECT_REASON_UNSPECIFIED"), + (RejectReason::PriceTooLow, "REJECT_REASON_PRICE_TOO_LOW"), + ( + RejectReason::DeadlineExpired, + "REJECT_REASON_DEADLINE_EXPIRED", + ), + ( + RejectReason::UnsupportedNetwork, + "REJECT_REASON_UNSUPPORTED_NETWORK", + ), + ( + RejectReason::SubgraphManifestUnavailable, + "REJECT_REASON_SUBGRAPH_MANIFEST_UNAVAILABLE", + ), + ( + RejectReason::UnexpectedServiceProvider, + "REJECT_REASON_UNEXPECTED_SERVICE_PROVIDER", + ), + ( + RejectReason::AgreementExpired, + "REJECT_REASON_AGREEMENT_EXPIRED", + ), + ( + RejectReason::UnsupportedMetadataVersion, + "REJECT_REASON_UNSUPPORTED_METADATA_VERSION", + ), + ( + RejectReason::InvalidSignature, + "REJECT_REASON_INVALID_SIGNATURE", + ), + ( + RejectReason::SenderNotTrusted, + "REJECT_REASON_SENDER_NOT_TRUSTED", + ), + ( + RejectReason::CapacityExceeded, + "REJECT_REASON_CAPACITY_EXCEEDED", + ), + ( + RejectReason::ManifestTooLarge, + "REJECT_REASON_MANIFEST_TOO_LARGE", + ), + ( + RejectReason::ReplayDetected, + "REJECT_REASON_REPLAY_DETECTED", + ), + ( + RejectReason::InsufficientEscrow, + "REJECT_REASON_INSUFFICIENT_ESCROW", + ), + ( + RejectReason::IndexerUnavailable, + "REJECT_REASON_INDEXER_UNAVAILABLE", + ), + ]; + for (variant, name) in cases { + assert_eq!(variant.as_str_name(), name); + assert_eq!(RejectReason::from_str_name(name), Some(variant)); + } + } } From 2123472cae12dbd6c009312863586a5436386a19 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Sat, 6 Jun 2026 00:45:50 +1200 Subject: [PATCH 2/2] feat(dips): verify proposal manifest network is configured for DIPs The indexer reads a proposal's network from the subgraph manifest but only rejected an unconfigured network indirectly, when the price lookup later missed it, and an empty network field slipped through. Check the network against the indexer's configured DIPs networks up front, and reject an empty one. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/dips/src/ipfs.rs | 16 +++++++++++++ crates/dips/src/lib.rs | 53 +++++++++++++++++++++++++++++++++++------ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/crates/dips/src/ipfs.rs b/crates/dips/src/ipfs.rs index 6ed4f0bbd..0a7ca8bc6 100644 --- a/crates/dips/src/ipfs.rs +++ b/crates/dips/src/ipfs.rs @@ -239,6 +239,22 @@ impl IpfsFetcher for FailingIpfsFetcher { } } +/// Test IPFS fetcher returning a manifest whose single data source has an +/// empty network field, to exercise the malformed-manifest path. +#[derive(Debug, Clone, Default)] +pub struct EmptyNetworkIpfsFetcher; + +#[async_trait] +impl IpfsFetcher for EmptyNetworkIpfsFetcher { + async fn fetch(&self, _file: &str) -> Result { + Ok(GraphManifest { + data_sources: vec![DataSource { + network: String::new(), + }], + }) + } +} + impl Default for MockIpfsFetcher { fn default() -> Self { Self { diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index 7fb280722..c75796e39 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -330,16 +330,21 @@ pub async fn validate_and_create_rca( // Fetch IPFS manifest let manifest = ipfs_fetcher.fetch(&deployment_id).await?; - // Get network from manifest + // Get network from manifest; an empty network field is a malformed manifest. let network_name = manifest .network() + .filter(|n| !n.is_empty()) .ok_or_else(|| DipsError::InvalidSubgraphManifest(deployment_id.clone()))?; - // Validate network is supported - let network_supported = registry.get_network_by_id(network_name).is_some() - || additional_networks.contains_key(network_name); - - if !network_supported { + // Reject networks this indexer hasn't configured for DIPs at the manifest step, + // instead of relying on the price lookup to miss the network later. + if !price_calculator.is_supported(network_name) { + tracing::info!( + agreement_id = %agreement_id, + network = %network_name, + deployment_id = %deployment_id, + "network not in configured supported_networks, rejecting proposal" + ); return Err(DipsError::UnsupportedNetwork(network_name.to_string())); } @@ -426,7 +431,7 @@ mod test { use crate::{ derive_agreement_id, - ipfs::{FailingIpfsFetcher, MockIpfsFetcher}, + ipfs::{EmptyNetworkIpfsFetcher, FailingIpfsFetcher, MockIpfsFetcher}, price::PriceCalculator, server::DipsServerContext, store::{FailingRcaStore, InMemoryRcaStore}, @@ -940,6 +945,40 @@ mod test { ); } + #[tokio::test] + async fn test_validate_and_create_rca_empty_network() { + // Arrange + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + + // Context with a manifest whose data source has an empty network field + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(EmptyNetworkIpfsFetcher), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }); + + let rca_bytes = rca_to_wire_bytes(rca); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert + assert!( + matches!(result, Err(DipsError::InvalidSubgraphManifest(_))), + "Expected InvalidSubgraphManifest for empty network, got: {:?}", + result + ); + } + #[tokio::test] async fn test_validate_and_create_rca_store_failure() { // Arrange