// file: crates/ksp-app-backfill-desk/src/backfill_request.rs // version: 1 //! Strict application mapping from frontend campaign DTOs to the KSP Backfill request contract. /// Maps one app-owned campaign request to a validated KSP Backfill request without starting a Job. pub(crate) fn map_backfill_request( input: crate::BackfillStartRequestDto, options: &crate::BackfillDeskOptionsDto, job_id: ksp_job_api::JobId, ) -> ksp_core_lib::Result { let composition = validate_composition(options); if let std::result::Result::Err(error) = composition { return std::result::Result::Err(error); } let network = request_network(options); let network = match network { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let role = request_role(input.http_role.as_str(), options); let role = match role { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let commitment = request_commitment(input.commitment.as_str()); let commitment = match commitment { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let min_context_slot = parse_min_context_slot(input.min_context_slot.as_deref()); let min_context_slot = match min_context_slot { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let scope = request_scope(&input); let scope = match scope { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let page_size = usize::try_from(input.page_size); let page_size = match page_size { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("page_size", error)), }; let max_pages = usize::try_from(input.max_pages); let max_pages = match max_pages { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("max_pages", error)), }; let max_candidates = usize::try_from(input.max_candidates); let max_candidates = match max_candidates { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("max_candidates", error)), }; let hydration_concurrency = usize::try_from(input.hydration_concurrency); let hydration_concurrency = match hydration_concurrency { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("hydration_concurrency", error)), }; return ksp_job_backfill_lib::BackfillRequest::new( job_id, network, role, commitment, scope, page_size, max_pages, max_candidates, hydration_concurrency, min_context_slot, ); } /// Builds a safe request preview from one already validated KSP Backfill request. pub(crate) fn project_backfill_request(request: &ksp_job_backfill_lib::BackfillRequest) -> ksp_core_lib::Result { let explicit_signature_count = match request.scope().signatures() { std::option::Option::Some(signatures) => u32::try_from(signatures.len()), std::option::Option::None => std::result::Result::Ok(0), }; let explicit_signature_count = match explicit_signature_count { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("explicit_signature_count", error)), }; let hydration_concurrency = u32::try_from(request.hydration_concurrency()); let hydration_concurrency = match hydration_concurrency { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("hydration_concurrency", error)), }; let max_candidates = u32::try_from(request.max_candidates()); let max_candidates = match max_candidates { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("max_candidates", error)), }; let max_pages = u32::try_from(request.max_pages()); let max_pages = match max_pages { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("max_pages", error)), }; let page_size = u32::try_from(request.page_size()); let page_size = match page_size { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("page_size", error)), }; return std::result::Result::Ok(crate::BackfillRequestPreviewDto { address_present: request.scope().address().is_some(), anchor_present: request.scope().anchor().is_some(), commitment: request.commitment().code().to_owned(), explicit_signature_count, hydration_concurrency, http_role: request.role().as_str().to_owned(), max_candidates, max_pages, min_context_slot_present: request.min_context_slot().is_some(), network: request.network().as_str().to_owned(), page_size, scope_kind: request.scope().kind().code().to_owned(), }); } fn parse_min_context_slot(value: std::option::Option<&str>) -> ksp_core_lib::Result> { let value = match value { std::option::Option::Some(value) => value, std::option::Option::None => return std::result::Result::Ok(std::option::Option::None), }; if value.is_empty() || value.trim() != value { return std::result::Result::Err(request_field_error("min_context_slot")); } let parsed = value.parse::(); return match parsed { std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value)), std::result::Result::Err(error) => std::result::Result::Err(request_field_error("min_context_slot").with_source(error)), }; } fn parse_pubkey(value: &str, field: &'static str) -> ksp_core_lib::Result { if value.is_empty() || value.trim() != value { return std::result::Result::Err(request_field_error(field)); } let parsed = value.parse::(); return match parsed { std::result::Result::Ok(value) => std::result::Result::Ok(value), std::result::Result::Err(error) => std::result::Result::Err(request_field_error(field).with_source(error)), }; } fn parse_signature(value: &str, field: &'static str) -> ksp_core_lib::Result { if value.is_empty() || value.trim() != value { return std::result::Result::Err(request_field_error(field)); } let parsed = ksp_job_backfill_lib::BackfillSignature::new(value.to_owned()); return match parsed { std::result::Result::Ok(value) => std::result::Result::Ok(value), std::result::Result::Err(error) => std::result::Result::Err(request_field_error(field).with_source(error)), }; } fn request_commitment(value: &str) -> ksp_core_lib::Result { return match value { "confirmed" => std::result::Result::Ok(ksp_job_backfill_lib::BackfillCommitment::Confirmed), "finalized" => std::result::Result::Ok(ksp_job_backfill_lib::BackfillCommitment::Finalized), _ => std::result::Result::Err(request_field_error("commitment")), }; } fn request_conversion_error(field: &'static str, source: impl std::error::Error + Send + Sync + 'static) -> ksp_core_lib::Error { return request_field_error(field).with_source(source); } fn request_field_error(field: &'static str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_BACKFILL_REQUEST_INVALID, "Backfill Desk campaign request is invalid").with_context("field", field); } fn request_network(options: &crate::BackfillDeskOptionsDto) -> ksp_core_lib::Result { let network = options.store_network.as_deref(); let network = match network { std::option::Option::Some(value) => value, std::option::Option::None => return std::result::Result::Err(request_field_error("network")), }; let parsed = ksp_store_lib::RawNetworkId::new(network.to_owned()); return match parsed { std::result::Result::Ok(value) => std::result::Result::Ok(value), std::result::Result::Err(error) => std::result::Result::Err(request_field_error("network").with_source(error)), }; } fn request_role(value: &str, options: &crate::BackfillDeskOptionsDto) -> ksp_core_lib::Result { let admitted = options.http_routes.iter().any(|route| return route.role == value); if !admitted || value.is_empty() || value.trim() != value { return std::result::Result::Err(request_field_error("http_role")); } return std::result::Result::Ok(ksp_onchain_transport_lib::HttpRoleName::new(value.to_owned())); } fn request_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result { return match input.scope_kind.as_str() { "latest_address" => latest_address_scope(input), "before_address" => before_address_scope(input), "after_address" => after_address_scope(input), "explicit_signatures" => explicit_signatures_scope(input), _ => std::result::Result::Err(request_field_error("scope_kind")), }; } fn latest_address_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result { let shape = require_no_anchor_or_signatures(input); if let std::result::Result::Err(error) = shape { return std::result::Result::Err(error); } let address = required_address(input.address.as_deref()); let address = match address { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return std::result::Result::Ok(ksp_job_backfill_lib::BackfillScope::latest_address(address)); } fn before_address_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result { let shape = require_no_explicit_signatures(input); if let std::result::Result::Err(error) = shape { return std::result::Result::Err(error); } let address = required_address(input.address.as_deref()); let address = match address { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let anchor = required_anchor(input.anchor_signature.as_deref()); let anchor = match anchor { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return std::result::Result::Ok(ksp_job_backfill_lib::BackfillScope::before_address(address, anchor)); } fn after_address_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result { let shape = require_no_explicit_signatures(input); if let std::result::Result::Err(error) = shape { return std::result::Result::Err(error); } let address = required_address(input.address.as_deref()); let address = match address { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let anchor = required_anchor(input.anchor_signature.as_deref()); let anchor = match anchor { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return std::result::Result::Ok(ksp_job_backfill_lib::BackfillScope::after_address(address, anchor)); } fn explicit_signatures_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result { if input.address.is_some() || input.anchor_signature.is_some() || input.min_context_slot.is_some() { return std::result::Result::Err(request_field_error("scope")); } if input.explicit_signatures.is_empty() || input.explicit_signatures.len() > ksp_job_backfill_lib::MAX_BACKFILL_CANDIDATES { return std::result::Result::Err(request_field_error("explicit_signatures")); } let mut signatures = std::vec::Vec::with_capacity(input.explicit_signatures.len()); for value in &input.explicit_signatures { let signature = parse_signature(value.as_str(), "explicit_signatures"); let signature = match signature { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; signatures.push(signature); } return ksp_job_backfill_lib::BackfillScope::explicit_signatures(signatures); } fn require_no_anchor_or_signatures(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result<()> { if input.anchor_signature.is_some() || !input.explicit_signatures.is_empty() { return std::result::Result::Err(request_field_error("scope")); } return std::result::Result::Ok(()); } fn require_no_explicit_signatures(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result<()> { if !input.explicit_signatures.is_empty() { return std::result::Result::Err(request_field_error("explicit_signatures")); } return std::result::Result::Ok(()); } fn required_address(value: std::option::Option<&str>) -> ksp_core_lib::Result { return match value { std::option::Option::Some(value) => parse_pubkey(value, "address"), std::option::Option::None => std::result::Result::Err(request_field_error("address")), }; } fn required_anchor(value: std::option::Option<&str>) -> ksp_core_lib::Result { return match value { std::option::Option::Some(value) => parse_signature(value, "anchor_signature"), std::option::Option::None => std::result::Result::Err(request_field_error("anchor_signature")), }; } fn validate_composition(options: &crate::BackfillDeskOptionsDto) -> ksp_core_lib::Result<()> { if options.composition_ready && options.transport_ready && options.store_ready && options.network_coherent { return std::result::Result::Ok(()); } return std::result::Result::Err(ksp_core_lib::Error::new( crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY, "Backfill Desk cannot admit a campaign before Transport and Store composition is ready", )); } #[cfg(test)] #[path = "../unit_tests/backfill_request.rs"] mod tests;