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)); + } + } }